From fc3c7149f7bc531f1e4d4c4b78edf105143b398d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 12:53:54 +0000 Subject: [PATCH 01/31] Replace ASM and Gizmo with Java ClassFile API backport 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 --- .../kotlin/util/AnnotationNodeExtensions.kt | 174 ---- core/pom.xml | 6 +- .../java/dev/morphia/critter/Critter.java | 23 +- .../critter/parser/ExtensionFunctions.java | 37 +- .../dev/morphia/critter/parser/FieldInfo.java | 17 + .../morphia/critter/parser/MethodInfo.java | 36 + .../critter/parser/PropertyFinder.java | 218 ++--- .../parser/asm/AddFieldAccessorMethods.java | 120 ++- .../parser/asm/AddMethodAccessorMethods.java | 185 ++-- .../critter/parser/asm/BaseGenerator.java | 136 +-- .../parser/gizmo/BaseGizmoGenerator.java | 40 +- .../parser/gizmo/CritterGizmoGenerator.java | 41 +- .../gizmo/GizmoEntityModelGenerator.java | 294 +++---- .../critter/parser/gizmo/GizmoExtensions.java | 433 +++++----- .../gizmo/PropertyAccessorGenerator.java | 231 ++--- .../parser/gizmo/PropertyModelGenerator.java | 802 +++++++----------- .../gizmo/VarHandleAccessorGenerator.java | 575 +++++++------ .../critter/parser/java/CritterParser.java | 45 +- .../dev/morphia/critter/parser/TypesTest.java | 30 +- .../parser/gizmo/TestGizmoGeneration.java | 167 +--- pom.xml | 6 + 21 files changed, 1520 insertions(+), 2096 deletions(-) create mode 100644 core/src/main/java/dev/morphia/critter/parser/FieldInfo.java create mode 100644 core/src/main/java/dev/morphia/critter/parser/MethodInfo.java diff --git a/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt b/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt index 47a314b9481..44c725f53dc 100644 --- a/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt +++ b/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt @@ -1,7 +1,5 @@ package util -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.ClassName.Companion.bestGuess import java.io.File import java.io.FileFilter import java.io.FileWriter @@ -15,9 +13,7 @@ import org.apache.maven.plugins.annotations.Parameter import org.apache.maven.project.MavenProject import org.jboss.forge.roaster.ParserException import org.jboss.forge.roaster.Roaster -import org.jboss.forge.roaster.model.Type import org.jboss.forge.roaster.model.source.JavaAnnotationSource -import org.objectweb.asm.Type as AsmType @Mojo(name = "morphia-annotation-node", defaultPhase = GENERATE_SOURCES) class AnnotationNodeExtensions : AbstractMojo() { @@ -91,70 +87,6 @@ class AnnotationNodeExtensions : AbstractMojo() { body.appendLine() body.appendLine(" private AnnotationNodeExtensions() {}") body.appendLine() - body.appendLine( - """ - private static java.util.Map toMap(org.objectweb.asm.tree.AnnotationNode annotationNode) { - if (annotationNode.values == null) return java.util.Collections.emptyMap(); - java.util.Map map = new java.util.LinkedHashMap<>(); - java.util.List values = annotationNode.values; - for (int i = 0; i < values.size() - 1; i += 2) { - Object key = values.get(i); - map.put(key != null ? key.toString() : "value", values.get(i + 1)); - } - return map; - } - - private static Class loadClass(String name) { - try { - return Class.forName(name); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } -""" - ) - - // setBuilderValues dispatch - body.appendLine( - " public void setBuilderValues(org.objectweb.asm.tree.AnnotationNode annotationNode, io.quarkus.gizmo.MethodCreator creator, io.quarkus.gizmo.ResultHandle local) {" - ) - body.appendLine(" switch (annotationNode.desc) {") - for (source in builders.values) { - val type = bestGuess(source.qualifiedName).toType() - body.appendLine( - " case \"${type.descriptor}\" -> set${source.name}Values(annotationNode, creator, local);" - ) - } - body.appendLine( - " default -> throw new IllegalArgumentException(\"Unknown annotation type: \" + annotationNode.desc);" - ) - body.appendLine(" }") - body.appendLine(" }") - body.appendLine() - - // toMorphiaAnnotation dispatch - body.appendLine( - " public T toMorphiaAnnotation(org.objectweb.asm.tree.AnnotationNode annotationNode) {" - ) - body.appendLine(" return (T) switch (annotationNode.desc) {") - for (source in builders.values) { - val type = bestGuess(source.qualifiedName).toType() - body.appendLine( - " case \"${type.descriptor}\" -> to${source.name}(annotationNode);" - ) - } - body.appendLine( - " default -> throw new IllegalArgumentException(\"Unknown annotation type: \" + annotationNode.desc);" - ) - body.appendLine(" };") - body.appendLine(" }") - body.appendLine() - - // Per-annotation methods - for (source in builders.values) { - emitToAnnotationMethod(body, source) - emitSetAnnotationValuesMethod(body, source) - } body.appendLine("}") @@ -168,110 +100,4 @@ class AnnotationNodeExtensions : AbstractMojo() { File(generated, pkg.replace('.', '/') + "/AnnotationNodeExtensions.kt").delete() FileWriter(outputFile).use { out -> out.write(body.toString()) } } - - private fun emitToAnnotationMethod(sb: StringBuilder, source: JavaAnnotationSource) { - val builderClass = - "${source.qualifiedName.substringBeforeLast('.')}.internal.${source.name}Builder" - val methodName = source.name.first().lowercaseChar() + source.name.substring(1) + "Builder" - - sb.appendLine( - " private ${source.qualifiedName} to${source.name}(org.objectweb.asm.tree.AnnotationNode annotationNode) {" - ) - if (source.annotationElements.isNotEmpty()) { - sb.appendLine(" java.util.Map map = toMap(annotationNode);") - } - sb.appendLine(" var builder = $builderClass.$methodName();") - - for (element in source.annotationElements) { - val name = element.name - val varName = "__${name}" - sb.appendLine(" Object $varName = map.get(\"$name\");") - sb.appendLine(" if ($varName != null) {") - sb.appendLine(" builder.$name(${processTypeJava(element.type, varName)});") - sb.appendLine(" }") - } - - sb.appendLine(" return builder.build();") - sb.appendLine(" }") - sb.appendLine() - } - - private fun emitSetAnnotationValuesMethod(sb: StringBuilder, source: JavaAnnotationSource) { - val builderClass = - "${source.qualifiedName.substringBeforeLast('.')}.internal.${source.name}Builder" - - sb.appendLine( - " private void set${source.name}Values(org.objectweb.asm.tree.AnnotationNode annotationNode, io.quarkus.gizmo.MethodCreator creator, io.quarkus.gizmo.ResultHandle local) {" - ) - if (source.annotationElements.isNotEmpty()) { - sb.appendLine(" java.util.Map map = toMap(annotationNode);") - } - - for (element in source.annotationElements) { - val name = element.name - val varName = "__${name}" - sb.appendLine(" Object $varName = map.get(\"$name\");") - sb.appendLine(" if ($varName != null) {") - sb.appendLine( - " java.lang.reflect.Type type = dev.morphia.critter.parser.gizmo.GizmoExtensions.attributeType(${source.qualifiedName}.class, \"$name\");" - ) - sb.appendLine( - " io.quarkus.gizmo.MethodDescriptor method = io.quarkus.gizmo.MethodDescriptor.ofMethod($builderClass.class, \"$name\", $builderClass.class, dev.morphia.critter.parser.gizmo.GizmoExtensions.rawType(type));" - ) - sb.appendLine( - " creator.invokeVirtualMethod(method, local, dev.morphia.critter.parser.gizmo.GizmoExtensions.load(creator, type, $varName));" - ) - sb.appendLine(" }") - } - - sb.appendLine(" }") - sb.appendLine() - } - - private fun processTypeJava(type: Type, varName: String): String { - val typeName = type.name - - return when { - typeName == "boolean" -> "(Boolean) $varName" - typeName == "String" -> "(String) $varName" - typeName == "int" -> "(Integer) $varName" - typeName == "long" -> "((Number) $varName).longValue()" - typeName == "Class" && !type.isArray -> - "loadClass(((org.objectweb.asm.Type) $varName).getClassName())" - type.isArray -> processArrayTypeJava(type, varName) - type.qualifiedName.startsWith("com.mongodb.client.model.") -> - "${type.qualifiedName}.valueOf(((String[]) $varName)[1])" - type.qualifiedName.startsWith("dev.morphia.mapping.") -> - "${type.qualifiedName}.valueOf(((String[]) $varName)[1])" - type.qualifiedName.startsWith("dev.morphia.annotations.") -> - "to${type.simpleName}((org.objectweb.asm.tree.AnnotationNode) $varName)" - else -> { - System.out.printf( - "unknown type: %n\t%s %n\t%s %n\t%s %n", - typeName, - type.qualifiedName, - type.origin.isEnum, - ) - "" - } - } - } - - private fun processArrayTypeJava(type: Type, varName: String): String { - val simpleName: String = type.simpleName - val parameterized = type.isParameterized - val params = if (parameterized) type.typeArguments else emptyList() - return when { - type.qualifiedName == "java.lang.Class" -> { - """((java.util.List) $varName).stream().map(t -> loadClass(((org.objectweb.asm.Type) t).getClassName())).toArray(java.lang.Class[]::new)""" - } - type.qualifiedName.startsWith("dev.morphia.annotations.") -> - "((java.util.List) $varName).stream().map(a -> to${simpleName}((org.objectweb.asm.tree.AnnotationNode) a)).toArray(${type.qualifiedName}[]::new)" - type.qualifiedName == "java.lang.String" -> - "((java.util.List) $varName).toArray(new String[0])" - else -> TODO("unknown array type: $type") - } - } } - -private fun ClassName.toType() = AsmType.getType("L${canonicalName};".replace('.', '/')) diff --git a/core/pom.xml b/core/pom.xml index 06371f4587e..9bd26538292 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -166,16 +166,18 @@ byte-buddy - io.quarkus.gizmo - gizmo + io.github.dmlloyd + jdk-classfile-backport org.ow2.asm asm-tree + test org.ow2.asm asm-util + test org.slf4j diff --git a/core/src/main/java/dev/morphia/critter/Critter.java b/core/src/main/java/dev/morphia/critter/Critter.java index 180e9ce5553..b10a9ad1d4a 100644 --- a/core/src/main/java/dev/morphia/critter/Critter.java +++ b/core/src/main/java/dev/morphia/critter/Critter.java @@ -9,22 +9,19 @@ import dev.morphia.annotations.Property; import dev.morphia.annotations.Transient; -import org.objectweb.asm.Type; - /** * Core utility class for Critter code generation, providing shared constants and helper methods. */ public class Critter { - /** Annotation types that mark a field or method as a mapped property. */ - public static final List propertyAnnotations = new ArrayList<>(List.of(Type.getType(Property.class))); - /** Annotation types that mark a field or method as transient (not persisted). */ - public static final List transientAnnotations = new ArrayList<>(List.of(Type.getType(Transient.class))); + /** Annotation descriptors that mark a field or method as a mapped property. */ + public static final List propertyAnnotations = new ArrayList<>( + List.of("L" + Property.class.getName().replace('.', '/') + ";")); + /** Annotation descriptors that mark a field or method as transient (not persisted). */ + public static final List transientAnnotations = new ArrayList<>( + List.of("L" + Transient.class.getName().replace('.', '/') + ";")); /** * Returns the package name used for generated Critter classes for the given entity. - * - * @param entity the entity class - * @return the generated package name */ public static String critterPackage(Class entity) { return "%s.__morphia.%s".formatted(entity.getPackageName(), entity.getSimpleName().toLowerCase()); @@ -32,9 +29,6 @@ public static String critterPackage(Class entity) { /** * Converts a string to title case by capitalizing the first character. - * - * @param s the input string - * @return the string with the first character upper-cased, or the original string if null or empty */ public static String titleCase(String s) { if (s == null || s.isEmpty()) @@ -44,9 +38,6 @@ public static String titleCase(String s) { /** * Converts a string to identifier (camel) case by lower-casing the first character. - * - * @param s the input string - * @return the string with the first character lower-cased, or the original string if null or empty */ public static String identifierCase(String s) { if (s == null || s.isEmpty()) @@ -60,8 +51,6 @@ public static String identifierCase(String s) { /** * Creates a new Critter instance rooted at the given directory. - * - * @param root the root directory for source files */ public Critter(File root) { this.root = root; diff --git a/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java b/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java index f19aa198d6e..939bda12900 100644 --- a/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java +++ b/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java @@ -3,11 +3,8 @@ import java.util.Locale; import java.util.regex.Pattern; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.MethodNode; - /** - * Utility methods for string and ASM type transformations used during Critter code generation. + * Utility methods for string and type transformations used during Critter code generation. */ public class ExtensionFunctions { @@ -19,9 +16,6 @@ private ExtensionFunctions() { /** * Converts a string to title case by capitalizing the first character. - * - * @param s the input string - * @return the string with the first character upper-cased, or the original string if null or empty */ public static String titleCase(String s) { if (s == null || s.isEmpty()) @@ -31,9 +25,6 @@ public static String titleCase(String s) { /** * Converts a string to method (camel) case by lower-casing the first character. - * - * @param s the input string - * @return the string with the first character lower-cased, or the original string if null or empty */ public static String methodCase(String s) { if (s == null || s.isEmpty()) @@ -43,9 +34,6 @@ public static String methodCase(String s) { /** * Converts a camelCase string to snake_case. - * - * @param s the camelCase input string - * @return the snake_case equivalent */ public static String snakeCase(String s) { return SNAKE_CASE_REGEX.matcher(s).replaceAll(m -> "_" + m.group().toLowerCase(Locale.getDefault())); @@ -57,16 +45,9 @@ public static String snakeCase(String s) { * - "isActive" -> "active" * - "getX" -> "x" * - "name()" with matching field "name" -> "name" - * - * @param method the method node - * @param entity the class to check for matching fields when method name doesn't follow standard - * getter naming - * @return the property name derived from the getter method */ - public static String getterToPropertyName(MethodNode method, Class entity) { - String methodName = method.name; - - // Standard getter patterns + public static String getterToPropertyName(MethodInfo method, Class entity) { + String methodName = method.name(); if (methodName.startsWith("get") && methodName.length() > 3) { return methodCase(methodName.substring(3)); } @@ -74,12 +55,14 @@ public static String getterToPropertyName(MethodNode method, Class entity) { return methodCase(methodName.substring(2)); } - // Check if method name matches a field: no parameters and return type matches field type - Type[] argTypes = Type.getArgumentTypes(method.desc); - Type returnType = Type.getReturnType(method.desc); - if (argTypes.length == 0) { + // Parse desc to find argument types and return type + java.lang.constant.MethodTypeDesc mtd = java.lang.constant.MethodTypeDesc.ofDescriptor(method.desc()); + if (mtd.parameterCount() == 0) { + String returnDesc = mtd.returnType().descriptorString(); for (java.lang.reflect.Field field : entity.getDeclaredFields()) { - if (field.getName().equals(methodName) && Type.getType(field.getType()).equals(returnType)) { + String fieldDesc = io.github.dmlloyd.classfile.TypeKind + .from(java.lang.constant.ClassDesc.ofDescriptor(returnDesc)).upperBound().descriptorString(); + if (field.getName().equals(methodName)) { return methodName; } } diff --git a/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java b/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java new file mode 100644 index 00000000000..5eb3486dece --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java @@ -0,0 +1,17 @@ +package dev.morphia.critter.parser; + +import java.util.List; + +import io.github.dmlloyd.classfile.Annotation; + +/** + * Immutable record capturing the bytecode-level information for a single entity field, + * replacing direct use of ASM {@code FieldNode}. + */ +public record FieldInfo( + String name, + String desc, + String signature, + int access, + List visibleAnnotations) { +} diff --git a/core/src/main/java/dev/morphia/critter/parser/MethodInfo.java b/core/src/main/java/dev/morphia/critter/parser/MethodInfo.java new file mode 100644 index 00000000000..d1daa493ef9 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/MethodInfo.java @@ -0,0 +1,36 @@ +package dev.morphia.critter.parser; + +import java.util.List; + +import io.github.dmlloyd.classfile.Annotation; + +/** + * Immutable record capturing the bytecode-level information for a single entity method, + * replacing direct use of ASM {@code MethodNode}. + */ +public record MethodInfo( + String name, + String desc, + String signature, + int access, + List visibleAnnotations) { + + /** + * Returns a new MethodInfo that merges annotations from this (getter) and the setter. + * If the setter has no annotations, returns {@code this}. + */ + public MethodInfo mergeAnnotations(MethodInfo setter) { + List setterAnnotations = setter.visibleAnnotations() != null + ? setter.visibleAnnotations() + : List.of(); + if (setterAnnotations.isEmpty()) { + return this; + } + java.util.List combined = new java.util.ArrayList<>(); + if (this.visibleAnnotations != null) { + combined.addAll(this.visibleAnnotations); + } + combined.addAll(setterAnnotations); + return new MethodInfo(this.name, this.desc, this.signature, this.access, combined); + } +} diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 90c791bcdac..54cdf713b7d 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -14,18 +14,21 @@ import dev.morphia.mapping.Mapper; import dev.morphia.mapping.PropertyDiscovery; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.AnnotationNode; -import org.objectweb.asm.tree.ClassNode; -import org.objectweb.asm.tree.FieldNode; -import org.objectweb.asm.tree.MethodNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.github.dmlloyd.classfile.Annotation; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; +import io.github.dmlloyd.classfile.FieldModel; +import io.github.dmlloyd.classfile.MethodModel; +import io.github.dmlloyd.classfile.attribute.RuntimeVisibleAnnotationsAttribute; + +import static io.github.dmlloyd.classfile.Attributes.runtimeVisibleAnnotations; +import static io.github.dmlloyd.classfile.Attributes.signature; + /** - * Discovers entity properties (fields or getter methods) from a parsed ASM {@link ClassNode} + * Discovers entity properties (fields or getter methods) from a parsed class model * and produces the corresponding {@link PropertyModelGenerator} instances. */ public class PropertyFinder { @@ -37,13 +40,6 @@ public class PropertyFinder { private final CritterGizmoGenerator critterGizmoGenerator; private final PropertyDiscovery propertyDiscovery; - /** - * Creates a new PropertyFinder. - * - * @param mapper the Morphia mapper - * @param classLoader the class loader for registering generated accessor classes - * @param runtimeMode {@code true} to generate VarHandle-based accessors instead of synthetic method accessors - */ public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean runtimeMode) { this.providerMap = new LinkedHashMap<>(); for (var provider : mapper.getConfig().propertyAnnotationProviders()) { @@ -55,22 +51,15 @@ public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean run this.propertyDiscovery = mapper.getConfig().propertyDiscovery(); } - /** - * Discovers the properties for the given entity and returns a generator for each one. - * - * @param entityType the entity class being processed - * @param classNode the ASM class node for the entity - * @return a list of property model generators, one per discovered property - */ - public List find(Class entityType, ClassNode classNode) { + public List find(Class entityType, ClassModel classModel) { List models = new ArrayList<>(); - List methods = discoverPropertyMethods(entityType, classNode); + List methods = discoverPropertyMethods(entityType, classModel); if (methods.isEmpty()) { - List fields = discoverAllFields(entityType, classNode); + List fields = discoverAllFields(entityType, classModel); if (!runtimeMode) { classLoader.register(entityType.getName(), critterGizmoGenerator.fieldAccessors(entityType, fields)); } - for (FieldNode field : fields) { + for (FieldInfo field : fields) { if (runtimeMode) { critterGizmoGenerator.varHandleAccessor(entityType, classLoader, field); } else { @@ -82,7 +71,7 @@ public List find(Class entityType, ClassNode classNod if (!runtimeMode) { classLoader.register(entityType.getName(), critterGizmoGenerator.methodAccessors(entityType, methods)); } - for (MethodNode method : methods) { + for (MethodInfo method : methods) { if (runtimeMode) { critterGizmoGenerator.varHandleAccessor(entityType, classLoader, method); } else { @@ -94,166 +83,185 @@ public List find(Class entityType, ClassNode classNod return models; } - private boolean isPropertyAnnotated(List annotationNodes, boolean allowUnannotated) { - List annotations = annotationNodes != null ? annotationNodes : List.of(); + private boolean isPropertyAnnotated(List annotations, boolean allowUnannotated) { + List anns = annotations != null ? annotations : List.of(); List keys = providerMap.keySet().stream() - .map(type -> Type.getType(type).getDescriptor()) + .map(type -> "L" + type.getName().replace('.', '/') + ";") .toList(); - return allowUnannotated || annotations.stream().anyMatch(a -> keys.contains(a.desc)); + return allowUnannotated || anns.stream() + .anyMatch(a -> keys.contains(a.classSymbol().descriptorString())); } - private List discoverAllFields(Class entityType, ClassNode classNode) { - List fields = new ArrayList<>(); + private List discoverAllFields(Class entityType, ClassModel classModel) { + List fields = new ArrayList<>(); Map seen = new LinkedHashMap<>(); Class current = entityType; - ClassNode currentNode = classNode; + ClassModel currentModel = classModel; while (current != null && current != Object.class) { - ClassNode node = currentNode != null ? currentNode : readClassNode(current); - if (node == null) + ClassModel model = currentModel != null ? currentModel : readClassModel(current); + if (model == null) break; - for (FieldNode field : discoverFields(node)) { - if (seen.putIfAbsent(field.name, Boolean.TRUE) == null) { + for (FieldInfo field : discoverFields(model)) { + if (seen.putIfAbsent(field.name(), Boolean.TRUE) == null) { fields.add(field); } } current = current.getSuperclass(); - currentNode = null; + currentModel = null; } return fields; } - private ClassNode readClassNode(Class type) { + 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; } - return node; } - private List discoverFields(ClassNode classNode) { + private List discoverFields(ClassModel classModel) { List transientDescs = CritterParser.INSTANCE.transientAnnotations(); - List result = new ArrayList<>(); - for (FieldNode field : classNode.fields) { - List visible = field.visibleAnnotations != null ? field.visibleAnnotations : List.of(); - boolean isTransient = (field.access & org.objectweb.asm.Opcodes.ACC_TRANSIENT) != 0 - || visible.stream().map(a -> a.desc).anyMatch(transientDescs::contains); - if (!isTransient && isPropertyAnnotated(field.visibleAnnotations, true)) { - result.add(field); + List result = new ArrayList<>(); + for (FieldModel field : classModel.fields()) { + List visible = visibleAnnotations(field); + boolean isTransient = (field.flags().flagsMask() & ClassFile.ACC_TRANSIENT) != 0 + || visible.stream().map(a -> a.classSymbol().descriptorString()).anyMatch(transientDescs::contains); + if (!isTransient && isPropertyAnnotated(visible, true)) { + String sig = field.findAttribute(signature()) + .map(a -> a.signature().stringValue()) + .orElse(null); + result.add(new FieldInfo( + field.fieldName().stringValue(), + field.fieldType().stringValue(), + sig, + field.flags().flagsMask(), + visible)); } } return result; } - private List discoverPropertyMethods(Class entityType, ClassNode classNode) { - List result = new ArrayList<>(); + private List discoverPropertyMethods(Class entityType, ClassModel classModel) { + List result = new ArrayList<>(); Map seen = new LinkedHashMap<>(); Class current = entityType; - ClassNode currentNode = classNode; + ClassModel currentModel = classModel; while (current != null && current != Object.class) { - ClassNode node = currentNode != null ? currentNode : readClassNode(current); - if (node == null) + ClassModel model = currentModel != null ? currentModel : readClassModel(current); + if (model == null) break; boolean isSuperclass = current != entityType; - for (MethodNode method : node.methods) { + for (MethodModel method : model.methods()) { if (!isGetter(method)) continue; - // Private methods are not inherited and cannot be invoked from a subclass context - if (isSuperclass && (method.access & Opcodes.ACC_PRIVATE) != 0) + if (isSuperclass && (method.flags().flagsMask() & ClassFile.ACC_PRIVATE) != 0) continue; String propName = getterPropertyName(method); - // Subclass definition already registered; superclass version is shadowed if (seen.containsKey(propName)) continue; + MethodInfo methodInfo = toMethodInfo(method); + if (propertyDiscovery == PropertyDiscovery.METHODS) { - // In METHODS mode: include all getters that have a matching setter, - // merging annotations from both getter and setter so that annotations - // placed on the setter (e.g. @Version, @Text) are visible to downstream generators. - MethodNode setter = findSetterInHierarchy(node, current, propName, Type.getReturnType(method.desc)); + java.lang.constant.MethodTypeDesc mtd = java.lang.constant.MethodTypeDesc + .ofDescriptor(method.methodType().stringValue()); + MethodInfo setter = findSetterInHierarchy(model, current, propName, + mtd.returnType().descriptorString()); if (setter != null) { seen.put(propName, Boolean.TRUE); - result.add(mergeAnnotations(method, setter)); + result.add(methodInfo.mergeAnnotations(setter)); } - } else if (isPropertyAnnotated(method.visibleAnnotations, false)) { + } else if (isPropertyAnnotated(methodInfo.visibleAnnotations(), false)) { seen.put(propName, Boolean.TRUE); - result.add(method); + result.add(methodInfo); } } current = current.getSuperclass(); - currentNode = null; + currentModel = null; } return result; } - private boolean isGetter(MethodNode method) { - return (method.name.startsWith("get") || method.name.startsWith("is")) - && Type.getArgumentTypes(method.desc).length == 0 - && !Type.getReturnType(method.desc).equals(Type.VOID_TYPE) - && (method.access & Opcodes.ACC_STATIC) == 0; + private MethodInfo toMethodInfo(MethodModel method) { + String sig = method.findAttribute(signature()) + .map(a -> a.signature().stringValue()) + .orElse(null); + return new MethodInfo( + method.methodName().stringValue(), + method.methodType().stringValue(), + sig, + method.flags().flagsMask(), + visibleAnnotations(method)); } - private String getterPropertyName(MethodNode method) { - String prefix = method.name.startsWith("is") ? "is" : "get"; - String name = method.name.substring(prefix.length()); - return Character.toLowerCase(name.charAt(0)) + name.substring(1); + private boolean isGetter(MethodModel method) { + String name = method.methodName().stringValue(); + if (!name.startsWith("get") && !name.startsWith("is")) + return false; + if ((method.flags().flagsMask() & ClassFile.ACC_STATIC) != 0) + return false; + java.lang.constant.MethodTypeDesc mtd = java.lang.constant.MethodTypeDesc + .ofDescriptor(method.methodType().stringValue()); + return mtd.parameterCount() == 0 && !mtd.returnType().equals(java.lang.constant.ConstantDescs.CD_void); } - private MethodNode findSetter(ClassNode classNode, String propertyName, Type returnType) { + private String getterPropertyName(MethodModel method) { + String name = method.methodName().stringValue(); + String prefix = name.startsWith("is") ? "is" : "get"; + String prop = name.substring(prefix.length()); + return Character.toLowerCase(prop.charAt(0)) + prop.substring(1); + } + + private MethodInfo findSetter(ClassModel classModel, String propertyName, String returnDesc) { String setterName = "set" + Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1); - String setterDesc = "(" + returnType.getDescriptor() + ")V"; - for (MethodNode method : classNode.methods) { - if (method.name.equals(setterName) && method.desc.equals(setterDesc)) { - return method; + String setterDesc = "(" + returnDesc + ")V"; + for (MethodModel method : classModel.methods()) { + if (method.methodName().stringValue().equals(setterName) + && method.methodType().stringValue().equals(setterDesc)) { + return toMethodInfo(method); } } return null; } - private MethodNode findSetterInHierarchy(ClassNode startNode, Class startClass, String propName, Type returnType) { - ClassNode node = startNode; + private MethodInfo findSetterInHierarchy(ClassModel startModel, Class startClass, String propName, + String returnDesc) { + ClassModel model = startModel; Class current = startClass; while (current != null && current != Object.class) { - if (node == null) - node = readClassNode(current); - if (node != null) { - MethodNode setter = findSetter(node, propName, returnType); - // Private setters are inaccessible from generated accessor bytecode - if (setter != null && (setter.access & Opcodes.ACC_PRIVATE) == 0) { + if (model == null) + model = readClassModel(current); + if (model != null) { + MethodInfo setter = findSetter(model, propName, returnDesc); + if (setter != null && (setter.access() & ClassFile.ACC_PRIVATE) == 0) { return setter; } } current = current.getSuperclass(); - node = null; + model = null; } return null; } - private MethodNode mergeAnnotations(MethodNode getter, MethodNode setter) { - List setterAnnotations = setter.visibleAnnotations != null ? setter.visibleAnnotations : List.of(); - if (setterAnnotations.isEmpty()) { - return getter; - } - MethodNode merged = new MethodNode(getter.access, getter.name, getter.desc, getter.signature, null); - List combined = new ArrayList<>(); - if (getter.visibleAnnotations != null) { - combined.addAll(getter.visibleAnnotations); - } - combined.addAll(setterAnnotations); - merged.visibleAnnotations = combined; - return merged; + private List visibleAnnotations(io.github.dmlloyd.classfile.AttributedElement element) { + return element.findAttribute(runtimeVisibleAnnotations()) + .map(RuntimeVisibleAnnotationsAttribute::annotations) + .orElse(List.of()); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java index 3a4e2dfa177..f07a48d05da 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java @@ -1,98 +1,74 @@ package dev.morphia.critter.parser.asm; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; import java.util.List; import dev.morphia.critter.Critter; +import dev.morphia.critter.parser.FieldInfo; -import org.objectweb.asm.Label; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.FieldNode; - -import static org.objectweb.asm.Opcodes.ACC_PUBLIC; -import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC; -import static org.objectweb.asm.Opcodes.ALOAD; -import static org.objectweb.asm.Opcodes.GETFIELD; -import static org.objectweb.asm.Opcodes.ILOAD; -import static org.objectweb.asm.Opcodes.IRETURN; -import static org.objectweb.asm.Opcodes.PUTFIELD; -import static org.objectweb.asm.Opcodes.RETURN; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; +import io.github.dmlloyd.classfile.ClassTransform; +import io.github.dmlloyd.classfile.MethodModel; +import io.github.dmlloyd.classfile.TypeKind; /** * Generates synthetic {@code __readXxx} and {@code __writeXxx} accessor methods directly * into an entity class bytecode for each of its fields. */ public class AddFieldAccessorMethods extends BaseGenerator { - private final List fields; + private final List fields; /** * Creates a generator that will add accessor methods for the given fields to the entity class. - * - * @param entity the entity class to augment - * @param fields the fields for which accessor methods should be generated */ - public AddFieldAccessorMethods(Class entity, List fields) { + public AddFieldAccessorMethods(Class entity, List fields) { super(entity); this.fields = fields; - readClassFiltering(entity); } @Override public byte[] emit() { - for (FieldNode field : fields) { - String name = field.name; - Type type = Type.getType(field.desc); - reader(name, type); - writer(name, type); - } - classWriter.visitEnd(); - return classWriter.toByteArray(); - } + ClassModel model = readClassFiltering(); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); - private void writer(String field, Type fieldType) { - var mv = classWriter.visitMethod( - ACC_PUBLIC | ACC_SYNTHETIC, - "__write%s".formatted(Critter.titleCase(field)), - "(%s)V".formatted(fieldType.getDescriptor()), - null, - null); - mv.visitCode(); - Label label0 = new Label(); - mv.visitLabel(label0); - mv.visitLineNumber(18, label0); - mv.visitVarInsn(ALOAD, 0); - mv.visitVarInsn(fieldType.getOpcode(ILOAD), 1); - mv.visitFieldInsn(PUTFIELD, entityType.getInternalName(), field, fieldType.getDescriptor()); - Label label1 = new Label(); - mv.visitLabel(label1); - mv.visitLineNumber(19, label1); - mv.visitInsn(RETURN); - Label label2 = new Label(); - mv.visitLabel(label2); - mv.visitLocalVariable("this", entityType.getDescriptor(), null, label0, label2, 0); - mv.visitLocalVariable("value", fieldType.getDescriptor(), null, label0, label2, 1); - mv.visitMaxs(2, 2); - mv.visitEnd(); - } + ClassTransform transform = ClassTransform.dropping( + element -> element instanceof MethodModel m + && (m.flags().flagsMask() & ClassFile.ACC_SYNTHETIC) != 0 + && (m.methodName().stringValue().startsWith("__read") + || m.methodName().stringValue().startsWith("__write"))) + .andThen(ClassTransform.endHandler(classBuilder -> { + for (FieldInfo field : fields) { + String name = field.name(); + ClassDesc fieldDesc = ClassDesc.ofDescriptor(field.desc()); + TypeKind kind = TypeKind.fromDescriptor(field.desc()); + + // __readXxx(): returns field type + String readerName = "__read%s".formatted(Critter.titleCase(name)); + MethodTypeDesc readerMtd = MethodTypeDesc.of(fieldDesc); + classBuilder.withMethodBody(readerName, readerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + cod.getfield(entityDesc, name, fieldDesc); + cod.return_(kind); + }); + + // __writeXxx(fieldType): void + String writerName = "__write%s".formatted(Critter.titleCase(name)); + MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), fieldDesc); + classBuilder.withMethodBody(writerName, writerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + cod.loadLocal(kind, 1); + cod.putfield(entityDesc, name, fieldDesc); + cod.return_(); + }); + } + })); - private void reader(String field, Type fieldType) { - String name = "__read%s".formatted(Critter.titleCase(field)); - var mv = classWriter.visitMethod( - ACC_PUBLIC | ACC_SYNTHETIC, - name, - "()%s".formatted(fieldType.getDescriptor()), - null, - null); - mv.visitCode(); - Label label0 = new Label(); - mv.visitLabel(label0); - mv.visitLineNumber(14, label0); - mv.visitVarInsn(ALOAD, 0); - mv.visitFieldInsn(GETFIELD, entityType.getInternalName(), field, fieldType.getDescriptor()); - mv.visitInsn(fieldType.getOpcode(IRETURN)); - Label label1 = new Label(); - mv.visitLabel(label1); - mv.visitLocalVariable("this", entityType.getDescriptor(), null, label0, label1, 0); - mv.visitMaxs(1, 1); - mv.visitEnd(); + return ClassFile.of().transformClass(model, transform); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java index b62d33fa7ed..43b2c728666 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java @@ -1,144 +1,101 @@ package dev.morphia.critter.parser.asm; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; import java.util.List; import dev.morphia.critter.Critter; import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.MethodInfo; -import org.objectweb.asm.Label; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.MethodNode; - -import static org.objectweb.asm.Opcodes.ACC_PUBLIC; -import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC; -import static org.objectweb.asm.Opcodes.ALOAD; -import static org.objectweb.asm.Opcodes.ATHROW; -import static org.objectweb.asm.Opcodes.DUP; -import static org.objectweb.asm.Opcodes.ILOAD; -import static org.objectweb.asm.Opcodes.INVOKESPECIAL; -import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL; -import static org.objectweb.asm.Opcodes.IRETURN; -import static org.objectweb.asm.Opcodes.NEW; -import static org.objectweb.asm.Opcodes.RETURN; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; +import io.github.dmlloyd.classfile.ClassTransform; +import io.github.dmlloyd.classfile.MethodModel; +import io.github.dmlloyd.classfile.TypeKind; /** * Generates synthetic {@code __readXxx} and {@code __writeXxx} accessor methods into an entity class * bytecode for properties backed by getter/setter methods rather than direct fields. */ public class AddMethodAccessorMethods extends BaseGenerator { - private final Class entity; - private final List methods; + private final List methods; /** * Creates a generator that will add accessor methods for the given getter methods to the entity class. - * - * @param entity the entity class to augment - * @param methods the getter methods for which accessor methods should be generated */ - public AddMethodAccessorMethods(Class entity, List methods) { + public AddMethodAccessorMethods(Class entity, List methods) { super(entity); - this.entity = entity; this.methods = methods; - readClassFiltering(entity); } @Override public byte[] emit() { - for (MethodNode method : methods) { - String propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - Type returnType = Type.getReturnType(method.desc); - reader(propertyName, returnType, method.name); - writer(propertyName, returnType); - } - classWriter.visitEnd(); - return classWriter.toByteArray(); - } + ClassModel model = readClassFiltering(); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); - private void writer(String propertyName, Type propertyType) { - String setterName = "set%s".formatted(Critter.titleCase(propertyName)); - boolean hasSetter = false; - for (java.lang.reflect.Method m : entity.getMethods()) { - if (m.getName().equals(setterName) && m.getParameterCount() == 1) { - hasSetter = true; - break; - } - } + ClassTransform transform = ClassTransform.dropping( + element -> element instanceof MethodModel m + && (m.flags().flagsMask() & ClassFile.ACC_SYNTHETIC) != 0 + && (m.methodName().stringValue().startsWith("__read") + || m.methodName().stringValue().startsWith("__write"))) + .andThen(ClassTransform.endHandler(classBuilder -> { + for (MethodInfo method : methods) { + String propertyName = ExtensionFunctions.getterToPropertyName(method, entity); + MethodTypeDesc methodMtd = MethodTypeDesc.ofDescriptor(method.desc()); + ClassDesc returnDesc = methodMtd.returnType(); + TypeKind returnKind = TypeKind.fromDescriptor(returnDesc.descriptorString()); + String getterName = method.name(); + String setterName = "set%s".formatted(Critter.titleCase(propertyName)); - var mv = classWriter.visitMethod( - ACC_PUBLIC | ACC_SYNTHETIC, - "__write%s".formatted(Critter.titleCase(propertyName)), - "(%s)V".formatted(propertyType.getDescriptor()), - null, - null); - mv.visitCode(); - Label label0 = new Label(); - mv.visitLabel(label0); - mv.visitLineNumber(18, label0); + boolean hasSetter = false; + for (java.lang.reflect.Method m : entity.getMethods()) { + if (m.getName().equals(setterName) && m.getParameterCount() == 1) { + hasSetter = true; + break; + } + } - if (hasSetter) { - // Call the setter method - mv.visitVarInsn(ALOAD, 0); - mv.visitVarInsn(propertyType.getOpcode(ILOAD), 1); - mv.visitMethodInsn( - INVOKEVIRTUAL, - entityType.getInternalName(), - setterName, - "(%s)V".formatted(propertyType.getDescriptor()), - false); - Label label1 = new Label(); - mv.visitLabel(label1); - mv.visitLineNumber(19, label1); - mv.visitInsn(RETURN); - Label label2 = new Label(); - mv.visitLabel(label2); - mv.visitLocalVariable("this", entityType.getDescriptor(), null, label0, label2, 0); - mv.visitLocalVariable("value", propertyType.getDescriptor(), null, label0, label2, 1); - } else { - // Throw UnsupportedOperationException for read-only properties - mv.visitTypeInsn(NEW, "java/lang/UnsupportedOperationException"); - mv.visitInsn(DUP); - mv.visitLdcInsn("Property '%s' is read-only".formatted(propertyName)); - mv.visitMethodInsn( - INVOKESPECIAL, - "java/lang/UnsupportedOperationException", - "", - "(Ljava/lang/String;)V", - false); - mv.visitInsn(ATHROW); - Label label1 = new Label(); - mv.visitLabel(label1); - mv.visitLocalVariable("this", entityType.getDescriptor(), null, label0, label1, 0); - mv.visitLocalVariable("value", propertyType.getDescriptor(), null, label0, label1, 1); - } + // __readXxx(): return type of getter + String readerName = "__read%s".formatted(Critter.titleCase(propertyName)); + MethodTypeDesc readerMtd = MethodTypeDesc.of(returnDesc); + classBuilder.withMethodBody(readerName, readerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + cod.invokevirtual(entityDesc, getterName, MethodTypeDesc.of(returnDesc)); + cod.return_(returnKind); + }); - mv.visitMaxs(2, 2); - mv.visitEnd(); - } + // __writeXxx(T): void + String writerName = "__write%s".formatted(Critter.titleCase(propertyName)); + MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), returnDesc); + if (hasSetter) { + classBuilder.withMethodBody(writerName, writerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + cod.loadLocal(returnKind, 1); + cod.invokevirtual(entityDesc, setterName, + MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), returnDesc)); + cod.return_(); + }); + } else { + classBuilder.withMethodBody(writerName, writerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + ClassDesc uoeDesc = ClassDesc.of("java.lang.UnsupportedOperationException"); + cod.new_(uoeDesc); + cod.dup(); + cod.ldc("Property '%s' is read-only".formatted(propertyName)); + cod.invokespecial(uoeDesc, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + cod.athrow(); + }); + } + } + })); - private void reader(String propertyName, Type returnType, String getterName) { - String name = "__read%s".formatted(Critter.titleCase(propertyName)); - var mv = classWriter.visitMethod( - ACC_PUBLIC | ACC_SYNTHETIC, - name, - "()%s".formatted(returnType.getDescriptor()), - null, - null); - mv.visitCode(); - Label label0 = new Label(); - mv.visitLabel(label0); - mv.visitLineNumber(14, label0); - mv.visitVarInsn(ALOAD, 0); - mv.visitMethodInsn( - INVOKEVIRTUAL, - entityType.getInternalName(), - getterName, - "()%s".formatted(returnType.getDescriptor()), - false); - mv.visitInsn(returnType.getOpcode(IRETURN)); - Label label1 = new Label(); - mv.visitLabel(label1); - mv.visitLocalVariable("this", entityType.getDescriptor(), null, label0, label1, 0); - mv.visitMaxs(1, 1); - mv.visitEnd(); + return ClassFile.of().transformClass(model, transform); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java b/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java index 846241ffc8f..37fb16e4939 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java @@ -1,151 +1,45 @@ package dev.morphia.critter.parser.asm; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.ClassVisitor; -import org.objectweb.asm.ClassWriter; -import org.objectweb.asm.Label; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.Type; +import java.io.IOException; +import java.io.InputStream; -import static org.objectweb.asm.ClassWriter.COMPUTE_FRAMES; -import static org.objectweb.asm.ClassWriter.COMPUTE_MAXS; -import static org.objectweb.asm.Opcodes.ACC_PUBLIC; -import static org.objectweb.asm.Opcodes.ACC_SUPER; -import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC; -import static org.objectweb.asm.Opcodes.ASM9; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; /** - * Base class for ASM-based bytecode generators used by Critter to produce accessor and model classes. + * Base class for bytecode generators that read and transform existing class files. */ public abstract class BaseGenerator { - /** The ASM {@link ClassWriter} used to assemble the generated class bytecode. */ - protected ClassWriter classWriter = new ClassWriter(COMPUTE_MAXS | COMPUTE_FRAMES); - /** The ASM type of the entity class being processed. */ - protected final Type entityType; - /** The current source line number used when emitting debug line-number instructions. */ - protected int lineNumber = 0; - /** The ASM type of the class being generated. */ - protected Type generatedType; + /** The entity class whose bytecode will be augmented. */ + protected final Class entity; /** * Creates a new generator for the given entity class. - * - * @param entity the entity class whose bytecode will be augmented or used as a template */ protected BaseGenerator(Class entity) { - this.entityType = Type.getType(entity); + this.entity = entity; } /** * Emits the generated or augmented class bytecode. - * - * @return the bytecode of the generated class */ public abstract byte[] emit(); /** - * 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); if (inputStream == null) { throw new IllegalArgumentException("Could not find class file for %s".formatted(entity.getName())); } - ClassVisitor filteringVisitor = new ClassVisitor(ASM9, classWriter) { - @Override - public MethodVisitor visitMethod(int access, String name, String descriptor, - String signature, String[] exceptions) { - if ((access & ACC_SYNTHETIC) != 0 - && (name.startsWith("__read") || name.startsWith("__write"))) { - return null; - } - return super.visitMethod(access, name, descriptor, signature, exceptions); - } - }; try { - new ClassReader(inputStream).accept(filteringVisitor, 0); - } catch (java.io.IOException e) { + 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); } } - - /** - * Visits a method on the class writer and records the starting line number. - * - * @param access the method access flags - * @param name the method name - * @param descriptor the method descriptor - * @param signature the generic signature, or {@code null} - * @param exceptions the internal names of thrown exception types, or {@code null} - * @param lineNumber the source line number to associate with this method - * @return the {@link MethodVisitor} for the new method - */ - protected MethodVisitor method(int access, String name, String descriptor, String signature, - String[] exceptions, int lineNumber) { - this.lineNumber = lineNumber; - return classWriter.visitMethod(access, name, descriptor, signature, exceptions); - } - - /** - * Creates a new label, visits it, and emits a line-number debug instruction. - * - * @param mv the method visitor - * @param lineNumber the line number to associate with the label - * @return the created label - */ - protected Label label(MethodVisitor mv, int lineNumber) { - return label(mv, lineNumber, true); - } - - /** - * Creates a new label, visits it, and emits a line-number debug instruction using the current line number. - * - * @param mv the method visitor - * @return the created label - */ - protected Label label(MethodVisitor mv) { - return label(mv, this.lineNumber, true); - } - - /** - * Creates a new label and optionally visits it with a line-number debug instruction. - * - * @param mv the method visitor - * @param lineNumber the line number to associate with the label - * @param visit {@code true} to visit the label and emit the line number instruction - * @return the created label - */ - protected Label label(MethodVisitor mv, int lineNumber, boolean visit) { - Label label0 = new Label(); - if (visit) { - mv.visitLabel(label0); - mv.visitLineNumber(lineNumber, label0); - this.lineNumber = lineNumber + 1; - } - return label0; - } - - /** - * Creates a new label without visiting it on the method visitor. - * - * @param mv the method visitor (unused; present for API consistency) - * @return the created label - */ - protected Label labelNoVisit(MethodVisitor mv) { - Label label0 = new Label(); - return label0; - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java index 7013cf679da..5cf4f496013 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java @@ -3,10 +3,8 @@ import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; -import io.quarkus.gizmo.ClassCreator; - /** - * Base class for Gizmo-based code generators that produce Critter accessor and model classes. + * Base class for ClassFile-based code generators that produce Critter accessor and model classes. */ public abstract class BaseGizmoGenerator { /** The entity class for which code is being generated. */ @@ -15,48 +13,12 @@ public abstract class BaseGizmoGenerator { protected final CritterClassLoader critterClassLoader; /** The fully-qualified name of the class being generated. */ protected String generatedType; - /** The base package name derived from the entity, used to namespace generated types. */ protected final String baseName; - private ClassCreator.Builder builder; - private ClassCreator creator; - - /** - * Creates a new generator for the given entity using the supplied class loader. - * - * @param entity the entity class for which code will be generated - * @param critterClassLoader the class loader that will receive generated bytecode - */ protected BaseGizmoGenerator(Class entity, CritterClassLoader critterClassLoader) { this.entity = entity; this.critterClassLoader = critterClassLoader; this.baseName = Critter.critterPackage(entity); } - - /** - * Returns the lazily created {@link ClassCreator.Builder} configured to output bytecode via the class loader. - * - * @return the class creator builder - */ - protected ClassCreator.Builder getBuilder() { - if (builder == null) { - builder = ClassCreator.builder() - .classOutput((name, data) -> critterClassLoader.register(name.replace('/', '.'), data)) - .className(generatedType); - } - return builder; - } - - /** - * Returns the lazily created {@link ClassCreator}, building it from the builder on first access. - * - * @return the class creator - */ - protected ClassCreator getCreator() { - if (creator == null) { - creator = getBuilder().build(); - } - return creator; - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java index e850c976859..89deb1d8113 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java @@ -1,21 +1,22 @@ package dev.morphia.critter.parser.gizmo; import java.io.IOException; +import java.io.InputStream; import java.util.List; import dev.morphia.critter.CritterClassLoader; +import dev.morphia.critter.parser.FieldInfo; +import dev.morphia.critter.parser.MethodInfo; import dev.morphia.critter.parser.PropertyFinder; import dev.morphia.critter.parser.asm.AddFieldAccessorMethods; import dev.morphia.critter.parser.asm.AddMethodAccessorMethods; import dev.morphia.mapping.Mapper; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.tree.ClassNode; -import org.objectweb.asm.tree.FieldNode; -import org.objectweb.asm.tree.MethodNode; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; /** - * Facade that orchestrates the full Gizmo-based code generation pipeline for a Morphia entity, + * Facade that orchestrates the full ClassFile-based code generation pipeline for a Morphia entity, * including field/method accessor injection and property/entity model generation. */ public class CritterGizmoGenerator { @@ -39,20 +40,20 @@ public CritterGizmoGenerator(Mapper mapper) { * @return the generated entity model generator */ 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) { throw new RuntimeException("Failed to read class %s".formatted(type.getName()), e); } PropertyFinder propertyFinder = new PropertyFinder(mapper, critterClassLoader, runtimeMode); - return entityModel(type, critterClassLoader, classNode, propertyFinder.find(type, classNode)); + return entityModel(type, critterClassLoader, classModel, propertyFinder.find(type, classModel)); } /** @@ -74,7 +75,7 @@ public GizmoEntityModelGenerator generate(Class type, CritterClassLoader crit * @param fields the fields for which accessor methods should be generated * @return the augmented class bytecode */ - public byte[] fieldAccessors(Class entityType, List fields) { + public byte[] fieldAccessors(Class entityType, List fields) { return new AddFieldAccessorMethods(entityType, fields).emit(); } @@ -85,7 +86,7 @@ public byte[] fieldAccessors(Class entityType, List fields) { * @param methods the getter methods for which accessor methods should be generated * @return the augmented class bytecode */ - public byte[] methodAccessors(Class entityType, List methods) { + public byte[] methodAccessors(Class entityType, List methods) { return new AddMethodAccessorMethods(entityType, methods).emit(); } @@ -97,7 +98,7 @@ public byte[] methodAccessors(Class entityType, List methods) { * @param field the field for which a property accessor should be generated * @return the emitted property accessor generator */ - public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { + public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { return new PropertyAccessorGenerator(entityType, critterClassLoader, field).emit(); } @@ -109,7 +110,7 @@ public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterCl * @param method the getter method for which a property accessor should be generated * @return the emitted property accessor generator */ - public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { + public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { return new PropertyAccessorGenerator(entityType, critterClassLoader, method).emit(); } @@ -121,7 +122,7 @@ public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterCl * @param field the field for which a VarHandle accessor should be generated * @return the emitted VarHandle accessor generator */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { + public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { return new VarHandleAccessorGenerator(entityType, critterClassLoader, field).emit(); } @@ -133,7 +134,7 @@ public VarHandleAccessorGenerator varHandleAccessor(Class entityType, Critter * @param method the getter method for which a VarHandle accessor should be generated * @return the emitted VarHandle accessor generator */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { + public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { return new VarHandleAccessorGenerator(entityType, critterClassLoader, method).emit(); } @@ -145,7 +146,7 @@ public VarHandleAccessorGenerator varHandleAccessor(Class entityType, Critter * @param field the field for which a property model should be generated * @return the emitted property model generator */ - public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { + public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, field).emit(); } @@ -157,7 +158,7 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte * @param method the getter method for which a property model should be generated * @return the emitted property model generator */ - public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { + public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, method).emit(); } @@ -166,12 +167,12 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte * * @param type the entity class * @param critterClassLoader the class loader that will receive the generated bytecode - * @param classNode the ASM class node for the entity + * @param classModel the ClassModel for the entity * @param properties the list of property model generators for the entity's properties * @return the emitted entity model generator */ public GizmoEntityModelGenerator entityModel(Class type, CritterClassLoader critterClassLoader, - ClassNode classNode, List properties) { - return new GizmoEntityModelGenerator(mapper, type, critterClassLoader, classNode, properties).emit(); + ClassModel classModel, List properties) { + return new GizmoEntityModelGenerator(mapper, type, critterClassLoader, properties).emit(); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java index 1e1fd913287..03e1a3ece15 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java @@ -1,42 +1,31 @@ package dev.morphia.critter.parser.gizmo; -import java.io.IOException; -import java.io.InputStream; import java.lang.annotation.Annotation; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import dev.morphia.annotations.Entity; -import dev.morphia.annotations.internal.AnnotationNodeExtensions; import dev.morphia.critter.CritterClassLoader; import dev.morphia.mapping.Mapper; -import dev.morphia.mapping.codec.pojo.EntityModel; import dev.morphia.mapping.codec.pojo.PropertyModel; import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.tree.AnnotationNode; -import org.objectweb.asm.tree.ClassNode; - -import io.quarkus.gizmo.MethodCreator; -import io.quarkus.gizmo.MethodDescriptor; - -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.TypeKind; /** - * Generates a Gizmo-based {@code CritterEntityModel} implementation for a Morphia entity class. + * Generates a ClassFile-based {@code CritterEntityModel} implementation for a Morphia entity class. */ public class GizmoEntityModelGenerator extends BaseGizmoGenerator { private final Mapper mapper; - private final ClassNode classNode; private final List properties; - private final List annotations; - private final List morphiaAnnotations; private final Entity entityAnnotation; + private final List morphiaAnnotations; /** * Creates a new entity model generator for the given entity. @@ -44,19 +33,15 @@ public class GizmoEntityModelGenerator extends BaseGizmoGenerator { * @param mapper the Morphia mapper * @param type the entity class annotated with {@code @Entity} * @param critterClassLoader the class loader that will receive the generated bytecode - * @param classNode the ASM class node for the entity * @param properties the property model generators for each of the entity's properties * @throws IllegalStateException if the entity class does not have an {@code @Entity} annotation */ public GizmoEntityModelGenerator(Mapper mapper, Class type, CritterClassLoader critterClassLoader, - ClassNode classNode, List properties) { + List properties) { super(type, critterClassLoader); this.mapper = mapper; - this.classNode = classNode; this.properties = properties; - generatedType = "%s.%sEntityModel".formatted(baseName, type.getSimpleName()); - this.annotations = classNode.visibleAnnotations != null ? classNode.visibleAnnotations : Collections.emptyList(); Entity ann = type.getAnnotation(Entity.class); if (ann == null) { @@ -64,20 +49,24 @@ public GizmoEntityModelGenerator(Mapper mapper, Class type, CritterClassLoade } this.entityAnnotation = ann; - this.morphiaAnnotations = new ArrayList<>(); - for (AnnotationNode a : annotations) { - if (a.desc.startsWith("Ldev/morphia/annotations/")) { - morphiaAnnotations.add((Annotation) AnnotationNodeExtensions.INSTANCE.toMorphiaAnnotation(a)); + // Collect morphia annotations from this class and ancestors (de-duplicated) + Set registered = new LinkedHashSet<>(); + List allAnnotations = new java.util.ArrayList<>(); + Class current = type; + while (current != null && current != Object.class) { + for (Annotation a : current.getAnnotations()) { + if (a.annotationType().getName().startsWith("dev.morphia.annotations.") + && registered.add(a.annotationType().getName())) { + allAnnotations.add(a); + } } + current = current.getSuperclass(); } + this.morphiaAnnotations = List.copyOf(allAnnotations); } /** * Returns the annotation of the given type from the entity class, or {@code null} if not present. - * - * @param the annotation type - * @param type the annotation class to look up - * @return the annotation instance, or {@code null} */ @SuppressWarnings("unchecked") public T annotation(Class type) { @@ -86,8 +75,6 @@ public T annotation(Class type) { /** * Returns the fully-qualified name of the generated entity model class. - * - * @return the generated class name */ public String getGeneratedType() { return generatedType; @@ -95,147 +82,128 @@ public String getGeneratedType() { /** * Emits the generated entity model class and returns this generator. - * - * @return this generator after emitting */ public GizmoEntityModelGenerator emit() { - getBuilder().superClass(CritterEntityModel.class); - - try (var creator = getCreator()) { - ctor(); - collectionName(); - discriminator(); - discriminatorKey(); - isAbstract(); - isInterface(); - useDiscriminator(); - } - - return this; - } - - private void useDiscriminator() { - try (MethodCreator mc = getCreator().getMethodCreator("useDiscriminator", boolean.class)) { - mc.returnValue(mc.load(entityAnnotation.useDiscriminator())); - } - } - - private void isInterface() { - try (MethodCreator mc = getCreator().getMethodCreator("isInterface", boolean.class)) { - mc.returnValue(mc.load(entity.isInterface())); - } - } - - private void isAbstract() { - try (MethodCreator mc = getCreator().getMethodCreator("isAbstract", boolean.class)) { - mc.returnValue(mc.load(Modifier.isAbstract(entity.getModifiers()))); - } - } + ClassDesc thisDesc = ClassDesc.of(generatedType); + ClassDesc superDesc = ClassDesc.of(CritterEntityModel.class.getName()); + ClassDesc mapperDesc = ClassDesc.of(Mapper.class.getName()); + ClassDesc entityModelDesc = ClassDesc.of(dev.morphia.mapping.codec.pojo.EntityModel.class.getName()); + ClassDesc propertyModelDesc = ClassDesc.of(PropertyModel.class.getName()); + ClassDesc annotationDesc = ClassDesc.of(Annotation.class.getName()); + + String collectionNameStr = computeCollectionName(); + String discriminatorStr = computeDiscriminator(); + String discriminatorKeyStr = computeDiscriminatorKey(); + boolean isAbstractFlag = Modifier.isAbstract(entity.getModifiers()); + boolean isInterfaceFlag = entity.isInterface(); + boolean useDiscriminatorFlag = entityAnnotation.useDiscriminator(); + + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(superDesc); + + // Constructor: (Mapper) -> void + cb.withMethodBody("", MethodTypeDesc.of(ConstantDescs.CD_void, mapperDesc), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.aload(1); + GizmoExtensions.emitClassRef(cod, entity); + cod.invokespecial(superDesc, "", + MethodTypeDesc.of(ConstantDescs.CD_void, mapperDesc, ConstantDescs.CD_Class)); + + // setType(entityClass) + cod.aload(0); + GizmoExtensions.emitClassRef(cod, entity); + cod.invokevirtual(thisDesc, "setType", + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_Class)); + + // Add property models + for (PropertyModelGenerator prop : properties) { + ClassDesc propDesc = ClassDesc.of(prop.generatedType); + cod.aload(0); + cod.new_(propDesc); + cod.dup(); + cod.aload(0); + cod.invokespecial(propDesc, "", + MethodTypeDesc.of(ConstantDescs.CD_void, entityModelDesc)); + cod.invokevirtual(entityModelDesc, "addProperty", + MethodTypeDesc.of(ConstantDescs.CD_boolean, propertyModelDesc)); + cod.pop(); + } - private void discriminatorKey() { - try (MethodCreator mc = getCreator().getMethodCreator("discriminatorKey", String.class)) { - String key = entityAnnotation.discriminatorKey(); - String result = Mapper.IGNORED_FIELDNAME.equals(key) - ? mapper.getConfig().discriminatorKey() - : key; - mc.returnValue(mc.load(result)); - } - } + // Register morphia annotations + for (Annotation ann : morphiaAnnotations) { + cod.aload(0); + GizmoExtensions.emitAnnotationOnStack(cod, ann); + cod.invokevirtual(entityModelDesc, "annotation", + MethodTypeDesc.of(ConstantDescs.CD_void, annotationDesc)); + } - private void discriminator() { - try (MethodCreator mc = getCreator().getMethodCreator("discriminator", String.class)) { - String discriminator = mapper.getConfig().discriminator() - .apply(entity, entityAnnotation.discriminator()); - mc.returnValue(mc.load(discriminator)); - } + // configureProperties(mapper) + cod.aload(0); + cod.aload(1); + cod.invokevirtual(thisDesc, "configureProperties", + MethodTypeDesc.of(ConstantDescs.CD_void, mapperDesc)); + + cod.return_(); + }); + + // collectionName(): String + cb.withMethodBody("collectionName", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc(collectionNameStr); + cod.areturn(); + }); + + // discriminator(): String + cb.withMethodBody("discriminator", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc(discriminatorStr); + cod.areturn(); + }); + + // discriminatorKey(): String + cb.withMethodBody("discriminatorKey", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc(discriminatorKeyStr); + cod.areturn(); + }); + + // isAbstract(): boolean + emitBooleanMethod(cb, "isAbstract", isAbstractFlag); + // isInterface(): boolean + emitBooleanMethod(cb, "isInterface", isInterfaceFlag); + // useDiscriminator(): boolean + emitBooleanMethod(cb, "useDiscriminator", useDiscriminatorFlag); + }); + + critterClassLoader.register(generatedType, bytes); + return this; } - private void collectionName() { - try (MethodCreator mc = getCreator().getMethodCreator("collectionName", String.class)) { - String key = entityAnnotation.value(); - String result = Mapper.IGNORED_FIELDNAME.equals(key) - ? mapper.getConfig().collectionNaming().apply(entity.getSimpleName()) - : key; - mc.returnValue(mc.load(result)); - } + private String computeCollectionName() { + String key = entityAnnotation.value(); + return Mapper.IGNORED_FIELDNAME.equals(key) + ? mapper.getConfig().collectionNaming().apply(entity.getSimpleName()) + : key; } - private void ctor() { - try (MethodCreator constructor = getCreator().getConstructorCreator(Mapper.class)) { - constructor.invokeSpecialMethod( - MethodDescriptor.ofConstructor(CritterEntityModel.class, Mapper.class, Class.class), - constructor.getThis(), - constructor.getMethodParam(0), - GizmoExtensions.emitClassRef(constructor, entity)); - constructor.setParameterNames(new String[] { "mapper" }); - - constructor.invokeVirtualMethod( - ofMethod(generatedType, "setType", "void", Class.class), - constructor.getThis(), - GizmoExtensions.emitClassRef(constructor, entity)); - loadProperties(constructor); - registerAnnotations(constructor); - constructor.invokeVirtualMethod( - ofMethod(generatedType, "configureProperties", "void", Mapper.class), - constructor.getThis(), - constructor.getMethodParam(0)); - constructor.returnVoid(); - } + private String computeDiscriminator() { + return mapper.getConfig().discriminator().apply(entity, entityAnnotation.discriminator()); } - private void loadProperties(MethodCreator creator) { - MethodDescriptor addProperty = ofMethod(generatedType, "addProperty", "boolean", PropertyModel.class); - for (PropertyModelGenerator property : properties) { - MethodDescriptor modelCtor = MethodDescriptor.ofConstructor(property.generatedType, EntityModel.class); - var model = creator.newInstance(modelCtor, creator.getThis()); - creator.invokeVirtualMethod(addProperty, creator.getThis(), model); - } - } - - private void registerAnnotations(MethodCreator constructor) { - MethodDescriptor annotationMethod = ofMethod(generatedType, "annotation", "void", Annotation.class); - Set registered = new LinkedHashSet<>(); - - // Register annotations directly on this class first - for (AnnotationNode annotation : annotations) { - if (isMorphiaAnnotation(annotation) && registered.add(annotation.desc)) { - constructor.invokeVirtualMethod( - annotationMethod, - constructor.getThis(), - GizmoExtensions.annotationBuilder(annotation, constructor)); - } - } - - // Walk parent class hierarchy to register inherited annotations not on this class - Class parent = entity.getSuperclass(); - while (parent != null && !parent.equals(Object.class)) { - String resourceName = "%s.class".formatted(parent.getName().replace('.', '/')); - InputStream inputStream = parent.getClassLoader() != null - ? parent.getClassLoader().getResourceAsStream(resourceName) - : ClassLoader.getSystemResourceAsStream(resourceName); - if (inputStream != null) { - try { - ClassNode parentNode = new ClassNode(); - new ClassReader(inputStream).accept(parentNode, 0); - if (parentNode.visibleAnnotations != null) { - for (AnnotationNode annotation : parentNode.visibleAnnotations) { - if (isMorphiaAnnotation(annotation) && registered.add(annotation.desc)) { - constructor.invokeVirtualMethod( - annotationMethod, - constructor.getThis(), - GizmoExtensions.annotationBuilder(annotation, constructor)); - } - } - } - } catch (IOException e) { - // skip if parent class bytecode can't be read - } - } - parent = parent.getSuperclass(); - } + private String computeDiscriminatorKey() { + String key = entityAnnotation.discriminatorKey(); + return Mapper.IGNORED_FIELDNAME.equals(key) + ? mapper.getConfig().discriminatorKey() + : key; } - private static boolean isMorphiaAnnotation(AnnotationNode annotation) { - return annotation.desc.startsWith("Ldev/morphia/annotations/"); + private static void emitBooleanMethod(io.github.dmlloyd.classfile.ClassBuilder cb, String name, boolean value) { + cb.withMethodBody(name, MethodTypeDesc.ofDescriptor("()Z"), ClassFile.ACC_PUBLIC, cod -> { + cod.loadConstant(value ? 1 : 0); + cod.return_(TypeKind.INT); + }); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java index 0bf5742ebb6..99203766f9a 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java @@ -1,273 +1,286 @@ package dev.morphia.critter.parser.gizmo; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; -import java.util.ArrayList; import java.util.List; -import dev.morphia.critter.parser.ExtensionFunctions; import dev.morphia.mapping.codec.pojo.TypeData; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.AnnotationNode; - -import io.quarkus.gizmo.BytecodeCreator; -import io.quarkus.gizmo.FieldDescriptor; -import io.quarkus.gizmo.MethodCreator; -import io.quarkus.gizmo.MethodDescriptor; -import io.quarkus.gizmo.ResultHandle; - -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; -import static org.objectweb.asm.Type.ARRAY; +import io.github.dmlloyd.classfile.CodeBuilder; +import io.github.dmlloyd.classfile.TypeKind; /** - * Static utility methods that bridge ASM annotation nodes and Morphia type data with the Gizmo bytecode generation API. + * Static utility methods bridging annotation introspection and Morphia type data with the ClassFile API. */ public class GizmoExtensions { - /** @hidden */ private GizmoExtensions() { } /** - * Emits Gizmo bytecode that instantiates the Morphia annotation represented by the given ASM annotation node. - * - * @param annotationNode the ASM annotation node to convert - * @param creator the Gizmo method creator in which the bytecode is emitted - * @return a result handle for the constructed annotation instance + * Emits bytecode that leaves the given annotation instance on the stack. + * Uses the annotation builder pattern: XxxBuilder.xxxBuilder().field1(v1).build(). */ - public static ResultHandle annotationBuilder(AnnotationNode annotationNode, MethodCreator creator) { - Type type = Type.getType(annotationNode.desc); - String classPackage = type.getClassName().substring(0, type.getClassName().lastIndexOf('.')); - String className = type.getClassName().substring(type.getClassName().lastIndexOf('.') + 1); - Type builderType = Type.getType("L%s.internal.%sBuilder;".formatted(classPackage, className).replace('.', '/')); - MethodDescriptor builder = ofMethod( - builderType.getClassName(), - ExtensionFunctions.methodCase(className) + "Builder", - builderType.getClassName()); - ResultHandle local = creator.invokeStaticMethod(builder); + public static void emitAnnotationOnStack(CodeBuilder cod, java.lang.annotation.Annotation annotation) { + Class annType = annotation.annotationType(); + String className = annType.getName(); + String classPackage = className.substring(0, className.lastIndexOf('.')); + String simpleName = className.substring(className.lastIndexOf('.') + 1); + String builderClassName = classPackage + ".internal." + simpleName + "Builder"; + ClassDesc builderDesc = ClassDesc.of(builderClassName); + ClassDesc annDesc = ClassDesc.of(className); + String factoryMethod = Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1) + "Builder"; - setBuilderValues(annotationNode, creator, local); - - return creator.invokeVirtualMethod(ofMethod(builderType.getClassName(), "build", type.getClassName()), local); - } + // Call builder factory: XxxBuilder.xxxBuilder() + cod.invokestatic(builderDesc, factoryMethod, MethodTypeDesc.of(builderDesc)); + int builderSlot = cod.allocateLocal(TypeKind.REFERENCE); + cod.astore(builderSlot); - /** - * Resolves an ASM {@link Type} to a {@link Class} using the given class loader. - * - * @param type the ASM type to resolve - * @param classLoader the class loader used to locate the class - * @return the corresponding Java class - * @throws RuntimeException if the class cannot be found - */ - public static Class asClass(Type type, ClassLoader classLoader) { - return switch (type.getSort()) { - case Type.VOID -> void.class; - case Type.BOOLEAN -> boolean.class; - case Type.CHAR -> char.class; - case Type.BYTE -> byte.class; - case Type.SHORT -> short.class; - case Type.INT -> int.class; - case Type.FLOAT -> float.class; - case Type.LONG -> long.class; - case Type.DOUBLE -> double.class; - default -> { - String className = type.getSort() == ARRAY - ? type.getDescriptor().replace('/', '.') - : type.getClassName(); - try { - yield Class.forName(className, false, classLoader); - } catch (ClassNotFoundException e) { - throw new RuntimeException("Could not find class: %s".formatted(className), e); + // For each annotation element that has a non-default value, emit setter call + for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { + try { + Object value = method.invoke(annotation); + Object defaultValue = method.getDefaultValue(); + if (value == null || value.equals(defaultValue)) { + continue; } + java.lang.reflect.Type elemType = method.getGenericReturnType(); + ClassDesc paramDesc = rawTypeDesc(elemType); + + cod.aload(builderSlot); + emitAnnotationElementValue(cod, elemType, value); + cod.invokevirtual(builderDesc, method.getName(), MethodTypeDesc.of(builderDesc, paramDesc)); + cod.astore(builderSlot); + } catch (Exception e) { + throw new RuntimeException("Failed to emit annotation element " + method.getName(), e); } - }; - } + } - /** - * Populates the annotation builder with values from the ASM annotation node. - * - * @param annotationNode the ASM annotation node whose values should be applied - * @param creator the Gizmo method creator in which the bytecode is emitted - * @param local the result handle referencing the annotation builder instance - */ - public static void setBuilderValues(AnnotationNode annotationNode, MethodCreator creator, ResultHandle local) { - dev.morphia.annotations.internal.AnnotationNodeExtensions.INSTANCE.setBuilderValues(annotationNode, creator, local); + // Call .build() + cod.aload(builderSlot); + cod.invokevirtual(builderDesc, "build", MethodTypeDesc.of(annDesc)); } - /** - * Emits Gizmo bytecode that loads a {@link Class} reference. Non-public classes (e.g. private - * inner classes) cannot be referenced via an LDC constant from a different classloader, so this - * generates a {@code Class.forName()} call in that case. - * - * @param creator the Gizmo bytecode creator in which the bytecode is emitted - * @param cls the class to load - * @return a result handle for the class reference - */ - public static ResultHandle emitClassRef(BytecodeCreator creator, Class cls) { - if (java.lang.reflect.Modifier.isPublic(cls.getModifiers())) { - return creator.loadClass(cls); + @SuppressWarnings("unchecked") + private static void emitAnnotationElementValue(CodeBuilder cod, java.lang.reflect.Type type, Object value) { + if (type == String.class) { + cod.ldc((String) value); + } else if (type == boolean.class || type == Boolean.class) { + cod.loadConstant(((Boolean) value) ? 1 : 0); + } else if (type == int.class || type == Integer.class) { + cod.loadConstant((int) value); + } else if (type == long.class || type == Long.class) { + cod.loadConstant((long) value); + } else if (type == float.class || type == Float.class) { + cod.loadConstant((float) value); + } else if (type == double.class || type == Double.class) { + cod.loadConstant((double) value); + } else if (type == Class.class) { + emitClassRef(cod, (Class) value); + } else if (type instanceof Class t && t.isEnum()) { + Enum e = (Enum) value; + ClassDesc enumDesc = ClassDesc.of(e.getDeclaringClass().getName()); + cod.getstatic(enumDesc, e.name(), enumDesc); + } else if (type instanceof Class t && t.isAnnotation()) { + emitAnnotationOnStack(cod, (java.lang.annotation.Annotation) value); + } else if (type instanceof Class t && t.isArray()) { + Class componentType = t.getComponentType(); + Object[] arr = (Object[]) value; + emitObjectArray(cod, componentType, arr); + } else if (type instanceof ParameterizedType pt && pt.getRawType() == Class.class) { + emitClassRef(cod, (Class) value); + } else if (type instanceof GenericArrayType gat) { + java.lang.reflect.Type compType = gat.getGenericComponentType(); + Class compClass = (compType instanceof ParameterizedType pt) + ? (Class) pt.getRawType() + : (Class) compType; + Object[] arr = (Object[]) value; + emitObjectArray(cod, compClass, arr); + } else { + throw new UnsupportedOperationException("Unsupported annotation element type: " + type); } - ResultHandle name = creator.load(cls.getName()); - ResultHandle falseHandle = creator.load(false); - ResultHandle thread = creator.invokeStaticMethod( - ofMethod(Thread.class, "currentThread", Thread.class)); - ResultHandle tccl = creator.invokeVirtualMethod( - ofMethod(Thread.class, "getContextClassLoader", ClassLoader.class), - thread); - return creator.invokeStaticMethod( - ofMethod(Class.class, "forName", Class.class, String.class, boolean.class, ClassLoader.class), - name, falseHandle, tccl); } - /** - * Emits Gizmo bytecode that constructs a {@link TypeData} instance matching the given type data. - * - * @param data the type data to emit - * @param methodCreator the Gizmo method creator in which the bytecode is emitted - * @return a result handle for the constructed {@code TypeData} instance - */ - public static ResultHandle emitTypeData(TypeData data, MethodCreator methodCreator) { - ResultHandle array = methodCreator.newArray(TypeData.class, data.getTypeParameters().size()); - List> typeParameters = data.getTypeParameters(); - for (int index = 0; index < typeParameters.size(); index++) { - methodCreator.writeArrayValue(array, index, emitTypeData(typeParameters.get(index), methodCreator)); + @SuppressWarnings("unchecked") + private static void emitObjectArray(CodeBuilder cod, Class componentType, Object[] arr) { + cod.loadConstant(arr.length); + cod.anewarray(ClassDesc.of(componentType.getName())); + for (int i = 0; i < arr.length; i++) { + cod.dup(); + cod.loadConstant(i); + emitAnnotationElementValue(cod, componentType, arr[i]); + cod.aastore(); } - List list = new ArrayList<>(); - list.add(emitClassRef(methodCreator, data.getType())); - list.add(array); - MethodDescriptor descriptor = MethodDescriptor.ofConstructor( - TypeData.class, - Class.class, - "[" + Type.getType(TypeData.class).getDescriptor()); - return methodCreator.newInstance(descriptor, list.toArray(new ResultHandle[0])); } /** - * Returns the ASM type descriptor for the raw (erased) form of the given {@link java.lang.reflect.Type}. - * - * @param type the type to erase - * @return the ASM descriptor string for the raw type + * Emits bytecode that loads a Class reference. Non-public classes use Class.forName(). */ - public static String rawType(java.lang.reflect.Type type) { - if (type instanceof GenericArrayType arrayType) { - ParameterizedType type1 = (ParameterizedType) arrayType.getGenericComponentType(); - return Type.getType("[" + Type.getType((Class) type1.getRawType()).getDescriptor()).getDescriptor(); - } else if (type instanceof ParameterizedType paramType) { - return Type.getType((Class) paramType.getRawType()).getDescriptor(); + public static void emitClassRef(CodeBuilder cod, Class cls) { + if (Modifier.isPublic(cls.getModifiers())) { + cod.loadConstant(ClassDesc.of(cls.getName())); } else { - return Type.getType((Class) type).getDescriptor(); + cod.ldc(cls.getName()); + cod.iconst_0(); + cod.invokestatic( + ClassDesc.of("java.lang.Thread"), + "currentThread", + MethodTypeDesc.of(ClassDesc.of("java.lang.Thread"))); + cod.invokevirtual( + ClassDesc.of("java.lang.Thread"), + "getContextClassLoader", + MethodTypeDesc.of(ClassDesc.of("java.lang.ClassLoader"))); + cod.invokestatic( + ConstantDescs.CD_Class, + "forName", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;")); } } /** - * Returns the generic return type of the named annotation element method. - * - * @param type the annotation class containing the element - * @param name the name of the annotation element - * @return the generic return type of the element method - * @throws RuntimeException if the element method cannot be found + * Emits bytecode that constructs a TypeData instance. */ - public static java.lang.reflect.Type attributeType(Class type, String name) { - try { - return type.getDeclaredMethod(name).getGenericReturnType(); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Cannot find annotation element '%s' in %s".formatted(name, type.getName()), e); + public static void emitTypeData(TypeData data, CodeBuilder cod) { + ClassDesc tdDesc = ClassDesc.of("dev.morphia.mapping.codec.pojo.TypeData"); + cod.new_(tdDesc); + cod.dup(); + emitClassRef(cod, data.getType()); + List> params = data.getTypeParameters(); + cod.loadConstant(params.size()); + cod.anewarray(tdDesc); + for (int i = 0; i < params.size(); i++) { + cod.dup(); + cod.loadConstant(i); + emitTypeData(params.get(i), cod); + cod.aastore(); } + cod.invokespecial(tdDesc, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Class;[Ldev/morphia/mapping/codec/pojo/TypeData;)V")); } /** - * Emits Gizmo bytecode that loads the given value as the specified {@link java.lang.reflect.Type}. - * - * @param creator the Gizmo method creator in which the bytecode is emitted - * @param type the target type - * @param value the value to load - * @return a result handle for the loaded value + * Returns the raw type ClassDesc for use as a builder method parameter descriptor. */ - @SuppressWarnings("unchecked") - public static ResultHandle load(MethodCreator creator, java.lang.reflect.Type type, Object value) { - if (type instanceof Class classType) { - return load(creator, classType, value); - } else if (type instanceof ParameterizedType paramType) { - return load(creator, (Class) paramType.getRawType(), value); - } else if (type instanceof GenericArrayType arrayType) { - String componentTypeName; - if (arrayType.getGenericComponentType() instanceof ParameterizedType pType) { - componentTypeName = ((Class) pType.getRawType()).getName(); - } else { - componentTypeName = ((Class) arrayType.getGenericComponentType()).getName(); + public static ClassDesc rawTypeDesc(java.lang.reflect.Type type) { + if (type instanceof Class c) { + if (c.isPrimitive()) { + return primitiveDesc(c); } - List valueList = (List) value; - ResultHandle newArray = creator.newArray(componentTypeName, valueList.size()); - for (int index = 0; index < valueList.size(); index++) { - Object element = valueList.get(index); - creator.writeArrayValue(newArray, index, load(creator, element.getClass(), element)); + if (c.isArray()) { + return ClassDesc.ofDescriptor(classToDescriptor(c)); } - return newArray; + return ClassDesc.of(c.getName()); + } else if (type instanceof ParameterizedType pt) { + return ClassDesc.of(((Class) pt.getRawType()).getName()); + } else if (type instanceof GenericArrayType gat) { + java.lang.reflect.Type comp = gat.getGenericComponentType(); + Class rawComp = (comp instanceof ParameterizedType pt) ? (Class) pt.getRawType() : (Class) comp; + return ClassDesc.ofDescriptor("[L" + rawComp.getName().replace('.', '/') + ";"); } else { - throw new UnsupportedOperationException("Unknown type: %s".formatted(type)); + throw new UnsupportedOperationException("Unknown type: " + type); + } + } + + private static ClassDesc primitiveDesc(Class c) { + if (c == boolean.class) + return ConstantDescs.CD_boolean; + if (c == byte.class) + return ConstantDescs.CD_byte; + if (c == char.class) + return ConstantDescs.CD_char; + if (c == short.class) + return ConstantDescs.CD_short; + if (c == int.class) + return ConstantDescs.CD_int; + if (c == long.class) + return ConstantDescs.CD_long; + if (c == float.class) + return ConstantDescs.CD_float; + if (c == double.class) + return ConstantDescs.CD_double; + throw new IllegalArgumentException("Not a primitive: " + c); + } + + private static String classToDescriptor(Class c) { + if (c.isArray()) { + return "[" + classToDescriptor(c.getComponentType()); + } + if (c.isPrimitive()) { + if (c == boolean.class) + return "Z"; + if (c == byte.class) + return "B"; + if (c == char.class) + return "C"; + if (c == short.class) + return "S"; + if (c == int.class) + return "I"; + if (c == long.class) + return "J"; + if (c == float.class) + return "F"; + if (c == double.class) + return "D"; + if (c == void.class) + return "V"; } + return "L" + c.getName().replace('.', '/') + ";"; } /** - * Emits Gizmo bytecode that loads the given value as the specified class type. - * - * @param creator the Gizmo method creator in which the bytecode is emitted - * @param type the target class type - * @param value the value to load - * @return a result handle for the loaded value + * Resolves a ClassDesc to a Class using the given class loader. */ - @SuppressWarnings("unchecked") - public static ResultHandle load(MethodCreator creator, Class type, Object value) { - if (type == String.class) { - return creator.load((String) value); - } else if (type == int.class || type == Integer.class) { - return creator.load((int) value); - } else if (type == long.class || type == Long.class) { - return creator.load((long) value); - } else if (type == boolean.class || type == Boolean.class) { - return creator.load((boolean) value); - } else if (type == AnnotationNode.class) { - return annotationBuilder((AnnotationNode) value, creator); - } else if (type.isAnnotation()) { - return annotationBuilder((AnnotationNode) value, creator); - } else if (type.isArray()) { - List valueList = (List) value; - ResultHandle newArray = creator.newArray(type.getComponentType(), valueList.size()); - for (int index = 0; index < valueList.size(); index++) { - Object element = valueList.get(index); - creator.writeArrayValue(newArray, index, load(creator, element.getClass(), element)); - } - return newArray; - } else if (type.isEnum()) { - return creator.readStaticField(FieldDescriptor.of(type, ((String[]) value)[1], type)); - } else if (value instanceof Type asmType) { - return creator.loadClass(asmType.getClassName()); - } else { - throw new UnsupportedOperationException("%s is not yet supported".formatted(type)); + public static Class asClass(ClassDesc cd, ClassLoader classLoader) { + String desc = cd.descriptorString(); + if (desc.length() == 1) { + return switch (desc.charAt(0)) { + case 'V' -> void.class; + case 'Z' -> boolean.class; + case 'C' -> char.class; + case 'B' -> byte.class; + case 'S' -> short.class; + case 'I' -> int.class; + case 'J' -> long.class; + case 'F' -> float.class; + case 'D' -> double.class; + default -> throw new IllegalArgumentException("Unknown descriptor: " + desc); + }; + } + String className = desc.startsWith("[") ? desc.replace('/', '.') : desc.substring(1, desc.length() - 1).replace('/', '.'); + try { + return Class.forName(className, false, classLoader); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Could not find class: " + className, e); + } + } + + /** + * Returns the generic return type of the named annotation element method. + */ + public static java.lang.reflect.Type attributeType(Class type, String name) { + try { + return type.getDeclaredMethod(name).getGenericReturnType(); + } catch (NoSuchMethodException e) { + throw new RuntimeException("Cannot find annotation element '%s' in %s".formatted(name, type.getName()), e); } } /** - * Creates a {@link TypeData} from the given ASM type with explicit type parameters. - * - * @param type the ASM type to convert - * @param classLoader the class loader used to resolve the type - * @param typeParameters the list of generic type parameters - * @return a {@code TypeData} representing the type with its parameters + * Creates a TypeData from the given ClassDesc with explicit type parameters. */ - public static TypeData typeDataFromType(Type type, ClassLoader classLoader, List> typeParameters) { - return new TypeData<>(asClass(type, classLoader), typeParameters); + public static TypeData typeDataFromDesc(ClassDesc desc, ClassLoader classLoader, List> typeParameters) { + return new TypeData<>(asClass(desc, classLoader), typeParameters); } /** - * Creates a {@link TypeData} from the given ASM type with no type parameters. - * - * @param type the ASM type to convert - * @param classLoader the class loader used to resolve the type - * @return a {@code TypeData} representing the raw type + * Creates a TypeData from the given ClassDesc with no type parameters. */ - public static TypeData typeDataFromType(Type type, ClassLoader classLoader) { - return typeDataFromType(type, classLoader, List.of()); + public static TypeData typeDataFromDesc(ClassDesc desc, ClassLoader classLoader) { + return typeDataFromDesc(desc, classLoader, List.of()); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java index a58769709a8..bbc30dc26ea 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java @@ -1,30 +1,22 @@ package dev.morphia.critter.parser.gizmo; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.util.Map; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.FieldInfo; +import dev.morphia.critter.parser.MethodInfo; -import org.bson.codecs.pojo.PropertyAccessor; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.FieldNode; -import org.objectweb.asm.tree.MethodNode; - -import io.quarkus.gizmo.MethodDescriptor; -import io.quarkus.gizmo.ResultHandle; - -import static io.quarkus.gizmo.MethodDescriptor.ofConstructor; -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; -import static io.quarkus.gizmo.SignatureBuilder.forClass; -import static io.quarkus.gizmo.SignatureBuilder.forMethod; -import static io.quarkus.gizmo.Type.classType; -import static io.quarkus.gizmo.Type.parameterizedType; -import static io.quarkus.gizmo.Type.typeVariable; -import static io.quarkus.gizmo.Type.voidType; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassSignature; +import io.github.dmlloyd.classfile.attribute.SignatureAttribute; /** - * Generates a Gizmo-based {@link org.bson.codecs.pojo.PropertyAccessor} implementation for a single + * Generates a {@link org.bson.codecs.pojo.PropertyAccessor} implementation for a single * entity property, delegating to the synthetic {@code __readXxx}/{@code __writeXxx} methods. */ public class PropertyAccessorGenerator extends BaseGizmoGenerator { @@ -41,120 +33,141 @@ public class PropertyAccessorGenerator extends BaseGizmoGenerator { private final String propertyName; private final String propertyType; - /** - * Creates a generator for a property accessor backed by a field. - * - * @param entity the entity class that owns the field - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param field the field representing the property - */ - public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldNode field) { + public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); - this.propertyName = field.name; - this.propertyType = Type.getType(field.desc).getClassName(); + this.propertyName = field.name(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } - /** - * Creates a generator for a property accessor backed by a getter method. - * - * @param entity the entity class that owns the getter method - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param method the getter method representing the property - */ - public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodNode method) { + private static String typeClassName(ClassDesc cd) { + String desc = cd.descriptorString(); + if (desc.length() == 1) { + return switch (desc.charAt(0)) { + case 'Z' -> "boolean"; + case 'C' -> "char"; + case 'B' -> "byte"; + case 'S' -> "short"; + case 'I' -> "int"; + case 'J' -> "long"; + case 'F' -> "float"; + case 'D' -> "double"; + default -> throw new IllegalArgumentException("Unknown primitive: " + desc); + }; + } + return desc.substring(1, desc.length() - 1).replace('/', '.'); + } + + public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - this.propertyType = Type.getReturnType(method.desc).getClassName(); + String returnDesc = MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } - /** - * Returns {@code true} if the property type is a Java primitive type. - * - * @return {@code true} if the property type is primitive - */ public boolean isPrimitive() { return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); } - /** - * Returns the fully-qualified name of the wrapper type for the property type, - * or the property type itself if it is not primitive. - * - * @return the wrapper type name - */ public String getWrapperType() { return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } - /** - * Emits the generated property accessor class and returns this generator. - * - * @return this generator after emitting - */ public PropertyAccessorGenerator emit() { - getBuilder().signature( - forClass() - .addInterface( - parameterizedType( - classType(PropertyAccessor.class), - classType(propertyType)))); - - try (var creator = getCreator()) { - ctor(); - get(); - set(); - } - + ClassDesc thisDesc = ClassDesc.of(generatedType); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); + ClassDesc propertyDesc = ClassDesc.ofDescriptor(isPrimitive() ? primitiveDescriptor() : "L" + propertyType.replace('.', '/') + ";"); + ClassDesc wrapperDesc = ClassDesc.of(getWrapperType()); + ClassDesc accessorDesc = ClassDesc.of("org.bson.codecs.pojo.PropertyAccessor"); + + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withInterfaceSymbols(accessorDesc); + + // Class signature: Ljava/lang/Object;Lorg/bson/codecs/pojo/PropertyAccessor; + String sigStr = "Ljava/lang/Object;L" + + accessorDesc.descriptorString().substring(1, accessorDesc.descriptorString().length() - 1) + ";"; + cb.with(SignatureAttribute.of(ClassSignature.parseFrom(sigStr))); + + // default constructor + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + cod.return_(); + }); + + // get(Object model): Object + cb.withMethodBody("get", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;"), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(1); + cod.checkcast(entityDesc); + String readName = "__read%s".formatted(Critter.titleCase(propertyName)); + if (isPrimitive()) { + cod.invokevirtual(entityDesc, readName, MethodTypeDesc.of(propertyDesc)); + // box the primitive + cod.invokestatic(wrapperDesc, "valueOf", + MethodTypeDesc.of(wrapperDesc, propertyDesc)); + } else { + cod.invokevirtual(entityDesc, readName, MethodTypeDesc.of(propertyDesc)); + } + cod.areturn(); + }); + + // set(Object model, Object value): void + cb.withMethodBody("set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V"), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(1); + cod.checkcast(entityDesc); + String writeName = "__write%s".formatted(Critter.titleCase(propertyName)); + if (isPrimitive()) { + cod.aload(2); + cod.checkcast(wrapperDesc); + // unbox: WrapperType.primitiveValue() + String unboxMethod = primitiveUnboxMethod(); + cod.invokevirtual(wrapperDesc, unboxMethod, MethodTypeDesc.of(propertyDesc)); + } else { + cod.aload(2); + cod.checkcast(propertyDesc); + } + cod.invokevirtual(entityDesc, writeName, + MethodTypeDesc.of(ConstantDescs.CD_void, propertyDesc)); + cod.return_(); + }); + }); + + critterClassLoader.register(generatedType, bytes); return this; } - private void get() { - 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 castModel = method.checkCast(method.getMethodParam(0), entity); - MethodDescriptor toInvoke = ofMethod(entity, "__read%s".formatted(Critter.titleCase(propertyName)), propertyType); - ResultHandle result = method.invokeVirtualMethod(toInvoke, castModel); - ResultHandle boxed = isPrimitive() ? method.smartCast(result, getWrapperType()) : result; - method.returnValue(boxed); - } - - private void set() { - var method = getCreator().getMethodCreator( - ofMethod(generatedType, "set", "void", Object.class.getName(), Object.class.getName())); - method.setSignature( - forMethod() - .addTypeParameter(typeVariable("S")) - .setReturnType(voidType()) - .addParameterType(typeVariable("S")) - .addParameterType(classType(propertyType)) - .build()); - method.setParameterNames(new String[] { "model", "value" }); - ResultHandle castModel = method.checkCast(method.getMethodParam(0), entity); - ResultHandle castValue; - if (isPrimitive()) { - ResultHandle boxed = method.checkCast(method.getMethodParam(1), getWrapperType()); - castValue = method.smartCast(boxed, propertyType); - } else { - castValue = method.checkCast(method.getMethodParam(1), propertyType); - } - MethodDescriptor toInvoke = ofMethod(entity, "__write%s".formatted(Critter.titleCase(propertyName)), "void", propertyType); - method.invokeVirtualMethod(toInvoke, castModel, castValue); - method.returnValue(null); + private String primitiveDescriptor() { + return switch (propertyType) { + case "boolean" -> "Z"; + case "byte" -> "B"; + case "char" -> "C"; + case "short" -> "S"; + case "int" -> "I"; + case "long" -> "J"; + case "float" -> "F"; + case "double" -> "D"; + default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); + }; } - private void ctor() { - var constructor = getCreator().getConstructorCreator(new String[0]); - constructor.invokeSpecialMethod(ofConstructor(Object.class), constructor.getThis()); - constructor.returnVoid(); - constructor.close(); + private String primitiveUnboxMethod() { + return switch (propertyType) { + case "boolean" -> "booleanValue"; + case "byte" -> "byteValue"; + case "char" -> "charValue"; + case "short" -> "shortValue"; + case "int" -> "intValue"; + case "long" -> "longValue"; + case "float" -> "floatValue"; + case "double" -> "doubleValue"; + default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); + }; } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java index d7eae341bc9..9f9be8a6d11 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java @@ -1,11 +1,17 @@ package dev.morphia.critter.parser.gizmo; import java.lang.annotation.Annotation; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -14,182 +20,81 @@ import dev.morphia.annotations.AlsoLoad; import dev.morphia.annotations.Reference; -import dev.morphia.annotations.internal.AnnotationNodeExtensions; import dev.morphia.config.MorphiaConfig; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.conventions.PropertyConvention; import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.FieldInfo; +import dev.morphia.critter.parser.MethodInfo; import dev.morphia.mapping.codec.pojo.EntityModel; import dev.morphia.mapping.codec.pojo.PropertyModel; import dev.morphia.mapping.codec.pojo.TypeData; import dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel; import org.bson.codecs.pojo.PropertyAccessor; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.Type; -import org.objectweb.asm.signature.SignatureReader; -import org.objectweb.asm.signature.SignatureVisitor; -import org.objectweb.asm.tree.AnnotationNode; -import org.objectweb.asm.tree.FieldNode; -import org.objectweb.asm.tree.MethodNode; - -import io.quarkus.gizmo.MethodCreator; -import io.quarkus.gizmo.MethodDescriptor; -import io.quarkus.gizmo.ResultHandle; - -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; -import static org.objectweb.asm.Type.ARRAY; -import static org.objectweb.asm.Type.getReturnType; + +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.TypeKind; /** - * Generates a Gizmo-based {@link dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel} implementation + * Generates a ClassFile-based {@link dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel} implementation * for a single property of a Morphia entity class. */ public class PropertyModelGenerator extends BaseGizmoGenerator { private final MorphiaConfig config; - - private Type[] typeArguments = new Type[0]; - private FieldNode field; - private MethodNode method; - private String propertyName; - private Type propertyType; - private String accessorType; - private List annotations; - - // Lazy fields - private Map annotationMap; - private TypeData typeData; - private io.quarkus.gizmo.FieldCreator modelField; - private io.quarkus.gizmo.FieldCreator accessorField; + private final String propertyName; + private final String accessorType; + private final boolean isFieldBased; + private final int accessFlags; + private final java.lang.reflect.Type genericType; + private final Map annotationMap; + private final TypeData typeData; + private final List morphiaAnnotations; /** * Creates a generator for a field-based property. - * - * @param config the Morphia configuration - * @param entity the entity class that owns the field - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param field the ASM field node representing the property */ - public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, FieldNode field) { + public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.config = config; - this.field = field; - this.propertyName = ExtensionFunctions.methodCase(field.name); - this.propertyType = Type.getType(field.desc); - String signature = field.signature; - if (signature != null) { - this.typeArguments = Type.getArgumentTypes("()" + signature); - } + this.isFieldBased = true; + this.propertyName = field.name(); generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - this.annotations = field.visibleAnnotations != null ? field.visibleAnnotations : Collections.emptyList(); + + Field reflectedField = findField(entity, field.name()); + this.accessFlags = reflectedField != null ? reflectedField.getModifiers() : field.access(); + this.genericType = reflectedField != null ? reflectedField.getGenericType() : Object.class; + this.annotationMap = buildAnnotationMap(reflectedField != null ? reflectedField.getAnnotations() : new Annotation[0]); + this.typeData = computeTypeData(this.genericType, entity.getClassLoader()); + this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); } /** * Creates a generator for a method-based (getter) property. - * - * @param config the Morphia configuration - * @param entity the entity class that owns the getter - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param method the ASM method node representing the getter */ - public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, MethodNode method) { + public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { super(entity, critterClassLoader); this.config = config; - this.method = method; + this.isFieldBased = false; this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - this.propertyType = getReturnType(method.desc); - if (method.signature != null) { - this.typeArguments = Type.getArgumentTypes(method.signature); - } generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - this.annotations = method.visibleAnnotations != null ? method.visibleAnnotations : Collections.emptyList(); - } - - /** - * Returns {@code true} if the given ASM type represents an array type. - * - * @param type the ASM type to check - * @return {@code true} if the type is an array - */ - public static boolean isArray(Type type) { - return type.getSort() == ARRAY; - } - - private Map getAnnotationMap() { - if (annotationMap == null) { - annotationMap = new LinkedHashMap<>(); - for (AnnotationNode ann : annotations) { - if (ann.desc.startsWith("Ldev/morphia/annotations/")) { - Annotation a = AnnotationNodeExtensions.INSTANCE.toMorphiaAnnotation(ann); - annotationMap.put(a.annotationType().getName(), a); - } - } - } - return annotationMap; - } - private int getAccessValue() { - return field != null ? field.access : method.access; + Method reflectedMethod = findMethod(entity, method.name()); + this.accessFlags = reflectedMethod != null ? reflectedMethod.getModifiers() : method.access(); + this.genericType = reflectedMethod != null ? reflectedMethod.getGenericReturnType() : Object.class; + this.annotationMap = buildAnnotationMap(reflectedMethod != null ? reflectedMethod.getAnnotations() : new Annotation[0]); + this.typeData = computeTypeData(this.genericType, entity.getClassLoader()); + this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); } - private TypeData getTypeData() { - if (typeData == null) { - String input; - if (field != null) { - input = field.signature != null ? field.signature : field.desc; - } else { - String sig = method.signature; - if (sig != null) { - // Extract return type from generic signature (after the last ')') - input = sig.substring(sig.lastIndexOf(')') + 1); - } else { - input = getReturnType(method.desc).getDescriptor(); - } - } - List> results = typeData(input, entity.getClassLoader()); - TypeData raw = results.isEmpty() ? new TypeData<>(Object.class, List.of()) : results.get(0); - - // If the result is Object due to type-variable erasure, try to resolve - // the actual type argument via Java reflection (e.g., T → UUID when the - // entity subclass specifies GenericEntity). - if (raw.getType() == Object.class && isTypeVariable(input)) { - String typeVarName = extractTypeVarName(input); - String lookupName = field != null ? field.name : ExtensionFunctions.getterToPropertyName(method, entity); - Class declaringClass = findDeclaringClass(lookupName, entity); - Class resolved = resolveTypeVariable(typeVarName, entity, declaringClass); - if (resolved != null && resolved != Object.class) { - raw = new TypeData<>(resolved, List.of()); - } - } - typeData = raw; - } - return typeData; - } - - /** Returns true if the signature string represents a single type variable (e.g., {@code TT;}). */ - private static boolean isTypeVariable(String signature) { - return signature != null && signature.startsWith("T") && !signature.startsWith("T[") - && signature.endsWith(";") && !signature.contains("<") && !signature.contains("/"); - } - - /** Extracts the type-variable name from a signature like {@code TT;} → {@code T}. */ - private static String extractTypeVarName(String signature) { - return signature.substring(1, signature.length() - 1); - } - - /** - * Finds the class in the hierarchy of {@code concreteClass} that declares a field with the given name. - * Returns {@code null} if not found. - */ - private static Class findDeclaringClass(String fieldName, Class concreteClass) { - Class current = concreteClass; + private static Field findField(Class cls, String name) { + Class current = cls; while (current != null && current != Object.class) { try { - current.getDeclaredField(fieldName); - return current; + return current.getDeclaredField(name); } catch (NoSuchFieldException e) { current = current.getSuperclass(); } @@ -197,388 +102,303 @@ private static Class findDeclaringClass(String fieldName, Class concreteCl return null; } - /** - * Resolves a type-variable name (e.g., {@code "T"}) to its actual class by walking - * the generic superclass chain of {@code concreteClass}. - * Stops traversal after binding {@code declaringClass}'s type parameters so that a - * type variable declared on a closer ancestor is not overwritten by a same-named - * variable on a more distant one. - * Returns {@code null} if no concrete binding can be determined. - */ - private static Class resolveTypeVariable(String typeVarName, Class concreteClass, Class declaringClass) { - // Build a map from type-variable name to its resolved type, layer by layer - Map bindings = new HashMap<>(); - Class current = concreteClass; + private static Method findMethod(Class cls, String name) { + Class current = cls; while (current != null && current != Object.class) { - java.lang.reflect.Type genericSuper = current.getGenericSuperclass(); - Class superClass = current.getSuperclass(); - if (superClass == null || superClass == Object.class) { - break; - } - if (genericSuper instanceof ParameterizedType paramType) { - TypeVariable[] typeParams = superClass.getTypeParameters(); - java.lang.reflect.Type[] typeArgs = paramType.getActualTypeArguments(); - for (int i = 0; i < typeParams.length && i < typeArgs.length; i++) { - // If the argument is itself a TypeVariable, resolve it through the accumulated bindings - java.lang.reflect.Type arg = typeArgs[i]; - if (arg instanceof TypeVariable tv && bindings.containsKey(tv.getName())) { - arg = bindings.get(tv.getName()); - } - bindings.put(typeParams[i].getName(), arg); + for (Method m : current.getDeclaredMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == 0) { + return m; } } - // Stop after binding the declaring class's own type parameters so a same-named - // variable on a more distant ancestor does not overwrite the correct binding. - if (declaringClass != null && superClass == declaringClass) { - break; - } - current = superClass; - } - java.lang.reflect.Type resolved = bindings.get(typeVarName); - if (resolved instanceof Class c) { - return c; + current = current.getSuperclass(); } return null; } - private io.quarkus.gizmo.FieldCreator getModelField() { - if (modelField == null) { - modelField = getCreator().getFieldCreator("entityModel", EntityModel.class); + private static Map buildAnnotationMap(Annotation[] annotations) { + Map map = new LinkedHashMap<>(); + for (Annotation ann : annotations) { + map.put(ann.annotationType().getName(), ann); } - return modelField; - } - - private io.quarkus.gizmo.FieldCreator getAccessorField() { - if (accessorField == null) { - accessorField = getCreator().getFieldCreator("accessor", accessorType); - } - return accessorField; + return map; } /** - * Emits the generated property model class and returns this generator. - * - * @return this generator after emitting + * Converts a java.lang.reflect.Type into a TypeData instance. */ - public PropertyModelGenerator emit() { - getBuilder().superClass(CritterPropertyModel.class); - - try (var creator = getCreator()) { - // Initialize fields first - getModelField(); - getAccessorField(); - ctor(); - getAccessor(); - getFullName(); - getLoadNames(); - getMappedName(); - getName(); - getNormalizedType(); - isArray(); - isFinal(); - isReference(); - isTransient(); - isMap(); - isSet(); - isCollection(); - getType(); - emitGetTypeData(); - getEntityModel(); - } - return this; - } - - private void isArray() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isArray", boolean.class)) { - methodCreator.returnValue(methodCreator.load(isArray(propertyType))); - } - } - - private void isMap() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isMap", boolean.class)) { - methodCreator.returnValue(methodCreator.load(Map.class.isAssignableFrom(getTypeData().getType()))); - } - } - - private void isSet() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isSet", boolean.class)) { - methodCreator.returnValue(methodCreator.load(java.util.Set.class.isAssignableFrom(getTypeData().getType()))); - } - } - - private void getType() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getType", Class.class)) { - methodCreator.returnValue(GizmoExtensions.emitClassRef(methodCreator, getTypeData().getType())); - } - } - - private void getEntityModel() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getEntityModel", EntityModel.class)) { - methodCreator.returnValue( - methodCreator.readInstanceField(getModelField().getFieldDescriptor(), methodCreator.getThis())); - } - } - - private void emitGetTypeData() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getTypeData", TypeData.class)) { - methodCreator.returnValue(GizmoExtensions.emitTypeData(this.getTypeData(), methodCreator)); - } - } - - private void isCollection() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isCollection", boolean.class)) { - methodCreator.returnValue( - methodCreator.load(java.util.Collection.class.isAssignableFrom(getTypeData().getType()))); - } - } - - private void isFinal() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isFinal", boolean.class)) { - methodCreator.returnValue(methodCreator.load(checkMask(Opcodes.ACC_FINAL))); - } - } - - private void isTransient() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isTransient", boolean.class)) { - boolean isTransient = checkMask(Opcodes.ACC_TRANSIENT) - || PropertyConvention.transientAnnotations().stream() - .anyMatch(c -> getAnnotationMap().containsKey(c.getName())); - methodCreator.returnValue(methodCreator.load(isTransient)); - } - } - - private void isReference() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("isReference", boolean.class)) { - methodCreator.returnValue(methodCreator.load( - propertyType.equals(Type.getType(DBRef.class)) - || getAnnotationMap().containsKey(Reference.class.getName()))); - } - } - - private boolean checkMask(int mask) { - return (getAccessValue() & mask) == mask; - } - - private void getNormalizedType() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getNormalizedType", Class.class)) { - Class normalizedType = PropertyModel.normalize(getTypeData()); - methodCreator.returnValue(GizmoExtensions.emitClassRef(methodCreator, normalizedType)); - } - } - - private void getLoadNames() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getLoadNames", List.class)) { - AlsoLoad alsoLoad = (AlsoLoad) getAnnotationMap().get(AlsoLoad.class.getName()); - int size = alsoLoad != null ? alsoLoad.value().length : 0; - ResultHandle names = methodCreator.newArray(Object.class, size); - if (alsoLoad != null) { - String[] values = alsoLoad.value(); - for (int index = 0; index < values.length; index++) { - methodCreator.writeArrayValue(names, index, methodCreator.load(values[index])); - } + public static TypeData computeTypeData(java.lang.reflect.Type type, ClassLoader classLoader) { + if (type instanceof Class c) { + return new TypeData<>(c, List.of()); + } else if (type instanceof ParameterizedType pt) { + Class raw = (Class) pt.getRawType(); + @SuppressWarnings("unchecked") + List> params = (List>) (List) Arrays.stream(pt.getActualTypeArguments()) + .map(a -> computeTypeData(a, classLoader)) + .toList(); + return new TypeData<>(raw, params); + } else if (type instanceof GenericArrayType gat) { + java.lang.reflect.Type comp = gat.getGenericComponentType(); + Class compClass; + if (comp instanceof Class c) { + compClass = c; + } else if (comp instanceof ParameterizedType pt) { + compClass = (Class) pt.getRawType(); + } else { + compClass = Object.class; } - MethodDescriptor asList = ofMethod( - java.util.Arrays.class, - "asList", - List.class, - Object[].class); - methodCreator.returnValue(methodCreator.invokeStaticMethod(asList, names)); - } - } - - private void getName() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getName", String.class)) { - methodCreator.returnValue(methodCreator.load(propertyName)); - } - } - - private void getMappedName() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getMappedName", String.class)) { - methodCreator.returnValue(methodCreator.load( - PropertyConvention.mappedName(config, getAnnotationMap(), propertyName))); - } - } - - private void getFullName() { - try (MethodCreator methodCreator = getCreator().getMethodCreator("getFullName", String.class)) { - methodCreator.returnValue(methodCreator.load("%s#%s".formatted(entity.getName(), propertyName))); - } - } - - private void ctor() { - try (MethodCreator constructor = getCreator().getConstructorCreator(EntityModel.class)) { - constructor.invokeSpecialMethod( - MethodDescriptor.ofConstructor(CritterPropertyModel.class, EntityModel.class), - constructor.getThis(), - constructor.getMethodParam(0)); - constructor.setParameterNames(new String[] { "model" }); - constructor.writeInstanceField( - getModelField().getFieldDescriptor(), - constructor.getThis(), - constructor.getMethodParam(0)); - ResultHandle accessorInstance = constructor.newInstance( - MethodDescriptor.ofConstructor(accessorType)); - constructor.writeInstanceField( - getAccessorField().getFieldDescriptor(), - constructor.getThis(), - accessorInstance); - registerAnnotations(constructor); - constructor.returnVoid(); - } - } - - private void registerAnnotations(MethodCreator constructor) { - MethodDescriptor annotationMethod = ofMethod( - PropertyModel.class.getName(), - "annotation", - PropertyModel.class.getName(), - Annotation.class); - for (AnnotationNode annotation : annotations) { - constructor.invokeVirtualMethod( - annotationMethod, - constructor.getThis(), - GizmoExtensions.annotationBuilder(annotation, constructor)); + try { + Class arrayClass = java.lang.reflect.Array.newInstance(compClass, 0).getClass(); + return new TypeData<>(arrayClass, List.of()); + } catch (Exception e) { + return new TypeData<>(Object.class, List.of()); + } + } else if (type instanceof TypeVariable tv) { + // Resolve bounds if possible + java.lang.reflect.Type[] bounds = tv.getBounds(); + if (bounds.length > 0 && bounds[0] != Object.class) { + return computeTypeData(bounds[0], classLoader); + } + return new TypeData<>(Object.class, List.of()); + } else if (type instanceof WildcardType wt) { + java.lang.reflect.Type[] upper = wt.getUpperBounds(); + if (upper.length > 0) { + return computeTypeData(upper[0], classLoader); + } + return new TypeData<>(Object.class, List.of()); } + return new TypeData<>(Object.class, List.of()); } - private void getAccessor() { - MethodCreator method = getCreator().getMethodCreator( - ofMethod(generatedType, "getAccessor", PropertyAccessor.class.getName())); - method.returnValue(method.readInstanceField(getAccessorField().getFieldDescriptor(), method.getThis())); - method.close(); - } - - // ---- Static utility methods (formerly package-level functions) ---- - /** - * Parses an ASM type signature string into a list of {@link TypeData} instances. + * Parses a classfile signature string into a list of {@link TypeData} instances. + * Uses the ClassFile API's {@code Signature.parseFrom()} for type argument extraction. * - * @param input the ASM type signature to parse (field/return-type signature) + * @param input the classfile type signature to parse * @param classLoader the class loader used to resolve referenced types * @return a single-element list containing the parsed type data, or empty if parsing fails */ public static List> typeData(String input, ClassLoader classLoader) { - if (input.isEmpty()) - return Collections.emptyList(); + if (input == null || input.isEmpty()) + return java.util.Collections.emptyList(); try { - TypeDataVisitor visitor = new TypeDataVisitor(classLoader); - new SignatureReader(input).acceptType(visitor); - return visitor.result != null ? List.of(visitor.result) : Collections.emptyList(); + io.github.dmlloyd.classfile.Signature sig = io.github.dmlloyd.classfile.Signature.parseFrom(input); + TypeData result = typeDataFromSignature(sig, classLoader); + return result != null ? List.of(result) : java.util.Collections.emptyList(); } catch (Exception e) { - return Collections.emptyList(); + return java.util.Collections.emptyList(); + } + } + + private static TypeData typeDataFromSignature(io.github.dmlloyd.classfile.Signature sig, ClassLoader classLoader) { + if (sig instanceof io.github.dmlloyd.classfile.Signature.ClassTypeSig cts) { + java.lang.constant.ClassDesc cd = cts.classDesc(); + Class raw = GizmoExtensions.asClass(cd, classLoader); + @SuppressWarnings("unchecked") + List> params = (List>) (List) cts.typeArgs().stream() + .map(arg -> typeDataFromTypeArg(arg, classLoader)) + .toList(); + return new TypeData<>(raw, params); + } else if (sig instanceof io.github.dmlloyd.classfile.Signature.ArrayTypeSig ats) { + TypeData component = typeDataFromSignature(ats.componentSignature(), classLoader); + if (component == null) + return new TypeData<>(Object.class, List.of()); + try { + Class arrayClass = java.lang.reflect.Array.newInstance(component.getType(), 0).getClass(); + return new TypeData<>(arrayClass, List.of()); + } catch (Exception e) { + return new TypeData<>(Object.class, List.of()); + } + } else if (sig instanceof io.github.dmlloyd.classfile.Signature.BaseTypeSig bts) { + Class primitive = switch (bts.baseType()) { + case 'Z' -> boolean.class; + case 'C' -> char.class; + case 'B' -> byte.class; + case 'S' -> short.class; + case 'I' -> int.class; + case 'J' -> long.class; + case 'F' -> float.class; + case 'D' -> double.class; + case 'V' -> void.class; + default -> Object.class; + }; + return new TypeData<>(primitive, List.of()); + } else if (sig instanceof io.github.dmlloyd.classfile.Signature.TypeVarSig) { + return new TypeData<>(Object.class, List.of()); } + return new TypeData<>(Object.class, List.of()); } - private static class TypeDataVisitor extends SignatureVisitor { - private final ClassLoader classLoader; - TypeData result; - - private String pendingClass; - private final List typeArgVisitors = new ArrayList<>(); - - TypeDataVisitor(ClassLoader classLoader) { - super(Opcodes.ASM9); - this.classLoader = classLoader; + private static TypeData typeDataFromTypeArg(io.github.dmlloyd.classfile.Signature.TypeArg arg, ClassLoader classLoader) { + if (arg instanceof io.github.dmlloyd.classfile.Signature.TypeArg.Bounded bounded) { + return typeDataFromSignature(bounded.boundType(), classLoader); } + return new TypeData<>(Object.class, List.of()); + } - @Override - public void visitClassType(String name) { - pendingClass = "L" + name + ";"; - typeArgVisitors.clear(); - } - - @Override - public void visitInnerClassType(String name) { - // Strip trailing ';' and append '$Name;' - pendingClass = pendingClass.substring(0, pendingClass.length() - 1) + "$" + name + ";"; - } - - @Override - public SignatureVisitor visitTypeArgument(char wildcard) { - TypeDataVisitor child = new TypeDataVisitor(classLoader); - typeArgVisitors.add(child); - return child; - } - - @Override - public void visitTypeArgument() { - // Unbounded wildcard '*': use Object as erasure - TypeDataVisitor child = new TypeDataVisitor(classLoader); - child.result = new TypeData<>(Object.class, List.of()); - typeArgVisitors.add(child); - } - - @Override - public void visitTypeVariable(String name) { - result = new TypeData<>(Object.class, List.of()); - } - - @Override - public void visitBaseType(char descriptor) { - result = GizmoExtensions.typeDataFromType(Type.getType(String.valueOf(descriptor)), classLoader); - } - - @Override - public SignatureVisitor visitArrayType() { - return new ArrayVisitor(classLoader, this); - } - - private static class ArrayVisitor extends TypeDataVisitor { - private final TypeDataVisitor outer; - - ArrayVisitor(ClassLoader classLoader, TypeDataVisitor outer) { - super(classLoader); - this.outer = outer; - } - - private void propagate() { - if (result != null) { - try { - Class arrayClass = java.lang.reflect.Array.newInstance(result.getType(), 0).getClass(); - outer.result = new TypeData<>(arrayClass, List.of()); - } catch (Exception ignored) { - outer.result = new TypeData<>(Object.class, List.of()); - } - // If outer is itself an ArrayVisitor, propagate the chain upward. - if (outer instanceof ArrayVisitor av) { - av.propagate(); - } - } - } - - @Override - public void visitBaseType(char descriptor) { - super.visitBaseType(descriptor); - propagate(); - } - - @Override - public void visitEnd() { - super.visitEnd(); - propagate(); - } - - @Override - public void visitTypeVariable(String name) { - super.visitTypeVariable(name); - propagate(); - } - - @Override - public SignatureVisitor visitArrayType() { - return new ArrayVisitor(outer.classLoader, this); - } - } + /** + * Emits the generated property model class and returns this generator. + */ + public PropertyModelGenerator emit() { + ClassDesc thisDesc = ClassDesc.of(generatedType); + ClassDesc superDesc = ClassDesc.of(CritterPropertyModel.class.getName()); + ClassDesc entityModelDesc = ClassDesc.of(EntityModel.class.getName()); + ClassDesc propertyModelDesc = ClassDesc.of(PropertyModel.class.getName()); + ClassDesc accessorDesc = ClassDesc.of(PropertyAccessor.class.getName()); + ClassDesc accessorImplDesc = ClassDesc.of(accessorType); + ClassDesc typeDataDesc = ClassDesc.of(TypeData.class.getName()); + ClassDesc annotationDesc = ClassDesc.of(Annotation.class.getName()); + + boolean isFinalFlag = java.lang.reflect.Modifier.isFinal(accessFlags); + boolean isTransientFlag = java.lang.reflect.Modifier.isTransient(accessFlags) + || PropertyConvention.transientAnnotations().stream().anyMatch(c -> annotationMap.containsKey(c.getName())); + boolean isReferenceFlag = DBRef.class.isAssignableFrom(typeData.getType()) + || annotationMap.containsKey(Reference.class.getName()); + boolean isArrayFlag = typeData.getType().isArray(); + boolean isMapFlag = Map.class.isAssignableFrom(typeData.getType()); + boolean isSetFlag = java.util.Set.class.isAssignableFrom(typeData.getType()); + boolean isCollectionFlag = java.util.Collection.class.isAssignableFrom(typeData.getType()); + String mappedName = PropertyConvention.mappedName(config, annotationMap, propertyName); + Class normalizedType = PropertyModel.normalize(typeData); + AlsoLoad alsoLoad = (AlsoLoad) annotationMap.get(AlsoLoad.class.getName()); + String[] loadNamesArr = alsoLoad != null ? alsoLoad.value() : new String[0]; + + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(superDesc); + + cb.withField("entityModel", entityModelDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + cb.withField("accessor", accessorImplDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + + // Constructor: (EntityModel) -> void + cb.withMethodBody("", MethodTypeDesc.of(ConstantDescs.CD_void, entityModelDesc), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.aload(1); + cod.invokespecial(superDesc, "", MethodTypeDesc.of(ConstantDescs.CD_void, entityModelDesc)); + + cod.aload(0); + cod.aload(1); + cod.putfield(thisDesc, "entityModel", entityModelDesc); + + cod.aload(0); + cod.new_(accessorImplDesc); + cod.dup(); + cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); + cod.putfield(thisDesc, "accessor", accessorImplDesc); + + // Register annotations + for (Annotation ann : morphiaAnnotations) { + if (ann.annotationType().getName().startsWith("dev.morphia.annotations.")) { + cod.aload(0); + GizmoExtensions.emitAnnotationOnStack(cod, ann); + cod.invokevirtual(propertyModelDesc, "annotation", + MethodTypeDesc.of(propertyModelDesc, annotationDesc)); + cod.pop(); + } + } + + cod.return_(); + }); + + // getAccessor(): PropertyAccessor + cb.withMethodBody("getAccessor", MethodTypeDesc.of(accessorDesc), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.getfield(thisDesc, "accessor", accessorImplDesc); + cod.areturn(); + }); + + // getEntityModel(): EntityModel + cb.withMethodBody("getEntityModel", MethodTypeDesc.of(entityModelDesc), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.getfield(thisDesc, "entityModel", entityModelDesc); + cod.areturn(); + }); + + // getFullName(): String + cb.withMethodBody("getFullName", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc("%s#%s".formatted(entity.getName(), propertyName)); + cod.areturn(); + }); + + // getName(): String + cb.withMethodBody("getName", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc(propertyName); + cod.areturn(); + }); + + // getMappedName(): String + cb.withMethodBody("getMappedName", MethodTypeDesc.of(ConstantDescs.CD_String), + ClassFile.ACC_PUBLIC, cod -> { + cod.ldc(mappedName); + cod.areturn(); + }); + + // getLoadNames(): List + cb.withMethodBody("getLoadNames", MethodTypeDesc.of(ClassDesc.of("java.util.List")), + ClassFile.ACC_PUBLIC, cod -> { + cod.loadConstant(loadNamesArr.length); + cod.anewarray(ConstantDescs.CD_Object); + for (int i = 0; i < loadNamesArr.length; i++) { + cod.dup(); + cod.loadConstant(i); + cod.ldc(loadNamesArr[i]); + cod.aastore(); + } + cod.invokestatic(ClassDesc.of("java.util.Arrays"), "asList", + MethodTypeDesc.ofDescriptor("([Ljava/lang/Object;)Ljava/util/List;")); + cod.areturn(); + }); + + // getNormalizedType(): Class + cb.withMethodBody("getNormalizedType", MethodTypeDesc.of(ConstantDescs.CD_Class), + ClassFile.ACC_PUBLIC, cod -> { + GizmoExtensions.emitClassRef(cod, normalizedType); + cod.areturn(); + }); + + // getType(): Class + cb.withMethodBody("getType", MethodTypeDesc.of(ConstantDescs.CD_Class), + ClassFile.ACC_PUBLIC, cod -> { + GizmoExtensions.emitClassRef(cod, typeData.getType()); + cod.areturn(); + }); + + // getTypeData(): TypeData + cb.withMethodBody("getTypeData", MethodTypeDesc.of(typeDataDesc), + ClassFile.ACC_PUBLIC, cod -> { + GizmoExtensions.emitTypeData(typeData, cod); + cod.areturn(); + }); + + // isArray(): boolean + emitBooleanMethod(cb, "isArray", isArrayFlag); + // isFinal(): boolean + emitBooleanMethod(cb, "isFinal", isFinalFlag); + // isReference(): boolean + emitBooleanMethod(cb, "isReference", isReferenceFlag); + // isTransient(): boolean + emitBooleanMethod(cb, "isTransient", isTransientFlag); + // isMap(): boolean + emitBooleanMethod(cb, "isMap", isMapFlag); + // isSet(): boolean + emitBooleanMethod(cb, "isSet", isSetFlag); + // isCollection(): boolean + emitBooleanMethod(cb, "isCollection", isCollectionFlag); + }); + + critterClassLoader.register(generatedType, bytes); + return this; + } - @Override - public void visitEnd() { - if (pendingClass != null) { - List> params = typeArgVisitors.stream() - .map(v -> v.result != null ? v.result : new TypeData<>(Object.class, List.of())) - .toList(); - result = GizmoExtensions.typeDataFromType(Type.getType(pendingClass), classLoader, params); - pendingClass = null; - } - } + private static void emitBooleanMethod(io.github.dmlloyd.classfile.ClassBuilder cb, String name, boolean value) { + cb.withMethodBody(name, MethodTypeDesc.ofDescriptor("()Z"), ClassFile.ACC_PUBLIC, cod -> { + cod.loadConstant(value ? 1 : 0); + cod.return_(TypeKind.INT); + }); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java index f2dcf1c3fa2..2b6f3a43b34 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java @@ -1,10 +1,12 @@ package dev.morphia.critter.parser.gizmo; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.invoke.VarHandle; -import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.Map; @@ -12,31 +14,16 @@ import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.FieldInfo; +import dev.morphia.critter.parser.MethodInfo; -import org.bson.codecs.pojo.PropertyAccessor; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.FieldNode; -import org.objectweb.asm.tree.MethodNode; - -import io.quarkus.gizmo.BranchResult; -import io.quarkus.gizmo.BytecodeCreator; -import io.quarkus.gizmo.FieldCreator; -import io.quarkus.gizmo.FieldDescriptor; -import io.quarkus.gizmo.ResultHandle; -import io.quarkus.gizmo.TryBlock; - -import static io.quarkus.gizmo.MethodDescriptor.ofConstructor; -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; -import static io.quarkus.gizmo.SignatureBuilder.forClass; -import static io.quarkus.gizmo.SignatureBuilder.forMethod; -import static io.quarkus.gizmo.Type.classType; -import static io.quarkus.gizmo.Type.parameterizedType; -import static io.quarkus.gizmo.Type.typeVariable; -import static io.quarkus.gizmo.Type.voidType; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassSignature; +import io.github.dmlloyd.classfile.TypeKind; +import io.github.dmlloyd.classfile.attribute.SignatureAttribute; /** - * Generates a Gizmo-based {@link org.bson.codecs.pojo.PropertyAccessor} implementation that uses + * Generates a ClassFile-based {@link org.bson.codecs.pojo.PropertyAccessor} implementation that uses * {@link java.lang.invoke.VarHandle} (for fields) or {@link java.lang.invoke.MethodHandle} (for getter/setter pairs) * to access a single property of a Morphia entity class. */ @@ -73,14 +60,14 @@ public class VarHandleAccessorGenerator extends BaseGizmoGenerator { * * @param entity the entity class that owns the field * @param critterClassLoader the class loader that will receive the generated bytecode - * @param field the ASM field node representing the property + * @param field the field info representing the property */ - public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldNode field) { + public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); - this.propertyName = field.name; - this.propertyType = Type.getType(field.desc).getClassName(); + this.propertyName = field.name(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); this.isFieldBased = true; - this.isFinalField = (field.access & Opcodes.ACC_FINAL) != 0; + this.isFinalField = (field.access() & ClassFile.ACC_FINAL) != 0; this.getterName = null; this.setterName = null; generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); @@ -91,19 +78,38 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla * * @param entity the entity class that owns the getter * @param critterClassLoader the class loader that will receive the generated bytecode - * @param method the ASM method node representing the getter + * @param method the method info representing the getter */ - public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodNode method) { + public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - this.propertyType = Type.getReturnType(method.desc).getClassName(); + String returnDesc = java.lang.constant.MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); this.isFieldBased = false; this.isFinalField = false; - this.getterName = method.name; + this.getterName = method.name(); this.setterName = "set%s".formatted(Critter.titleCase(propertyName)); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } + private static String typeClassName(ClassDesc cd) { + String desc = cd.descriptorString(); + if (desc.length() == 1) { + return switch (desc.charAt(0)) { + case 'Z' -> "boolean"; + case 'C' -> "char"; + case 'B' -> "byte"; + case 'S' -> "short"; + case 'I' -> "int"; + case 'J' -> "long"; + case 'F' -> "float"; + case 'D' -> "double"; + default -> throw new IllegalArgumentException("Unknown primitive: " + desc); + }; + } + return desc.substring(1, desc.length() - 1).replace('/', '.'); + } + /** * Returns {@code true} if the property type is a Java primitive. * @@ -142,7 +148,7 @@ private boolean hasSetter() { return true; } } catch (NoSuchMethodException e) { - // expected: this class does not declare the setter; walk up to the superclass + // walk up to superclass } current = current.getSuperclass(); } @@ -155,245 +161,298 @@ private boolean hasSetter() { * @return this generator after emitting */ public VarHandleAccessorGenerator emit() { - getBuilder().signature( - forClass() - .addInterface( - parameterizedType( - classType(PropertyAccessor.class), - classType(propertyType)))); - - FieldDescriptor handleDesc; - FieldDescriptor setterHandleDesc = null; - - if (isFieldBased) { - FieldCreator varHandleCreator = getCreator().getFieldCreator("varHandle", VarHandle.class); - varHandleCreator.setModifiers(Modifier.PRIVATE | Modifier.FINAL); - handleDesc = varHandleCreator.getFieldDescriptor(); - } else { - FieldCreator getterHandleCreator = getCreator().getFieldCreator("getterHandle", MethodHandle.class); - getterHandleCreator.setModifiers(Modifier.PRIVATE | Modifier.FINAL); - handleDesc = getterHandleCreator.getFieldDescriptor(); - if (hasSetter()) { - FieldCreator setterHandleCreator = getCreator().getFieldCreator("setterHandle", MethodHandle.class); - setterHandleCreator.setModifiers(Modifier.PRIVATE | Modifier.FINAL); - setterHandleDesc = setterHandleCreator.getFieldDescriptor(); + ClassDesc thisDesc = ClassDesc.of(generatedType); + ClassDesc accessorDesc = ClassDesc.of("org.bson.codecs.pojo.PropertyAccessor"); + ClassDesc propertyDesc = propertyClassDesc(); + ClassDesc wrapperDesc = ClassDesc.of(getWrapperType()); + + ClassDesc varHandleDesc = ClassDesc.of(VarHandle.class.getName()); + ClassDesc methodHandleDesc = ClassDesc.of(MethodHandle.class.getName()); + ClassDesc methodHandlesDesc = ClassDesc.of(MethodHandles.class.getName()); + ClassDesc lookupDesc = ClassDesc.of(MethodHandles.Lookup.class.getName()); + ClassDesc methodTypeDesc2 = ClassDesc.of(MethodType.class.getName()); + ClassDesc roeDesc = ClassDesc.of(ReflectiveOperationException.class.getName()); + ClassDesc rteDesc = ClassDesc.of(RuntimeException.class.getName()); + + boolean useVarHandle = isFieldBased; + boolean useSetterHandle = !isFieldBased && hasSetter(); + + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withInterfaceSymbols(accessorDesc); + + // Class signature: implements PropertyAccessor + String propInternalName = propertyType.replace('.', '/'); + String wrapperInternalName = getWrapperType().replace('.', '/'); + String accInternal = "org/bson/codecs/pojo/PropertyAccessor"; + String sigStr = "Ljava/lang/Object;L" + accInternal + ";"; + cb.with(SignatureAttribute.of(ClassSignature.parseFrom(sigStr))); + + // Field: varHandle or getterHandle + if (useVarHandle) { + cb.withField("varHandle", varHandleDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + } else { + cb.withField("getterHandle", methodHandleDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + if (useSetterHandle) { + cb.withField("setterHandle", methodHandleDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + } } - } - - try (var creator = getCreator()) { - ctor(handleDesc, setterHandleDesc); - get(handleDesc); - set(handleDesc, setterHandleDesc); - } + // Constructor + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + + // try { ... } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } + cod.trying(tryBody -> { + // MethodHandles.lookup() + tryBody.invokestatic(methodHandlesDesc, "lookup", + MethodTypeDesc.of(lookupDesc)); + int callerLookupSlot = tryBody.allocateLocal(TypeKind.REFERENCE); + tryBody.astore(callerLookupSlot); + + // Thread.currentThread().getContextClassLoader() + tryBody.invokestatic(ClassDesc.of("java.lang.Thread"), "currentThread", + MethodTypeDesc.of(ClassDesc.of("java.lang.Thread"))); + tryBody.invokevirtual(ClassDesc.of("java.lang.Thread"), "getContextClassLoader", + MethodTypeDesc.of(ClassDesc.of("java.lang.ClassLoader"))); + int tcclSlot = tryBody.allocateLocal(TypeKind.REFERENCE); + tryBody.astore(tcclSlot); + + // Class.forName(entityName, true, tccl) + tryBody.ldc(entity.getName()); + tryBody.iconst_1(); + tryBody.aload(tcclSlot); + tryBody.invokestatic(ConstantDescs.CD_Class, "forName", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;")); + int entityClassSlot = tryBody.allocateLocal(TypeKind.REFERENCE); + tryBody.astore(entityClassSlot); + + // MethodHandles.privateLookupIn(entityClass, callerLookup) + tryBody.aload(entityClassSlot); + tryBody.aload(callerLookupSlot); + tryBody.invokestatic(methodHandlesDesc, "privateLookupIn", + MethodTypeDesc.of(lookupDesc, ConstantDescs.CD_Class, lookupDesc)); + int privateLookupSlot = tryBody.allocateLocal(TypeKind.REFERENCE); + tryBody.astore(privateLookupSlot); + + if (useVarHandle) { + // privateLookup.findVarHandle(entityClass, fieldName, fieldType) + tryBody.aload(privateLookupSlot); + tryBody.aload(entityClassSlot); + tryBody.ldc(propertyName); + emitLoadClass(tryBody, propertyType, propertyDesc); + tryBody.invokevirtual(lookupDesc, "findVarHandle", + MethodTypeDesc.of(varHandleDesc, ConstantDescs.CD_Class, ConstantDescs.CD_String, ConstantDescs.CD_Class)); + tryBody.aload(0); + tryBody.swap(); + tryBody.putfield(thisDesc, "varHandle", varHandleDesc); + } else { + // privateLookup.findVirtual(entityClass, getterName, MethodType.methodType(returnType)) + tryBody.aload(privateLookupSlot); + tryBody.aload(entityClassSlot); + tryBody.ldc(getterName); + emitLoadClass(tryBody, propertyType, propertyDesc); + tryBody.invokestatic(methodTypeDesc2, "methodType", + MethodTypeDesc.of(methodTypeDesc2, ConstantDescs.CD_Class)); + tryBody.invokevirtual(lookupDesc, "findVirtual", + MethodTypeDesc.of(methodHandleDesc, ConstantDescs.CD_Class, ConstantDescs.CD_String, methodTypeDesc2)); + tryBody.aload(0); + tryBody.swap(); + tryBody.putfield(thisDesc, "getterHandle", methodHandleDesc); + + if (useSetterHandle) { + // privateLookup.findVirtual(entityClass, setterName, MethodType.methodType(void.class, paramType)) + tryBody.aload(privateLookupSlot); + tryBody.aload(entityClassSlot); + tryBody.ldc(setterName); + tryBody.loadConstant(ConstantDescs.CD_void); + emitLoadClass(tryBody, propertyType, propertyDesc); + tryBody.invokestatic(methodTypeDesc2, "methodType", + MethodTypeDesc.of(methodTypeDesc2, ConstantDescs.CD_Class, ConstantDescs.CD_Class)); + tryBody.invokevirtual(lookupDesc, "findVirtual", + MethodTypeDesc.of(methodHandleDesc, ConstantDescs.CD_Class, ConstantDescs.CD_String, methodTypeDesc2)); + tryBody.aload(0); + tryBody.swap(); + tryBody.putfield(thisDesc, "setterHandle", methodHandleDesc); + } + } + tryBody.return_(); + }, catches -> catches.catching(roeDesc, catchBody -> { + catchBody.astore(1); + catchBody.new_(rteDesc); + catchBody.dup(); + catchBody.aload(1); + catchBody.invokespecial(rteDesc, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Throwable;)V")); + catchBody.athrow(); + })); + }); + + // get(Object model): Object + cb.withMethodBody("get", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;"), + ClassFile.ACC_PUBLIC, cod -> { + // null check + cod.aload(1); + var label = cod.newLabel(); + cod.ifnonnull(label); + cod.aconst_null(); + cod.areturn(); + cod.labelBinding(label); + + if (useVarHandle) { + cod.aload(0); + cod.getfield(thisDesc, "varHandle", varHandleDesc); + cod.aload(1); + cod.invokevirtual(varHandleDesc, "get", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;")); + } else { + cod.aload(0); + cod.getfield(thisDesc, "getterHandle", methodHandleDesc); + cod.aload(1); + cod.invokevirtual(methodHandleDesc, "invoke", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;")); + } + if (isPrimitive()) { + cod.checkcast(wrapperDesc); + } + cod.areturn(); + }); + + // set(Object model, Object value): void + cb.withMethodBody("set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V"), + ClassFile.ACC_PUBLIC, cod -> { + if (!isFieldBased && !useSetterHandle) { + cod.new_(ClassDesc.of("java.lang.UnsupportedOperationException")); + cod.dup(); + cod.ldc("Property '%s' is read-only".formatted(propertyName)); + cod.invokespecial(ClassDesc.of("java.lang.UnsupportedOperationException"), "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + cod.athrow(); + return; + } + + if (isFinalField) { + // Use reflection to set final field + ClassDesc fieldDesc2 = ClassDesc.of("java.lang.reflect.Field"); + cod.trying(tryBody -> { + tryBody.aload(1); + tryBody.checkcast(ClassDesc.of(entity.getName())); + GizmoExtensions.emitClassRef(tryBody, entity); + tryBody.ldc(propertyName); + tryBody.invokevirtual(ConstantDescs.CD_Class, "getDeclaredField", + MethodTypeDesc.of(fieldDesc2, ConstantDescs.CD_String)); + int fieldSlot = tryBody.allocateLocal(TypeKind.REFERENCE); + tryBody.astore(fieldSlot); + tryBody.aload(fieldSlot); + tryBody.iconst_1(); + tryBody.invokevirtual(fieldDesc2, "setAccessible", + MethodTypeDesc.ofDescriptor("(Z)V")); + tryBody.aload(fieldSlot); + tryBody.aload(1); + tryBody.aload(2); + tryBody.invokevirtual(fieldDesc2, "set", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); + tryBody.return_(); + }, 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, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + catchBody.athrow(); + })); + return; + } + + if (useVarHandle) { + cod.aload(0); + cod.getfield(thisDesc, "varHandle", varHandleDesc); + cod.aload(1); + cod.aload(2); + if (isPrimitive()) { + cod.checkcast(wrapperDesc); + cod.invokevirtual(wrapperDesc, primitiveUnboxMethod(), + MethodTypeDesc.of(propertyDesc)); + cod.invokevirtual(varHandleDesc, "set", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;" + propertyDesc.descriptorString() + ")V")); + } else { + cod.checkcast(propertyDesc); + cod.invokevirtual(varHandleDesc, "set", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); + } + } else { + cod.aload(0); + cod.getfield(thisDesc, "setterHandle", methodHandleDesc); + cod.aload(1); + cod.aload(2); + if (isPrimitive()) { + cod.checkcast(wrapperDesc); + cod.invokevirtual(wrapperDesc, primitiveUnboxMethod(), + MethodTypeDesc.of(propertyDesc)); + cod.invokevirtual(methodHandleDesc, "invoke", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;" + propertyDesc.descriptorString() + ")V")); + } else { + cod.checkcast(propertyDesc); + cod.invokevirtual(methodHandleDesc, "invoke", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); + } + } + cod.return_(); + }); + }); + + critterClassLoader.register(generatedType, bytes); return this; } - private void ctor(FieldDescriptor handleDesc, FieldDescriptor setterHandleDesc) { - var constructor = getCreator().getConstructorCreator(new String[0]); - constructor.invokeSpecialMethod(ofConstructor(Object.class), constructor.getThis()); - TryBlock tryBlock = constructor.tryBlock(); - - ResultHandle[] lookupHandles = emitPrivateLookup(tryBlock); - ResultHandle entityClass = lookupHandles[0]; - ResultHandle privateLookup = lookupHandles[1]; - - if (isFieldBased) { - emitVarHandleLookup(tryBlock, privateLookup, entityClass, handleDesc); + 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()); + private static Class classForName(String name) { + try { + return Class.forName(name); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); } - - 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); - } - - ResultHandle boxed = isPrimitive() ? method.smartCast(result, getWrapperType()) : result; - method.returnValue(boxed); } - private void set(FieldDescriptor handleDesc, FieldDescriptor setterHandleDesc) { - var method = getCreator().getMethodCreator( - ofMethod(generatedType, "set", "void", Object.class.getName(), Object.class.getName())); - method.setSignature( - forMethod() - .addTypeParameter(typeVariable("S")) - .setReturnType(voidType()) - .addParameterType(typeVariable("S")) - .addParameterType(classType(propertyType)) - .build()); - method.setParameterNames(new String[] { "model", "value" }); - - if (!isFieldBased && setterHandleDesc == null) { - // Read-only property — no setter - method.throwException(UnsupportedOperationException.class, "Property '%s' is read-only".formatted(propertyName)); - return; - } - - // Final fields: VarHandle.set() is not supported; fall back to reflection - if (isFinalField) { - TryBlock tryBlock = method.tryBlock(); - ResultHandle fieldRef = tryBlock.invokeVirtualMethod( - ofMethod(Class.class, "getDeclaredField", Field.class, String.class), - GizmoExtensions.emitClassRef(tryBlock, entity), - tryBlock.load(propertyName)); - tryBlock.invokeVirtualMethod( - ofMethod(Field.class, "setAccessible", void.class, boolean.class), - fieldRef, - tryBlock.load(true)); - tryBlock.invokeVirtualMethod( - ofMethod(Field.class, "set", void.class, Object.class, Object.class), - fieldRef, - tryBlock.getMethodParam(0), - tryBlock.getMethodParam(1)); - tryBlock.returnValue(null); - var catchBlock = tryBlock.addCatch(Exception.class); - catchBlock.throwException(RuntimeException.class, "Failed to set final field '%s'".formatted(propertyName)); - return; - } - - ResultHandle castModel = method.getMethodParam(0); - ResultHandle castValue; + private ClassDesc propertyClassDesc() { if (isPrimitive()) { - ResultHandle boxed = method.checkCast(method.getMethodParam(1), getWrapperType()); - castValue = method.smartCast(boxed, propertyType); - } else { - castValue = method.checkCast(method.getMethodParam(1), propertyType); - } - - ResultHandle handleRef = isFieldBased - ? method.readInstanceField(handleDesc, method.getThis()) - : method.readInstanceField(setterHandleDesc, method.getThis()); - - if (isFieldBased) { - method.invokeVirtualMethod( - ofMethod(VarHandle.class, "set", "void", Object.class.getName(), Object.class.getName()), - handleRef, - castModel, - castValue); - } else { - method.invokeVirtualMethod( - ofMethod(MethodHandle.class, "invoke", "void", Object.class.getName(), Object.class.getName()), - handleRef, - castModel, - castValue); + return switch (propertyType) { + case "boolean" -> ConstantDescs.CD_boolean; + case "byte" -> ConstantDescs.CD_byte; + case "char" -> ConstantDescs.CD_char; + case "short" -> ConstantDescs.CD_short; + case "int" -> ConstantDescs.CD_int; + case "long" -> ConstantDescs.CD_long; + case "float" -> ConstantDescs.CD_float; + case "double" -> ConstantDescs.CD_double; + default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); + }; } + return ClassDesc.of(propertyType); + } - method.returnValue(null); + private String primitiveUnboxMethod() { + return switch (propertyType) { + case "boolean" -> "booleanValue"; + case "byte" -> "byteValue"; + case "char" -> "charValue"; + case "short" -> "shortValue"; + case "int" -> "intValue"; + case "long" -> "longValue"; + case "float" -> "floatValue"; + case "double" -> "doubleValue"; + default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); + }; } } diff --git a/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java b/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java index a43437136c5..29bd5585f7e 100644 --- a/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java +++ b/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java @@ -1,65 +1,30 @@ package dev.morphia.critter.parser.java; -import java.io.File; -import java.io.PrintWriter; -import java.io.StringWriter; import java.util.List; -import java.util.stream.Collectors; import dev.morphia.critter.Critter; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.Type; -import org.objectweb.asm.util.ASMifier; -import org.objectweb.asm.util.TraceClassVisitor; - /** - * Singleton parser providing ASMifier-based bytecode inspection and annotation descriptor resolution utilities. + * Singleton parser providing annotation descriptor resolution utilities. */ public class CritterParser { /** The singleton instance of this parser. */ public static final CritterParser INSTANCE = new CritterParser(); - /** Optional output directory for writing generated source files; {@code null} disables file output. */ - public File outputGenerated = null; - private CritterParser() { } /** - * Converts the given class bytecode to its ASMifier source representation. - * - * @param bytes the class bytecode - * @return the ASMifier source code as a string - */ - public String asmify(byte[] bytes) { - ClassReader classReader = new ClassReader(bytes); - StringWriter traceWriter = new StringWriter(); - PrintWriter printWriter = new PrintWriter(traceWriter); - TraceClassVisitor traceClassVisitor = new TraceClassVisitor(null, new ASMifier(), printWriter); - classReader.accept(traceClassVisitor, 0); - return traceWriter.toString(); - } - - /** - * Returns the ASM descriptors of all annotation types that mark a field or method as a mapped property. - * - * @return list of property annotation descriptors + * Returns the descriptors of all annotation types that mark a field or method as a mapped property. */ public List propertyAnnotations() { - return Critter.propertyAnnotations.stream() - .map(Type::getDescriptor) - .collect(Collectors.toList()); + return Critter.propertyAnnotations; } /** - * Returns the ASM descriptors of all annotation types that mark a field or method as transient (not persisted). - * - * @return list of transient annotation descriptors + * Returns the descriptors of all annotation types that mark a field or method as transient (not persisted). */ public List transientAnnotations() { - return Critter.transientAnnotations.stream() - .map(Type::getDescriptor) - .collect(Collectors.toList()); + return Critter.transientAnnotations; } } diff --git a/core/src/test/java/dev/morphia/critter/parser/TypesTest.java b/core/src/test/java/dev/morphia/critter/parser/TypesTest.java index 18d195ad667..7db4d4ebca6 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TypesTest.java +++ b/core/src/test/java/dev/morphia/critter/parser/TypesTest.java @@ -1,5 +1,6 @@ package dev.morphia.critter.parser; +import java.lang.constant.ClassDesc; import java.math.BigDecimal; import java.time.Instant; import java.util.Date; @@ -13,7 +14,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.objectweb.asm.Type; public class TypesTest { @@ -80,11 +80,35 @@ static Stream types() { Arguments.of(String[][][].class)); } + private static String classToDescriptor(Class c) { + if (c.isArray()) + return "[" + classToDescriptor(c.getComponentType()); + if (c == boolean.class) + return "Z"; + if (c == char.class) + return "C"; + if (c == byte.class) + return "B"; + if (c == short.class) + return "S"; + if (c == int.class) + return "I"; + if (c == long.class) + return "J"; + if (c == float.class) + return "F"; + if (c == double.class) + return "D"; + if (c == void.class) + return "V"; + return "L" + c.getName().replace('.', '/') + ";"; + } + @ParameterizedTest @MethodSource("types") public void asClassConversion(Class expected) { - Type type = Type.getType(expected); + ClassDesc type = ClassDesc.ofDescriptor(classToDescriptor(expected)); Class actual = GizmoExtensions.asClass(type, Thread.currentThread().getContextClassLoader()); - Assertions.assertEquals(expected, actual, "Type " + type.getDescriptor() + " should convert to " + expected.getName()); + Assertions.assertEquals(expected, actual, "Type " + type.descriptorString() + " should convert to " + expected.getName()); } } diff --git a/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java b/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java index 79b58116ee6..faf77905e1e 100644 --- a/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java +++ b/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java @@ -19,8 +19,8 @@ import dev.morphia.annotations.internal.IndexBuilder; import dev.morphia.annotations.internal.IndexOptionsBuilder; import dev.morphia.annotations.internal.IndexesBuilder; -import dev.morphia.critter.ClassfileOutput; import dev.morphia.critter.CritterClassLoader; +import dev.morphia.critter.parser.MethodInfo; import dev.morphia.critter.parser.asm.AddMethodAccessorMethods; import dev.morphia.critter.sources.Example; import dev.morphia.critter.sources.MethodExample; @@ -31,18 +31,14 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.AnnotationNode; -import org.objectweb.asm.tree.ClassNode; -import org.objectweb.asm.tree.MethodNode; -import io.quarkus.gizmo.ClassCreator; -import io.quarkus.gizmo.MethodDescriptor; +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; +import io.github.dmlloyd.classfile.attribute.RuntimeVisibleAnnotationsAttribute; import static com.mongodb.client.model.CollationCaseFirst.LOWER; import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; -import static io.quarkus.gizmo.MethodDescriptor.ofMethod; +import static io.github.dmlloyd.classfile.Attributes.runtimeVisibleAnnotations; public class TestGizmoGeneration { private final CritterClassLoader critterClassLoader = new CritterClassLoader(); @@ -50,12 +46,6 @@ public class TestGizmoGeneration { @Test public void testMapStringExample() { String descString = "Ljava/util/Map;"; - String descriptor = descriptor( - java.util.Map.class, - descriptor(String.class), - descriptor(Example.class)); - - Assertions.assertEquals(descString, descriptor); TypeData typeData = PropertyModelGenerator.typeData(descString, Thread.currentThread().getContextClassLoader()).get(0); Assertions.assertEquals(typeDataHelper(java.util.Map.class, typeDataHelper(String.class), typeDataHelper(Example.class)), typeData); } @@ -63,14 +53,6 @@ public void testMapStringExample() { @Test public void testListMapStringExample() { String descString = "Ljava/util/List;>;"; - String descriptor = descriptor( - java.util.List.class, - descriptor( - java.util.Map.class, - descriptor(String.class), - descriptor(Example.class))); - Assertions.assertEquals(descString, descriptor); - TypeData typeData = PropertyModelGenerator.typeData(descString, Thread.currentThread().getContextClassLoader()).get(0); Assertions.assertEquals(typeDataHelper(java.util.List.class, typeDataHelper(java.util.Map.class, typeDataHelper(String.class), typeDataHelper(Example.class))), typeData); @@ -79,12 +61,7 @@ public void testListMapStringExample() { @Test public void testMapOfList() { String descString = "Ljava/util/Map;>;"; - String descriptor = descriptor( - java.util.Map.class, - descriptor(String.class), - descriptor(java.util.List.class, descriptor(Example.class))); - Assertions.assertEquals(descString, descriptor); - TypeData typeData = PropertyModelGenerator.typeData(descriptor, Thread.currentThread().getContextClassLoader()).get(0); + TypeData typeData = PropertyModelGenerator.typeData(descString, Thread.currentThread().getContextClassLoader()).get(0); Assertions.assertEquals(typeDataHelper(java.util.Map.class, typeDataHelper(String.class), typeDataHelper(java.util.List.class, typeDataHelper(Example.class))), typeData); @@ -98,7 +75,6 @@ public void testPrimitiveArray() { @Test public void testMultiDimensionalArray() { - // int[][] — verifies that nested visitArrayType() calls propagate correctly TypeData typeData = PropertyModelGenerator.typeData("[[I", Thread.currentThread().getContextClassLoader()).get(0); Assertions.assertTrue(typeData.isArray()); Assertions.assertEquals(int[][].class, typeData.getType()); @@ -106,42 +82,10 @@ public void testMultiDimensionalArray() { @Test public void testMalformedSignatureReturnsEmpty() { - // Malformed signatures must return empty rather than throw var result = PropertyModelGenerator.typeData("!!not-a-valid-signature!!", Thread.currentThread().getContextClassLoader()); Assertions.assertTrue(result.isEmpty()); } - @Test - public void testAnnotationBuilding() throws Exception { - AnnotationNode index = new AnnotationNode("Ldev/morphia/annotations/Index;"); - AnnotationNode field = new AnnotationNode("Ldev/morphia/annotations/Field;"); - index.values = List.of("fields", List.of(field)); - - try (ClassCreator creator = ClassCreator.builder() - .className("critter.AnnotationTest") - .superClass(EntityModel.class) - .classOutput((name, data) -> { - String className = name.replace('/', '.'); - critterClassLoader.register(className, data); - try { - ClassfileOutput.dump(name, data); - } catch (Exception ignored) { - } - }) - .build()) { - var mc = creator.getMethodCreator("test", Void.class); - MethodDescriptor annotationMethod = ofMethod( - EntityModel.class.getName(), - "annotation", - EntityModel.class.getName(), - java.lang.annotation.Annotation.class); - mc.invokeVirtualMethod( - annotationMethod, - mc.getThis(), - GizmoExtensions.annotationBuilder(index, mc)); - } - } - @Test public void testGizmo() throws Exception { new CritterGizmoGenerator(defaultMapper()).generate(Example.class, critterClassLoader, false); @@ -213,77 +157,48 @@ private void invokeAll(Class type, Class klass) { } } - @Test - public void testConstructors() throws Exception { - String className = "dev.morphia.critter.GizmoSubclass"; - - try (ClassCreator constructorCall = ClassCreator.builder() - .classOutput((name, data) -> critterClassLoader.register(name.replace('/', '.'), data)) - .className("dev.morphia.critter.ConstructorCall") - .build()) { - var fieldCreator = constructorCall.getFieldCreator("name", String.class) - .setModifiers(Modifier.PUBLIC); - var constructorCreator = constructorCall.getConstructorCreator(String.class); - constructorCreator.invokeSpecialMethod( - MethodDescriptor.ofConstructor(Object.class), - constructorCreator.getThis()); - constructorCreator.setParameterNames(new String[] { "name" }); - constructorCreator.writeInstanceField( - fieldCreator.getFieldDescriptor(), - constructorCreator.getThis(), - constructorCreator.getMethodParam(0)); - constructorCreator.returnVoid(); - } - - critterClassLoader.loadClass("dev.morphia.critter.ConstructorCall") - .getConstructor(String.class) - .newInstance("here i am"); - - try (ClassCreator creator = ClassCreator.builder() - .classOutput((name, data) -> critterClassLoader.register(name.replace('/', '.'), data)) - .className(className) - .superClass("dev.morphia.critter.ConstructorCall") - .build()) { - var constructor = creator.getConstructorCreator(String.class); - constructor.invokeSpecialMethod( - MethodDescriptor.ofConstructor("dev.morphia.critter.ConstructorCall", String.class), - constructor.getThis(), - constructor.getMethodParam(0)); - constructor.setParameterNames(new String[] { "subName" }); - constructor.returnVoid(); - } - - Object instance = critterClassLoader.loadClass(className) - .getConstructor(String.class) - .newInstance("This is my name"); - Assertions.assertNotNull(instance); - } - @Test public void testMethodBasedAccessors() throws Exception { CritterClassLoader classLoader = new CritterClassLoader(); String resourceName = MethodExample.class.getName().replace('.', '/') + ".class"; - var inputStream = MethodExample.class.getClassLoader().getResourceAsStream(resourceName); - ClassNode classNode = new ClassNode(); - new ClassReader(inputStream).accept(classNode, 0); - - List methodNodes = classNode.methods.stream() - .filter(node -> node.name.startsWith("get")) - .filter(node -> Type.getArgumentTypes(node.desc).length == 0) - .filter(node -> node.visibleAnnotations != null - && node.visibleAnnotations.stream().anyMatch( - ann -> List.of("Ldev/morphia/annotations/Id;", "Ldev/morphia/annotations/Property;").contains(ann.desc))) + byte[] classBytes; + try (var inputStream = MethodExample.class.getClassLoader().getResourceAsStream(resourceName)) { + classBytes = inputStream.readAllBytes(); + } + ClassModel classModel = ClassFile.of().parse(classBytes); + + List targetAnnotations = List.of("Ldev/morphia/annotations/Id;", "Ldev/morphia/annotations/Property;"); + List methodInfos = classModel.methods().stream() + .filter(m -> m.methodName().stringValue().startsWith("get")) + .filter(m -> { + var rva = m.findAttribute(runtimeVisibleAnnotations()); + if (rva.isEmpty()) + return false; + return rva.get().annotations().stream() + .anyMatch(ann -> targetAnnotations.contains(ann.classSymbol().descriptorString())); + }) + .map(m -> { + var rva = m.findAttribute(runtimeVisibleAnnotations()); + List anns = rva.map(RuntimeVisibleAnnotationsAttribute::annotations) + .orElse(List.of()); + return new MethodInfo( + m.methodName().stringValue(), + m.methodType().stringValue(), + null, + m.flags().flagsMask(), + anns); + }) .collect(Collectors.toList()); - List methodNames = methodNodes.stream().map(n -> n.name).collect(Collectors.toList()); + List methodNames = methodInfos.stream().map(MethodInfo::name).collect(Collectors.toList()); Assertions.assertTrue(methodNames.contains("getId"), "Should find getId method"); Assertions.assertTrue(methodNames.contains("getCount"), "Should find getCount method"); Assertions.assertTrue(methodNames.contains("getScore"), "Should find getScore method"); Assertions.assertTrue(methodNames.contains("getComputedValue"), "Should find getComputedValue method"); - Assertions.assertEquals(methodNodes.size(), 4, "Should find exactly 4 annotated getter methods"); + Assertions.assertEquals(4, methodInfos.size(), "Should find exactly 4 annotated getter methods"); - byte[] bytecode = new AddMethodAccessorMethods(MethodExample.class, methodNodes).emit(); + byte[] bytecode = new AddMethodAccessorMethods(MethodExample.class, methodInfos).emit(); classLoader.register(MethodExample.class.getName(), bytecode); Class modifiedClass = classLoader.loadClass(MethodExample.class.getName()); @@ -312,16 +227,6 @@ public void testMethodBasedAccessors() throws Exception { } } - private String descriptor(Class type, String... typeParameters) { - String desc = Type.getDescriptor(type); - if (typeParameters.length > 0) { - desc = desc.substring(0, desc.length() - 1) - + "<" + String.join("", typeParameters) + ">" - + ";"; - } - return desc; - } - @SuppressWarnings("unchecked") private static TypeData typeDataHelper(Class clazz, TypeData... params) { return new TypeData<>(clazz, Arrays.asList(params)); diff --git a/pom.xml b/pom.xml index c0933a593bf..b4cfdb41ce1 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,7 @@ https://mongodb.github.io/mongo-java-driver/${driver.minor.version}/apidocs https://morphia.dev/morphia/${morphia.minor.version}/javadoc 1.10.1 + 25.1 2.22.0 2.22 1.5.3 @@ -375,6 +376,11 @@ gizmo ${gizmo.version} + + io.github.dmlloyd + jdk-classfile-backport + ${classfile.backport.version} + net.bytebuddy byte-buddy From 6e262212e326146f1f2642d0417e5aad8adbee0c Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 19:55:06 -0400 Subject: [PATCH 02/31] Fix critter mapper test failures with ClassFile API backport - 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 --- .../critter/parser/ExtensionFunctions.java | 4 +- .../critter/parser/PropertyFinder.java | 6 +- .../critter/parser/gizmo/GizmoExtensions.java | 28 ++++- .../gizmo/PropertyAccessorGenerator.java | 23 +++- .../parser/gizmo/PropertyModelGenerator.java | 102 +++++++++++++++++- .../gizmo/VarHandleAccessorGenerator.java | 22 ++-- .../critter/parser/java/CritterParser.java | 5 +- .../pojo/critter/CritterPropertyModel.java | 40 +++++++ 8 files changed, 209 insertions(+), 21 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java b/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java index 939bda12900..eebc77a1cb2 100644 --- a/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java +++ b/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java @@ -60,9 +60,7 @@ public static String getterToPropertyName(MethodInfo method, Class entity) { if (mtd.parameterCount() == 0) { String returnDesc = mtd.returnType().descriptorString(); for (java.lang.reflect.Field field : entity.getDeclaredFields()) { - String fieldDesc = io.github.dmlloyd.classfile.TypeKind - .from(java.lang.constant.ClassDesc.ofDescriptor(returnDesc)).upperBound().descriptorString(); - if (field.getName().equals(methodName)) { + if (field.getName().equals(methodName) && field.getType().descriptorString().equals(returnDesc)) { return methodName; } } diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 54cdf713b7d..4afc8ddcda9 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -214,7 +214,11 @@ private boolean isGetter(MethodModel method) { String name = method.methodName().stringValue(); if (!name.startsWith("get") && !name.startsWith("is")) return false; - if ((method.flags().flagsMask() & ClassFile.ACC_STATIC) != 0) + int flags = method.flags().flagsMask(); + if ((flags & ClassFile.ACC_STATIC) != 0) + return false; + // 0x0040 = ACC_BRIDGE: skip compiler-generated covariant bridge methods + if ((flags & 0x0040) != 0) return false; java.lang.constant.MethodTypeDesc mtd = java.lang.constant.MethodTypeDesc .ofDescriptor(method.methodType().stringValue()); diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java index 99203766f9a..26d72dfc0c9 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java @@ -121,7 +121,11 @@ private static void emitObjectArray(CodeBuilder cod, Class componentType, Obj * Emits bytecode that loads a Class reference. Non-public classes use Class.forName(). */ public static void emitClassRef(CodeBuilder cod, Class cls) { - if (Modifier.isPublic(cls.getModifiers())) { + if (cls.isPrimitive()) { + // Primitives are not loadable via ClassDesc.of; use the wrapper's TYPE field + String wrapper = primitiveWrapperName(cls); + cod.getstatic(ClassDesc.of(wrapper), "TYPE", ConstantDescs.CD_Class); + } else if (Modifier.isPublic(cls.getModifiers())) { cod.loadConstant(ClassDesc.of(cls.getName())); } else { cod.ldc(cls.getName()); @@ -141,6 +145,28 @@ public static void emitClassRef(CodeBuilder cod, Class cls) { } } + private static String primitiveWrapperName(Class primitive) { + if (primitive == boolean.class) + return "java.lang.Boolean"; + if (primitive == byte.class) + return "java.lang.Byte"; + if (primitive == char.class) + return "java.lang.Character"; + if (primitive == short.class) + return "java.lang.Short"; + if (primitive == int.class) + return "java.lang.Integer"; + if (primitive == long.class) + return "java.lang.Long"; + if (primitive == float.class) + return "java.lang.Float"; + if (primitive == double.class) + return "java.lang.Double"; + if (primitive == void.class) + return "java.lang.Void"; + throw new IllegalArgumentException("Not a primitive: " + primitive); + } + /** * Emits bytecode that constructs a TypeData instance. */ diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java index bbc30dc26ea..ead5a6f3f69 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java @@ -55,6 +55,11 @@ private static String typeClassName(ClassDesc cd) { default -> throw new IllegalArgumentException("Unknown primitive: " + desc); }; } + if (desc.startsWith("[")) { + // Array: return Class.forName-compatible form, e.g. [Ljava.lang.String; + return desc.replace('/', '.'); + } + // Object type: Ljava/lang/String; → java.lang.String return desc.substring(1, desc.length() - 1).replace('/', '.'); } @@ -77,7 +82,14 @@ public String getWrapperType() { public PropertyAccessorGenerator emit() { ClassDesc thisDesc = ClassDesc.of(generatedType); ClassDesc entityDesc = ClassDesc.of(entity.getName()); - ClassDesc propertyDesc = ClassDesc.ofDescriptor(isPrimitive() ? primitiveDescriptor() : "L" + propertyType.replace('.', '/') + ";"); + ClassDesc propertyDesc; + if (isPrimitive()) { + propertyDesc = ClassDesc.ofDescriptor(primitiveDescriptor()); + } else if (propertyType.startsWith("[")) { + propertyDesc = ClassDesc.ofDescriptor(propertyType.replace('.', '/')); + } else { + propertyDesc = ClassDesc.ofDescriptor("L" + propertyType.replace('.', '/') + ";"); + } ClassDesc wrapperDesc = ClassDesc.of(getWrapperType()); ClassDesc accessorDesc = ClassDesc.of("org.bson.codecs.pojo.PropertyAccessor"); @@ -87,10 +99,13 @@ public PropertyAccessorGenerator emit() { cb.withSuperclass(ConstantDescs.CD_Object); cb.withInterfaceSymbols(accessorDesc); - // Class signature: Ljava/lang/Object;Lorg/bson/codecs/pojo/PropertyAccessor; + // Class signature: Ljava/lang/Object;Lorg/bson/codecs/pojo/PropertyAccessor; + String propDesc = propertyType.startsWith("[") + ? propertyType.replace('.', '/') + : "L" + propertyType.replace('.', '/') + ";"; String sigStr = "Ljava/lang/Object;L" - + accessorDesc.descriptorString().substring(1, accessorDesc.descriptorString().length() - 1) + ";"; + + accessorDesc.descriptorString().substring(1, accessorDesc.descriptorString().length() - 1) + "<" + + propDesc + ">" + ";"; cb.with(SignatureAttribute.of(ClassSignature.parseFrom(sigStr))); // default constructor diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java index 9f9be8a6d11..155fedb0ae3 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java @@ -12,6 +12,7 @@ import java.lang.reflect.WildcardType; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -51,6 +52,7 @@ public class PropertyModelGenerator extends BaseGizmoGenerator { private final Map annotationMap; private final TypeData typeData; private final List morphiaAnnotations; + private final String getterName; /** * Creates a generator for a field-based property. @@ -67,8 +69,9 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas this.accessFlags = reflectedField != null ? reflectedField.getModifiers() : field.access(); this.genericType = reflectedField != null ? reflectedField.getGenericType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedField != null ? reflectedField.getAnnotations() : new Annotation[0]); - this.typeData = computeTypeData(this.genericType, entity.getClassLoader()); + this.typeData = computeTypeData(resolveGenericType(this.genericType, field.name(), entity), entity.getClassLoader()); this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); + this.getterName = null; } /** @@ -86,8 +89,17 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas this.accessFlags = reflectedMethod != null ? reflectedMethod.getModifiers() : method.access(); this.genericType = reflectedMethod != null ? reflectedMethod.getGenericReturnType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedMethod != null ? reflectedMethod.getAnnotations() : new Annotation[0]); - this.typeData = computeTypeData(this.genericType, entity.getClassLoader()); + // Also collect setter annotations — some annotations (e.g. @Version, @Text) live on the setter, not the getter + String setterName = "set" + Critter.titleCase(this.propertyName); + Method reflectedSetter = findSetterMethod(entity, setterName); + if (reflectedSetter != null) { + for (Annotation ann : reflectedSetter.getAnnotations()) { + this.annotationMap.putIfAbsent(ann.annotationType().getName(), ann); + } + } + this.typeData = computeTypeData(resolveGenericType(this.genericType, this.propertyName, entity), entity.getClassLoader()); this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); + this.getterName = method.name(); } private static Field findField(Class cls, String name) { @@ -106,7 +118,20 @@ private static Method findMethod(Class cls, String name) { Class current = cls; while (current != null && current != Object.class) { for (Method m : current.getDeclaredMethods()) { - if (m.getName().equals(name) && m.getParameterCount() == 0) { + if (m.getName().equals(name) && m.getParameterCount() == 0 && !m.isBridge()) { + return m; + } + } + current = current.getSuperclass(); + } + return null; + } + + private static Method findSetterMethod(Class cls, String setterName) { + Class current = cls; + while (current != null && current != Object.class) { + for (Method m : current.getDeclaredMethods()) { + if (m.getName().equals(setterName) && m.getParameterCount() == 1 && !m.isBridge()) { return m; } } @@ -123,6 +148,57 @@ private static Map buildAnnotationMap(Annotation[] annotatio return map; } + private static java.lang.reflect.Type resolveGenericType(java.lang.reflect.Type type, String memberName, Class entity) { + if (!(type instanceof TypeVariable tv)) { + return type; + } + Class declaringClass = findDeclaringClass(memberName, entity); + Class resolved = resolveTypeVariable(tv.getName(), entity, declaringClass); + return (resolved != null && resolved != Object.class) ? resolved : type; + } + + private static Class findDeclaringClass(String memberName, Class concreteClass) { + Class current = concreteClass; + while (current != null && current != Object.class) { + try { + current.getDeclaredField(memberName); + return current; + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } + + private static Class resolveTypeVariable(String typeVarName, Class concreteClass, Class declaringClass) { + Map bindings = new HashMap<>(); + Class current = concreteClass; + while (current != null && current != Object.class) { + java.lang.reflect.Type genericSuper = current.getGenericSuperclass(); + Class superClass = current.getSuperclass(); + if (superClass == null || superClass == Object.class) { + break; + } + if (genericSuper instanceof ParameterizedType paramType) { + TypeVariable[] typeParams = superClass.getTypeParameters(); + java.lang.reflect.Type[] typeArgs = paramType.getActualTypeArguments(); + for (int i = 0; i < typeParams.length && i < typeArgs.length; i++) { + java.lang.reflect.Type arg = typeArgs[i]; + if (arg instanceof TypeVariable argTv && bindings.containsKey(argTv.getName())) { + arg = bindings.get(argTv.getName()); + } + bindings.put(typeParams[i].getName(), arg); + } + } + if (declaringClass != null && superClass == declaringClass) { + break; + } + current = superClass; + } + java.lang.reflect.Type resolved = bindings.get(typeVarName); + return resolved instanceof Class c ? c : null; + } + /** * Converts a java.lang.reflect.Type into a TypeData instance. */ @@ -287,7 +363,7 @@ public PropertyModelGenerator emit() { cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); cod.putfield(thisDesc, "accessor", accessorImplDesc); - // Register annotations + // Register Morphia annotations via generated builders for (Annotation ann : morphiaAnnotations) { if (ann.annotationType().getName().startsWith("dev.morphia.annotations.")) { cod.aload(0); @@ -298,6 +374,24 @@ public PropertyModelGenerator emit() { } } + // Register all annotations (including non-Morphia ones like @NonNull) via reflection + ClassDesc critterPmDesc = ClassDesc.of("dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel"); + if (isFieldBased) { + cod.aload(0); + GizmoExtensions.emitClassRef(cod, entity); + cod.ldc(propertyName); + cod.invokestatic(critterPmDesc, "registerFieldAnnotations", + MethodTypeDesc.ofDescriptor( + "(Ldev/morphia/mapping/codec/pojo/PropertyModel;Ljava/lang/Class;Ljava/lang/String;)V")); + } else { + cod.aload(0); + GizmoExtensions.emitClassRef(cod, entity); + cod.ldc(getterName); + cod.invokestatic(critterPmDesc, "registerMethodAnnotations", + MethodTypeDesc.ofDescriptor( + "(Ldev/morphia/mapping/codec/pojo/PropertyModel;Ljava/lang/Class;Ljava/lang/String;)V")); + } + cod.return_(); }); diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java index 2b6f3a43b34..4800812c271 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java @@ -107,6 +107,11 @@ private static String typeClassName(ClassDesc cd) { default -> throw new IllegalArgumentException("Unknown primitive: " + desc); }; } + if (desc.startsWith("[")) { + // Array: return Class.forName-compatible form, e.g. [Ljava.lang.String; + return desc.replace('/', '.'); + } + // Object type: Ljava/lang/String; → java.lang.String return desc.substring(1, desc.length() - 1).replace('/', '.'); } @@ -184,10 +189,12 @@ public VarHandleAccessorGenerator emit() { cb.withInterfaceSymbols(accessorDesc); // Class signature: implements PropertyAccessor - String propInternalName = propertyType.replace('.', '/'); String wrapperInternalName = getWrapperType().replace('.', '/'); String accInternal = "org/bson/codecs/pojo/PropertyAccessor"; - String sigStr = "Ljava/lang/Object;L" + accInternal + ";"; + String wrapperTypeArg = wrapperInternalName.startsWith("[") + ? wrapperInternalName + : "L" + wrapperInternalName + ";"; + String sigStr = "Ljava/lang/Object;L" + accInternal + "<" + wrapperTypeArg + ">" + ";"; cb.with(SignatureAttribute.of(ClassSignature.parseFrom(sigStr))); // Field: varHandle or getterHandle @@ -338,8 +345,6 @@ public VarHandleAccessorGenerator emit() { // Use reflection to set final field ClassDesc fieldDesc2 = ClassDesc.of("java.lang.reflect.Field"); cod.trying(tryBody -> { - tryBody.aload(1); - tryBody.checkcast(ClassDesc.of(entity.getName())); GizmoExtensions.emitClassRef(tryBody, entity); tryBody.ldc(propertyName); tryBody.invokevirtual(ConstantDescs.CD_Class, "getDeclaredField", @@ -380,7 +385,8 @@ public VarHandleAccessorGenerator emit() { cod.invokevirtual(varHandleDesc, "set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;" + propertyDesc.descriptorString() + ")V")); } else { - cod.checkcast(propertyDesc); + // skip checkcast: VarHandle.set(Object,Object) accepts Object; + // checkcast to a non-public inner class would cause IllegalAccessError cod.invokevirtual(varHandleDesc, "set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); } @@ -396,7 +402,8 @@ public VarHandleAccessorGenerator emit() { cod.invokevirtual(methodHandleDesc, "invoke", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;" + propertyDesc.descriptorString() + ")V")); } else { - cod.checkcast(propertyDesc); + // skip checkcast: MethodHandle.invoke(Object,Object) accepts Object; + // checkcast to a non-public inner class would cause IllegalAccessError cod.invokevirtual(methodHandleDesc, "invoke", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); } @@ -439,6 +446,9 @@ private ClassDesc propertyClassDesc() { default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); }; } + if (propertyType.startsWith("[")) { + return ClassDesc.ofDescriptor(propertyType.replace('.', '/')); + } return ClassDesc.of(propertyType); } diff --git a/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java b/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java index 29bd5585f7e..037711939c6 100644 --- a/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java +++ b/core/src/main/java/dev/morphia/critter/parser/java/CritterParser.java @@ -1,5 +1,6 @@ package dev.morphia.critter.parser.java; +import java.util.Collections; import java.util.List; import dev.morphia.critter.Critter; @@ -18,13 +19,13 @@ private CritterParser() { * Returns the descriptors of all annotation types that mark a field or method as a mapped property. */ public List propertyAnnotations() { - return Critter.propertyAnnotations; + return Collections.unmodifiableList(Critter.propertyAnnotations); } /** * Returns the descriptors of all annotation types that mark a field or method as transient (not persisted). */ public List transientAnnotations() { - return Critter.transientAnnotations; + return Collections.unmodifiableList(Critter.transientAnnotations); } } diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java index 85b62f6a616..94c954403ea 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java @@ -1,6 +1,8 @@ package dev.morphia.mapping.codec.pojo.critter; import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.List; import dev.morphia.mapping.codec.pojo.EntityModel; @@ -15,6 +17,44 @@ public CritterPropertyModel(EntityModel entityModel) { super(entityModel); } + /** + * Registers all annotations from the entity's field (walking the class hierarchy) into this model. + * Called from generated subclass constructors so non-Morphia annotations (e.g. @NonNull) are also recorded. + */ + public static void registerFieldAnnotations(PropertyModel model, Class entityClass, String fieldName) { + Class current = entityClass; + while (current != null && current != Object.class) { + try { + Field field = current.getDeclaredField(fieldName); + for (Annotation ann : field.getDeclaredAnnotations()) { + model.annotation(ann); + } + return; + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + } + + /** + * Registers all annotations from the entity's getter method (walking the class hierarchy) into this model. + * Called from generated subclass constructors so non-Morphia annotations on getters are also recorded. + */ + public static void registerMethodAnnotations(PropertyModel model, Class entityClass, String getterName) { + Class current = entityClass; + while (current != null && current != Object.class) { + for (Method m : current.getDeclaredMethods()) { + if (m.getName().equals(getterName) && m.getParameterCount() == 0 && !m.isBridge()) { + for (Annotation ann : m.getDeclaredAnnotations()) { + model.annotation(ann); + } + return; + } + } + current = current.getSuperclass(); + } + } + @Override public abstract PropertyAccessor getAccessor(); From 27d2f1b8b44d89da8e949bae18b1b156993c5c8d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 20:57:04 -0400 Subject: [PATCH 03/31] removing dead code --- .../src/main/java/util/AsmBuilders.java | 454 ------------------ .../java/util/KotlinAnnotationExtensions.java | 339 ------------- .../kotlin/util/AnnotationNodeExtensions.kt | 103 ---- 3 files changed, 896 deletions(-) delete mode 100644 build-plugins/src/main/java/util/AsmBuilders.java delete mode 100644 build-plugins/src/main/java/util/KotlinAnnotationExtensions.java delete mode 100644 build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt diff --git a/build-plugins/src/main/java/util/AsmBuilders.java b/build-plugins/src/main/java/util/AsmBuilders.java deleted file mode 100644 index 635e5eaca0b..00000000000 --- a/build-plugins/src/main/java/util/AsmBuilders.java +++ /dev/null @@ -1,454 +0,0 @@ -package util; - -import java.io.File; -import java.io.FileFilter; -import java.io.FileWriter; -import java.io.IOException; -import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.StringJoiner; -import java.util.TreeMap; - -import org.apache.maven.api.Language; -import org.apache.maven.api.ProjectScope; -import org.apache.maven.plugin.AbstractMojo; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.project.MavenProject; -import org.jboss.forge.roaster.ParserException; -import org.jboss.forge.roaster.Roaster; -import org.jboss.forge.roaster.model.source.AnnotationElementSource; -import org.jboss.forge.roaster.model.source.JavaAnnotationSource; -import org.jboss.forge.roaster.model.source.JavaClassSource; -import org.jboss.forge.roaster.model.source.JavaDocSource; -import org.jboss.forge.roaster.model.source.JavaSource; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.Opcodes; -import org.objectweb.asm.Type; -import org.objectweb.asm.tree.AnnotationNode; - -import static java.lang.String.format; -import static java.util.Arrays.asList; -import static util.AnnotationBuilders.methodCase; - -/** - * Maven Mojo that generates ASM-based builder classes for Morphia annotation types. - */ -@Mojo(name = "morphia-annotations-asm", defaultPhase = LifecyclePhase.GENERATE_SOURCES) -public class AsmBuilders extends AbstractMojo { - - /** Creates a new Mojo instance. */ - public AsmBuilders() { - } - - private final Map builders = new TreeMap<>(); - - @Parameter(defaultValue = "${project}", required = true, readonly = true) - private MavenProject project; - - private JavaClassSource factory; - - private File generated; - - private final FileFilter filter = pathname -> pathname.getName().endsWith(".java") - && !pathname.getName().endsWith("Handler.java") - && !pathname.getName().endsWith("Helper.java") - && !pathname.getName().equals("package-info.java"); - - @Override - @SuppressWarnings("ConstantConditions") - public void execute() throws MojoExecutionException { - generated = new File(project.getBaseDirectory() + "/target/generated-sources/morphia-annotations/"); - - String path = core() + "/src/main/java/dev/morphia/annotations"; - List files = new ArrayList<>(find(path)); - project.addSourceRoot(ProjectScope.MAIN, Language.JAVA_FAMILY, generated.getAbsolutePath()); - - try { - for (File file : files) { - try { - var source = Roaster.parse(JavaAnnotationSource.class, file); - if (source.isPublic()) { - builders.put(source.getName(), source); - } - } catch (ParserException e) { - throw new MojoExecutionException("Could not parse " + file, e); - } - } - emitFactory(); - } catch (Exception e) { - throw new MojoExecutionException(e.getMessage(), e); - } - } - - private File core() { - var dir = project.getBaseDirectory().toFile(); - while (!new File(dir, ".git").exists()) { - dir = dir.getParentFile(); - } - return new File(dir, "core"); - } - - private void emitFactory() throws Exception { - if (factory == null) { - factory = createClass(); - factory.addImport(Type.class); - factory.addImport(MethodVisitor.class); - factory.addImport(AnnotationNode.class); - - var ifTree = new StringJoiner(" else "); - builders.values().forEach(builder -> { - String name = methodCase(builder.getName()) + "Type"; - factory.addField() - .setStatic(true) - .setFinal(true) - .setPrivate() - .setName(name) - .setType(String.class) - .setStringInitializer(descriptor(builder.getQualifiedName())); - ifTree.add(MessageFormat.format(""" - if (annotation.desc.equals({0})) '{' - emit{1}(mv, annotation); - '}' - """, name, builder.getName())); - }); - - var factoryMethod = factory.addMethod() - .setPublic() - .setStatic(true); - factoryMethod - .setName("build") - .addParameter(MethodVisitor.class, "mv"); - factoryMethod - .addParameter(AnnotationNode.class, "annotation"); - - var code = ifTree.toString(); - factoryMethod.setBody(code); - - emitBuilderConstruction(factory); - invokeAnnotation(factory); - invokeBoolean(factory); - invokeClass(factory); - invokeEnum(factory); - invokeInt(factory); - invokeLong(factory); - invokeString(factory); - for (JavaAnnotationSource source : builders.values()) { - emitterMethod(factory, source); - } - } - - var outputFile = new File(generated, factory.getQualifiedName().replace('.', '/') + ".java"); - if (!outputFile.getParentFile().mkdirs() && !outputFile.getParentFile().exists()) { - throw new IOException(format("Could not create directory: %s", outputFile.getParentFile())); - } - try (var writer = new FileWriter(outputFile)) { - writer.write(factory.toString()); - } - } - - private void emitBuilderConstruction(JavaClassSource factory) { - var method = factory.addMethod() - .setName("emitBuilderConstruction") - .setStatic(true); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(Type.class, "builder"); - method.addParameter(String.class, "methodName"); - factory.addImport(Opcodes.class); - - var code = """ - mv.visitVarInsn(Opcodes.ALOAD, 0); - mv.visitMethodInsn(Opcodes.INVOKESTATIC, "dev/morphia/annotations/internal/PropertyBuilder", - "propertyBuilder", "()Ldev/morphia/annotations/internal/PropertyBuilder;", false); - """; - method.setBody(code); - } - - private void emitterMethod(JavaClassSource factory, JavaAnnotationSource builder) { - var method = factory.addMethod() - .setStatic(true) - .setName("emit" + builder.getName()); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(AnnotationNode.class, "node"); - - var builderName = builderName(builder); - - Type builderType = Type.getType(descriptor(builderName)); - - var code = MessageFormat.format(""" - emitBuilderConstruction(mv, Type.getType("{0}"), "{1}Builder"); - if (node.values != null) '{' - for (int i = 0; i < node.values.size(); i+=2) '{' - var name = (String)node.values.get(i); - var value = node.values.get(i++);""", - builderType.getDescriptor(), methodCase(builder.getName())); - - var first = true; - for (AnnotationElementSource element : builder.getAnnotationElements()) { - if (!first) { - code += " else "; - } - code += "if (name.equals(\"%s\")) {".formatted(element.getName()); - if (element.getType().getName().equals("String")) { - code += "invokeString(mv, \"%s\", \"%s\", name, (String)value);".formatted(builderType.getDescriptor(), - builderType.getInternalName()); - } else if (element.getType().getName().equals("boolean")) { - code += "invokeBoolean(mv, \"%s\", \"%s\", name, Boolean.valueOf((String)value));".formatted(builderType.getDescriptor(), - builderType.getInternalName()); - } else if (element.getType().getName().equals("int")) { - code += "invokeInt(mv, \"%s\", \"%s\", name, (int)value);".formatted(builderType.getDescriptor(), - builderType.getInternalName()); - } else if (element.getType().getName().equals("long")) { - code += "invokeLong(mv, \"%s\", \"%s\", name, (long)value);" - .formatted(builderType.getDescriptor(), builderType.getInternalName()); - } else if (element.getType().getName().equals("Class")) { - code += "invokeClass(mv, \"%s\", \"%s\", name, (String)value);".formatted(builderType.getDescriptor(), - builderType.getInternalName()); - } else if (element.getType().getQualifiedName().startsWith("com.mongodb.client.model.") - || element.getType().getQualifiedName().startsWith("dev.morphia.mapping.")) { - Type type = type(element.getType().getQualifiedName()); - code += "invokeEnum(mv, \"%s\", \"%s\", \"%s\", \"%s\", name, (String)value);" - .formatted(type.getDescriptor(), type.getInternalName(), builderType.getDescriptor(), - builderType.getInternalName()); - } else if (element.getType().isArray()) { - System.out.printf("unknown type: %n\t%s %n\t%s %n\t%s %n", - element.getType().getName(), - element.getType().getQualifiedName(), - element.getType().getOrigin().isEnum()); - } else if (element.getType().getQualifiedName().startsWith("dev.morphia.annotations.")) { - Type type = type(element.getType().getQualifiedName()); - code += "invokeAnnotation(mv, \"%s\", \"%s\", \"%s\", name, (AnnotationNode)value);" - .formatted(type.getDescriptor(), builderType.getDescriptor(), - builderType.getInternalName()); - } else { - System.out.printf("unknown type: %n\t%s %n\t%s %n\t%s %n", - element.getType().getName(), - element.getType().getQualifiedName(), - element.getType().getOrigin().isEnum()); - - } - code += "}"; - first = false; - } - - code += "}"; - code += "}"; - try { - method.setBody(code); - } catch (Exception e) { - System.out.println("***** code = " + code); - System.out.println(e.getMessage()); - System.exit(1); - } - } - - private Type type(String qualifiedName) { - return Type.getType(descriptor(qualifiedName)); - } - - private String descriptor(String qualifiedName) { - return "L%s;".formatted(qualifiedName.replace('.', '/')); - } - - private static String builderName(JavaSource builder) { - var pkg = builder.getPackage() + ".internal."; - var name = builder.getName() + "Builder"; - return pkg + name; - } - - private void invokeString(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeString"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(String.class, "value"); - - method.setBody(""" - mv.visitLdcInsn(value); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, - "(Ljava/lang/String;)" + descriptor, false);"""); - } - - private void invokeBoolean(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeBoolean"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(boolean.class, "value"); - - method.setBody(""" - mv.visitLdcInsn(value); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, - "(Z)" + descriptor, false);"""); - } - - private void invokeInt(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeInt"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(int.class, "value"); - - method.setBody(""" - mv.visitLdcInsn(value); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, - "(I)" + descriptor, false);"""); - } - - private void invokeLong(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeLong"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(long.class, "value"); - - method.setBody(""" - mv.visitLdcInsn(value); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, - "(J)" + descriptor, false);"""); - } - - private void invokeClass(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeClass"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(String.class, "value"); - - method.setBody(""" - mv.visitLdcInsn(Type.getType(value)); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, - "(Ljava/lang/Class;)" + descriptor, false);"""); - } - - private void invokeEnum(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeEnum"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "enumDescriptor"); - method.addParameter(String.class, "enumInternalName"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(String.class, "value"); - - method.setBody(""" - mv.visitFieldInsn(Opcodes.GETSTATIC, enumInternalName, value, enumDescriptor); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, "(" + enumDescriptor + ")" + descriptor, false);"""); - } - - private void invokeAnnotation(JavaClassSource factory) { - var method = factory.addMethod() - .setStatic(true) - .setName("invokeAnnotation"); - method.addParameter(MethodVisitor.class, "mv"); - method.addParameter(String.class, "annotationDescriptor"); - method.addParameter(String.class, "descriptor"); - method.addParameter(String.class, "internalName"); - method.addParameter(String.class, "method"); - method.addParameter(AnnotationNode.class, "value"); - - method.setBody(""" - build(mv, value); - mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, internalName, method, "(" + annotationDescriptor + ")" + descriptor, false);"""); - } - - /* - * - * private fun AnnotationNode.toBuilder() { - * var label = Label() - * methodVisitor.visitLabel(label) - * methodVisitor.visitLineNumber(24, label) - * methodVisitor.visitVarInsn(ALOAD, 0) - * val builderType = lookupBuilder(this) - * methodVisitor.visitMethodInsn( - * INVOKESTATIC, - * builderType.internalName, - * builderType.className.substringAfterLast(".").identifierCase(), - * "()${builderType.descriptor}", - * false - * ) - * values.windowed(2, 2) { (name, value) -> - * println("**************** name = ${name}") - * println("**************** value = ${value}") - * if (value is List<*>) { - * "DUMMY" - * } else { - * methodVisitor.visitLdcInsn(value) - * } - * val label3 = Label() - * methodVisitor.visitLabel(label3) - * methodVisitor.visitLineNumber(25, label3) - * val paramType = Type.getType(value::class.java) - * methodVisitor.visitMethodInsn( - * INVOKEVIRTUAL, - * builderType.internalName, - * name as String, - * "(${paramType.descriptor})${builderType.descriptor}", - * false - * ) - * } - * label = Label() - * methodVisitor.visitLabel(label) - * methodVisitor.visitLineNumber(26, label) - * methodVisitor.visitMethodInsn( - * INVOKEVIRTUAL, - * builderType.internalName, - * "build", - * "()${this.desc}", - * false - * ) - * label = Label() - * methodVisitor.visitLabel(label) - * methodVisitor.visitLineNumber(24, label) - * methodVisitor.visitMethodInsn( - * INVOKEVIRTUAL, - * generatedType.internalName, - * "annotation", - * "(Ljava/lang/annotation/Annotation;)Ldev/morphia/mapping/codec/pojo/PropertyModel;", - * false - * ) - * methodVisitor.visitInsn(POP) - * } - * - * - */ - private JavaClassSource createClass() { - var classBuilder = Roaster.create(JavaClassSource.class) - .setName("AnnotationAsmFactory") - .setPackage(builders.values().iterator().next().getPackage() + ".internal") - .setFinal(true); - classBuilder.addAnnotation("dev.morphia.annotations.internal.MorphiaInternal"); - JavaDocSource javaDoc = classBuilder.getJavaDoc(); - javaDoc.addTagValue("@since", "2.3"); - javaDoc.addTagValue("@hidden", ""); - javaDoc.addTagValue("@morphia.internal", ""); - - return classBuilder; - } - - private List find(String path) { - File[] files = new File(path).listFiles(filter); - return files != null ? asList(files) : List.of(); - } -} diff --git a/build-plugins/src/main/java/util/KotlinAnnotationExtensions.java b/build-plugins/src/main/java/util/KotlinAnnotationExtensions.java deleted file mode 100644 index 27d0a592f5a..00000000000 --- a/build-plugins/src/main/java/util/KotlinAnnotationExtensions.java +++ /dev/null @@ -1,339 +0,0 @@ -package util; - -import java.io.File; -import java.io.FileFilter; -import java.io.FileWriter; -import java.io.IOException; -import java.text.MessageFormat; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; - -import com.google.devtools.ksp.symbol.KSAnnotation; -import com.google.devtools.ksp.symbol.KSClassDeclaration; -import com.squareup.kotlinpoet.AnnotationSpec; -import com.squareup.kotlinpoet.AnnotationSpec.UseSiteTarget; -import com.squareup.kotlinpoet.ClassName; -import com.squareup.kotlinpoet.FileSpec; -import com.squareup.kotlinpoet.FunSpec; -import com.squareup.kotlinpoet.TypeSpec; -import com.squareup.kotlinpoet.TypeSpec.Builder; - -import org.apache.maven.plugin.AbstractMojo; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.project.MavenProject; -import org.jboss.forge.roaster.ParserException; -import org.jboss.forge.roaster.Roaster; -import org.jboss.forge.roaster.model.Type; -import org.jboss.forge.roaster.model.source.AnnotationElementSource; -import org.jboss.forge.roaster.model.source.JavaAnnotationSource; -import org.jboss.forge.roaster.model.source.JavaClassSource; -import org.jboss.forge.roaster.model.source.JavaDocSource; -import org.jboss.forge.roaster.model.source.JavaSource; -import org.jboss.forge.roaster.model.util.Types; -import org.jetbrains.annotations.NotNull; - -import kotlin.Suppress; - -import static java.lang.String.format; -import static java.util.Arrays.asList; -import static java.util.Collections.emptyList; -import static util.AnnotationBuilders.methodCase; - -/** - * Maven Mojo that generates Kotlin extension functions for Morphia annotation types. - */ -@Mojo(name = "morphia-annotations-kotlin", defaultPhase = LifecyclePhase.GENERATE_SOURCES) -public class KotlinAnnotationExtensions extends AbstractMojo { - - /** Creates a new Mojo instance. */ - public KotlinAnnotationExtensions() { - } - - private Map builders = new TreeMap<>(); - - private FileSpec.Builder fileBuilder; - - @Parameter(defaultValue = "${project}", required = true, readonly = true) - private MavenProject project; - - private Builder factory; - - private File generated; - - private final FileFilter filter = pathname -> (pathname.getName().endsWith(".java") || pathname.getName().endsWith(".kt")) - && !pathname.getName().endsWith("Handler.java") - && !pathname.getName().endsWith("Helper.java") - && !pathname.getName().equals("package-info.java"); - - @Override - @SuppressWarnings("ConstantConditions") - public void execute() throws MojoExecutionException { - List files = new ArrayList<>(); - generated = new File(project.getBasedir() + "/target/generated-sources/morphia-annotations/"); - - String path = core() + "/src/main/java/dev/morphia/annotations"; - files.addAll(find(path)); - project.addCompileSourceRoot(generated.getAbsolutePath()); - - try { - for (File file : files) { - try { - var source = Roaster.parse(JavaAnnotationSource.class, file); - if (source.isPublic()) { - builders.put(source.getName(), source); - } - } catch (ParserException e) { - throw new MojoExecutionException("Could not parse " + file, e); - } - } - fileBuilder = FileSpec.builder(builders.values().iterator().next().getPackage() + ".internal", "AnnotationKspFactory"); - fileBuilder.addAnnotation(AnnotationSpec.builder(Suppress.class) - .addMember("%S", "UNCHECKED_CAST") - .useSiteTarget(UseSiteTarget.FILE) - .build()); - - fileBuilder.addImport("dev.morphia.critter.parser.ksp.extensions", - "allAnnotations", "name", "className"); - fileBuilder.addImport("dev.morphia.mapping", "MappingException"); - fileBuilder.addImport("java.util", "Objects"); - emitFactory(); - } catch (Exception e) { - throw new MojoExecutionException(e.getMessage(), e); - } - } - - private File core() { - var dir = project.getBasedir(); - while (!new File(dir, ".git").exists()) { - dir = dir.getParentFile(); - } - return new File(dir, "core"); - } - - private void emitFactory() throws Exception { - if (factory == null) { - factory = createFactory(); - for (JavaAnnotationSource source : builders.values()) { - annotationConverters(source); - annotationExtractors(source); - annotationCodeBuilders(source); - } - } - - fileBuilder.addType(factory.build()); - - FileSpec fileSpec = fileBuilder.build(); - var outputFile = new File(generated, fileSpec.getRelativePath()); - if (!outputFile.getParentFile().mkdirs() && !outputFile.getParentFile().exists()) { - throw new IOException(format("Could not create directory: %s", outputFile.getParentFile())); - } - try (var out = new FileWriter(outputFile)) { - fileSpec.writeTo(out); - } - } - - private void annotationExtractors(JavaAnnotationSource source) { - var method = FunSpec.builder("%sAnnotation".formatted(methodCase(source.getName()))) - .receiver(KSClassDeclaration.class) - .returns(ClassName.bestGuess(source.getQualifiedName())); - - method.addCode(""" - return try { - allAnnotations() - .first { it.annotationType.className() == %L::class.java.name } - .to%L() - } catch (e: NoSuchElementException) { - throw MappingException("No Entity annotation found on ${name()}") - } - """, source.getName(), source.getName()); - - factory.addFunction(method.build()); - } - - private void annotationConverters(JavaAnnotationSource source) { - var method = FunSpec.builder("to" + source.getName()) - .receiver(KSAnnotation.class) - .returns(ClassName.bestGuess(source.getQualifiedName())); - - var code = MessageFormat.format(""" - val map = arguments - .map '{' it -> (it.name?.asString() ?: "value") to it.value } - .toMap() - var builder = {0}Builder.{1}Builder().apply '{' - """, - source.getName(), methodCase(source.getName())); - - for (AnnotationElementSource element : source.getAnnotationElements()) { - String name = element.getName(); - String cast = processType(element.getType()); - if (element.getType().isArray()) { - cast = "*(%s)".formatted(cast); - } - code += ("map[\"%s\"]?.let { %s(%s) }\n").formatted(name, name, cast); - } - - code += "}\n return builder.build()"; - method.addCode(code); - - factory.addFunction(method.build()); - } - - private void annotationCodeBuilders(JavaAnnotationSource source) { - var method = FunSpec.builder(methodCase(source.getName()) + "CodeGen") - .receiver(KSAnnotation.class) - .returns(ClassName.bestGuess("kotlin.String")); - - ClassName className = ClassName.bestGuess(builderName(source)); - fileBuilder.addImport(className.getPackageName(), className.getSimpleName()); - method.addCode(MessageFormat.format(""" - val map = arguments - .map '{' it -> (it.name?.asString() ?: "value") to it.value } - .toMap() - var code = "{0}Builder.{1}Builder()" - """, source.getName(), methodCase(source.getName()))); - - for (AnnotationElementSource element : source.getAnnotationElements()) { - String name = element.getName(); - String value; - - method.addCode(""" - map["%s"]?.let { - """.formatted(name)); - - Type type = element.getType(); - if (type.getQualifiedName().startsWith("dev.morphia.annotations.")) { - String typeName = type.getSimpleName(); - method.addCode(""" - if (!Objects.equals(%sBuilder.defaults.%s, (it as KSAnnotation).to%s())) { - """.formatted(source.getName(), name, typeName)); - value = "${it.%sCodeGen()}".formatted(methodCase(typeName)); - } else { - method.addCode(""" - if (!Objects.equals(%sBuilder.defaults.%s, it)) { - """.formatted(source.getName(), name)); - - value = getValue(type); - } - - method.addCode(""" - code += ".%s(%s)" - } - } - """.formatted(name, value)); - } - - method.addCode(""" - code += ".build()" - """); - method.addCode("return code"); - - factory.addFunction(method.build()); - } - - private static String builderName(JavaSource builder) { - var pkg = builder.getPackage() + ".internal."; - var name = builder.getName() + "Builder"; - return pkg + name; - } - - private String getValue(Type type) { - String typeName = type.getName(); - String code = "$it"; - - if (typeName.equals("String")) { - code = "\\\"%s\\\"".formatted(code); - } else if (typeName.equals("Class")) { - code += ".class"; - } - return code; - } - - private String processType(Type type) { - String typeName = type.getName(); - String code = "NOT SET"; - String cast = "it as %s"; - - if (typeName.equals("boolean")) { - code = "Boolean"; - } else if (typeName.equals("String")) { - code = "String"; - } else if (typeName.equals("int")) { - code = "Int"; - } else if (typeName.equals("long")) { - code = "Long"; - cast = "(it as Number).to%s()"; - } else if (typeName.equals("Class")) { - code = "Class<*>"; - } else if (type.isArray()) { - code = processArrayType(type); - } else if (type.getQualifiedName().startsWith("com.mongodb.client.model.") - || type.getQualifiedName().startsWith("dev.morphia.mapping.")) { - code = type.getQualifiedName(); - } else if (type.getQualifiedName().startsWith("dev.morphia.annotations.")) { - cast = "(it as KSAnnotation).to%s()"; - code = type.getSimpleName(); - } else { - System.out.printf("unknown type: %n\t%s %n\t%s %n\t%s %n", - typeName, - type.getQualifiedName(), - type.getOrigin().isEnum()); - code = ""; - } - - return cast.formatted(code); - } - - @NotNull - private static String processArrayType(Type type) { - String code; - code = type.getSimpleName(); - var parameterized = type.isParameterized(); - List> params = parameterized ? type.getTypeArguments() - : emptyList(); - if (!params.isEmpty()) { - Type param = params.get(0); - var parameterName = Types.toSimpleName(param.getQualifiedName()); - if (param.isWildcard()) { - parameterName = parameterName.substring(parameterName.lastIndexOf(' ') + 1); - } - if (!Types.isQualified(parameterName)) { - var target = parameterName; - var imp = type.getOrigin().getImports().stream().filter(i -> i.getSimpleName().equals(target)) - .findFirst().orElseThrow(); - parameterName = imp.getQualifiedName(); - } - if (param.isWildcard()) { - parameterName += "<*>"; - } - - code = "%s<%s>".formatted(type.getSimpleName(), parameterName); - } - code = "Array".formatted(code); - return code; - } - - private Builder createFactory() { - Builder annotationKspFactory = TypeSpec.objectBuilder("AnnotationKspFactory"); - var classBuilder = Roaster.create(JavaClassSource.class) - .setName("AnnotationKspFactory") - .setPackage(builders.values().iterator().next().getPackage() + ".internal") - .setFinal(true); - classBuilder.addAnnotation("dev.morphia.annotations.internal.MorphiaInternal"); - JavaDocSource javaDoc = classBuilder.getJavaDoc(); - javaDoc.addTagValue("@since", "3.0"); - javaDoc.addTagValue("@hidden", ""); - javaDoc.addTagValue("@morphia.internal", ""); - - return annotationKspFactory; - } - - private List find(String path) { - File[] files = new File(path).listFiles(filter); - return files != null ? asList(files) : List.of(); - } -} diff --git a/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt b/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt deleted file mode 100644 index 44c725f53dc..00000000000 --- a/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt +++ /dev/null @@ -1,103 +0,0 @@ -package util - -import java.io.File -import java.io.FileFilter -import java.io.FileWriter -import java.io.IOException -import java.util.TreeMap -import org.apache.maven.plugin.AbstractMojo -import org.apache.maven.plugin.MojoExecutionException -import org.apache.maven.plugins.annotations.LifecyclePhase.GENERATE_SOURCES -import org.apache.maven.plugins.annotations.Mojo -import org.apache.maven.plugins.annotations.Parameter -import org.apache.maven.project.MavenProject -import org.jboss.forge.roaster.ParserException -import org.jboss.forge.roaster.Roaster -import org.jboss.forge.roaster.model.source.JavaAnnotationSource - -@Mojo(name = "morphia-annotation-node", defaultPhase = GENERATE_SOURCES) -class AnnotationNodeExtensions : AbstractMojo() { - companion object { - fun find(path: String, filter: FileFilter): List { - val array = File(path).listFiles(filter) - return if (array != null) listOf(*array) else listOf() - } - } - - private val builders: MutableMap = TreeMap() - - @Parameter(defaultValue = "\${project}", required = true, readonly = true) - private val project: MavenProject? = null - private var generated: File? = null - private val filter = FileFilter { pathname: File -> - (pathname.name.endsWith(".java") || pathname.name.endsWith(".kt")) && - !pathname.name.endsWith("Handler.java") && - !pathname.name.endsWith("Helper.java") && - pathname.name != "package-info.java" - } - - @Throws(MojoExecutionException::class) - override fun execute() { - val files: MutableList = ArrayList() - generated = - File(project!!.basedir.toString() + "/target/generated-sources/morphia-annotations/") - val path = annotations().toString() + "/src/main/java/dev/morphia/annotations" - files.addAll(find(path, filter)) - project.addCompileSourceRoot(generated!!.absolutePath) - - try { - for (file in files) { - try { - val source = Roaster.parse(JavaAnnotationSource::class.java, file) - if (source.isPublic) { - builders[source.name] = source - } - } catch (e: ParserException) { - throw MojoExecutionException("Could not parse $file", e) - } - } - emitJavaFactory() - } catch (e: Exception) { - throw MojoExecutionException(e.message, e) - } - } - - private fun annotations(): File { - var dir = project!!.basedir - while (!File(dir, ".git").exists()) { - dir = dir.parentFile - } - return File(dir, "annotations") - } - - @Throws(Exception::class) - private fun emitJavaFactory() { - val pkg = builders.values.iterator().next().getPackage() + ".internal" - val body = StringBuilder() - - body.appendLine("package $pkg;") - body.appendLine() - body.appendLine("@dev.morphia.annotations.internal.MorphiaInternal") - body.appendLine("@SuppressWarnings(\"unchecked\")") - body.appendLine("public final class AnnotationNodeExtensions {") - body.appendLine() - body.appendLine( - " public static final AnnotationNodeExtensions INSTANCE = new AnnotationNodeExtensions();" - ) - body.appendLine() - body.appendLine(" private AnnotationNodeExtensions() {}") - body.appendLine() - - body.appendLine("}") - - val outputFile = File(generated, pkg.replace('.', '/') + "/AnnotationNodeExtensions.java") - if (!outputFile.parentFile.mkdirs() && !outputFile.parentFile.exists()) { - throw IOException( - String.format("Could not create directory: %s", outputFile.parentFile) - ) - } - // Delete any stale Kotlin file from previous builds - File(generated, pkg.replace('.', '/') + "/AnnotationNodeExtensions.kt").delete() - FileWriter(outputFile).use { out -> out.write(body.toString()) } - } -} From 1315dd701d1179a0794994b563319c5553330225 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:00:57 -0400 Subject: [PATCH 04/31] remove outdated plugin reference --- core/pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 9bd26538292..b5625a4df7d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -111,12 +111,6 @@ generate-sources - - morphia-annotations-asm - - morphia-annotation-node - - From 650912271a355b63fd6cc7855c84b251689e56d7 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:19:25 -0400 Subject: [PATCH 05/31] suppress unused for two methods called reflectively --- .../mapping/codec/pojo/critter/CritterPropertyModel.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java index 94c954403ea..8a32dc0fb3f 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java @@ -21,6 +21,7 @@ public CritterPropertyModel(EntityModel entityModel) { * Registers all annotations from the entity's field (walking the class hierarchy) into this model. * Called from generated subclass constructors so non-Morphia annotations (e.g. @NonNull) are also recorded. */ + @SuppressWarnings("unused") public static void registerFieldAnnotations(PropertyModel model, Class entityClass, String fieldName) { Class current = entityClass; while (current != null && current != Object.class) { @@ -40,6 +41,7 @@ public static void registerFieldAnnotations(PropertyModel model, Class entity * Registers all annotations from the entity's getter method (walking the class hierarchy) into this model. * Called from generated subclass constructors so non-Morphia annotations on getters are also recorded. */ + @SuppressWarnings("unused") public static void registerMethodAnnotations(PropertyModel model, Class entityClass, String getterName) { Class current = entityClass; while (current != null && current != Object.class) { From c8bcced5297a9fbdf38aa68d81eed92e51ce8e4d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:43:27 -0400 Subject: [PATCH 06/31] Fix VarHandleAccessorGenerator using system classloader for property 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. --- audits/classfile-api-review.md | 137 ++++++++++++++++++ .../gizmo/VarHandleAccessorGenerator.java | 28 ++-- .../critter/parser/TestVarHandleAccessor.java | 107 ++++++++++++++ 3 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 audits/classfile-api-review.md diff --git a/audits/classfile-api-review.md b/audits/classfile-api-review.md new file mode 100644 index 00000000000..1271848e680 --- /dev/null +++ b/audits/classfile-api-review.md @@ -0,0 +1,137 @@ +3# Code Review: Replace ASM/Gizmo with ClassFile API + +Branch: `replace-asm-gizmo-with-classfile-api` +Commits reviewed: `fc3c7149f7b`, `6e262212e32`, `27d2f1b8b44` + +--- + +## Bugs (Confirmed) + +### ~~1. `VarHandleAccessorGenerator.java:143` — `hasSetter()` uses single-arg `Class.forName`, silently treats property as read-only for app-classpath types~~ ✓ FIXED + +`hasSetter()` calls `Class.forName(propertyType)` — the one-argument form that uses the +caller's classloader (the Morphia library classloader, not the application classloader). +If the property type lives only on the application classpath (any non-JDK class), this +throws `ClassNotFoundException`, which is caught silently, and the method returns `false`. +The generated accessor then throws `UnsupportedOperationException` on every `set()` call, +making the property silently read-only. + +```java +// Line 143 +} catch (ClassNotFoundException e) { + return false; // ← wrong: class exists, just not on this classloader +} +``` + +Fix: use `Class.forName(propertyType, false, entity.getClassLoader())`, with a null guard +for bootstrap-loaded classes (`entity.getClassLoader() != null ? entity.getClassLoader() : ClassLoader.getSystemClassLoader()`). + +--- + +### 2. `PropertyFinder.java` (`findSetter`/`findSetterInHierarchy`) — static setter methods not filtered + +`findSetter()` matches a method by name and descriptor only — no `ACC_STATIC` check. +`findSetterInHierarchy()` filters `ACC_PRIVATE` but not `ACC_STATIC`. A static method named +`setXxx(T)V` with the right descriptor would be returned as the property setter. +The accessor generators then emit `invokevirtual` against a static method, producing a +`VerifyError` or `IncompatibleClassChangeError` at class-load or invocation time. + +Fix: add `(flags & ACC_STATIC) == 0` to both `findSetter` and `findSetterInHierarchy`. + +--- + +### 3. `PropertyFinder.java:213-232` — method named exactly `"is"` causes `StringIndexOutOfBoundsException` + +`isGetter()` checks `name.startsWith("is")` with no minimum-length guard. A no-arg +non-void method named exactly `"is"` passes all checks. `getterPropertyName()` then +computes `name.substring(2)` → `""` and calls `prop.charAt(0)`, throwing +`StringIndexOutOfBoundsException`, crashing property discovery for the entire entity. + +The same applies to a method named exactly `"get"`. + +Fix: add `name.length() > 2` / `name.length() > 3` guards in `isGetter()`. + +--- + +### 4. `asm/BaseGenerator.java:33` — null classloader when entity is bootstrap-loaded + +`readClassFiltering()` calls `entity.getClassLoader().getResourceAsStream(...)` directly. +For classes loaded by the bootstrap classloader, `getClassLoader()` returns `null`, +causing an immediate `NullPointerException`. `PropertyFinder.readClassModel()` already +applies the correct guard; `BaseGenerator` does not. + +```java +// PropertyFinder.java:118 — has the guard: +ClassLoader cl = type.getClassLoader() != null ? type.getClassLoader() : ClassLoader.getSystemClassLoader(); + +// BaseGenerator.java:33 — missing the guard: +entity.getClassLoader().getResourceAsStream(...) // NPE if bootstrap-loaded +``` + +Fix: apply the same null guard as `PropertyFinder.readClassModel()`. + +--- + +### 5. `GizmoExtensions.java:48` — array annotation element defaults compared by identity, not value + +`emitAnnotationOnStack` skips emitting a builder setter call when +`value.equals(defaultValue)`. For array-typed annotation elements (`String[]`, `Class[]`, +annotation-array), annotation proxy methods return a fresh defensive copy on every call +(per JDK spec). So `method.invoke(annotation)` and `method.getDefaultValue()` always +return distinct instances, meaning `value.equals(defaultValue)` is always `false` for +arrays — even when the content is identical. + +Effect: builder setter calls are emitted unnecessarily for all array elements equal to +their defaults. This inflates generated bytecode and may trigger errors if the builder +rejects empty arrays in unexpected ways. + +Fix: compare with `Arrays.deepEquals(new Object[]{value}, new Object[]{defaultValue})` +when `value` is an array type, or use `java.util.Objects.deepEquals(value, defaultValue)`. + +--- + +## Architecture / Design Concerns + +### 6. `PropertyModelGenerator.java` — double annotation registration (Morphia annotations registered twice per property) + +The generated constructor: +1. Calls `emitAnnotationOnStack` for each annotation in `morphiaAnnotations` (builder-constructed proxy). +2. Then calls `CritterPropertyModel.registerFieldAnnotations` / `registerMethodAnnotations`, which walks the real class hierarchy via `getDeclaredAnnotations()` and re-registers **all** annotations including the Morphia ones from step 1. + +`PropertyModel.annotation()` uses a `HashMap` — `put` overwrites. +The second call overwrites step 1's builder-constructed proxies with reflection-obtained +instances. Result is correct (same annotation type and values), but step 1's work is +entirely wasted. The `emitAnnotationOnStack` path for Morphia annotations can be removed; +`registerFieldAnnotations`/`registerMethodAnnotations` already covers them. + +--- + +### 7. `CritterPropertyModel.java:24-56` — runtime reflection on the hot deserialization path + +`registerFieldAnnotations` and `registerMethodAnnotations` walk the class hierarchy +via `getDeclaredField` / `getDeclaredMethods` at property-model *construction* time +(which happens per deserialized document for runtime-generated models). `getDeclaredMethods()` +allocates a fresh `Method[]` on every call. For an entity with 10 method-backed properties +and a 3-level hierarchy, every document read triggers ~30 `getDeclaredMethods()` copies. + +The codegen has the `Method`/`Field` in hand at generation time. The fix is to emit the +annotations inline into the generated `` bytecode, eliminating the runtime +reflection entirely. The static helpers on `CritterPropertyModel` also place generator- +support infrastructure on a runtime model class, which is the wrong ownership. + +--- + +### 8. Duplicated primitive and utility code across generator classes + +Several things are independently duplicated: + +| What | Where | +|------|-------| +| `PRIMITIVE_TO_WRAPPER` map | `VarHandleAccessorGenerator:31` and `PropertyAccessorGenerator:23` | +| `typeClassName(ClassDesc)` method | `VarHandleAccessorGenerator:95` and `PropertyAccessorGenerator:43` | +| `emitBooleanMethod(ClassBuilder, String, boolean)` | `GizmoEntityModelGenerator` and `PropertyModelGenerator` | +| Class-bytes-to-ClassModel loading | `BaseGenerator.readClassFiltering()`, `PropertyFinder.readClassModel()`, and `CritterGizmoGenerator.generate()` | + +These should be consolidated in `GizmoExtensions` (or `BaseGizmoGenerator`) and called +from all sites. The loading logic in particular has divergent null-guard behavior +(bug #4 above) precisely because it was duplicated rather than shared. diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java index 4800812c271..69b7767e720 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java @@ -140,7 +140,7 @@ private boolean hasSetter() { Class paramClass = isPrimitive() ? PRIMITIVE_CLASSES.get(propertyType) : null; if (paramClass == null) { try { - paramClass = Class.forName(propertyType); + paramClass = Class.forName(propertyType, false, entityClassLoader()); } catch (ClassNotFoundException e) { return false; } @@ -250,7 +250,7 @@ public VarHandleAccessorGenerator emit() { tryBody.aload(privateLookupSlot); tryBody.aload(entityClassSlot); tryBody.ldc(propertyName); - emitLoadClass(tryBody, propertyType, propertyDesc); + emitLoadClass(tryBody, propertyType, propertyDesc, tcclSlot); tryBody.invokevirtual(lookupDesc, "findVarHandle", MethodTypeDesc.of(varHandleDesc, ConstantDescs.CD_Class, ConstantDescs.CD_String, ConstantDescs.CD_Class)); tryBody.aload(0); @@ -261,7 +261,7 @@ public VarHandleAccessorGenerator emit() { tryBody.aload(privateLookupSlot); tryBody.aload(entityClassSlot); tryBody.ldc(getterName); - emitLoadClass(tryBody, propertyType, propertyDesc); + emitLoadClass(tryBody, propertyType, propertyDesc, tcclSlot); tryBody.invokestatic(methodTypeDesc2, "methodType", MethodTypeDesc.of(methodTypeDesc2, ConstantDescs.CD_Class)); tryBody.invokevirtual(lookupDesc, "findVirtual", @@ -276,7 +276,7 @@ public VarHandleAccessorGenerator emit() { tryBody.aload(entityClassSlot); tryBody.ldc(setterName); tryBody.loadConstant(ConstantDescs.CD_void); - emitLoadClass(tryBody, propertyType, propertyDesc); + emitLoadClass(tryBody, propertyType, propertyDesc, tcclSlot); tryBody.invokestatic(methodTypeDesc2, "methodType", MethodTypeDesc.of(methodTypeDesc2, ConstantDescs.CD_Class, ConstantDescs.CD_Class)); tryBody.invokevirtual(lookupDesc, "findVirtual", @@ -416,20 +416,24 @@ public VarHandleAccessorGenerator emit() { return this; } - private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String typeName, ClassDesc desc) { + private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String typeName, ClassDesc desc, + int tcclSlot) { if (isPrimitive()) { cod.loadConstant(desc); } else { - GizmoExtensions.emitClassRef(cod, classForName(typeName)); + // Emit Class.forName(typeName, false, tccl) so the property type is resolved via the + // same classloader used to load the entity, not the accessor's defining classloader. + cod.ldc(typeName); + cod.iconst_0(); + cod.aload(tcclSlot); + cod.invokestatic(ConstantDescs.CD_Class, "forName", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;")); } } - private static Class classForName(String name) { - try { - return Class.forName(name); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } + private ClassLoader entityClassLoader() { + ClassLoader cl = entity.getClassLoader(); + return cl != null ? cl : ClassLoader.getSystemClassLoader(); } private ClassDesc propertyClassDesc() { diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index f61e36570bb..2688f44feb8 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -1,8 +1,12 @@ package dev.morphia.critter.parser; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import dev.morphia.annotations.Entity; @@ -10,6 +14,7 @@ import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; +import dev.morphia.critter.parser.gizmo.VarHandleAccessorGenerator; import dev.morphia.critter.sources.Example; import org.bson.codecs.pojo.PropertyAccessor; @@ -19,6 +24,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import io.github.dmlloyd.classfile.ClassFile; + import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -143,4 +150,104 @@ private PropertyAccessor loadAccessor(CritterClassLoader loader, Class Critter.critterPackage(entityType) + "." + Critter.titleCase(fieldName) + "Accessor"); return cls.getConstructor().newInstance(); } + + /** + * Regression test for bug #1 from the ClassFile API review: + * VarHandleAccessorGenerator uses single-arg Class.forName() (caller classloader) instead of + * entity.getClassLoader(), so method-based properties whose type is only known to a child + * classloader (typical in app-server deployments) cause emit() to crash and set() to be + * unavailable. + * + * The test fails while the bug is present: emit() throws RuntimeException because + * Class.forName("IsolatedValue") uses the system CL, which cannot see the dynamically-loaded type. + * Once fixed (Class.forName with entity.getClassLoader()), emit() succeeds and set() works. + */ + @Test + public void testMethodBasedAccessorWorksWhenPropertyTypeIsOnChildClassloader() throws Exception { + ClassDesc valueTypeDesc = ClassDesc.of("dev.morphia.critter.test.isolate.IsolatedValue"); + byte[] valueTypeBytes = ClassFile.of().build(valueTypeDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + cod.return_(); + }); + }); + + ClassDesc entityDesc = ClassDesc.of("dev.morphia.critter.test.isolate.EntityWithIsolatedProp"); + byte[] entityBytes = ClassFile.of().build(entityDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withField("value", valueTypeDesc, ClassFile.ACC_PRIVATE); + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + cod.return_(); + }); + cb.withMethodBody("getValue", MethodTypeDesc.of(valueTypeDesc), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.getfield(entityDesc, "value", valueTypeDesc); + cod.areturn(); + }); + cb.withMethodBody("setValue", + MethodTypeDesc.of(ConstantDescs.CD_void, valueTypeDesc), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.aload(1); + cod.putfield(entityDesc, "value", valueTypeDesc); + cod.return_(); + }); + }); + + Map generatedClasses = Map.of( + "dev.morphia.critter.test.isolate.IsolatedValue", valueTypeBytes, + "dev.morphia.critter.test.isolate.EntityWithIsolatedProp", entityBytes); + ClassLoader isolatedLoader = new ClassLoader(ClassLoader.getSystemClassLoader()) { + @Override + protected Class findClass(String name) throws ClassNotFoundException { + byte[] bytes = generatedClasses.get(name); + if (bytes != null) { + return defineClass(name, bytes, 0, bytes.length); + } + throw new ClassNotFoundException(name); + } + }; + Class entityClass = isolatedLoader.loadClass("dev.morphia.critter.test.isolate.EntityWithIsolatedProp"); + Class valueClass = isolatedLoader.loadClass("dev.morphia.critter.test.isolate.IsolatedValue"); + + MethodInfo getterInfo = new MethodInfo( + "getValue", + "()Ldev/morphia/critter/test/isolate/IsolatedValue;", + null, + ClassFile.ACC_PUBLIC, + List.of()); + + CritterClassLoader critterLoader = new CritterClassLoader(); + // Bug: emit() throws RuntimeException(ClassNotFoundException) here because + // classForName() calls Class.forName(typeName) with the system CL, not entity.getClassLoader() + new VarHandleAccessorGenerator(entityClass, critterLoader, getterInfo).emit(); + + // The generated accessor constructor resolves entity/value types via TCCL at runtime + ClassLoader prev = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(isolatedLoader); + try { + String accessorName = Critter.critterPackage(entityClass) + ".ValueAccessor"; + @SuppressWarnings("unchecked") + PropertyAccessor accessor = (PropertyAccessor) critterLoader + .loadClass(accessorName).getConstructor().newInstance(); + + Object entity = entityClass.getConstructor().newInstance(); + Object value = valueClass.getConstructor().newInstance(); + + Assertions.assertNull(accessor.get(entity), "initial value should be null"); + // Bug: set() throws UnsupportedOperationException because hasSetter() also called + // Class.forName without entity.getClassLoader() and silently returned false + accessor.set(entity, value); + Assertions.assertSame(value, accessor.get(entity), "get() must return the value that was set"); + } finally { + Thread.currentThread().setContextClassLoader(prev); + } + } } From a1a0fbf69466491e34e3f5cc58093f5ab4f9d8a0 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:44:51 -0400 Subject: [PATCH 07/31] Remove audits/ from git tracking --- .gitignore | 2 + audits/classfile-api-review.md | 137 --------------------------------- 2 files changed, 2 insertions(+), 137 deletions(-) delete mode 100644 audits/classfile-api-review.md diff --git a/.gitignore b/.gitignore index 9b2fb3f0f63..5581173db40 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ docs/reference/public docs/reference/server mongodb-linux* +audits/ + # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml diff --git a/audits/classfile-api-review.md b/audits/classfile-api-review.md deleted file mode 100644 index 1271848e680..00000000000 --- a/audits/classfile-api-review.md +++ /dev/null @@ -1,137 +0,0 @@ -3# Code Review: Replace ASM/Gizmo with ClassFile API - -Branch: `replace-asm-gizmo-with-classfile-api` -Commits reviewed: `fc3c7149f7b`, `6e262212e32`, `27d2f1b8b44` - ---- - -## Bugs (Confirmed) - -### ~~1. `VarHandleAccessorGenerator.java:143` — `hasSetter()` uses single-arg `Class.forName`, silently treats property as read-only for app-classpath types~~ ✓ FIXED - -`hasSetter()` calls `Class.forName(propertyType)` — the one-argument form that uses the -caller's classloader (the Morphia library classloader, not the application classloader). -If the property type lives only on the application classpath (any non-JDK class), this -throws `ClassNotFoundException`, which is caught silently, and the method returns `false`. -The generated accessor then throws `UnsupportedOperationException` on every `set()` call, -making the property silently read-only. - -```java -// Line 143 -} catch (ClassNotFoundException e) { - return false; // ← wrong: class exists, just not on this classloader -} -``` - -Fix: use `Class.forName(propertyType, false, entity.getClassLoader())`, with a null guard -for bootstrap-loaded classes (`entity.getClassLoader() != null ? entity.getClassLoader() : ClassLoader.getSystemClassLoader()`). - ---- - -### 2. `PropertyFinder.java` (`findSetter`/`findSetterInHierarchy`) — static setter methods not filtered - -`findSetter()` matches a method by name and descriptor only — no `ACC_STATIC` check. -`findSetterInHierarchy()` filters `ACC_PRIVATE` but not `ACC_STATIC`. A static method named -`setXxx(T)V` with the right descriptor would be returned as the property setter. -The accessor generators then emit `invokevirtual` against a static method, producing a -`VerifyError` or `IncompatibleClassChangeError` at class-load or invocation time. - -Fix: add `(flags & ACC_STATIC) == 0` to both `findSetter` and `findSetterInHierarchy`. - ---- - -### 3. `PropertyFinder.java:213-232` — method named exactly `"is"` causes `StringIndexOutOfBoundsException` - -`isGetter()` checks `name.startsWith("is")` with no minimum-length guard. A no-arg -non-void method named exactly `"is"` passes all checks. `getterPropertyName()` then -computes `name.substring(2)` → `""` and calls `prop.charAt(0)`, throwing -`StringIndexOutOfBoundsException`, crashing property discovery for the entire entity. - -The same applies to a method named exactly `"get"`. - -Fix: add `name.length() > 2` / `name.length() > 3` guards in `isGetter()`. - ---- - -### 4. `asm/BaseGenerator.java:33` — null classloader when entity is bootstrap-loaded - -`readClassFiltering()` calls `entity.getClassLoader().getResourceAsStream(...)` directly. -For classes loaded by the bootstrap classloader, `getClassLoader()` returns `null`, -causing an immediate `NullPointerException`. `PropertyFinder.readClassModel()` already -applies the correct guard; `BaseGenerator` does not. - -```java -// PropertyFinder.java:118 — has the guard: -ClassLoader cl = type.getClassLoader() != null ? type.getClassLoader() : ClassLoader.getSystemClassLoader(); - -// BaseGenerator.java:33 — missing the guard: -entity.getClassLoader().getResourceAsStream(...) // NPE if bootstrap-loaded -``` - -Fix: apply the same null guard as `PropertyFinder.readClassModel()`. - ---- - -### 5. `GizmoExtensions.java:48` — array annotation element defaults compared by identity, not value - -`emitAnnotationOnStack` skips emitting a builder setter call when -`value.equals(defaultValue)`. For array-typed annotation elements (`String[]`, `Class[]`, -annotation-array), annotation proxy methods return a fresh defensive copy on every call -(per JDK spec). So `method.invoke(annotation)` and `method.getDefaultValue()` always -return distinct instances, meaning `value.equals(defaultValue)` is always `false` for -arrays — even when the content is identical. - -Effect: builder setter calls are emitted unnecessarily for all array elements equal to -their defaults. This inflates generated bytecode and may trigger errors if the builder -rejects empty arrays in unexpected ways. - -Fix: compare with `Arrays.deepEquals(new Object[]{value}, new Object[]{defaultValue})` -when `value` is an array type, or use `java.util.Objects.deepEquals(value, defaultValue)`. - ---- - -## Architecture / Design Concerns - -### 6. `PropertyModelGenerator.java` — double annotation registration (Morphia annotations registered twice per property) - -The generated constructor: -1. Calls `emitAnnotationOnStack` for each annotation in `morphiaAnnotations` (builder-constructed proxy). -2. Then calls `CritterPropertyModel.registerFieldAnnotations` / `registerMethodAnnotations`, which walks the real class hierarchy via `getDeclaredAnnotations()` and re-registers **all** annotations including the Morphia ones from step 1. - -`PropertyModel.annotation()` uses a `HashMap` — `put` overwrites. -The second call overwrites step 1's builder-constructed proxies with reflection-obtained -instances. Result is correct (same annotation type and values), but step 1's work is -entirely wasted. The `emitAnnotationOnStack` path for Morphia annotations can be removed; -`registerFieldAnnotations`/`registerMethodAnnotations` already covers them. - ---- - -### 7. `CritterPropertyModel.java:24-56` — runtime reflection on the hot deserialization path - -`registerFieldAnnotations` and `registerMethodAnnotations` walk the class hierarchy -via `getDeclaredField` / `getDeclaredMethods` at property-model *construction* time -(which happens per deserialized document for runtime-generated models). `getDeclaredMethods()` -allocates a fresh `Method[]` on every call. For an entity with 10 method-backed properties -and a 3-level hierarchy, every document read triggers ~30 `getDeclaredMethods()` copies. - -The codegen has the `Method`/`Field` in hand at generation time. The fix is to emit the -annotations inline into the generated `` bytecode, eliminating the runtime -reflection entirely. The static helpers on `CritterPropertyModel` also place generator- -support infrastructure on a runtime model class, which is the wrong ownership. - ---- - -### 8. Duplicated primitive and utility code across generator classes - -Several things are independently duplicated: - -| What | Where | -|------|-------| -| `PRIMITIVE_TO_WRAPPER` map | `VarHandleAccessorGenerator:31` and `PropertyAccessorGenerator:23` | -| `typeClassName(ClassDesc)` method | `VarHandleAccessorGenerator:95` and `PropertyAccessorGenerator:43` | -| `emitBooleanMethod(ClassBuilder, String, boolean)` | `GizmoEntityModelGenerator` and `PropertyModelGenerator` | -| Class-bytes-to-ClassModel loading | `BaseGenerator.readClassFiltering()`, `PropertyFinder.readClassModel()`, and `CritterGizmoGenerator.generate()` | - -These should be consolidated in `GizmoExtensions` (or `BaseGizmoGenerator`) and called -from all sites. The loading logic in particular has divergent null-guard behavior -(bug #4 above) precisely because it was duplicated rather than shared. From b5f7ba04b5428fdc4d871073bf38f5ee108154c9 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:46:09 -0400 Subject: [PATCH 08/31] Revert accidental audits/ gitignore entry --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 5581173db40..9b2fb3f0f63 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,6 @@ docs/reference/public docs/reference/server mongodb-linux* -audits/ - # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml From 10d427159b89677f00a6bca7902ff801b62ace3d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:55:03 -0400 Subject: [PATCH 09/31] Fix PropertyFinder accepting static methods as property setters in METHODS mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../critter/parser/PropertyFinder.java | 6 ++- .../critter/parser/TestVarHandleAccessor.java | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 4afc8ddcda9..ac8a312e3e6 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -237,7 +237,8 @@ private MethodInfo findSetter(ClassModel classModel, String propertyName, String String setterDesc = "(" + returnDesc + ")V"; for (MethodModel method : classModel.methods()) { if (method.methodName().stringValue().equals(setterName) - && method.methodType().stringValue().equals(setterDesc)) { + && method.methodType().stringValue().equals(setterDesc) + && (method.flags().flagsMask() & ClassFile.ACC_STATIC) == 0) { return toMethodInfo(method); } } @@ -253,7 +254,8 @@ private MethodInfo findSetterInHierarchy(ClassModel startModel, Class startCl model = readClassModel(current); if (model != null) { MethodInfo setter = findSetter(model, propName, returnDesc); - if (setter != null && (setter.access() & ClassFile.ACC_PRIVATE) == 0) { + if (setter != null && (setter.access() & ClassFile.ACC_PRIVATE) == 0 + && (setter.access() & ClassFile.ACC_STATIC) == 0) { return setter; } } diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index 2688f44feb8..ed788eb40b1 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -11,11 +11,14 @@ import dev.morphia.annotations.Entity; import dev.morphia.annotations.Id; +import dev.morphia.config.ManualMorphiaConfig; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; import dev.morphia.critter.parser.gizmo.VarHandleAccessorGenerator; import dev.morphia.critter.sources.Example; +import dev.morphia.mapping.PropertyDiscovery; +import dev.morphia.mapping.ReflectiveMapper; import org.bson.codecs.pojo.PropertyAccessor; import org.bson.types.ObjectId; @@ -131,6 +134,54 @@ public void testFinalFieldReflectionFallback() throws Exception { } } + /** + * Regression test for bug #2: findSetter/findSetterInHierarchy did not filter ACC_STATIC methods. + * + * In METHODS discovery mode, a static setXxx method with matching descriptor was accepted as a + * property setter, causing the property to be treated as method-based. hasSetter() then rejected + * the static method (it has a reflection-level isStatic guard), leaving no setter handle — so + * set() threw UnsupportedOperationException even though the property IS writable via its backing + * field. + * + * After the fix: the static setter is filtered by PropertyFinder, the property falls back to + * field-based VarHandle discovery, and set() works correctly. + */ + @Test + public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exception { + var methodsMapper = new ReflectiveMapper( + new ManualMorphiaConfig().propertyDiscovery(PropertyDiscovery.METHODS)); + CritterClassLoader loader = new CritterClassLoader(); + new CritterGizmoGenerator(methodsMapper).generate(StaticSetterEntity.class, loader, true); + + String accessorName = Critter.critterPackage(StaticSetterEntity.class) + ".ValueAccessor"; + @SuppressWarnings("unchecked") + PropertyAccessor accessor = (PropertyAccessor) loader + .loadClass(accessorName).getConstructor().newInstance(); + + StaticSetterEntity entity = new StaticSetterEntity(); + Assertions.assertNull(accessor.get(entity), "initial value should be null"); + // Bug: set() threw UnsupportedOperationException because the static setter was found by + // PropertyFinder, making the property method-based, then hasSetter() rejected it. + // Fix: PropertyFinder ignores static setters; property falls back to field VarHandle. + accessor.set(entity, "hello"); + Assertions.assertEquals("hello", accessor.get(entity), "set() must write through to the backing field"); + } + + @Entity("static_setter_test") + public static class StaticSetterEntity { + @Id + public ObjectId id; + private String value; + + public String getValue() { + return value; + } + + public static void setValue(String v) { + // static — must not be used as a property setter + } + } + @Entity("final_field_test") public static class FinalFieldEntity { @Id From 4e77b16cf2467461c3349386926de78fab9f2f6c Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 22:59:54 -0400 Subject: [PATCH 10/31] remove references to gizmo --- .../critter/parser/PropertyFinder.java | 24 +++++++++---------- .../BaseGenerator.java} | 6 ++--- .../CritterGenerator.java} | 16 ++++++------- .../EntityModelGenerator.java} | 14 +++++------ .../GenerationUtils.java} | 6 ++--- .../PropertyAccessorGenerator.java | 4 ++-- .../PropertyModelGenerator.java | 18 +++++++------- .../VarHandleAccessorGenerator.java | 6 ++--- .../dev/morphia/mapping/CritterMapper.java | 16 ++++++------- .../morphia/critter/parser/GeneratorTest.java | 6 ++--- .../critter/parser/TestVarHandleAccessor.java | 10 ++++---- .../dev/morphia/critter/parser/TypesTest.java | 4 ++-- .../TestGeneration.java} | 8 +++---- .../morphia/mapping/TestCritterMapper.java | 2 +- .../morphia/critter/maven/CritterProcessor.kt | 4 ++-- 15 files changed, 72 insertions(+), 72 deletions(-) rename core/src/main/java/dev/morphia/critter/parser/{gizmo/BaseGizmoGenerator.java => generator/BaseGenerator.java} (82%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo/CritterGizmoGenerator.java => generator/CritterGenerator.java} (92%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo/GizmoEntityModelGenerator.java => generator/EntityModelGenerator.java} (95%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo/GizmoExtensions.java => generator/GenerationUtils.java} (99%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo => generator}/PropertyAccessorGenerator.java (98%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo => generator}/PropertyModelGenerator.java (97%) rename core/src/main/java/dev/morphia/critter/parser/{gizmo => generator}/VarHandleAccessorGenerator.java (99%) rename core/src/test/java/dev/morphia/critter/parser/{gizmo/TestGizmoGeneration.java => generator/TestGeneration.java} (98%) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index ac8a312e3e6..eefe10004a2 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -8,8 +8,8 @@ import java.util.Map; import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; -import dev.morphia.critter.parser.gizmo.PropertyModelGenerator; +import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.PropertyModelGenerator; import dev.morphia.critter.parser.java.CritterParser; import dev.morphia.mapping.Mapper; import dev.morphia.mapping.PropertyDiscovery; @@ -37,7 +37,7 @@ public class PropertyFinder { private final Map, Object> providerMap; private final CritterClassLoader classLoader; private final boolean runtimeMode; - private final CritterGizmoGenerator critterGizmoGenerator; + private final CritterGenerator critterGenerator; private final PropertyDiscovery propertyDiscovery; public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean runtimeMode) { @@ -47,7 +47,7 @@ public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean run } this.classLoader = classLoader; this.runtimeMode = runtimeMode; - this.critterGizmoGenerator = new CritterGizmoGenerator(mapper); + this.critterGenerator = new CritterGenerator(mapper); this.propertyDiscovery = mapper.getConfig().propertyDiscovery(); } @@ -57,27 +57,27 @@ public List find(Class entityType, ClassModel classMo if (methods.isEmpty()) { List fields = discoverAllFields(entityType, classModel); if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGizmoGenerator.fieldAccessors(entityType, fields)); + classLoader.register(entityType.getName(), critterGenerator.fieldAccessors(entityType, fields)); } for (FieldInfo field : fields) { if (runtimeMode) { - critterGizmoGenerator.varHandleAccessor(entityType, classLoader, field); + critterGenerator.varHandleAccessor(entityType, classLoader, field); } else { - critterGizmoGenerator.propertyAccessor(entityType, classLoader, field); + critterGenerator.propertyAccessor(entityType, classLoader, field); } - models.add(critterGizmoGenerator.propertyModelGenerator(entityType, classLoader, field)); + models.add(critterGenerator.propertyModelGenerator(entityType, classLoader, field)); } } else { if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGizmoGenerator.methodAccessors(entityType, methods)); + classLoader.register(entityType.getName(), critterGenerator.methodAccessors(entityType, methods)); } for (MethodInfo method : methods) { if (runtimeMode) { - critterGizmoGenerator.varHandleAccessor(entityType, classLoader, method); + critterGenerator.varHandleAccessor(entityType, classLoader, method); } else { - critterGizmoGenerator.propertyAccessor(entityType, classLoader, method); + critterGenerator.propertyAccessor(entityType, classLoader, method); } - models.add(critterGizmoGenerator.propertyModelGenerator(entityType, classLoader, method)); + models.add(critterGenerator.propertyModelGenerator(entityType, classLoader, method)); } } return models; diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java similarity index 82% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java index 5cf4f496013..a6c46b184c8 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -6,7 +6,7 @@ /** * Base class for ClassFile-based code generators that produce Critter accessor and model classes. */ -public abstract class BaseGizmoGenerator { +public abstract class BaseGenerator { /** The entity class for which code is being generated. */ protected final Class entity; /** The class loader used to register generated class bytecode. */ @@ -16,7 +16,7 @@ public abstract class BaseGizmoGenerator { /** The base package name derived from the entity, used to namespace generated types. */ protected final String baseName; - protected BaseGizmoGenerator(Class entity, CritterClassLoader critterClassLoader) { + protected BaseGenerator(Class entity, CritterClassLoader critterClassLoader) { this.entity = entity; this.critterClassLoader = critterClassLoader; this.baseName = Critter.critterPackage(entity); diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java similarity index 92% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index 89deb1d8113..ecf9d7c340b 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.io.IOException; import java.io.InputStream; @@ -19,15 +19,15 @@ * Facade that orchestrates the full ClassFile-based code generation pipeline for a Morphia entity, * including field/method accessor injection and property/entity model generation. */ -public class CritterGizmoGenerator { +public class CritterGenerator { private final Mapper mapper; /** - * Creates a new CritterGizmoGenerator with the given mapper. + * Creates a new CritterGenerator with the given mapper. * * @param mapper the Morphia mapper */ - public CritterGizmoGenerator(Mapper mapper) { + public CritterGenerator(Mapper mapper) { this.mapper = mapper; } @@ -39,7 +39,7 @@ public CritterGizmoGenerator(Mapper mapper) { * @param runtimeMode {@code true} to generate VarHandle-based accessors for runtime use * @return the generated entity model generator */ - public GizmoEntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { + public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName); if (inputStream == null) { @@ -64,7 +64,7 @@ public GizmoEntityModelGenerator generate(Class type, CritterClassLoader crit * @param critterClassLoader the class loader that will receive generated bytecode * @return the generated entity model generator */ - public GizmoEntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader) { + public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader) { return generate(type, critterClassLoader, false); } @@ -171,8 +171,8 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte * @param properties the list of property model generators for the entity's properties * @return the emitted entity model generator */ - public GizmoEntityModelGenerator entityModel(Class type, CritterClassLoader critterClassLoader, + public EntityModelGenerator entityModel(Class type, CritterClassLoader critterClassLoader, ClassModel classModel, List properties) { - return new GizmoEntityModelGenerator(mapper, type, critterClassLoader, properties).emit(); + return new EntityModelGenerator(mapper, type, critterClassLoader, properties).emit(); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java similarity index 95% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index 03e1a3ece15..49f13784763 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.annotation.Annotation; import java.lang.constant.ClassDesc; @@ -21,7 +21,7 @@ /** * Generates a ClassFile-based {@code CritterEntityModel} implementation for a Morphia entity class. */ -public class GizmoEntityModelGenerator extends BaseGizmoGenerator { +public class EntityModelGenerator extends BaseGenerator { private final Mapper mapper; private final List properties; private final Entity entityAnnotation; @@ -36,7 +36,7 @@ public class GizmoEntityModelGenerator extends BaseGizmoGenerator { * @param properties the property model generators for each of the entity's properties * @throws IllegalStateException if the entity class does not have an {@code @Entity} annotation */ - public GizmoEntityModelGenerator(Mapper mapper, Class type, CritterClassLoader critterClassLoader, + public EntityModelGenerator(Mapper mapper, Class type, CritterClassLoader critterClassLoader, List properties) { super(type, critterClassLoader); this.mapper = mapper; @@ -83,7 +83,7 @@ public String getGeneratedType() { /** * Emits the generated entity model class and returns this generator. */ - public GizmoEntityModelGenerator emit() { + public EntityModelGenerator emit() { ClassDesc thisDesc = ClassDesc.of(generatedType); ClassDesc superDesc = ClassDesc.of(CritterEntityModel.class.getName()); ClassDesc mapperDesc = ClassDesc.of(Mapper.class.getName()); @@ -108,13 +108,13 @@ public GizmoEntityModelGenerator emit() { ClassFile.ACC_PUBLIC, cod -> { cod.aload(0); cod.aload(1); - GizmoExtensions.emitClassRef(cod, entity); + GenerationUtils.emitClassRef(cod, entity); cod.invokespecial(superDesc, "", MethodTypeDesc.of(ConstantDescs.CD_void, mapperDesc, ConstantDescs.CD_Class)); // setType(entityClass) cod.aload(0); - GizmoExtensions.emitClassRef(cod, entity); + GenerationUtils.emitClassRef(cod, entity); cod.invokevirtual(thisDesc, "setType", MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_Class)); @@ -135,7 +135,7 @@ public GizmoEntityModelGenerator emit() { // Register morphia annotations for (Annotation ann : morphiaAnnotations) { cod.aload(0); - GizmoExtensions.emitAnnotationOnStack(cod, ann); + GenerationUtils.emitAnnotationOnStack(cod, ann); cod.invokevirtual(entityModelDesc, "annotation", MethodTypeDesc.of(ConstantDescs.CD_void, annotationDesc)); } diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java similarity index 99% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java rename to core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 26d72dfc0c9..15cda73037e 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; @@ -16,9 +16,9 @@ /** * Static utility methods bridging annotation introspection and Morphia type data with the ClassFile API. */ -public class GizmoExtensions { +public class GenerationUtils { - private GizmoExtensions() { + private GenerationUtils() { } /** diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java similarity index 98% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index ead5a6f3f69..728297ac384 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; @@ -19,7 +19,7 @@ * Generates a {@link org.bson.codecs.pojo.PropertyAccessor} implementation for a single * entity property, delegating to the synthetic {@code __readXxx}/{@code __writeXxx} methods. */ -public class PropertyAccessorGenerator extends BaseGizmoGenerator { +public class PropertyAccessorGenerator extends BaseGenerator { private static final Map PRIMITIVE_TO_WRAPPER = Map.of( "boolean", "java.lang.Boolean", "byte", "java.lang.Byte", diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java similarity index 97% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 155fedb0ae3..ee3cc2394b0 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.annotation.Annotation; import java.lang.constant.ClassDesc; @@ -42,7 +42,7 @@ * Generates a ClassFile-based {@link dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel} implementation * for a single property of a Morphia entity class. */ -public class PropertyModelGenerator extends BaseGizmoGenerator { +public class PropertyModelGenerator extends BaseGenerator { private final MorphiaConfig config; private final String propertyName; private final String accessorType; @@ -268,7 +268,7 @@ public static List> typeData(String input, ClassLoader classLoader) private static TypeData typeDataFromSignature(io.github.dmlloyd.classfile.Signature sig, ClassLoader classLoader) { if (sig instanceof io.github.dmlloyd.classfile.Signature.ClassTypeSig cts) { java.lang.constant.ClassDesc cd = cts.classDesc(); - Class raw = GizmoExtensions.asClass(cd, classLoader); + Class raw = GenerationUtils.asClass(cd, classLoader); @SuppressWarnings("unchecked") List> params = (List>) (List) cts.typeArgs().stream() .map(arg -> typeDataFromTypeArg(arg, classLoader)) @@ -367,7 +367,7 @@ public PropertyModelGenerator emit() { for (Annotation ann : morphiaAnnotations) { if (ann.annotationType().getName().startsWith("dev.morphia.annotations.")) { cod.aload(0); - GizmoExtensions.emitAnnotationOnStack(cod, ann); + GenerationUtils.emitAnnotationOnStack(cod, ann); cod.invokevirtual(propertyModelDesc, "annotation", MethodTypeDesc.of(propertyModelDesc, annotationDesc)); cod.pop(); @@ -378,14 +378,14 @@ public PropertyModelGenerator emit() { ClassDesc critterPmDesc = ClassDesc.of("dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel"); if (isFieldBased) { cod.aload(0); - GizmoExtensions.emitClassRef(cod, entity); + GenerationUtils.emitClassRef(cod, entity); cod.ldc(propertyName); cod.invokestatic(critterPmDesc, "registerFieldAnnotations", MethodTypeDesc.ofDescriptor( "(Ldev/morphia/mapping/codec/pojo/PropertyModel;Ljava/lang/Class;Ljava/lang/String;)V")); } else { cod.aload(0); - GizmoExtensions.emitClassRef(cod, entity); + GenerationUtils.emitClassRef(cod, entity); cod.ldc(getterName); cod.invokestatic(critterPmDesc, "registerMethodAnnotations", MethodTypeDesc.ofDescriptor( @@ -451,21 +451,21 @@ public PropertyModelGenerator emit() { // getNormalizedType(): Class cb.withMethodBody("getNormalizedType", MethodTypeDesc.of(ConstantDescs.CD_Class), ClassFile.ACC_PUBLIC, cod -> { - GizmoExtensions.emitClassRef(cod, normalizedType); + GenerationUtils.emitClassRef(cod, normalizedType); cod.areturn(); }); // getType(): Class cb.withMethodBody("getType", MethodTypeDesc.of(ConstantDescs.CD_Class), ClassFile.ACC_PUBLIC, cod -> { - GizmoExtensions.emitClassRef(cod, typeData.getType()); + GenerationUtils.emitClassRef(cod, typeData.getType()); cod.areturn(); }); // getTypeData(): TypeData cb.withMethodBody("getTypeData", MethodTypeDesc.of(typeDataDesc), ClassFile.ACC_PUBLIC, cod -> { - GizmoExtensions.emitTypeData(typeData, cod); + GenerationUtils.emitTypeData(typeData, cod); cod.areturn(); }); diff --git a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java similarity index 99% rename from core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index 69b7767e720..cd0e9726d93 100644 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; @@ -27,7 +27,7 @@ * {@link java.lang.invoke.VarHandle} (for fields) or {@link java.lang.invoke.MethodHandle} (for getter/setter pairs) * to access a single property of a Morphia entity class. */ -public class VarHandleAccessorGenerator extends BaseGizmoGenerator { +public class VarHandleAccessorGenerator extends BaseGenerator { private static final Map PRIMITIVE_TO_WRAPPER = Map.of( "boolean", "java.lang.Boolean", "byte", "java.lang.Byte", @@ -345,7 +345,7 @@ public VarHandleAccessorGenerator emit() { // Use reflection to set final field ClassDesc fieldDesc2 = ClassDesc.of("java.lang.reflect.Field"); cod.trying(tryBody -> { - GizmoExtensions.emitClassRef(tryBody, entity); + GenerationUtils.emitClassRef(tryBody, entity); tryBody.ldc(propertyName); tryBody.invokevirtual(ConstantDescs.CD_Class, "getDeclaredField", MethodTypeDesc.of(fieldDesc2, ConstantDescs.CD_String)); diff --git a/core/src/main/java/dev/morphia/mapping/CritterMapper.java b/core/src/main/java/dev/morphia/mapping/CritterMapper.java index 0d2ac1e3aba..4a30532fe8f 100644 --- a/core/src/main/java/dev/morphia/mapping/CritterMapper.java +++ b/core/src/main/java/dev/morphia/mapping/CritterMapper.java @@ -9,8 +9,8 @@ import dev.morphia.annotations.internal.MorphiaInternal; import dev.morphia.config.MorphiaConfig; import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; -import dev.morphia.critter.parser.gizmo.GizmoEntityModelGenerator; +import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.EntityModelGenerator; import dev.morphia.mapping.codec.pojo.EntityModel; import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; @@ -23,7 +23,7 @@ * Hybrid mapper using three-tier entity model discovery: *
    *
  1. Pre-generated models from the classpath (critter-maven AOT)
  2. - *
  3. Runtime Gizmo+VarHandle generation
  4. + *
  5. Runtime bytecode+VarHandle generation
  6. *
  7. Reflection-based fallback
  8. *
* @@ -35,7 +35,7 @@ public class CritterMapper extends AbstractMapper { private static final Logger LOG = LoggerFactory.getLogger(CritterMapper.class); private final CritterClassLoader critterClassLoader; - private final CritterGizmoGenerator gizmoGenerator; + private final CritterGenerator generator; private final Set fallbackTypes; /** @@ -62,7 +62,7 @@ public CritterMapper(MorphiaConfig config) { public CritterMapper(MorphiaConfig config, ClassLoader classLoader) { super(config, classLoader); this.critterClassLoader = classLoader instanceof CritterClassLoader ccl ? ccl : new CritterClassLoader(classLoader); - this.gizmoGenerator = new CritterGizmoGenerator(this); + this.generator = new CritterGenerator(this); this.fallbackTypes = ConcurrentHashMap.newKeySet(); } @@ -88,7 +88,7 @@ public CritterMapper(MorphiaConfig config, ClassLoader classLoader) { public CritterMapper(CritterMapper other) { super(other.config, other.classLoader); this.critterClassLoader = other.critterClassLoader; - this.gizmoGenerator = new CritterGizmoGenerator(this); + this.generator = new CritterGenerator(this); this.fallbackTypes = other.fallbackTypes; this.listeners.addAll(other.listeners); // Create independent copies of all entity models so that each mapper instance @@ -161,13 +161,13 @@ private EntityModel tryLoadPregenerated(Class type) { } /** - * Tier 2: Generate an entity model at runtime using Gizmo + VarHandle accessors. + * Tier 2: Generate an entity model at runtime using VarHandle accessors. * On failure, logs once per type and returns null so the caller falls through to reflection. */ @Nullable private EntityModel tryRuntimeGeneration(Class type) { try { - GizmoEntityModelGenerator generator = gizmoGenerator.generate(type, critterClassLoader, true); + EntityModelGenerator generator = this.generator.generate(type, critterClassLoader, true); Class modelClass = critterClassLoader.loadClass(generator.getGeneratedType()); Constructor ctor = modelClass.getConstructor(Mapper.class); return (EntityModel) ctor.newInstance(this); diff --git a/core/src/test/java/dev/morphia/critter/parser/GeneratorTest.java b/core/src/test/java/dev/morphia/critter/parser/GeneratorTest.java index cbb45db5f4e..de70c474301 100644 --- a/core/src/test/java/dev/morphia/critter/parser/GeneratorTest.java +++ b/core/src/test/java/dev/morphia/critter/parser/GeneratorTest.java @@ -11,8 +11,8 @@ import dev.morphia.critter.ClassfileOutput; import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; -import dev.morphia.critter.parser.gizmo.GizmoEntityModelGenerator; +import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.EntityModelGenerator; import dev.morphia.critter.sources.Example; import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; @@ -40,7 +40,7 @@ public class GeneratorTest { } catch (Exception ignored) { } - GizmoEntityModelGenerator gen = new CritterGizmoGenerator(defaultMapper()).generate(Example.class, critterClassLoader, false); + EntityModelGenerator gen = new CritterGenerator(defaultMapper()).generate(Example.class, critterClassLoader, false); try { entityModel = (CritterEntityModel) critterClassLoader .loadClass(gen.getGeneratedType()) diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index ed788eb40b1..88d08e24902 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -14,8 +14,8 @@ import dev.morphia.config.ManualMorphiaConfig; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator; -import dev.morphia.critter.parser.gizmo.VarHandleAccessorGenerator; +import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.VarHandleAccessorGenerator; import dev.morphia.critter.sources.Example; import dev.morphia.mapping.PropertyDiscovery; import dev.morphia.mapping.ReflectiveMapper; @@ -38,7 +38,7 @@ public class TestVarHandleAccessor { @BeforeAll public void setup() { classLoader = new CritterClassLoader(); - new CritterGizmoGenerator(defaultMapper()).generate(Example.class, classLoader, true); + new CritterGenerator(defaultMapper()).generate(Example.class, classLoader, true); } @Test @@ -110,7 +110,7 @@ public void testAccessorsInstantiatable() throws Exception { @Test public void testFinalFieldReflectionFallback() throws Exception { CritterClassLoader loader = new CritterClassLoader(); - new CritterGizmoGenerator(defaultMapper()).generate(FinalFieldEntity.class, loader, true); + new CritterGenerator(defaultMapper()).generate(FinalFieldEntity.class, loader, true); // Static check: the generated accessor must reference java/lang/reflect/Field String accessorName = Critter.critterPackage(FinalFieldEntity.class) + "." + Critter.titleCase("label") + "Accessor"; @@ -151,7 +151,7 @@ public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exceptio var methodsMapper = new ReflectiveMapper( new ManualMorphiaConfig().propertyDiscovery(PropertyDiscovery.METHODS)); CritterClassLoader loader = new CritterClassLoader(); - new CritterGizmoGenerator(methodsMapper).generate(StaticSetterEntity.class, loader, true); + new CritterGenerator(methodsMapper).generate(StaticSetterEntity.class, loader, true); String accessorName = Critter.critterPackage(StaticSetterEntity.class) + ".ValueAccessor"; @SuppressWarnings("unchecked") diff --git a/core/src/test/java/dev/morphia/critter/parser/TypesTest.java b/core/src/test/java/dev/morphia/critter/parser/TypesTest.java index 7db4d4ebca6..5a7e45dc4f9 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TypesTest.java +++ b/core/src/test/java/dev/morphia/critter/parser/TypesTest.java @@ -8,7 +8,7 @@ import java.util.UUID; import java.util.stream.Stream; -import dev.morphia.critter.parser.gizmo.GizmoExtensions; +import dev.morphia.critter.parser.generator.GenerationUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.params.ParameterizedTest; @@ -108,7 +108,7 @@ private static String classToDescriptor(Class c) { @MethodSource("types") public void asClassConversion(Class expected) { ClassDesc type = ClassDesc.ofDescriptor(classToDescriptor(expected)); - Class actual = GizmoExtensions.asClass(type, Thread.currentThread().getContextClassLoader()); + Class actual = GenerationUtils.asClass(type, Thread.currentThread().getContextClassLoader()); Assertions.assertEquals(expected, actual, "Type " + type.descriptorString() + " should convert to " + expected.getName()); } } diff --git a/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java similarity index 98% rename from core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java rename to core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java index faf77905e1e..ebf0b3fdf9d 100644 --- a/core/src/test/java/dev/morphia/critter/parser/gizmo/TestGizmoGeneration.java +++ b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.gizmo; +package dev.morphia.critter.parser.generator; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -40,7 +40,7 @@ import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; import static io.github.dmlloyd.classfile.Attributes.runtimeVisibleAnnotations; -public class TestGizmoGeneration { +public class TestGeneration { private final CritterClassLoader critterClassLoader = new CritterClassLoader(); @Test @@ -87,8 +87,8 @@ public void testMalformedSignatureReturnsEmpty() { } @Test - public void testGizmo() throws Exception { - new CritterGizmoGenerator(defaultMapper()).generate(Example.class, critterClassLoader, false); + public void testGenerator() throws Exception { + new CritterGenerator(defaultMapper()).generate(Example.class, critterClassLoader, false); critterClassLoader.loadClass("dev.morphia.critter.sources.__morphia.example.AgeModel"); Class nameModel = critterClassLoader.loadClass("dev.morphia.critter.sources.__morphia.example.NameModel"); invokeAll(PropertyModel.class, nameModel); diff --git a/core/src/test/java/dev/morphia/mapping/TestCritterMapper.java b/core/src/test/java/dev/morphia/mapping/TestCritterMapper.java index 27284c544ac..c8886a4e27a 100644 --- a/core/src/test/java/dev/morphia/mapping/TestCritterMapper.java +++ b/core/src/test/java/dev/morphia/mapping/TestCritterMapper.java @@ -308,7 +308,7 @@ public static String getKind() { /** * Verifies that CritterMapper correctly reports lifecycle methods. - * Previously, GizmoEntityModelGenerator hard-coded hasLifecycle() to always return false, + * Previously, EntityModelGenerator hard-coded hasLifecycle() to always return false, * silently skipping @PrePersist/@PostLoad/@PreLoad/@PostPersist callbacks for CritterMapper. */ @Test diff --git a/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt b/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt index e30c7d32f06..1e67fdbb247 100644 --- a/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt +++ b/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt @@ -3,7 +3,7 @@ package dev.morphia.critter.maven import dev.morphia.annotations.Entity import dev.morphia.config.MorphiaConfig import dev.morphia.critter.CritterClassLoader -import dev.morphia.critter.parser.gizmo.CritterGizmoGenerator +import dev.morphia.critter.parser.generator.CritterGenerator import dev.morphia.mapping.ReflectiveMapper import io.github.classgraph.ClassGraph import java.io.File @@ -20,7 +20,7 @@ class CritterProcessor( private val logger: Logger = LoggerFactory.getLogger(CritterProcessor::class.java) private val critterClassLoader = CritterClassLoader() - private val gizmoGenerator = CritterGizmoGenerator(ReflectiveMapper(config, critterClassLoader)) + private val gizmoGenerator = CritterGenerator(ReflectiveMapper(config, critterClassLoader)) fun process() { val entityClasses = findEntityClasses() From 0c1784a6f74d472c281f6d98e2eb1ff4472b4ffc Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 14 Jun 2026 23:19:07 -0400 Subject: [PATCH 11/31] remove references to gizmo and asm consolidate packages --- .../BaseGenerator.java => generator/AccessorMethods.java} | 6 +++--- .../parser/{asm => generator}/AddFieldAccessorMethods.java | 4 ++-- .../parser/{asm => generator}/AddMethodAccessorMethods.java | 4 ++-- .../morphia/critter/parser/generator/CritterGenerator.java | 2 -- .../morphia/critter/parser/generator/TestGeneration.java | 1 - 5 files changed, 7 insertions(+), 10 deletions(-) rename core/src/main/java/dev/morphia/critter/parser/{asm/BaseGenerator.java => generator/AccessorMethods.java} (91%) rename core/src/main/java/dev/morphia/critter/parser/{asm => generator}/AddFieldAccessorMethods.java (96%) rename core/src/main/java/dev/morphia/critter/parser/{asm => generator}/AddMethodAccessorMethods.java (97%) diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java similarity index 91% rename from core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java rename to core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java index 37fb16e4939..ed426ba00be 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.asm; +package dev.morphia.critter.parser.generator; import java.io.IOException; import java.io.InputStream; @@ -9,14 +9,14 @@ /** * Base class for bytecode generators that read and transform existing class files. */ -public abstract class BaseGenerator { +public abstract class AccessorMethods { /** The entity class whose bytecode will be augmented. */ protected final Class entity; /** * Creates a new generator for the given entity class. */ - protected BaseGenerator(Class entity) { + protected AccessorMethods(Class entity) { this.entity = entity; } diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java similarity index 96% rename from core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java rename to core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java index f07a48d05da..a970e73fe08 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.asm; +package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -17,7 +17,7 @@ * Generates synthetic {@code __readXxx} and {@code __writeXxx} accessor methods directly * into an entity class bytecode for each of its fields. */ -public class AddFieldAccessorMethods extends BaseGenerator { +public class AddFieldAccessorMethods extends AccessorMethods { private final List fields; /** diff --git a/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java similarity index 97% rename from core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java rename to core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java index 43b2c728666..df79910d355 100644 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -1,4 +1,4 @@ -package dev.morphia.critter.parser.asm; +package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -18,7 +18,7 @@ * Generates synthetic {@code __readXxx} and {@code __writeXxx} accessor methods into an entity class * bytecode for properties backed by getter/setter methods rather than direct fields. */ -public class AddMethodAccessorMethods extends BaseGenerator { +public class AddMethodAccessorMethods extends AccessorMethods { private final List methods; /** diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index ecf9d7c340b..9fedaf95f7d 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -8,8 +8,6 @@ import dev.morphia.critter.parser.FieldInfo; import dev.morphia.critter.parser.MethodInfo; import dev.morphia.critter.parser.PropertyFinder; -import dev.morphia.critter.parser.asm.AddFieldAccessorMethods; -import dev.morphia.critter.parser.asm.AddMethodAccessorMethods; import dev.morphia.mapping.Mapper; import io.github.dmlloyd.classfile.ClassFile; diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java index ebf0b3fdf9d..ccb61ac9be8 100644 --- a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java +++ b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java @@ -21,7 +21,6 @@ import dev.morphia.annotations.internal.IndexesBuilder; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.MethodInfo; -import dev.morphia.critter.parser.asm.AddMethodAccessorMethods; import dev.morphia.critter.sources.Example; import dev.morphia.critter.sources.MethodExample; import dev.morphia.mapping.codec.pojo.EntityModel; From 86acc7c4ada4a693e80fda45c52d163ffba0c9f0 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 15 Jun 2026 21:52:31 -0400 Subject: [PATCH 12/31] Fix null classloader NPE and consolidate duplicated generator utilities 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. --- .../parser/generator/CritterGenerator.java | 2 +- .../generator/EntityModelGenerator.java | 13 +---- .../parser/generator/GenerationUtils.java | 55 +++++++++++++++++++ .../generator/PropertyAccessorGenerator.java | 42 ++------------ .../generator/PropertyModelGenerator.java | 21 +++---- .../generator/VarHandleAccessorGenerator.java | 44 ++------------- 6 files changed, 75 insertions(+), 102 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index 9fedaf95f7d..491902694f0 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -39,7 +39,7 @@ public CritterGenerator(Mapper mapper) { */ public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); - InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName); + InputStream inputStream = GenerationUtils.safeClassLoader(type).getResourceAsStream(resourceName); if (inputStream == null) { throw new IllegalArgumentException("Could not find class file for %s".formatted(type.getName())); } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index 49f13784763..d233d24f1a7 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -16,7 +16,6 @@ import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; import io.github.dmlloyd.classfile.ClassFile; -import io.github.dmlloyd.classfile.TypeKind; /** * Generates a ClassFile-based {@code CritterEntityModel} implementation for a Morphia entity class. @@ -171,11 +170,11 @@ public EntityModelGenerator emit() { }); // isAbstract(): boolean - emitBooleanMethod(cb, "isAbstract", isAbstractFlag); + GenerationUtils.emitBooleanMethod(cb, "isAbstract", isAbstractFlag); // isInterface(): boolean - emitBooleanMethod(cb, "isInterface", isInterfaceFlag); + GenerationUtils.emitBooleanMethod(cb, "isInterface", isInterfaceFlag); // useDiscriminator(): boolean - emitBooleanMethod(cb, "useDiscriminator", useDiscriminatorFlag); + GenerationUtils.emitBooleanMethod(cb, "useDiscriminator", useDiscriminatorFlag); }); critterClassLoader.register(generatedType, bytes); @@ -200,10 +199,4 @@ private String computeDiscriminatorKey() { : key; } - private static void emitBooleanMethod(io.github.dmlloyd.classfile.ClassBuilder cb, String name, boolean value) { - cb.withMethodBody(name, MethodTypeDesc.ofDescriptor("()Z"), ClassFile.ACC_PUBLIC, cod -> { - cod.loadConstant(value ? 1 : 0); - cod.return_(TypeKind.INT); - }); - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 15cda73037e..3d9419a2869 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -7,9 +7,12 @@ import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.util.List; +import java.util.Map; import dev.morphia.mapping.codec.pojo.TypeData; +import io.github.dmlloyd.classfile.ClassBuilder; +import io.github.dmlloyd.classfile.ClassFile; import io.github.dmlloyd.classfile.CodeBuilder; import io.github.dmlloyd.classfile.TypeKind; @@ -18,9 +21,61 @@ */ public class GenerationUtils { + public static final Map PRIMITIVE_TO_WRAPPER = Map.of( + "boolean", "java.lang.Boolean", + "byte", "java.lang.Byte", + "char", "java.lang.Character", + "short", "java.lang.Short", + "int", "java.lang.Integer", + "long", "java.lang.Long", + "float", "java.lang.Float", + "double", "java.lang.Double"); + private GenerationUtils() { } + /** + * Converts a ClassDesc to a type class name string suitable for Class.forName(). + */ + public static String typeClassName(ClassDesc cd) { + String desc = cd.descriptorString(); + if (desc.length() == 1) { + return switch (desc.charAt(0)) { + case 'Z' -> "boolean"; + case 'C' -> "char"; + case 'B' -> "byte"; + case 'S' -> "short"; + case 'I' -> "int"; + case 'J' -> "long"; + case 'F' -> "float"; + case 'D' -> "double"; + default -> throw new IllegalArgumentException("Unknown primitive: " + desc); + }; + } + if (desc.startsWith("[")) { + return desc.replace('/', '.'); + } + return desc.substring(1, desc.length() - 1).replace('/', '.'); + } + + /** + * Emits a no-arg boolean method that returns a constant value. + */ + public static void emitBooleanMethod(ClassBuilder cb, String name, boolean value) { + cb.withMethodBody(name, MethodTypeDesc.ofDescriptor("()Z"), ClassFile.ACC_PUBLIC, cod -> { + cod.loadConstant(value ? 1 : 0); + cod.return_(TypeKind.INT); + }); + } + + /** + * Returns the classloader for the given type, falling back to the system classloader for bootstrap-loaded types. + */ + public static ClassLoader safeClassLoader(Class type) { + ClassLoader cl = type.getClassLoader(); + return cl != null ? cl : ClassLoader.getSystemClassLoader(); + } + /** * Emits bytecode that leaves the given annotation instance on the stack. * Uses the annotation builder pattern: XxxBuilder.xxxBuilder().field1(v1).build(). diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index 728297ac384..e3c105e94db 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -3,7 +3,6 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; -import java.util.Map; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -20,63 +19,30 @@ * entity property, delegating to the synthetic {@code __readXxx}/{@code __writeXxx} methods. */ public class PropertyAccessorGenerator extends BaseGenerator { - private static final Map PRIMITIVE_TO_WRAPPER = Map.of( - "boolean", "java.lang.Boolean", - "byte", "java.lang.Byte", - "char", "java.lang.Character", - "short", "java.lang.Short", - "int", "java.lang.Integer", - "long", "java.lang.Long", - "float", "java.lang.Float", - "double", "java.lang.Double"); - private final String propertyName; private final String propertyType; public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.propertyName = field.name(); - this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(field.desc())); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } - private static String typeClassName(ClassDesc cd) { - String desc = cd.descriptorString(); - if (desc.length() == 1) { - return switch (desc.charAt(0)) { - case 'Z' -> "boolean"; - case 'C' -> "char"; - case 'B' -> "byte"; - case 'S' -> "short"; - case 'I' -> "int"; - case 'J' -> "long"; - case 'F' -> "float"; - case 'D' -> "double"; - default -> throw new IllegalArgumentException("Unknown primitive: " + desc); - }; - } - if (desc.startsWith("[")) { - // Array: return Class.forName-compatible form, e.g. [Ljava.lang.String; - return desc.replace('/', '.'); - } - // Object type: Ljava/lang/String; → java.lang.String - return desc.substring(1, desc.length() - 1).replace('/', '.'); - } - public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); String returnDesc = MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); - this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); + this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(returnDesc)); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } public boolean isPrimitive() { - return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + return GenerationUtils.PRIMITIVE_TO_WRAPPER.containsKey(propertyType); } public String getWrapperType() { - return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + return GenerationUtils.PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } public PropertyAccessorGenerator emit() { diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index ee3cc2394b0..bd9bd529a61 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -36,7 +36,6 @@ import org.bson.codecs.pojo.PropertyAccessor; import io.github.dmlloyd.classfile.ClassFile; -import io.github.dmlloyd.classfile.TypeKind; /** * Generates a ClassFile-based {@link dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel} implementation @@ -470,29 +469,23 @@ public PropertyModelGenerator emit() { }); // isArray(): boolean - emitBooleanMethod(cb, "isArray", isArrayFlag); + GenerationUtils.emitBooleanMethod(cb, "isArray", isArrayFlag); // isFinal(): boolean - emitBooleanMethod(cb, "isFinal", isFinalFlag); + GenerationUtils.emitBooleanMethod(cb, "isFinal", isFinalFlag); // isReference(): boolean - emitBooleanMethod(cb, "isReference", isReferenceFlag); + GenerationUtils.emitBooleanMethod(cb, "isReference", isReferenceFlag); // isTransient(): boolean - emitBooleanMethod(cb, "isTransient", isTransientFlag); + GenerationUtils.emitBooleanMethod(cb, "isTransient", isTransientFlag); // isMap(): boolean - emitBooleanMethod(cb, "isMap", isMapFlag); + GenerationUtils.emitBooleanMethod(cb, "isMap", isMapFlag); // isSet(): boolean - emitBooleanMethod(cb, "isSet", isSetFlag); + GenerationUtils.emitBooleanMethod(cb, "isSet", isSetFlag); // isCollection(): boolean - emitBooleanMethod(cb, "isCollection", isCollectionFlag); + GenerationUtils.emitBooleanMethod(cb, "isCollection", isCollectionFlag); }); critterClassLoader.register(generatedType, bytes); return this; } - private static void emitBooleanMethod(io.github.dmlloyd.classfile.ClassBuilder cb, String name, boolean value) { - cb.withMethodBody(name, MethodTypeDesc.ofDescriptor("()Z"), ClassFile.ACC_PUBLIC, cod -> { - cod.loadConstant(value ? 1 : 0); - cod.return_(TypeKind.INT); - }); - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index cd0e9726d93..2548690a54d 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -28,16 +28,6 @@ * to access a single property of a Morphia entity class. */ public class VarHandleAccessorGenerator extends BaseGenerator { - private static final Map PRIMITIVE_TO_WRAPPER = Map.of( - "boolean", "java.lang.Boolean", - "byte", "java.lang.Byte", - "char", "java.lang.Character", - "short", "java.lang.Short", - "int", "java.lang.Integer", - "long", "java.lang.Long", - "float", "java.lang.Float", - "double", "java.lang.Double"); - private static final Map> PRIMITIVE_CLASSES = Map.of( "boolean", boolean.class, "byte", byte.class, @@ -65,7 +55,7 @@ public class VarHandleAccessorGenerator extends BaseGenerator { public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.propertyName = field.name(); - this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(field.desc())); this.isFieldBased = true; this.isFinalField = (field.access() & ClassFile.ACC_FINAL) != 0; this.getterName = null; @@ -84,7 +74,7 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); String returnDesc = java.lang.constant.MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); - this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); + this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(returnDesc)); this.isFieldBased = false; this.isFinalField = false; this.getterName = method.name(); @@ -92,36 +82,13 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } - private static String typeClassName(ClassDesc cd) { - String desc = cd.descriptorString(); - if (desc.length() == 1) { - return switch (desc.charAt(0)) { - case 'Z' -> "boolean"; - case 'C' -> "char"; - case 'B' -> "byte"; - case 'S' -> "short"; - case 'I' -> "int"; - case 'J' -> "long"; - case 'F' -> "float"; - case 'D' -> "double"; - default -> throw new IllegalArgumentException("Unknown primitive: " + desc); - }; - } - if (desc.startsWith("[")) { - // Array: return Class.forName-compatible form, e.g. [Ljava.lang.String; - return desc.replace('/', '.'); - } - // Object type: Ljava/lang/String; → java.lang.String - return desc.substring(1, desc.length() - 1).replace('/', '.'); - } - /** * Returns {@code true} if the property type is a Java primitive. * * @return {@code true} if the property type is primitive */ public boolean isPrimitive() { - return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + return GenerationUtils.PRIMITIVE_TO_WRAPPER.containsKey(propertyType); } /** @@ -131,7 +98,7 @@ public boolean isPrimitive() { * @return the wrapper type name */ public String getWrapperType() { - return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + return GenerationUtils.PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } private boolean hasSetter() { @@ -432,8 +399,7 @@ private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String t } private ClassLoader entityClassLoader() { - ClassLoader cl = entity.getClassLoader(); - return cl != null ? cl : ClassLoader.getSystemClassLoader(); + return GenerationUtils.safeClassLoader(entity); } private ClassDesc propertyClassDesc() { From f5336962e34b41013331fe8ac364e1fe8875f830 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 15 Jun 2026 21:56:05 -0400 Subject: [PATCH 13/31] Guard against methods named exactly "is" or "get" crashing property discovery 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. --- .../critter/parser/PropertyFinder.java | 2 ++ .../critter/parser/TestVarHandleAccessor.java | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index eefe10004a2..d41338fd531 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -214,6 +214,8 @@ private boolean isGetter(MethodModel method) { String name = method.methodName().stringValue(); if (!name.startsWith("get") && !name.startsWith("is")) return false; + if (name.equals("get") || name.equals("is")) + return false; int flags = method.flags().flagsMask(); if ((flags & ClassFile.ACC_STATIC) != 0) return false; diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index 88d08e24902..c62c8783095 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -167,6 +167,38 @@ public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exceptio Assertions.assertEquals("hello", accessor.get(entity), "set() must write through to the backing field"); } + /** + * Regression test for bug #3: isGetter() had no minimum-length guard for "is"/"get" method names. + * + * A no-arg non-void method named exactly "is" or "get" passed the startsWith check, then + * getterPropertyName() computed name.substring(2) == "" and crashed on prop.charAt(0) with + * StringIndexOutOfBoundsException, aborting property discovery for the entire entity. + * + * After the fix: methods named exactly "is" or "get" are rejected by isGetter(), so property + * discovery proceeds normally without throwing. + */ + @Test + public void testMethodNamedExactlyIsOrGetDoesNotCrashDiscovery() { + CritterClassLoader loader = new CritterClassLoader(); + // Before fix: StringIndexOutOfBoundsException from prop.charAt(0) in getterPropertyName() + Assertions.assertDoesNotThrow( + () -> new CritterGenerator(defaultMapper()).generate(BareGetterEntity.class, loader, true)); + } + + @Entity("bare_getter_test") + public static class BareGetterEntity { + @Id + public ObjectId id; + + public boolean is() { + return false; + } + + public Object get() { + return null; + } + } + @Entity("static_setter_test") public static class StaticSetterEntity { @Id From 96d401df42590a6c18aa65e36d7acc3f9918a51c Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 15 Jun 2026 22:07:32 -0400 Subject: [PATCH 14/31] Fix array annotation defaults compared by identity rather than value 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. --- .../parser/generator/GenerationUtils.java | 2 +- .../critter/parser/TestVarHandleAccessor.java | 41 +++++++++++++++++++ test-all.sh | 22 ++++++---- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 3d9419a2869..167d786bb45 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -100,7 +100,7 @@ public static void emitAnnotationOnStack(CodeBuilder cod, java.lang.annotation.A try { Object value = method.invoke(annotation); Object defaultValue = method.getDefaultValue(); - if (value == null || value.equals(defaultValue)) { + if (value == null || java.util.Objects.deepEquals(value, defaultValue)) { continue; } java.lang.reflect.Type elemType = method.getGenericReturnType(); diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index c62c8783095..c2c60e4b260 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -10,6 +10,7 @@ import java.util.stream.Collectors; import dev.morphia.annotations.Entity; +import dev.morphia.annotations.EntityListeners; import dev.morphia.annotations.Id; import dev.morphia.config.ManualMorphiaConfig; import dev.morphia.critter.Critter; @@ -167,6 +168,46 @@ public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exceptio Assertions.assertEquals("hello", accessor.get(entity), "set() must write through to the backing field"); } + /** + * Regression test for bug #5: emitAnnotationOnStack used value.equals(defaultValue) to skip + * emitting setter calls for annotation elements whose value matches the default. For array-typed + * elements, annotation proxy methods return a fresh defensive copy on every call, so two + * logically-identical arrays are never the same instance and equals() always returned false. + * + * Effect: the builder setter was emitted even when the value equalled the default, inflating + * the generated bytecode. + * + * After the fix (Objects.deepEquals), equal-content arrays are detected and the setter is + * skipped. Verified by asserting the builder class name is absent from the entity model bytecode + * when the annotation value matches its default. + */ + @Test + public void testArrayAnnotationDefaultValueDoesNotEmitBuilderSetter() throws Exception { + CritterClassLoader loader = new CritterClassLoader(); + new CritterGenerator(defaultMapper()).generate(EntityListenersDefaultEntity.class, loader, true); + + String entityModelName = Critter.critterPackage(EntityListenersDefaultEntity.class) + + "." + EntityListenersDefaultEntity.class.getSimpleName() + "EntityModel"; + byte[] modelBytes = loader.getTypeDefinitions().get(entityModelName); + Assertions.assertNotNull(modelBytes, "entity model bytecode must be registered"); + + // The builder factory (entityListenersBuilder) and build() are always emitted. + // The setter invokevirtual has descriptor ([Ljava/lang/Class;) — unique to the value() setter. + // Before fix: value.equals(defaultValue) was false for two distinct empty Class[]{}, + // so the setter was emitted and that descriptor appears in the constant pool. + // After fix: Objects.deepEquals correctly detects equal empty arrays, setter is skipped. + String bytecodeStr = new String(modelBytes, StandardCharsets.ISO_8859_1); + Assertions.assertFalse(bytecodeStr.contains("([Ljava/lang/Class;)"), + "setter descriptor ([Ljava/lang/Class;) must not appear when @EntityListeners value equals its default"); + } + + @Entity("entity_listeners_default_test") + @EntityListeners + public static class EntityListenersDefaultEntity { + @Id + public ObjectId id; + } + /** * Regression test for bug #3: isGetter() had no minimum-length guard for "is"/"get" method names. * diff --git a/test-all.sh b/test-all.sh index a9a1f30301a..b68b5ec9eb1 100755 --- a/test-all.sh +++ b/test-all.sh @@ -28,11 +28,10 @@ function findRoot() { } function selectServers() { - PS3="Select a server version: " - + LIST=`sanitize $( ${ROOT}/.github/BuildMatrix.java )` if [ -z "$SERVERS" ] then - LIST=`sanitize $( ${ROOT}/.github/BuildMatrix.java )` + PS3="Select a server version: " select SERVER in all $LIST do case $SERVER in @@ -45,15 +44,17 @@ function selectServers() { esac break done + elif [ "$SERVERS" = "all" ] + then + SERVERS=$LIST fi } function selectDrivers() { - PS3="Select a driver version: " - + LIST=$( sanitize $( ${ROOT}/.github/DriverVersions.java all ) ) if [ -z "$DRIVERS" ] then - LIST=$( sanitize $( ${ROOT}/.github/DriverVersions.java all ) ) + PS3="Select a driver version: " select DRIVER in all $LIST do case $DRIVER in @@ -66,14 +67,16 @@ function selectDrivers() { esac break done + elif [ "$DRIVERS" = "all" ] + then + DRIVERS=$LIST fi } function selectMappers() { - PS3="Select a mapper: " - if [ -z "$MAPPERS" ] then + PS3="Select a mapper: " select MAPPER in all reflection critter do case $MAPPER in @@ -86,6 +89,9 @@ function selectMappers() { esac break done + elif [ "$MAPPERS" = "all" ] + then + MAPPERS="reflection critter" fi } From 2c9fea7b7e14d716ccfb4f65bed9f1188af01045 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Wed, 17 Jun 2026 21:13:19 -0400 Subject: [PATCH 15/31] Replace builder-based annotation emission with ClassFile API impl generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/java/util/AnnotationBuilders.java | 6 +- .../generator/EntityModelGenerator.java | 12 +- .../parser/generator/GenerationUtils.java | 263 +++++++----------- .../generator/PropertyModelGenerator.java | 43 +-- .../pojo/critter/CritterPropertyModel.java | 42 --- critter/critter-integration-tests/pom.xml | 0 .../dev/morphia/critter/it/gen/Hotel.java | 18 ++ 7 files changed, 142 insertions(+), 242 deletions(-) delete mode 100644 critter/critter-integration-tests/pom.xml diff --git a/build-plugins/src/main/java/util/AnnotationBuilders.java b/build-plugins/src/main/java/util/AnnotationBuilders.java index 27a42ce4028..38161d61a62 100644 --- a/build-plugins/src/main/java/util/AnnotationBuilders.java +++ b/build-plugins/src/main/java/util/AnnotationBuilders.java @@ -293,7 +293,7 @@ private void equals(JavaClassSource annotation, List el } else { comparator = "Objects"; } - comparisons.add(format("%s.equals(%s, that.%s)", comparator, element.getName(), + comparisons.add(format("%s.equals(%s, that.%s())", comparator, element.getName(), element.getName())); } annotation.addMethod() @@ -303,10 +303,10 @@ private void equals(JavaClassSource annotation, List el .setBody("if (this == o) {\n" + " return true;\n" + "}\n" - + "if (!(o instanceof " + annotation.getName() + ")) {\n" + + "if (!(o instanceof " + source.getName() + ")) {\n" + " return false;\n" + "}\n" - + "var that = (" + annotation.getName() + ") o;\n" + + source.getName() + " that = (" + source.getName() + ") o;\n" + comparisons) .addParameter("Object", "o"); diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index d233d24f1a7..6fd4cab3e5a 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -97,6 +97,10 @@ public EntityModelGenerator emit() { boolean isInterfaceFlag = entity.isInterface(); boolean useDiscriminatorFlag = entityAnnotation.useDiscriminator(); + List annotationImpls = morphiaAnnotations.stream() + .map(ann -> GenerationUtils.generateAnnotationImpl(ann, generatedType, critterClassLoader)) + .toList(); + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); @@ -131,10 +135,12 @@ public EntityModelGenerator emit() { cod.pop(); } - // Register morphia annotations - for (Annotation ann : morphiaAnnotations) { + // Register morphia annotations — values known at generation time + for (ClassDesc implDesc : annotationImpls) { cod.aload(0); - GenerationUtils.emitAnnotationOnStack(cod, ann); + cod.new_(implDesc); + cod.dup(); + cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); cod.invokevirtual(entityModelDesc, "annotation", MethodTypeDesc.of(ConstantDescs.CD_void, annotationDesc)); } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 167d786bb45..72a40aeacaf 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -1,5 +1,6 @@ package dev.morphia.critter.parser.generator; +import java.lang.annotation.Annotation; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Map; +import dev.morphia.critter.CritterClassLoader; import dev.morphia.mapping.codec.pojo.TypeData; import io.github.dmlloyd.classfile.ClassBuilder; @@ -77,99 +79,126 @@ public static ClassLoader safeClassLoader(Class type) { } /** - * Emits bytecode that leaves the given annotation instance on the stack. - * Uses the annotation builder pattern: XxxBuilder.xxxBuilder().field1(v1).build(). + * Generates a concrete class that implements the given annotation interface with all element + * values hardcoded as constants. The class is registered with the given class loader and its + * ClassDesc is returned for use in {@code new} bytecode instructions. + * + *

+ * This is used to emit annotation instances inline into generated property/entity model + * constructors, eliminating all runtime reflection. */ - public static void emitAnnotationOnStack(CodeBuilder cod, java.lang.annotation.Annotation annotation) { - Class annType = annotation.annotationType(); - String className = annType.getName(); - String classPackage = className.substring(0, className.lastIndexOf('.')); - String simpleName = className.substring(className.lastIndexOf('.') + 1); - String builderClassName = classPackage + ".internal." + simpleName + "Builder"; - ClassDesc builderDesc = ClassDesc.of(builderClassName); - ClassDesc annDesc = ClassDesc.of(className); - String factoryMethod = Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1) + "Builder"; + public static ClassDesc generateAnnotationImpl(Annotation ann, String baseName, CritterClassLoader classLoader) { + Class annType = ann.annotationType(); + String implName = baseName + "$$" + annType.getName().replace('.', '_').replace('$', '_'); + ClassDesc thisDesc = ClassDesc.of(implName); + ClassDesc annDesc = ClassDesc.of(annType.getName()); - // Call builder factory: XxxBuilder.xxxBuilder() - cod.invokestatic(builderDesc, factoryMethod, MethodTypeDesc.of(builderDesc)); - int builderSlot = cod.allocateLocal(TypeKind.REFERENCE); - cod.astore(builderSlot); + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER | ClassFile.ACC_SYNTHETIC); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withInterfaceSymbols(annDesc); - // For each annotation element that has a non-default value, emit setter call - for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { - try { - Object value = method.invoke(annotation); - Object defaultValue = method.getDefaultValue(); - if (value == null || java.util.Objects.deepEquals(value, defaultValue)) { - continue; - } - java.lang.reflect.Type elemType = method.getGenericReturnType(); - ClassDesc paramDesc = rawTypeDesc(elemType); + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + cod.return_(); + }); + + cb.withMethodBody("annotationType", MethodTypeDesc.of(ConstantDescs.CD_Class), + ClassFile.ACC_PUBLIC, cod -> { + emitClassRef(cod, annType); + cod.areturn(); + }); - cod.aload(builderSlot); - emitAnnotationElementValue(cod, elemType, value); - cod.invokevirtual(builderDesc, method.getName(), MethodTypeDesc.of(builderDesc, paramDesc)); - cod.astore(builderSlot); - } catch (Exception e) { - throw new RuntimeException("Failed to emit annotation element " + method.getName(), e); + for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { + try { + Object value = method.invoke(ann); + Class returnType = method.getReturnType(); + ClassDesc returnDesc = ClassDesc.ofDescriptor(returnType.descriptorString()); + cb.withMethodBody(method.getName(), MethodTypeDesc.of(returnDesc), + ClassFile.ACC_PUBLIC, cod -> { + emitAnnotationValue(cod, method.getGenericReturnType(), returnType, value, baseName, classLoader); + cod.return_(typeKindOf(returnType)); + }); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to generate element method for " + method, e); + } } - } + }); - // Call .build() - cod.aload(builderSlot); - cod.invokevirtual(builderDesc, "build", MethodTypeDesc.of(annDesc)); + classLoader.register(implName, bytes); + return thisDesc; } - @SuppressWarnings("unchecked") - private static void emitAnnotationElementValue(CodeBuilder cod, java.lang.reflect.Type type, Object value) { - if (type == String.class) { + @SuppressWarnings("rawtypes") + private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type genericType, Class rawType, + Object value, String baseName, CritterClassLoader classLoader) { + if (rawType == String.class) { cod.ldc((String) value); - } else if (type == boolean.class || type == Boolean.class) { + } else if (rawType == boolean.class) { cod.loadConstant(((Boolean) value) ? 1 : 0); - } else if (type == int.class || type == Integer.class) { - cod.loadConstant((int) value); - } else if (type == long.class || type == Long.class) { + } else if (rawType == byte.class || rawType == short.class || rawType == int.class) { + cod.loadConstant(((Number) value).intValue()); + } else if (rawType == char.class) { + cod.loadConstant((int) (char) (Character) value); + } else if (rawType == long.class) { cod.loadConstant((long) value); - } else if (type == float.class || type == Float.class) { + } else if (rawType == float.class) { cod.loadConstant((float) value); - } else if (type == double.class || type == Double.class) { + } else if (rawType == double.class) { cod.loadConstant((double) value); - } else if (type == Class.class) { + } else if (rawType == Class.class || (genericType instanceof ParameterizedType pt && pt.getRawType() == Class.class)) { emitClassRef(cod, (Class) value); - } else if (type instanceof Class t && t.isEnum()) { - Enum e = (Enum) value; + } else if (rawType.isEnum()) { + Enum e = (Enum) value; ClassDesc enumDesc = ClassDesc.of(e.getDeclaringClass().getName()); cod.getstatic(enumDesc, e.name(), enumDesc); - } else if (type instanceof Class t && t.isAnnotation()) { - emitAnnotationOnStack(cod, (java.lang.annotation.Annotation) value); - } else if (type instanceof Class t && t.isArray()) { - Class componentType = t.getComponentType(); + } else if (rawType.isAnnotation()) { + ClassDesc implDesc = generateAnnotationImpl((Annotation) value, baseName, classLoader); + cod.new_(implDesc); + cod.dup(); + cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); + } else if (rawType.isArray()) { + Class compType = rawType.getComponentType(); Object[] arr = (Object[]) value; - emitObjectArray(cod, componentType, arr); - } else if (type instanceof ParameterizedType pt && pt.getRawType() == Class.class) { - emitClassRef(cod, (Class) value); - } else if (type instanceof GenericArrayType gat) { - java.lang.reflect.Type compType = gat.getGenericComponentType(); - Class compClass = (compType instanceof ParameterizedType pt) + cod.loadConstant(arr.length); + cod.anewarray(ClassDesc.ofDescriptor(compType.descriptorString())); + for (int i = 0; i < arr.length; i++) { + cod.dup(); + cod.loadConstant(i); + emitAnnotationValue(cod, compType, compType, arr[i], baseName + "_" + i, classLoader); + cod.aastore(); + } + } else if (genericType instanceof GenericArrayType gat) { + java.lang.reflect.Type compGeneric = gat.getGenericComponentType(); + Class compClass = (compGeneric instanceof ParameterizedType pt) ? (Class) pt.getRawType() - : (Class) compType; + : (Class) compGeneric; Object[] arr = (Object[]) value; - emitObjectArray(cod, compClass, arr); + cod.loadConstant(arr.length); + cod.anewarray(ClassDesc.of(compClass.getName())); + for (int i = 0; i < arr.length; i++) { + cod.dup(); + cod.loadConstant(i); + emitAnnotationValue(cod, compGeneric, compClass, arr[i], baseName + "_" + i, classLoader); + cod.aastore(); + } } else { - throw new UnsupportedOperationException("Unsupported annotation element type: " + type); + throw new UnsupportedOperationException("Unsupported annotation element type: " + rawType); } } - @SuppressWarnings("unchecked") - private static void emitObjectArray(CodeBuilder cod, Class componentType, Object[] arr) { - cod.loadConstant(arr.length); - cod.anewarray(ClassDesc.of(componentType.getName())); - for (int i = 0; i < arr.length; i++) { - cod.dup(); - cod.loadConstant(i); - emitAnnotationElementValue(cod, componentType, arr[i]); - cod.aastore(); - } + private static TypeKind typeKindOf(Class type) { + if (type == long.class) + return TypeKind.LONG; + if (type == float.class) + return TypeKind.FLOAT; + if (type == double.class) + return TypeKind.DOUBLE; + if (type.isPrimitive()) + return TypeKind.INT; + return TypeKind.REFERENCE; } /** @@ -243,76 +272,6 @@ public static void emitTypeData(TypeData data, CodeBuilder cod) { MethodTypeDesc.ofDescriptor("(Ljava/lang/Class;[Ldev/morphia/mapping/codec/pojo/TypeData;)V")); } - /** - * Returns the raw type ClassDesc for use as a builder method parameter descriptor. - */ - public static ClassDesc rawTypeDesc(java.lang.reflect.Type type) { - if (type instanceof Class c) { - if (c.isPrimitive()) { - return primitiveDesc(c); - } - if (c.isArray()) { - return ClassDesc.ofDescriptor(classToDescriptor(c)); - } - return ClassDesc.of(c.getName()); - } else if (type instanceof ParameterizedType pt) { - return ClassDesc.of(((Class) pt.getRawType()).getName()); - } else if (type instanceof GenericArrayType gat) { - java.lang.reflect.Type comp = gat.getGenericComponentType(); - Class rawComp = (comp instanceof ParameterizedType pt) ? (Class) pt.getRawType() : (Class) comp; - return ClassDesc.ofDescriptor("[L" + rawComp.getName().replace('.', '/') + ";"); - } else { - throw new UnsupportedOperationException("Unknown type: " + type); - } - } - - private static ClassDesc primitiveDesc(Class c) { - if (c == boolean.class) - return ConstantDescs.CD_boolean; - if (c == byte.class) - return ConstantDescs.CD_byte; - if (c == char.class) - return ConstantDescs.CD_char; - if (c == short.class) - return ConstantDescs.CD_short; - if (c == int.class) - return ConstantDescs.CD_int; - if (c == long.class) - return ConstantDescs.CD_long; - if (c == float.class) - return ConstantDescs.CD_float; - if (c == double.class) - return ConstantDescs.CD_double; - throw new IllegalArgumentException("Not a primitive: " + c); - } - - private static String classToDescriptor(Class c) { - if (c.isArray()) { - return "[" + classToDescriptor(c.getComponentType()); - } - if (c.isPrimitive()) { - if (c == boolean.class) - return "Z"; - if (c == byte.class) - return "B"; - if (c == char.class) - return "C"; - if (c == short.class) - return "S"; - if (c == int.class) - return "I"; - if (c == long.class) - return "J"; - if (c == float.class) - return "F"; - if (c == double.class) - return "D"; - if (c == void.class) - return "V"; - } - return "L" + c.getName().replace('.', '/') + ";"; - } - /** * Resolves a ClassDesc to a Class using the given class loader. */ @@ -340,28 +299,4 @@ public static Class asClass(ClassDesc cd, ClassLoader classLoader) { } } - /** - * Returns the generic return type of the named annotation element method. - */ - public static java.lang.reflect.Type attributeType(Class type, String name) { - try { - return type.getDeclaredMethod(name).getGenericReturnType(); - } catch (NoSuchMethodException e) { - throw new RuntimeException("Cannot find annotation element '%s' in %s".formatted(name, type.getName()), e); - } - } - - /** - * Creates a TypeData from the given ClassDesc with explicit type parameters. - */ - public static TypeData typeDataFromDesc(ClassDesc desc, ClassLoader classLoader, List> typeParameters) { - return new TypeData<>(asClass(desc, classLoader), typeParameters); - } - - /** - * Creates a TypeData from the given ClassDesc with no type parameters. - */ - public static TypeData typeDataFromDesc(ClassDesc desc, ClassLoader classLoader) { - return typeDataFromDesc(desc, classLoader, List.of()); - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index bd9bd529a61..ac6da7698f1 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -10,7 +10,6 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.TypeVariable; import java.lang.reflect.WildcardType; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; @@ -50,7 +49,6 @@ public class PropertyModelGenerator extends BaseGenerator { private final java.lang.reflect.Type genericType; private final Map annotationMap; private final TypeData typeData; - private final List morphiaAnnotations; private final String getterName; /** @@ -69,7 +67,6 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas this.genericType = reflectedField != null ? reflectedField.getGenericType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedField != null ? reflectedField.getAnnotations() : new Annotation[0]); this.typeData = computeTypeData(resolveGenericType(this.genericType, field.name(), entity), entity.getClassLoader()); - this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); this.getterName = null; } @@ -97,7 +94,6 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas } } this.typeData = computeTypeData(resolveGenericType(this.genericType, this.propertyName, entity), entity.getClassLoader()); - this.morphiaAnnotations = new ArrayList<>(annotationMap.values()); this.getterName = method.name(); } @@ -337,6 +333,11 @@ public PropertyModelGenerator emit() { AlsoLoad alsoLoad = (AlsoLoad) annotationMap.get(AlsoLoad.class.getName()); String[] loadNamesArr = alsoLoad != null ? alsoLoad.value() : new String[0]; + // Generate annotation impl classes from values known at generation time — zero runtime reflection + List annotationImpls = annotationMap.values().stream() + .map(ann -> GenerationUtils.generateAnnotationImpl(ann, generatedType, critterClassLoader)) + .toList(); + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); @@ -362,33 +363,15 @@ public PropertyModelGenerator emit() { cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); cod.putfield(thisDesc, "accessor", accessorImplDesc); - // Register Morphia annotations via generated builders - for (Annotation ann : morphiaAnnotations) { - if (ann.annotationType().getName().startsWith("dev.morphia.annotations.")) { - cod.aload(0); - GenerationUtils.emitAnnotationOnStack(cod, ann); - cod.invokevirtual(propertyModelDesc, "annotation", - MethodTypeDesc.of(propertyModelDesc, annotationDesc)); - cod.pop(); - } - } - - // Register all annotations (including non-Morphia ones like @NonNull) via reflection - ClassDesc critterPmDesc = ClassDesc.of("dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel"); - if (isFieldBased) { + // All annotation values are known at generation time — no runtime reflection + for (ClassDesc implDesc : annotationImpls) { cod.aload(0); - GenerationUtils.emitClassRef(cod, entity); - cod.ldc(propertyName); - cod.invokestatic(critterPmDesc, "registerFieldAnnotations", - MethodTypeDesc.ofDescriptor( - "(Ldev/morphia/mapping/codec/pojo/PropertyModel;Ljava/lang/Class;Ljava/lang/String;)V")); - } else { - cod.aload(0); - GenerationUtils.emitClassRef(cod, entity); - cod.ldc(getterName); - cod.invokestatic(critterPmDesc, "registerMethodAnnotations", - MethodTypeDesc.ofDescriptor( - "(Ldev/morphia/mapping/codec/pojo/PropertyModel;Ljava/lang/Class;Ljava/lang/String;)V")); + cod.new_(implDesc); + cod.dup(); + cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); + cod.invokevirtual(propertyModelDesc, "annotation", + MethodTypeDesc.of(propertyModelDesc, annotationDesc)); + cod.pop(); } cod.return_(); diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java index 8a32dc0fb3f..85b62f6a616 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/critter/CritterPropertyModel.java @@ -1,8 +1,6 @@ package dev.morphia.mapping.codec.pojo.critter; import java.lang.annotation.Annotation; -import java.lang.reflect.Field; -import java.lang.reflect.Method; import java.util.List; import dev.morphia.mapping.codec.pojo.EntityModel; @@ -17,46 +15,6 @@ public CritterPropertyModel(EntityModel entityModel) { super(entityModel); } - /** - * Registers all annotations from the entity's field (walking the class hierarchy) into this model. - * Called from generated subclass constructors so non-Morphia annotations (e.g. @NonNull) are also recorded. - */ - @SuppressWarnings("unused") - public static void registerFieldAnnotations(PropertyModel model, Class entityClass, String fieldName) { - Class current = entityClass; - while (current != null && current != Object.class) { - try { - Field field = current.getDeclaredField(fieldName); - for (Annotation ann : field.getDeclaredAnnotations()) { - model.annotation(ann); - } - return; - } catch (NoSuchFieldException e) { - current = current.getSuperclass(); - } - } - } - - /** - * Registers all annotations from the entity's getter method (walking the class hierarchy) into this model. - * Called from generated subclass constructors so non-Morphia annotations on getters are also recorded. - */ - @SuppressWarnings("unused") - public static void registerMethodAnnotations(PropertyModel model, Class entityClass, String getterName) { - Class current = entityClass; - while (current != null && current != Object.class) { - for (Method m : current.getDeclaredMethods()) { - if (m.getName().equals(getterName) && m.getParameterCount() == 0 && !m.isBridge()) { - for (Annotation ann : m.getDeclaredAnnotations()) { - model.annotation(ann); - } - return; - } - } - current = current.getSuperclass(); - } - } - @Override public abstract PropertyAccessor getAccessor(); diff --git a/critter/critter-integration-tests/pom.xml b/critter/critter-integration-tests/pom.xml deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java b/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java index f55a4aa3b5e..bc1a4f5d146 100644 --- a/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java +++ b/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java @@ -4,6 +4,9 @@ import dev.morphia.annotations.Id; import java.util.List; +import java.util.Objects; + +import com.mongodb.lang.NonNullApi; @Entity("hotels") public class Hotel { @@ -12,4 +15,19 @@ public class Hotel { private String name; private int stars; private List tags; + + @SafeVarargs + private String foo(String... bob) { + return ""; + } + + @Override + public int hashCode() { + return Objects.hash(id, name, stars, tags); + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } } From 5242a6d05d50aef86dc7033c35cdb26f3302c2d9 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Wed, 17 Jun 2026 22:12:41 -0400 Subject: [PATCH 16/31] Hybrid annotation approach: builders for Morphia, classfile attr + getDeclaredAnnotation for non-Morphia Morphia annotations in generated 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. --- .../generator/EntityModelGenerator.java | 13 +- .../parser/generator/GenerationUtils.java | 172 +++++++++++------- .../generator/PropertyModelGenerator.java | 38 +++- .../parser/TestEntityModelGenerator.java | 52 ------ .../parser/generator/DumpBytecodeTest.java | 30 +++ .../sources/ExampleAgeAccessorTemplate.java | 14 -- .../ExampleAgePropertyModelTemplate.java | 89 --------- .../sources/ExampleEntityModelTemplate.java | 132 -------------- .../sources/ExampleNameAccessorTemplate.java | 15 -- .../ExampleNamePropertyModelTemplate.java | 97 ---------- .../ExampleSalaryAccessorTemplate.java | 14 -- .../ExampleSalaryPropertyModelTemplate.java | 92 ---------- 12 files changed, 173 insertions(+), 585 deletions(-) delete mode 100644 core/src/test/java/dev/morphia/critter/parser/TestEntityModelGenerator.java create mode 100644 core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleAgeAccessorTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleAgePropertyModelTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleEntityModelTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleNameAccessorTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleNamePropertyModelTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleSalaryAccessorTemplate.java delete mode 100644 core/src/test/java/dev/morphia/critter/sources/ExampleSalaryPropertyModelTemplate.java diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index 6fd4cab3e5a..07ff6c29c5b 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -89,7 +89,6 @@ public EntityModelGenerator emit() { ClassDesc entityModelDesc = ClassDesc.of(dev.morphia.mapping.codec.pojo.EntityModel.class.getName()); ClassDesc propertyModelDesc = ClassDesc.of(PropertyModel.class.getName()); ClassDesc annotationDesc = ClassDesc.of(Annotation.class.getName()); - String collectionNameStr = computeCollectionName(); String discriminatorStr = computeDiscriminator(); String discriminatorKeyStr = computeDiscriminatorKey(); @@ -97,10 +96,6 @@ public EntityModelGenerator emit() { boolean isInterfaceFlag = entity.isInterface(); boolean useDiscriminatorFlag = entityAnnotation.useDiscriminator(); - List annotationImpls = morphiaAnnotations.stream() - .map(ann -> GenerationUtils.generateAnnotationImpl(ann, generatedType, critterClassLoader)) - .toList(); - byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); @@ -135,12 +130,10 @@ public EntityModelGenerator emit() { cod.pop(); } - // Register morphia annotations — values known at generation time - for (ClassDesc implDesc : annotationImpls) { + // Morphia annotations via builders — no reflection + for (Annotation ann : morphiaAnnotations) { cod.aload(0); - cod.new_(implDesc); - cod.dup(); - cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); + GenerationUtils.emitAnnotationViaBuilder(cod, ann); cod.invokevirtual(entityModelDesc, "annotation", MethodTypeDesc.of(ConstantDescs.CD_void, annotationDesc)); } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 72a40aeacaf..cf76eb8a08e 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -7,12 +7,14 @@ import java.lang.reflect.GenericArrayType; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; import java.util.List; import java.util.Map; -import dev.morphia.critter.CritterClassLoader; import dev.morphia.mapping.codec.pojo.TypeData; +import io.github.dmlloyd.classfile.AnnotationElement; +import io.github.dmlloyd.classfile.AnnotationValue; import io.github.dmlloyd.classfile.ClassBuilder; import io.github.dmlloyd.classfile.ClassFile; import io.github.dmlloyd.classfile.CodeBuilder; @@ -79,61 +81,122 @@ public static ClassLoader safeClassLoader(Class type) { } /** - * Generates a concrete class that implements the given annotation interface with all element - * values hardcoded as constants. The class is registered with the given class loader and its - * ClassDesc is returned for use in {@code new} bytecode instructions. - * - *

- * This is used to emit annotation instances inline into generated property/entity model - * constructors, eliminating all runtime reflection. + * Converts a runtime annotation instance into a ClassFile API annotation descriptor, suitable + * for use in {@link io.github.dmlloyd.classfile.attribute.RuntimeVisibleAnnotationsAttribute}. + * All element values are captured at generation time. */ - public static ClassDesc generateAnnotationImpl(Annotation ann, String baseName, CritterClassLoader classLoader) { + public static io.github.dmlloyd.classfile.Annotation toClassfileAnnotation(Annotation ann) { Class annType = ann.annotationType(); - String implName = baseName + "$$" + annType.getName().replace('.', '_').replace('$', '_'); - ClassDesc thisDesc = ClassDesc.of(implName); - ClassDesc annDesc = ClassDesc.of(annType.getName()); + List elements = new ArrayList<>(); + for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { + try { + Object value = method.invoke(ann); + AnnotationValue av = toAnnotationValue(method.getGenericReturnType(), method.getReturnType(), value); + elements.add(AnnotationElement.of(method.getName(), av)); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to read annotation element " + method, e); + } + } + return io.github.dmlloyd.classfile.Annotation.of(ClassDesc.of(annType.getName()), elements); + } - byte[] bytes = ClassFile.of().build(thisDesc, cb -> { - cb.withVersion(ClassFile.JAVA_17_VERSION, 0); - cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER | ClassFile.ACC_SYNTHETIC); - cb.withSuperclass(ConstantDescs.CD_Object); - cb.withInterfaceSymbols(annDesc); + @SuppressWarnings("rawtypes") + private static AnnotationValue toAnnotationValue(java.lang.reflect.Type genericType, Class rawType, Object value) { + if (rawType == String.class) + return AnnotationValue.ofString((String) value); + if (rawType == boolean.class) + return AnnotationValue.ofBoolean((Boolean) value); + if (rawType == byte.class) + return AnnotationValue.ofByte((Byte) value); + if (rawType == char.class) + return AnnotationValue.ofChar((Character) value); + if (rawType == short.class) + return AnnotationValue.ofShort((Short) value); + if (rawType == int.class) + return AnnotationValue.ofInt((Integer) value); + if (rawType == long.class) + return AnnotationValue.ofLong((Long) value); + if (rawType == float.class) + return AnnotationValue.ofFloat((Float) value); + if (rawType == double.class) + return AnnotationValue.ofDouble((Double) value); + if (rawType == Class.class || (genericType instanceof ParameterizedType pt && pt.getRawType() == Class.class)) { + return AnnotationValue.ofClass(ClassDesc.ofDescriptor(((Class) value).descriptorString())); + } + if (rawType.isEnum()) { + Enum e = (Enum) value; + return AnnotationValue.ofEnum(ClassDesc.of(e.getDeclaringClass().getName()), e.name()); + } + if (rawType.isAnnotation()) { + return AnnotationValue.ofAnnotation(toClassfileAnnotation((Annotation) value)); + } + if (rawType.isArray()) { + Class compType = rawType.getComponentType(); + Object[] arr = (Object[]) value; + List values = new ArrayList<>(); + for (Object elem : arr) { + values.add(toAnnotationValue(compType, compType, elem)); + } + return AnnotationValue.ofArray(values); + } + if (genericType instanceof GenericArrayType gat) { + java.lang.reflect.Type compGeneric = gat.getGenericComponentType(); + Class compClass = (compGeneric instanceof ParameterizedType pt) + ? (Class) pt.getRawType() + : (Class) compGeneric; + Object[] arr = (Object[]) value; + List values = new ArrayList<>(); + for (Object elem : arr) { + values.add(toAnnotationValue(compGeneric, compClass, elem)); + } + return AnnotationValue.ofArray(values); + } + throw new UnsupportedOperationException("Unsupported annotation element type: " + rawType); + } - cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { - cod.aload(0); - cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); - cod.return_(); - }); + /** + * Emits bytecode that calls the generated AnnotationBuilder for a Morphia annotation, + * leaving the built annotation instance on the operand stack. Values are hardcoded as + * bytecode constants — no runtime reflection. + * + *

+ * Assumes the annotation type lives under {@code dev.morphia.annotations} and that its + * builder follows the {@code XxxBuilder.xxxBuilder()...build()} convention. + */ + public static void emitAnnotationViaBuilder(CodeBuilder cod, Annotation ann) { + Class annType = ann.annotationType(); + String className = annType.getName(); + int lastDot = className.lastIndexOf('.'); + String simpleName = className.substring(lastDot + 1); + String builderClassName = className.substring(0, lastDot) + ".internal." + simpleName + "Builder"; + ClassDesc builderDesc = ClassDesc.of(builderClassName); + ClassDesc annDesc = ClassDesc.of(className); + String factoryMethod = Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1) + "Builder"; - cb.withMethodBody("annotationType", MethodTypeDesc.of(ConstantDescs.CD_Class), - ClassFile.ACC_PUBLIC, cod -> { - emitClassRef(cod, annType); - cod.areturn(); - }); + cod.invokestatic(builderDesc, factoryMethod, MethodTypeDesc.of(builderDesc)); - for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { - try { - Object value = method.invoke(ann); - Class returnType = method.getReturnType(); - ClassDesc returnDesc = ClassDesc.ofDescriptor(returnType.descriptorString()); - cb.withMethodBody(method.getName(), MethodTypeDesc.of(returnDesc), - ClassFile.ACC_PUBLIC, cod -> { - emitAnnotationValue(cod, method.getGenericReturnType(), returnType, value, baseName, classLoader); - cod.return_(typeKindOf(returnType)); - }); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed to generate element method for " + method, e); + for (java.lang.reflect.Method method : annType.getDeclaredMethods()) { + try { + Object value = method.invoke(ann); + Object defaultValue = method.getDefaultValue(); + if (value == null || java.util.Objects.deepEquals(value, defaultValue)) { + continue; } + Class rawType = method.getReturnType(); + ClassDesc paramDesc = ClassDesc.ofDescriptor(rawType.descriptorString()); + emitBuilderElementValue(cod, method.getGenericReturnType(), rawType, value); + cod.invokevirtual(builderDesc, method.getName(), MethodTypeDesc.of(builderDesc, paramDesc)); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to emit builder setter for " + method, e); } - }); + } - classLoader.register(implName, bytes); - return thisDesc; + cod.invokevirtual(builderDesc, "build", MethodTypeDesc.of(annDesc)); } @SuppressWarnings("rawtypes") - private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type genericType, Class rawType, - Object value, String baseName, CritterClassLoader classLoader) { + private static void emitBuilderElementValue(CodeBuilder cod, java.lang.reflect.Type genericType, + Class rawType, Object value) { if (rawType == String.class) { cod.ldc((String) value); } else if (rawType == boolean.class) { @@ -155,10 +218,7 @@ private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type ClassDesc enumDesc = ClassDesc.of(e.getDeclaringClass().getName()); cod.getstatic(enumDesc, e.name(), enumDesc); } else if (rawType.isAnnotation()) { - ClassDesc implDesc = generateAnnotationImpl((Annotation) value, baseName, classLoader); - cod.new_(implDesc); - cod.dup(); - cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); + emitAnnotationViaBuilder(cod, (Annotation) value); } else if (rawType.isArray()) { Class compType = rawType.getComponentType(); Object[] arr = (Object[]) value; @@ -167,7 +227,7 @@ private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type for (int i = 0; i < arr.length; i++) { cod.dup(); cod.loadConstant(i); - emitAnnotationValue(cod, compType, compType, arr[i], baseName + "_" + i, classLoader); + emitBuilderElementValue(cod, compType, compType, arr[i]); cod.aastore(); } } else if (genericType instanceof GenericArrayType gat) { @@ -181,7 +241,7 @@ private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type for (int i = 0; i < arr.length; i++) { cod.dup(); cod.loadConstant(i); - emitAnnotationValue(cod, compGeneric, compClass, arr[i], baseName + "_" + i, classLoader); + emitBuilderElementValue(cod, compGeneric, compClass, arr[i]); cod.aastore(); } } else { @@ -189,18 +249,6 @@ private static void emitAnnotationValue(CodeBuilder cod, java.lang.reflect.Type } } - private static TypeKind typeKindOf(Class type) { - if (type == long.class) - return TypeKind.LONG; - if (type == float.class) - return TypeKind.FLOAT; - if (type == double.class) - return TypeKind.DOUBLE; - if (type.isPrimitive()) - return TypeKind.INT; - return TypeKind.REFERENCE; - } - /** * Emits bytecode that loads a Class reference. Non-public classes use Class.forName(). */ diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index ac6da7698f1..4db747f3c0b 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -35,6 +35,7 @@ import org.bson.codecs.pojo.PropertyAccessor; import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.attribute.RuntimeVisibleAnnotationsAttribute; /** * Generates a ClassFile-based {@link dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel} implementation @@ -333,15 +334,23 @@ public PropertyModelGenerator emit() { AlsoLoad alsoLoad = (AlsoLoad) annotationMap.get(AlsoLoad.class.getName()); String[] loadNamesArr = alsoLoad != null ? alsoLoad.value() : new String[0]; - // Generate annotation impl classes from values known at generation time — zero runtime reflection - List annotationImpls = annotationMap.values().stream() - .map(ann -> GenerationUtils.generateAnnotationImpl(ann, generatedType, critterClassLoader)) + List morphiaAnnotations = annotationMap.values().stream() + .filter(a -> a.annotationType().getName().startsWith("dev.morphia.annotations.")) + .toList(); + List nonMorphiaAnnotations = annotationMap.values().stream() + .filter(a -> !a.annotationType().getName().startsWith("dev.morphia.annotations.")) + .toList(); + List cfAnnotations = nonMorphiaAnnotations.stream() + .map(GenerationUtils::toClassfileAnnotation) .toList(); byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); cb.withSuperclass(superDesc); + if (!cfAnnotations.isEmpty()) { + cb.with(RuntimeVisibleAnnotationsAttribute.of(cfAnnotations)); + } cb.withField("entityModel", entityModelDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); cb.withField("accessor", accessorImplDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); @@ -363,12 +372,25 @@ public PropertyModelGenerator emit() { cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); cod.putfield(thisDesc, "accessor", accessorImplDesc); - // All annotation values are known at generation time — no runtime reflection - for (ClassDesc implDesc : annotationImpls) { + // Morphia annotations: builder with hardcoded values — no reflection + for (Annotation ann : morphiaAnnotations) { cod.aload(0); - cod.new_(implDesc); - cod.dup(); - cod.invokespecial(implDesc, "", MethodTypeDesc.ofDescriptor("()V")); + GenerationUtils.emitAnnotationViaBuilder(cod, ann); + cod.invokevirtual(propertyModelDesc, "annotation", + MethodTypeDesc.of(propertyModelDesc, annotationDesc)); + cod.pop(); + } + + // Non-Morphia annotations: on the generated class via RuntimeVisibleAnnotationsAttribute + for (Annotation ann : nonMorphiaAnnotations) { + cod.aload(0); + cod.aload(0); + cod.invokevirtual(ConstantDescs.CD_Object, "getClass", + MethodTypeDesc.of(ConstantDescs.CD_Class)); + GenerationUtils.emitClassRef(cod, ann.annotationType()); + cod.invokevirtual(ConstantDescs.CD_Class, "getDeclaredAnnotation", + MethodTypeDesc.ofDescriptor( + "(Ljava/lang/Class;)Ljava/lang/annotation/Annotation;")); cod.invokevirtual(propertyModelDesc, "annotation", MethodTypeDesc.of(propertyModelDesc, annotationDesc)); cod.pop(); diff --git a/core/src/test/java/dev/morphia/critter/parser/TestEntityModelGenerator.java b/core/src/test/java/dev/morphia/critter/parser/TestEntityModelGenerator.java deleted file mode 100644 index c2b146167c6..00000000000 --- a/core/src/test/java/dev/morphia/critter/parser/TestEntityModelGenerator.java +++ /dev/null @@ -1,52 +0,0 @@ -package dev.morphia.critter.parser; - -import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.stream.Stream; - -import dev.morphia.critter.ClassfileOutput; -import dev.morphia.critter.CritterClassLoader; -import dev.morphia.mapping.Mapper; -import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class TestEntityModelGenerator { - private static final Logger LOG = LoggerFactory.getLogger(TestEntityModelGenerator.class); - - private final CritterEntityModel control; - private final Mapper mapper = GeneratorsTestHelper.defaultMapper(); - private final CritterClassLoader critterClassLoader = new CritterClassLoader(); - - public TestEntityModelGenerator() { - CritterEntityModel tmp; - try { - tmp = (CritterEntityModel) critterClassLoader - .loadClass("dev.morphia.critter.sources.ExampleEntityModelTemplate") - .getConstructor(Mapper.class) - .newInstance(mapper); - ClassfileOutput.dump(critterClassLoader, "dev.morphia.critter.sources.ExampleEntityModelTemplate"); - } catch (Exception e) { - LOG.error(e.getMessage(), e); - throw new RuntimeException(e); - } - control = tmp; - } - - // @ParameterizedTest - @MethodSource("methods") - public void testEntityModel(String name, Method method) throws Exception { - Object expected = method.invoke(control); - Object actual = method.invoke(GeneratorTest.entityModel); - Assertions.assertEquals(expected, actual, method.getName() + " should return the same value"); - } - - static Stream methods() { - return Arrays.stream(GeneratorTest.methodNames(CritterEntityModel.class)) - .map(row -> Arguments.of(row)); - } -} diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java b/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java new file mode 100644 index 00000000000..4fa951d93e8 --- /dev/null +++ b/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java @@ -0,0 +1,30 @@ +package dev.morphia.critter.parser.generator; + +import java.nio.file.Files; +import java.nio.file.Path; + +import dev.morphia.critter.CritterClassLoader; + +import org.junit.jupiter.api.Test; + +import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; + +public class DumpBytecodeTest { + @Test + public void dumpGeneratedClasses() throws Exception { + CritterClassLoader loader = new CritterClassLoader(); + new CritterGenerator(defaultMapper()).generate( + dev.morphia.critter.sources.Example.class, loader, false); + + Path outDir = Path.of("/tmp/critter-dump"); + Files.createDirectories(outDir); + for (var entry : loader.getTypeDefinitions().entrySet()) { + String name = entry.getKey(); + if (name.contains("NameModel") || name.contains("ExampleEntityModel")) { + Path file = outDir.resolve(name.replace('.', '_') + ".class"); + Files.write(file, entry.getValue()); + System.out.println("Wrote: " + file); + } + } + } +} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleAgeAccessorTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleAgeAccessorTemplate.java deleted file mode 100644 index 5858b4cfb24..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleAgeAccessorTemplate.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.morphia.critter.sources; - -import org.bson.codecs.pojo.PropertyAccessor; - -public class ExampleAgeAccessorTemplate implements PropertyAccessor { - @Override - public Integer get(S entity) { - return ((Example) entity).__readAgeTemplate(); - } - - public void set(S entity, Integer value) { - ((Example) entity).__writeAgeTemplate(value); - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleAgePropertyModelTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleAgePropertyModelTemplate.java deleted file mode 100644 index ca51b0607bf..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleAgePropertyModelTemplate.java +++ /dev/null @@ -1,89 +0,0 @@ -package dev.morphia.critter.sources; - -import java.util.List; - -import dev.morphia.mapping.codec.pojo.EntityModel; -import dev.morphia.mapping.codec.pojo.TypeData; -import dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel; - -import org.bson.codecs.pojo.PropertyAccessor; - -public class ExampleAgePropertyModelTemplate extends CritterPropertyModel { - - private PropertyAccessor accessor = new ExampleAgeAccessorTemplate(); - - public ExampleAgePropertyModelTemplate(EntityModel entityModel) { - super(entityModel); - - } - - @Override - public PropertyAccessor getAccessor() { - return (PropertyAccessor) accessor; - } - - @Override - public boolean isFinal() { - return false; - } - - @Override - public String getFullName() { - return "dev.morphia.critter.sources.Example#age"; - } - - @Override - public List getLoadNames() { - return List.of(); - } - - @Override - public String getMappedName() { - return "age"; - } - - @Override - public String getName() { - return "age"; - } - - @Override - public Class getNormalizedType() { - return int.class; - } - - @Override - public Class getType() { - return int.class; - } - - @Override - public TypeData getTypeData() { - return TypeData.get(int.class); - } - - @Override - public boolean isArray() { - return false; - } - - @Override - public boolean isMap() { - return false; - } - - @Override - public boolean isReference() { - return false; - } - - @Override - public boolean isSet() { - return false; - } - - @Override - public boolean isTransient() { - return false; - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleEntityModelTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleEntityModelTemplate.java deleted file mode 100644 index 9a94c3f059e..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleEntityModelTemplate.java +++ /dev/null @@ -1,132 +0,0 @@ -package dev.morphia.critter.sources; - -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; -import java.util.List; -import java.util.Set; - -import dev.morphia.annotations.Entity; -import dev.morphia.annotations.internal.EntityBuilder; -import dev.morphia.mapping.Mapper; -import dev.morphia.mapping.codec.Conversions; -import dev.morphia.mapping.codec.MorphiaInstanceCreator; -import dev.morphia.mapping.codec.pojo.EntityModel; -import dev.morphia.mapping.codec.pojo.PropertyModel; -import dev.morphia.mapping.codec.pojo.TypeData; -import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; -import dev.morphia.mapping.conventions.MorphiaDefaultsConvention; - -public class ExampleEntityModelTemplate extends CritterEntityModel { - - public ExampleEntityModelTemplate(Mapper mapper) { - super(mapper, Example.class); - addProperty(new ExampleNamePropertyModelTemplate(this)); - addProperty(new ExampleAgePropertyModelTemplate(this)); - addProperty(new ExampleSalaryPropertyModelTemplate(this)); - } - - @Override - public Set> classHierarchy() { - return Set.of(); - } - - @Override - public String collectionName() { - return mapper.getConfig().collectionNaming().apply("Example"); - } - - @Override - public List getShardKeys() { - return List.of(); - } - - @Override - public String discriminator() { - return mapper.getConfig().discriminator().apply(Example.class, "."); - } - - @Override - public String discriminatorKey() { - return MorphiaDefaultsConvention.applyDefaults(".", mapper.getConfig().discriminatorKey()); - } - - @Override - public Entity getEntityAnnotation() { - if (entityAnnotation == null) { - entityAnnotation = EntityBuilder - .entityBuilder(Example.class.getAnnotation(Entity.class)) - .build(); - } - return entityAnnotation; - } - - @Override - public PropertyModel getIdProperty() { - return null; - } - - @Override - public MorphiaInstanceCreator getInstanceCreator(Conversions conversions) { - return null; - } - - @Override - public List getProperties(Class type) { - return List.of(); - } - - @Override - public List getProperties() { - return List.of(); - } - - @Override - public Set getSubtypes() { - return Set.of(); - } - - @Override - public void addSubtype(EntityModel subtype) { - - } - - @Override - public EntityModel getSuperClass() { - return null; - } - - @Override - public Class getType() { - return Example.class; - } - - @Override - public TypeData getTypeData(Class type, TypeData suggested, Type genericType) { - return null; - } - - @Override - public PropertyModel getVersionProperty() { - return null; - } - - @Override - public boolean hasLifecycle(Class type) { - return false; - } - - @Override - public boolean isAbstract() { - return false; - } - - @Override - public boolean isInterface() { - return false; - } - - @Override - public boolean useDiscriminator() { - return true; - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleNameAccessorTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleNameAccessorTemplate.java deleted file mode 100644 index 5e972da4ee2..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleNameAccessorTemplate.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.morphia.critter.sources; - -import org.bson.codecs.pojo.PropertyAccessor; - -public class ExampleNameAccessorTemplate implements PropertyAccessor { - @Override - public String get(S entity) { - return ((Example) entity).__readNameTemplate(); - } - - @Override - public void set(S entity, String value) { - ((Example) entity).__writeNameTemplate(value); - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleNamePropertyModelTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleNamePropertyModelTemplate.java deleted file mode 100644 index 3e44b6d12f3..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleNamePropertyModelTemplate.java +++ /dev/null @@ -1,97 +0,0 @@ -package dev.morphia.critter.sources; - -import java.util.List; - -import dev.morphia.mapping.codec.pojo.EntityModel; -import dev.morphia.mapping.codec.pojo.TypeData; -import dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel; - -import org.bson.codecs.pojo.PropertyAccessor; - -public class ExampleNamePropertyModelTemplate extends CritterPropertyModel { - - private PropertyAccessor accessor = new ExampleNameAccessorTemplate(); - - public ExampleNamePropertyModelTemplate(EntityModel entityModel) { - super(entityModel); - /* - * annotation(propertyBuilder() - * .value("myName") - * .concreteClass(String.class) - * .build()); - * annotation(alsoLoadBuilder() - * .value("name1", "name2") - * .build()); - */ - } - - @Override - public PropertyAccessor getAccessor() { - return (PropertyAccessor) accessor; - } - - @Override - public boolean isFinal() { - return false; - } - - @Override - public String getFullName() { - return "dev.morphia.critter.sources.Example#name"; - } - - @Override - public List getLoadNames() { - return List.of("name1", "name2"); - } - - @Override - public String getMappedName() { - return "myName"; - } - - @Override - public String getName() { - return "name"; - } - - @Override - public Class getNormalizedType() { - return String.class; - } - - @Override - public Class getType() { - return String.class; - } - - @Override - public TypeData getTypeData() { - return TypeData.get(String.class); - } - - @Override - public boolean isArray() { - return false; - } - - @Override - public boolean isMap() { - return false; - } - - @Override - public boolean isReference() { - return false; - } - - @Override - public boolean isSet() { - return false; - } - - @Override - public boolean isTransient() { - return false; - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryAccessorTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryAccessorTemplate.java deleted file mode 100644 index ff1451ef68b..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryAccessorTemplate.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.morphia.critter.sources; - -import org.bson.codecs.pojo.PropertyAccessor; - -public class ExampleSalaryAccessorTemplate implements PropertyAccessor { - @Override - public Long get(S entity) { - return ((Example) entity).__readSalaryTemplate(); - } - - public void set(S entity, Long value) { - ((Example) entity).__writeSalaryTemplate(value); - } -} diff --git a/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryPropertyModelTemplate.java b/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryPropertyModelTemplate.java deleted file mode 100644 index d1a3c8a350b..00000000000 --- a/core/src/test/java/dev/morphia/critter/sources/ExampleSalaryPropertyModelTemplate.java +++ /dev/null @@ -1,92 +0,0 @@ -package dev.morphia.critter.sources; - -import java.util.List; - -import dev.morphia.mapping.codec.pojo.EntityModel; -import dev.morphia.mapping.codec.pojo.TypeData; -import dev.morphia.mapping.codec.pojo.critter.CritterPropertyModel; - -import org.bson.codecs.pojo.PropertyAccessor; - -import static dev.morphia.annotations.internal.PropertyBuilder.propertyBuilder; - -public class ExampleSalaryPropertyModelTemplate extends CritterPropertyModel { - - private PropertyAccessor accessor = new ExampleSalaryAccessorTemplate(); - - public ExampleSalaryPropertyModelTemplate(EntityModel entityModel) { - super(entityModel); - annotation(propertyBuilder() - .build()); - } - - @Override - public PropertyAccessor getAccessor() { - return (PropertyAccessor) accessor; - } - - @Override - public boolean isFinal() { - return false; - } - - @Override - public String getFullName() { - return "dev.morphia.critter.sources.Example#salary"; - } - - @Override - public List getLoadNames() { - return List.of(); - } - - @Override - public String getMappedName() { - return "salary"; - } - - @Override - public String getName() { - return "salary"; - } - - @Override - public Class getNormalizedType() { - return Long.class; - } - - @Override - public Class getType() { - return Long.class; - } - - @Override - public TypeData getTypeData() { - return TypeData.get(Long.class); - } - - @Override - public boolean isArray() { - return false; - } - - @Override - public boolean isMap() { - return false; - } - - @Override - public boolean isReference() { - return false; - } - - @Override - public boolean isSet() { - return false; - } - - @Override - public boolean isTransient() { - return false; - } -} From 03f2c2202c332052fa788f9167abeb14958eb3f4 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Wed, 17 Jun 2026 22:28:55 -0400 Subject: [PATCH 17/31] Add BytecodeDumpTest to generate ASM Textifier output for 5 representative 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. --- .../parser/generator/BytecodeDumpTest.java | 58 +++++++++++++++++++ .../parser/generator/DumpBytecodeTest.java | 30 ---------- 2 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 core/src/test/java/dev/morphia/critter/parser/generator/BytecodeDumpTest.java delete mode 100644 core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/BytecodeDumpTest.java b/core/src/test/java/dev/morphia/critter/parser/generator/BytecodeDumpTest.java new file mode 100644 index 00000000000..8b99054ba39 --- /dev/null +++ b/core/src/test/java/dev/morphia/critter/parser/generator/BytecodeDumpTest.java @@ -0,0 +1,58 @@ +package dev.morphia.critter.parser.generator; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import dev.morphia.critter.CritterClassLoader; +import dev.morphia.critter.sources.Example; +import dev.morphia.critter.sources.MethodExample; +import dev.morphia.mapping.CritterMapperTestEntity; +import dev.morphia.test.models.Author; +import dev.morphia.test.models.Book; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.util.Textifier; +import org.objectweb.asm.util.TraceClassVisitor; + +import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; + +/** + * Dumps readable ASM Textifier output for all classes generated from 5 representative entities + * into target/critter-bytecode/, preserving the package directory hierarchy. + */ +public class BytecodeDumpTest { + + private static final List> ENTITIES = List.of( + Example.class, + MethodExample.class, + Author.class, + Book.class, + CritterMapperTestEntity.class); + + @Test + public void dumpBytecodeAsText() throws Exception { + Path outRoot = Path.of("target/critter-bytecode"); + + for (Class entity : ENTITIES) { + CritterClassLoader loader = new CritterClassLoader(); + new CritterGenerator(defaultMapper()).generate(entity, loader, false); + + for (Map.Entry entry : loader.getTypeDefinitions().entrySet()) { + String className = entry.getKey(); + byte[] bytes = entry.getValue(); + + Path outFile = outRoot.resolve(className.replace('.', '/') + ".txt"); + Files.createDirectories(outFile.getParent()); + + StringWriter sw = new StringWriter(); + new ClassReader(bytes).accept(new TraceClassVisitor(null, new Textifier(), new PrintWriter(sw)), 0); + Files.writeString(outFile, sw.toString()); + } + } + } +} diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java b/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java deleted file mode 100644 index 4fa951d93e8..00000000000 --- a/core/src/test/java/dev/morphia/critter/parser/generator/DumpBytecodeTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.morphia.critter.parser.generator; - -import java.nio.file.Files; -import java.nio.file.Path; - -import dev.morphia.critter.CritterClassLoader; - -import org.junit.jupiter.api.Test; - -import static dev.morphia.critter.parser.GeneratorsTestHelper.defaultMapper; - -public class DumpBytecodeTest { - @Test - public void dumpGeneratedClasses() throws Exception { - CritterClassLoader loader = new CritterClassLoader(); - new CritterGenerator(defaultMapper()).generate( - dev.morphia.critter.sources.Example.class, loader, false); - - Path outDir = Path.of("/tmp/critter-dump"); - Files.createDirectories(outDir); - for (var entry : loader.getTypeDefinitions().entrySet()) { - String name = entry.getKey(); - if (name.contains("NameModel") || name.contains("ExampleEntityModel")) { - Path file = outDir.resolve(name.replace('.', '_') + ".class"); - Files.write(file, entry.getValue()); - System.out.println("Wrote: " + file); - } - } - } -} From 462105f7c2a78c425cdaefdb08addd432cb155f9 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 18 Jun 2026 09:05:39 -0400 Subject: [PATCH 18/31] copilot feedback --- .../critter/parser/PropertyFinder.java | 24 +++++++++++-------- .../parser/generator/AccessorMethods.java | 23 +++++++++++------- .../dev/morphia/critter/it/gen/Hotel.java | 16 ++++++++----- .../morphia/critter/maven/CritterProcessor.kt | 4 ++-- pom.xml | 6 ----- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index d41338fd531..86b75b5952e 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -12,6 +12,7 @@ import dev.morphia.critter.parser.generator.PropertyModelGenerator; import dev.morphia.critter.parser.java.CritterParser; import dev.morphia.mapping.Mapper; +import dev.morphia.mapping.MappingException; import dev.morphia.mapping.PropertyDiscovery; import org.slf4j.Logger; @@ -117,17 +118,20 @@ private ClassModel readClassModel(Class type) { String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); 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; - } - try { - byte[] bytes = inputStream.readAllBytes(); - return ClassFile.of().parse(bytes); + try (InputStream inputStream = cl.getResourceAsStream(resourceName)) { + if (inputStream == null) { + LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); + return null; + } + try { + 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; + } } catch (IOException e) { - LOG.warn("Failed to read bytecode for {}: {}", type.getName(), e.getMessage()); - return null; + throw new MappingException(e.getMessage(), e); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java index ed426ba00be..00df57e0238 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java @@ -3,6 +3,8 @@ import java.io.IOException; import java.io.InputStream; +import dev.morphia.mapping.MappingException; + import io.github.dmlloyd.classfile.ClassFile; import io.github.dmlloyd.classfile.ClassModel; @@ -30,16 +32,19 @@ protected AccessorMethods(Class entity) { */ 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; + try (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); + } } catch (IOException e) { - throw new RuntimeException("Failed to read class %s".formatted(entity.getName()), e); + throw new MappingException(e.getMessage(), e); } } } diff --git a/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java b/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java index bc1a4f5d146..63f2d5db1e7 100644 --- a/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java +++ b/critter/critter-maven/src/it/generation-test/src/main/java/dev/morphia/critter/it/gen/Hotel.java @@ -6,8 +6,6 @@ import java.util.List; import java.util.Objects; -import com.mongodb.lang.NonNullApi; - @Entity("hotels") public class Hotel { @Id @@ -22,12 +20,18 @@ private String foo(String... bob) { } @Override - public int hashCode() { - return Objects.hash(id, name, stars, tags); + public boolean equals(Object o) { + if (!(o instanceof Hotel hotel)) { + return false; + } + return stars == hotel.stars && + Objects.equals(id, hotel.id) && + Objects.equals(name, hotel.name) && + Objects.equals(tags, hotel.tags); } @Override - public boolean equals(Object obj) { - return super.equals(obj); + public int hashCode() { + return Objects.hash(id, name, stars, tags); } } diff --git a/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt b/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt index 1e67fdbb247..47bf21cfa32 100644 --- a/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt +++ b/critter/critter-maven/src/main/kotlin/dev/morphia/critter/maven/CritterProcessor.kt @@ -20,7 +20,7 @@ class CritterProcessor( private val logger: Logger = LoggerFactory.getLogger(CritterProcessor::class.java) private val critterClassLoader = CritterClassLoader() - private val gizmoGenerator = CritterGenerator(ReflectiveMapper(config, critterClassLoader)) + private val generator = CritterGenerator(ReflectiveMapper(config, critterClassLoader)) fun process() { val entityClasses = findEntityClasses() @@ -66,7 +66,7 @@ class CritterProcessor( private fun processClass(entityClass: Class<*>) { logger.info("Generating critter code for: ${entityClass.name}") - gizmoGenerator.generate(entityClass, critterClassLoader, false) + generator.generate(entityClass, critterClassLoader, false) } private fun writeGeneratedClasses() { diff --git a/pom.xml b/pom.xml index b4cfdb41ce1..326063d35b9 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,6 @@ 5.6 https://mongodb.github.io/mongo-java-driver/${driver.minor.version}/apidocs https://morphia.dev/morphia/${morphia.minor.version}/javadoc - 1.10.1 25.1 2.22.0 2.22 @@ -371,11 +370,6 @@ 4.10.2 compile - - io.quarkus.gizmo - gizmo - ${gizmo.version} - io.github.dmlloyd jdk-classfile-backport From 5473b931f22100fe3fcbbe7c778e22f5d714e23d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sat, 20 Jun 2026 19:44:36 -0400 Subject: [PATCH 19/31] Fix five bugs found in code review; centralize ClassModel reading in GenerationUtils - Add GenerationUtils.readClassModel() as the single place that reads a class file resource and parses it to ClassModel (using safeClassLoader); update CritterGenerator, AccessorMethods, and PropertyFinder to delegate to it, removing duplicated stream-handling and the null-classloader bug in AccessorMethods.readClassFiltering() - Fix getDeclaredAnnotation null guard in PropertyModelGenerator: emitted constructor bytecode now skips the annotation() call when the result is null (non-RUNTIME retention or classloader edge cases), preventing NPE in PropertyModel.annotation() - Fix findDeclaringClass to search getter methods after field lookup fails, so generic return types on method-based properties resolve correctly instead of falling back to Object - Fix primitive array handling in GenerationUtils annotation helpers: use Array.getLength/Array.get instead of (Object[]) cast; emit newarray/arrayStore for primitive component types instead of anewarray/aastore - Fix PropertyAccessorGenerator.set() to skip checkcast for non-public property types, mirroring the existing guard in VarHandleAccessorGenerator that prevents IllegalAccessError on package-private inner classes --- .../critter/parser/PropertyFinder.java | 25 ++------- .../parser/generator/AccessorMethods.java | 24 ++------- .../parser/generator/CritterGenerator.java | 14 +---- .../parser/generator/GenerationUtils.java | 52 +++++++++++++++---- .../generator/PropertyAccessorGenerator.java | 19 ++++++- .../generator/PropertyModelGenerator.java | 24 +++++++++ 6 files changed, 96 insertions(+), 62 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 86b75b5952e..f19bab19575 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -1,7 +1,5 @@ package dev.morphia.critter.parser; -import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -9,10 +7,10 @@ import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.GenerationUtils; import dev.morphia.critter.parser.generator.PropertyModelGenerator; import dev.morphia.critter.parser.java.CritterParser; import dev.morphia.mapping.Mapper; -import dev.morphia.mapping.MappingException; import dev.morphia.mapping.PropertyDiscovery; import org.slf4j.Logger; @@ -115,24 +113,11 @@ private List discoverAllFields(Class entityType, ClassModel classM } private ClassModel readClassModel(Class type) { - String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); - ClassLoader cl = type.getClassLoader() != null ? type.getClassLoader() - : ClassLoader.getSystemClassLoader(); - try (InputStream inputStream = cl.getResourceAsStream(resourceName)) { - if (inputStream == null) { - LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); - return null; - } - try { - 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; - } - } catch (IOException e) { - throw new MappingException(e.getMessage(), e); + ClassModel model = GenerationUtils.readClassModel(type); + if (model == null) { + LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); } + return model; } private List discoverFields(ClassModel classModel) { diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java index 00df57e0238..5f3040123ca 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java @@ -1,11 +1,7 @@ package dev.morphia.critter.parser.generator; -import java.io.IOException; -import java.io.InputStream; - import dev.morphia.mapping.MappingException; -import io.github.dmlloyd.classfile.ClassFile; import io.github.dmlloyd.classfile.ClassModel; /** @@ -28,23 +24,13 @@ protected AccessorMethods(Class entity) { public abstract byte[] emit(); /** - * Reads the class file bytes for the given entity, excluding any existing __read/__write synthetic methods. + * Reads the class file bytes for the given entity. */ protected ClassModel readClassFiltering() { - String resourceName = "%s.class".formatted(entity.getName().replace('.', '/')); - try (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); - } - } catch (IOException e) { - throw new MappingException(e.getMessage(), e); + ClassModel model = GenerationUtils.readClassModel(entity); + if (model == null) { + throw new MappingException("Could not find class file for %s".formatted(entity.getName())); } + return model; } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index 491902694f0..c61e66f518c 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -1,7 +1,5 @@ package dev.morphia.critter.parser.generator; -import java.io.IOException; -import java.io.InputStream; import java.util.List; import dev.morphia.critter.CritterClassLoader; @@ -10,7 +8,6 @@ import dev.morphia.critter.parser.PropertyFinder; import dev.morphia.mapping.Mapper; -import io.github.dmlloyd.classfile.ClassFile; import io.github.dmlloyd.classfile.ClassModel; /** @@ -38,17 +35,10 @@ public CritterGenerator(Mapper mapper) { * @return the generated entity model generator */ public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { - String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); - InputStream inputStream = GenerationUtils.safeClassLoader(type).getResourceAsStream(resourceName); - if (inputStream == null) { + ClassModel classModel = GenerationUtils.readClassModel(type); + if (classModel == null) { throw new IllegalArgumentException("Could not find class file for %s".formatted(type.getName())); } - ClassModel classModel; - try { - classModel = ClassFile.of().parse(inputStream.readAllBytes()); - } catch (IOException e) { - throw new RuntimeException("Failed to read class %s".formatted(type.getName()), e); - } PropertyFinder propertyFinder = new PropertyFinder(mapper, critterClassLoader, runtimeMode); return entityModel(type, critterClassLoader, classModel, propertyFinder.find(type, classModel)); diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index cf76eb8a08e..1d180c7a6a9 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -1,9 +1,12 @@ package dev.morphia.critter.parser.generator; +import java.io.IOException; +import java.io.InputStream; import java.lang.annotation.Annotation; import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Array; import java.lang.reflect.GenericArrayType; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; @@ -17,6 +20,7 @@ import io.github.dmlloyd.classfile.AnnotationValue; import io.github.dmlloyd.classfile.ClassBuilder; import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassModel; import io.github.dmlloyd.classfile.CodeBuilder; import io.github.dmlloyd.classfile.TypeKind; @@ -80,6 +84,22 @@ public static ClassLoader safeClassLoader(Class type) { return cl != null ? cl : ClassLoader.getSystemClassLoader(); } + /** + * Reads and parses the class file for the given type. Returns {@code null} if the class file + * resource cannot be found; throws {@link RuntimeException} on I/O or parse failure. + */ + public static ClassModel readClassModel(Class type) { + String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); + try (InputStream inputStream = safeClassLoader(type).getResourceAsStream(resourceName)) { + if (inputStream == null) { + return null; + } + return ClassFile.of().parse(inputStream.readAllBytes()); + } catch (IOException e) { + throw new RuntimeException("Failed to read class %s".formatted(type.getName()), e); + } + } + /** * Converts a runtime annotation instance into a ClassFile API annotation descriptor, suitable * for use in {@link io.github.dmlloyd.classfile.attribute.RuntimeVisibleAnnotationsAttribute}. @@ -132,9 +152,10 @@ private static AnnotationValue toAnnotationValue(java.lang.reflect.Type genericT } if (rawType.isArray()) { Class compType = rawType.getComponentType(); - Object[] arr = (Object[]) value; + int len = Array.getLength(value); List values = new ArrayList<>(); - for (Object elem : arr) { + for (int i = 0; i < len; i++) { + Object elem = Array.get(value, i); values.add(toAnnotationValue(compType, compType, elem)); } return AnnotationValue.ofArray(values); @@ -221,14 +242,25 @@ private static void emitBuilderElementValue(CodeBuilder cod, java.lang.reflect.T emitAnnotationViaBuilder(cod, (Annotation) value); } else if (rawType.isArray()) { Class compType = rawType.getComponentType(); - Object[] arr = (Object[]) value; - cod.loadConstant(arr.length); - cod.anewarray(ClassDesc.ofDescriptor(compType.descriptorString())); - for (int i = 0; i < arr.length; i++) { - cod.dup(); - cod.loadConstant(i); - emitBuilderElementValue(cod, compType, compType, arr[i]); - cod.aastore(); + int len = Array.getLength(value); + cod.loadConstant(len); + if (compType.isPrimitive()) { + TypeKind tk = TypeKind.fromDescriptor(compType.descriptorString()); + cod.newarray(tk); + for (int i = 0; i < len; i++) { + cod.dup(); + cod.loadConstant(i); + emitBuilderElementValue(cod, compType, compType, Array.get(value, i)); + cod.arrayStore(tk); + } + } else { + cod.anewarray(ClassDesc.ofDescriptor(compType.descriptorString())); + for (int i = 0; i < len; i++) { + cod.dup(); + cod.loadConstant(i); + emitBuilderElementValue(cod, compType, compType, Array.get(value, i)); + cod.aastore(); + } } } else if (genericType instanceof GenericArrayType gat) { java.lang.reflect.Type compGeneric = gat.getGenericComponentType(); diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index e3c105e94db..761449b7065 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -3,6 +3,7 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Modifier; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -112,7 +113,11 @@ public PropertyAccessorGenerator emit() { cod.invokevirtual(wrapperDesc, unboxMethod, MethodTypeDesc.of(propertyDesc)); } else { cod.aload(2); - cod.checkcast(propertyDesc); + // skip checkcast for non-public types: checkcast to a non-public inner + // class causes IllegalAccessError (mirrors VarHandleAccessorGenerator) + if (isPublicPropertyType()) { + cod.checkcast(propertyDesc); + } } cod.invokevirtual(entityDesc, writeName, MethodTypeDesc.of(ConstantDescs.CD_void, propertyDesc)); @@ -124,6 +129,18 @@ public PropertyAccessorGenerator emit() { return this; } + private boolean isPublicPropertyType() { + if (isPrimitive() || propertyType.startsWith("[")) { + return true; + } + try { + Class cls = Class.forName(propertyType, false, GenerationUtils.safeClassLoader(entity)); + return Modifier.isPublic(cls.getModifiers()); + } catch (ClassNotFoundException e) { + return false; + } + } + private String primitiveDescriptor() { return switch (propertyType) { case "boolean" -> "Z"; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 4db747f3c0b..9c3a447cf05 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -163,6 +163,19 @@ private static Class findDeclaringClass(String memberName, Class concreteC current = current.getSuperclass(); } } + // Fall back to getter for method-based properties + String titleName = Critter.titleCase(memberName); + current = concreteClass; + while (current != null && current != Object.class) { + for (Method m : current.getDeclaredMethods()) { + String name = m.getName(); + if ((name.equals("get" + titleName) || name.equals("is" + titleName)) + && m.getParameterCount() == 0 && !m.isBridge()) { + return current; + } + } + current = current.getSuperclass(); + } return null; } @@ -391,9 +404,20 @@ public PropertyModelGenerator emit() { cod.invokevirtual(ConstantDescs.CD_Class, "getDeclaredAnnotation", MethodTypeDesc.ofDescriptor( "(Ljava/lang/Class;)Ljava/lang/annotation/Annotation;")); + // getDeclaredAnnotation returns null if the annotation is absent at runtime + // (e.g. non-RUNTIME retention). Guard to avoid NPE in PropertyModel.annotation(). + var skipLabel = cod.newLabel(); + var endLabel = cod.newLabel(); + cod.dup(); + cod.ifnull(skipLabel); cod.invokevirtual(propertyModelDesc, "annotation", MethodTypeDesc.of(propertyModelDesc, annotationDesc)); cod.pop(); + cod.goto_(endLabel); + cod.labelBinding(skipLabel); + cod.pop(); // pop null + cod.pop(); // pop this + cod.labelBinding(endLabel); } cod.return_(); From f720d19198f36650a50aefb0a623fda7b241569d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sat, 20 Jun 2026 20:14:32 -0400 Subject: [PATCH 20/31] Support @ExternalEntity in critter pipeline Thread a stand-in/target-type distinction through CritterGenerator, PropertyFinder, PropertyModelGenerator, and EntityModelGenerator so that @ExternalEntity annotated classes generate accessor and entity model bytecode for the target type rather than silently falling back to reflection. --- .../critter/parser/PropertyFinder.java | 29 +++++++----- .../parser/generator/CritterGenerator.java | 40 +++++++++++++++-- .../generator/EntityModelGenerator.java | 44 +++++++++++++------ .../generator/PropertyModelGenerator.java | 36 ++++++++++++--- 4 files changed, 114 insertions(+), 35 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index f19bab19575..4ea87327968 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -51,32 +51,41 @@ public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean run } public List find(Class entityType, ClassModel classModel) { + return find(entityType, classModel, entityType); + } + + /** + * Discovers properties from {@code standinType}'s classfile but generates accessor and model + * bytecode targeting {@code targetType}. Used for {@code @ExternalEntity} stand-ins where the + * stand-in carries Morphia annotations but the target is the type actually persisted. + */ + public List find(Class standinType, ClassModel classModel, Class targetType) { List models = new ArrayList<>(); - List methods = discoverPropertyMethods(entityType, classModel); + List methods = discoverPropertyMethods(standinType, classModel); if (methods.isEmpty()) { - List fields = discoverAllFields(entityType, classModel); + List fields = discoverAllFields(standinType, classModel); if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGenerator.fieldAccessors(entityType, fields)); + classLoader.register(targetType.getName(), critterGenerator.fieldAccessors(targetType, fields)); } for (FieldInfo field : fields) { if (runtimeMode) { - critterGenerator.varHandleAccessor(entityType, classLoader, field); + critterGenerator.varHandleAccessor(targetType, classLoader, field); } else { - critterGenerator.propertyAccessor(entityType, classLoader, field); + critterGenerator.propertyAccessor(targetType, classLoader, field); } - models.add(critterGenerator.propertyModelGenerator(entityType, classLoader, field)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, field)); } } else { if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGenerator.methodAccessors(entityType, methods)); + classLoader.register(targetType.getName(), critterGenerator.methodAccessors(targetType, methods)); } for (MethodInfo method : methods) { if (runtimeMode) { - critterGenerator.varHandleAccessor(entityType, classLoader, method); + critterGenerator.varHandleAccessor(targetType, classLoader, method); } else { - critterGenerator.propertyAccessor(entityType, classLoader, method); + critterGenerator.propertyAccessor(targetType, classLoader, method); } - models.add(critterGenerator.propertyModelGenerator(entityType, classLoader, method)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, method)); } } return models; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index c61e66f518c..aa9c4f2d441 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -2,6 +2,7 @@ import java.util.List; +import dev.morphia.annotations.ExternalEntity; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.FieldInfo; import dev.morphia.critter.parser.MethodInfo; @@ -35,13 +36,20 @@ public CritterGenerator(Mapper mapper) { * @return the generated entity model generator */ public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { - ClassModel classModel = GenerationUtils.readClassModel(type); + // For @ExternalEntity, the annotated stand-in class describes the mapping; the target + // class is what actually gets persisted and needs accessor/model generation. + ExternalEntity ext = type.getAnnotation(ExternalEntity.class); + Class standinType = type; + Class targetType = ext != null ? ext.target() : type; + + ClassModel classModel = GenerationUtils.readClassModel(standinType); if (classModel == null) { - throw new IllegalArgumentException("Could not find class file for %s".formatted(type.getName())); + throw new IllegalArgumentException("Could not find class file for %s".formatted(standinType.getName())); } PropertyFinder propertyFinder = new PropertyFinder(mapper, critterClassLoader, runtimeMode); - return entityModel(type, critterClassLoader, classModel, propertyFinder.find(type, classModel)); + return entityModel(targetType, standinType, critterClassLoader, classModel, + propertyFinder.find(standinType, classModel, targetType)); } /** @@ -138,6 +146,14 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, field).emit(); } + /** + * Generates and registers a {@link PropertyModelGenerator} for the given field, reading annotations from {@code annotationSource}. + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, Class annotationSource, + CritterClassLoader critterClassLoader, FieldInfo field) { + return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, field).emit(); + } + /** * Generates and registers a {@link PropertyModelGenerator} for the property exposed by the given getter method. * @@ -150,6 +166,14 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, method).emit(); } + /** + * Generates and registers a {@link PropertyModelGenerator} for the given method, reading annotations from {@code annotationSource}. + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, Class annotationSource, + CritterClassLoader critterClassLoader, MethodInfo method) { + return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, method).emit(); + } + /** * Generates and registers the entity model class for the given type. * @@ -161,6 +185,14 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Critte */ public EntityModelGenerator entityModel(Class type, CritterClassLoader critterClassLoader, ClassModel classModel, List properties) { - return new EntityModelGenerator(mapper, type, critterClassLoader, properties).emit(); + return new EntityModelGenerator(mapper, type, type, critterClassLoader, properties).emit(); + } + + /** + * Generates and registers the entity model class for the given type, reading entity-level annotations from {@code standinType}. + */ + public EntityModelGenerator entityModel(Class type, Class standinType, CritterClassLoader critterClassLoader, + ClassModel classModel, List properties) { + return new EntityModelGenerator(mapper, type, standinType, critterClassLoader, properties).emit(); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index 07ff6c29c5b..0aea8a69a16 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -10,6 +10,7 @@ import java.util.Set; import dev.morphia.annotations.Entity; +import dev.morphia.annotations.ExternalEntity; import dev.morphia.critter.CritterClassLoader; import dev.morphia.mapping.Mapper; import dev.morphia.mapping.codec.pojo.PropertyModel; @@ -24,34 +25,39 @@ public class EntityModelGenerator extends BaseGenerator { private final Mapper mapper; private final List properties; private final Entity entityAnnotation; + private final ExternalEntity externalEntity; private final List morphiaAnnotations; /** - * Creates a new entity model generator for the given entity. + * Creates a new entity model generator for the given entity, reading entity-level annotations + * from {@code standinType} (which differs from {@code type} only for {@code @ExternalEntity}). * * @param mapper the Morphia mapper - * @param type the entity class annotated with {@code @Entity} + * @param type the target class for which code is generated (instances of this type are persisted) + * @param standinType the class carrying the entity-level Morphia annotations; equals {@code type} for normal entities * @param critterClassLoader the class loader that will receive the generated bytecode * @param properties the property model generators for each of the entity's properties - * @throws IllegalStateException if the entity class does not have an {@code @Entity} annotation + * @throws IllegalStateException if neither {@code @Entity} nor {@code @ExternalEntity} is present on {@code standinType} */ - public EntityModelGenerator(Mapper mapper, Class type, CritterClassLoader critterClassLoader, + public EntityModelGenerator(Mapper mapper, Class type, Class standinType, CritterClassLoader critterClassLoader, List properties) { super(type, critterClassLoader); this.mapper = mapper; this.properties = properties; generatedType = "%s.%sEntityModel".formatted(baseName, type.getSimpleName()); - Entity ann = type.getAnnotation(Entity.class); - if (ann == null) { - throw new IllegalStateException("Class %s does not have @Entity annotation".formatted(type.getName())); + this.entityAnnotation = standinType.getAnnotation(Entity.class); + this.externalEntity = standinType.getAnnotation(ExternalEntity.class); + if (this.entityAnnotation == null && this.externalEntity == null) { + throw new IllegalStateException( + "Class %s does not have @Entity or @ExternalEntity annotation".formatted(standinType.getName())); } - this.entityAnnotation = ann; - // Collect morphia annotations from this class and ancestors (de-duplicated) + // Collect morphia annotations from the stand-in class and its ancestors (de-duplicated). + // For @ExternalEntity, these are the annotations the user placed on the stand-in's fields. Set registered = new LinkedHashSet<>(); List allAnnotations = new java.util.ArrayList<>(); - Class current = type; + Class current = standinType; while (current != null && current != Object.class) { for (Annotation a : current.getAnnotations()) { if (a.annotationType().getName().startsWith("dev.morphia.annotations.") @@ -64,6 +70,14 @@ public EntityModelGenerator(Mapper mapper, Class type, CritterClassLoader cri this.morphiaAnnotations = List.copyOf(allAnnotations); } + /** + * Creates a new entity model generator where the entity class is also the annotation source. + */ + public EntityModelGenerator(Mapper mapper, Class type, CritterClassLoader critterClassLoader, + List properties) { + this(mapper, type, type, critterClassLoader, properties); + } + /** * Returns the annotation of the given type from the entity class, or {@code null} if not present. */ @@ -94,7 +108,8 @@ public EntityModelGenerator emit() { String discriminatorKeyStr = computeDiscriminatorKey(); boolean isAbstractFlag = Modifier.isAbstract(entity.getModifiers()); boolean isInterfaceFlag = entity.isInterface(); - boolean useDiscriminatorFlag = entityAnnotation.useDiscriminator(); + boolean useDiscriminatorFlag = entityAnnotation != null ? entityAnnotation.useDiscriminator() + : externalEntity.useDiscriminator(); byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); @@ -181,18 +196,19 @@ public EntityModelGenerator emit() { } private String computeCollectionName() { - String key = entityAnnotation.value(); + String key = entityAnnotation != null ? entityAnnotation.value() : externalEntity.value(); return Mapper.IGNORED_FIELDNAME.equals(key) ? mapper.getConfig().collectionNaming().apply(entity.getSimpleName()) : key; } private String computeDiscriminator() { - return mapper.getConfig().discriminator().apply(entity, entityAnnotation.discriminator()); + String disc = entityAnnotation != null ? entityAnnotation.discriminator() : externalEntity.discriminator(); + return mapper.getConfig().discriminator().apply(entity, disc); } private String computeDiscriminatorKey() { - String key = entityAnnotation.discriminatorKey(); + String key = entityAnnotation != null ? entityAnnotation.discriminatorKey() : externalEntity.discriminatorKey(); return Mapper.IGNORED_FIELDNAME.equals(key) ? mapper.getConfig().discriminatorKey() : key; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 9c3a447cf05..54f854eb8ff 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -54,8 +54,11 @@ public class PropertyModelGenerator extends BaseGenerator { /** * Creates a generator for a field-based property. + * + * @param annotationSource the class to reflect on for field annotations (may differ from entity for @ExternalEntity stand-ins) */ - public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { + public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, + CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.config = config; this.isFieldBased = true; @@ -63,18 +66,29 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - Field reflectedField = findField(entity, field.name()); + Field reflectedField = findField(annotationSource, field.name()); this.accessFlags = reflectedField != null ? reflectedField.getModifiers() : field.access(); this.genericType = reflectedField != null ? reflectedField.getGenericType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedField != null ? reflectedField.getAnnotations() : new Annotation[0]); - this.typeData = computeTypeData(resolveGenericType(this.genericType, field.name(), entity), entity.getClassLoader()); + this.typeData = computeTypeData(resolveGenericType(this.genericType, field.name(), annotationSource), + GenerationUtils.safeClassLoader(annotationSource)); this.getterName = null; } + /** + * Creates a generator for a field-based property where the entity class is also the annotation source. + */ + public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { + this(config, entity, entity, critterClassLoader, field); + } + /** * Creates a generator for a method-based (getter) property. + * + * @param annotationSource the class to reflect on for method annotations (may differ from entity for @ExternalEntity stand-ins) */ - public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { + public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, + CritterClassLoader critterClassLoader, MethodInfo method) { super(entity, critterClassLoader); this.config = config; this.isFieldBased = false; @@ -82,22 +96,30 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClas generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - Method reflectedMethod = findMethod(entity, method.name()); + Method reflectedMethod = findMethod(annotationSource, method.name()); this.accessFlags = reflectedMethod != null ? reflectedMethod.getModifiers() : method.access(); this.genericType = reflectedMethod != null ? reflectedMethod.getGenericReturnType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedMethod != null ? reflectedMethod.getAnnotations() : new Annotation[0]); // Also collect setter annotations — some annotations (e.g. @Version, @Text) live on the setter, not the getter String setterName = "set" + Critter.titleCase(this.propertyName); - Method reflectedSetter = findSetterMethod(entity, setterName); + Method reflectedSetter = findSetterMethod(annotationSource, setterName); if (reflectedSetter != null) { for (Annotation ann : reflectedSetter.getAnnotations()) { this.annotationMap.putIfAbsent(ann.annotationType().getName(), ann); } } - this.typeData = computeTypeData(resolveGenericType(this.genericType, this.propertyName, entity), entity.getClassLoader()); + this.typeData = computeTypeData(resolveGenericType(this.genericType, this.propertyName, annotationSource), + GenerationUtils.safeClassLoader(annotationSource)); this.getterName = method.name(); } + /** + * Creates a generator for a method-based property where the entity class is also the annotation source. + */ + public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { + this(config, entity, entity, critterClassLoader, method); + } + private static Field findField(Class cls, String name) { Class current = cls; while (current != null && current != Object.class) { From 9b6a20ae98944dd20772ab5611fa83636a3b0d32 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 12:52:01 -0400 Subject: [PATCH 21/31] Use Object in __readXxx/__writeXxx bridge descriptors; preserve exception cause Reference types in __readXxx() and __writeXxx() now use Object in their method descriptors so the generated accessor class (in the critter package) never references the concrete property type. The cast to the concrete type lives inside the entity where it has legal access. This prevents VerifyError when the property type is package-private. Also passes the caught exception as the cause when re-throwing from the final-field reflection fallback in VarHandleAccessorGenerator. --- .../generator/AddFieldAccessorMethods.java | 19 +++++++++---- .../generator/AddMethodAccessorMethods.java | 19 +++++++++---- .../generator/PropertyAccessorGenerator.java | 28 +++++-------------- .../generator/VarHandleAccessorGenerator.java | 3 +- .../parser/generator/TestGeneration.java | 6 ++-- 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java index a970e73fe08..e6f05234544 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java @@ -1,6 +1,7 @@ package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; import java.util.List; @@ -44,9 +45,11 @@ public byte[] emit() { ClassDesc fieldDesc = ClassDesc.ofDescriptor(field.desc()); TypeKind kind = TypeKind.fromDescriptor(field.desc()); - // __readXxx(): returns field type + // __readXxx(): returns Object (widens from concrete type inside entity, + // keeping non-public types out of the accessor's constant pool) String readerName = "__read%s".formatted(Critter.titleCase(name)); - MethodTypeDesc readerMtd = MethodTypeDesc.of(fieldDesc); + boolean isPrimitive = kind != TypeKind.REFERENCE; + MethodTypeDesc readerMtd = MethodTypeDesc.of(isPrimitive ? fieldDesc : ConstantDescs.CD_Object); classBuilder.withMethodBody(readerName, readerMtd, ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, cod -> { @@ -55,14 +58,20 @@ public byte[] emit() { cod.return_(kind); }); - // __writeXxx(fieldType): void + // __writeXxx(Object): void (cast to concrete type inside entity where it's accessible) String writerName = "__write%s".formatted(Critter.titleCase(name)); - MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), fieldDesc); + MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), + isPrimitive ? fieldDesc : ConstantDescs.CD_Object); classBuilder.withMethodBody(writerName, writerMtd, ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, cod -> { cod.aload(0); - cod.loadLocal(kind, 1); + if (isPrimitive) { + cod.loadLocal(kind, 1); + } else { + cod.aload(1); + cod.checkcast(fieldDesc); + } cod.putfield(entityDesc, name, fieldDesc); cod.return_(); }); diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java index df79910d355..800dad4a5e3 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -1,6 +1,7 @@ package dev.morphia.critter.parser.generator; import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; import java.util.List; @@ -56,9 +57,11 @@ public byte[] emit() { } } - // __readXxx(): return type of getter + // __readXxx(): returns Object for reference types (keeps non-public types + // out of the accessor's constant pool; concrete type widens inside entity) + boolean isPrimitive = returnKind != TypeKind.REFERENCE; String readerName = "__read%s".formatted(Critter.titleCase(propertyName)); - MethodTypeDesc readerMtd = MethodTypeDesc.of(returnDesc); + MethodTypeDesc readerMtd = MethodTypeDesc.of(isPrimitive ? returnDesc : ConstantDescs.CD_Object); classBuilder.withMethodBody(readerName, readerMtd, ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, cod -> { @@ -67,15 +70,21 @@ public byte[] emit() { cod.return_(returnKind); }); - // __writeXxx(T): void + // __writeXxx(Object): void for reference types (cast inside entity where concrete type is accessible) String writerName = "__write%s".formatted(Critter.titleCase(propertyName)); - MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), returnDesc); + MethodTypeDesc writerMtd = MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), + isPrimitive ? returnDesc : ConstantDescs.CD_Object); if (hasSetter) { classBuilder.withMethodBody(writerName, writerMtd, ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, cod -> { cod.aload(0); - cod.loadLocal(returnKind, 1); + if (isPrimitive) { + cod.loadLocal(returnKind, 1); + } else { + cod.aload(1); + cod.checkcast(returnDesc); + } cod.invokevirtual(entityDesc, setterName, MethodTypeDesc.of(ClassDesc.ofDescriptor("V"), returnDesc)); cod.return_(); diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index 761449b7065..27fd71ef302 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -3,7 +3,6 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; -import java.lang.reflect.Modifier; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -83,6 +82,7 @@ public PropertyAccessorGenerator emit() { }); // get(Object model): Object + // __readXxx() returns Object for reference types (bridge cast lives inside entity) cb.withMethodBody("get", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;"), ClassFile.ACC_PUBLIC, cod -> { cod.aload(1); @@ -94,12 +94,13 @@ public PropertyAccessorGenerator emit() { cod.invokestatic(wrapperDesc, "valueOf", MethodTypeDesc.of(wrapperDesc, propertyDesc)); } else { - cod.invokevirtual(entityDesc, readName, MethodTypeDesc.of(propertyDesc)); + cod.invokevirtual(entityDesc, readName, MethodTypeDesc.of(ConstantDescs.CD_Object)); } cod.areturn(); }); // set(Object model, Object value): void + // __writeXxx(Object) for reference types — no non-public type in descriptor cb.withMethodBody("set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V"), ClassFile.ACC_PUBLIC, cod -> { cod.aload(1); @@ -111,16 +112,13 @@ public PropertyAccessorGenerator emit() { // unbox: WrapperType.primitiveValue() String unboxMethod = primitiveUnboxMethod(); cod.invokevirtual(wrapperDesc, unboxMethod, MethodTypeDesc.of(propertyDesc)); + cod.invokevirtual(entityDesc, writeName, + MethodTypeDesc.of(ConstantDescs.CD_void, propertyDesc)); } else { cod.aload(2); - // skip checkcast for non-public types: checkcast to a non-public inner - // class causes IllegalAccessError (mirrors VarHandleAccessorGenerator) - if (isPublicPropertyType()) { - cod.checkcast(propertyDesc); - } + cod.invokevirtual(entityDesc, writeName, + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_Object)); } - cod.invokevirtual(entityDesc, writeName, - MethodTypeDesc.of(ConstantDescs.CD_void, propertyDesc)); cod.return_(); }); }); @@ -129,18 +127,6 @@ public PropertyAccessorGenerator emit() { return this; } - private boolean isPublicPropertyType() { - if (isPrimitive() || propertyType.startsWith("[")) { - return true; - } - try { - Class cls = Class.forName(propertyType, false, GenerationUtils.safeClassLoader(entity)); - return Modifier.isPublic(cls.getModifiers()); - } catch (ClassNotFoundException e) { - return false; - } - } - private String primitiveDescriptor() { return switch (propertyType) { case "boolean" -> "Z"; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index 2548690a54d..5c2c39dc239 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -333,8 +333,9 @@ public VarHandleAccessorGenerator emit() { catchBody.new_(rteDesc); catchBody.dup(); catchBody.ldc("Failed to set final field '%s'".formatted(propertyName)); + catchBody.aload(3); catchBody.invokespecial(rteDesc, "", - MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;Ljava/lang/Throwable;)V")); catchBody.athrow(); })); return; diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java index ccb61ac9be8..19b389bfe76 100644 --- a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java +++ b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java @@ -207,12 +207,14 @@ public void testMethodBasedAccessors() throws Exception { Assertions.assertNotNull(modifiedClass.getMethod("__readScore"), "Should have __readScore method"); Assertions.assertNotNull(modifiedClass.getMethod("__readComputedValue"), "Should have __readComputedValue method"); - Assertions.assertNotNull(modifiedClass.getMethod("__writeId", org.bson.types.ObjectId.class), "Should have __writeId method"); + // Reference types use Object in the bridge descriptor so non-public types never + // appear in the accessor's constant pool; primitives keep their concrete type. + Assertions.assertNotNull(modifiedClass.getMethod("__writeId", Object.class), "Should have __writeId method"); Assertions.assertNotNull(modifiedClass.getMethod("__writeCount", long.class), "Should have __writeCount method"); Assertions.assertNotNull(modifiedClass.getMethod("__writeScore", double.class), "Should have __writeScore method"); Object instance = modifiedClass.getConstructor().newInstance(); - Method writeComputedMethod = modifiedClass.getMethod("__writeComputedValue", String.class); + Method writeComputedMethod = modifiedClass.getMethod("__writeComputedValue", Object.class); try { writeComputedMethod.invoke(instance, "test value"); From c53018209d973c0dc805e7c8c036990f35f008d8 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 16:47:08 -0400 Subject: [PATCH 22/31] Fix setter visibility mismatch, remove computeTypeData, simplify primitiveWrapperName AddMethodAccessorMethods now walks getDeclaredMethod up the hierarchy (matching VarHandleAccessorGenerator) so package-private setters are recognized in build mode, eliminating the read-only/writable behavioral split between the two code paths. computeTypeData() in PropertyModelGenerator duplicated TypeData.get(Type) with subtly different wildcard and raw-class behavior; replaced both call sites with TypeData.get() and removed the method. primitiveWrapperName() was a 9-branch if-chain duplicating PRIMITIVE_TO_WRAPPER; replaced with a map lookup plus a void special case. --- .../generator/AddMethodAccessorMethods.java | 50 ++++++++++++++--- .../parser/generator/GenerationUtils.java | 25 +++------ .../generator/PropertyModelGenerator.java | 55 +------------------ 3 files changed, 52 insertions(+), 78 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java index 800dad4a5e3..925e2715fbc 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -3,6 +3,7 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Modifier; import java.util.List; import dev.morphia.critter.Critter; @@ -49,13 +50,7 @@ public byte[] emit() { String getterName = method.name(); String setterName = "set%s".formatted(Critter.titleCase(propertyName)); - boolean hasSetter = false; - for (java.lang.reflect.Method m : entity.getMethods()) { - if (m.getName().equals(setterName) && m.getParameterCount() == 1) { - hasSetter = true; - break; - } - } + boolean hasSetter = findSetter(entity, setterName, methodMtd.returnType()) != null; // __readXxx(): returns Object for reference types (keeps non-public types // out of the accessor's constant pool; concrete type widens inside entity) @@ -107,4 +102,45 @@ public byte[] emit() { return ClassFile.of().transformClass(model, transform); } + + private static java.lang.reflect.Method findSetter(Class start, String name, ClassDesc paramDesc) { + Class paramType; + try { + String paramName = paramDesc.descriptorString(); + if (paramName.length() == 1) { + paramType = switch (paramName) { + case "Z" -> boolean.class; + case "B" -> byte.class; + case "C" -> char.class; + case "S" -> short.class; + case "I" -> int.class; + case "J" -> long.class; + case "F" -> float.class; + case "D" -> double.class; + default -> throw new IllegalArgumentException("Unknown primitive: " + paramName); + }; + } else { + String className = paramDesc.packageName().isEmpty() + ? paramDesc.displayName() + : paramDesc.packageName() + "." + paramDesc.displayName(); + paramType = Class.forName(className, false, start.getClassLoader() != null + ? start.getClassLoader() + : ClassLoader.getSystemClassLoader()); + } + } catch (ClassNotFoundException e) { + return null; + } + Class current = start; + while (current != null && current != Object.class) { + try { + java.lang.reflect.Method m = current.getDeclaredMethod(name, paramType); + if (!Modifier.isPrivate(m.getModifiers()) && !Modifier.isStatic(m.getModifiers())) { + return m; + } + } catch (NoSuchMethodException ignored) { + } + current = current.getSuperclass(); + } + return null; + } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 1d180c7a6a9..4479a2e3f95 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -310,25 +310,14 @@ public static void emitClassRef(CodeBuilder cod, Class cls) { } private static String primitiveWrapperName(Class primitive) { - if (primitive == boolean.class) - return "java.lang.Boolean"; - if (primitive == byte.class) - return "java.lang.Byte"; - if (primitive == char.class) - return "java.lang.Character"; - if (primitive == short.class) - return "java.lang.Short"; - if (primitive == int.class) - return "java.lang.Integer"; - if (primitive == long.class) - return "java.lang.Long"; - if (primitive == float.class) - return "java.lang.Float"; - if (primitive == double.class) - return "java.lang.Double"; - if (primitive == void.class) + if (primitive == void.class) { return "java.lang.Void"; - throw new IllegalArgumentException("Not a primitive: " + primitive); + } + String wrapper = PRIMITIVE_TO_WRAPPER.get(primitive.getName()); + if (wrapper == null) { + throw new IllegalArgumentException("Not a primitive: " + primitive); + } + return wrapper; } /** diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 54f854eb8ff..70f19c6b8c3 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -5,12 +5,9 @@ import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Field; -import java.lang.reflect.GenericArrayType; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.TypeVariable; -import java.lang.reflect.WildcardType; -import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -70,8 +67,7 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class an this.accessFlags = reflectedField != null ? reflectedField.getModifiers() : field.access(); this.genericType = reflectedField != null ? reflectedField.getGenericType() : Object.class; this.annotationMap = buildAnnotationMap(reflectedField != null ? reflectedField.getAnnotations() : new Annotation[0]); - this.typeData = computeTypeData(resolveGenericType(this.genericType, field.name(), annotationSource), - GenerationUtils.safeClassLoader(annotationSource)); + this.typeData = TypeData.get(resolveGenericType(this.genericType, field.name(), annotationSource)); this.getterName = null; } @@ -108,8 +104,7 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class an this.annotationMap.putIfAbsent(ann.annotationType().getName(), ann); } } - this.typeData = computeTypeData(resolveGenericType(this.genericType, this.propertyName, annotationSource), - GenerationUtils.safeClassLoader(annotationSource)); + this.typeData = TypeData.get(resolveGenericType(this.genericType, this.propertyName, annotationSource)); this.getterName = method.name(); } @@ -230,52 +225,6 @@ private static Class resolveTypeVariable(String typeVarName, Class concret return resolved instanceof Class c ? c : null; } - /** - * Converts a java.lang.reflect.Type into a TypeData instance. - */ - public static TypeData computeTypeData(java.lang.reflect.Type type, ClassLoader classLoader) { - if (type instanceof Class c) { - return new TypeData<>(c, List.of()); - } else if (type instanceof ParameterizedType pt) { - Class raw = (Class) pt.getRawType(); - @SuppressWarnings("unchecked") - List> params = (List>) (List) Arrays.stream(pt.getActualTypeArguments()) - .map(a -> computeTypeData(a, classLoader)) - .toList(); - return new TypeData<>(raw, params); - } else if (type instanceof GenericArrayType gat) { - java.lang.reflect.Type comp = gat.getGenericComponentType(); - Class compClass; - if (comp instanceof Class c) { - compClass = c; - } else if (comp instanceof ParameterizedType pt) { - compClass = (Class) pt.getRawType(); - } else { - compClass = Object.class; - } - try { - Class arrayClass = java.lang.reflect.Array.newInstance(compClass, 0).getClass(); - return new TypeData<>(arrayClass, List.of()); - } catch (Exception e) { - return new TypeData<>(Object.class, List.of()); - } - } else if (type instanceof TypeVariable tv) { - // Resolve bounds if possible - java.lang.reflect.Type[] bounds = tv.getBounds(); - if (bounds.length > 0 && bounds[0] != Object.class) { - return computeTypeData(bounds[0], classLoader); - } - return new TypeData<>(Object.class, List.of()); - } else if (type instanceof WildcardType wt) { - java.lang.reflect.Type[] upper = wt.getUpperBounds(); - if (upper.length > 0) { - return computeTypeData(upper[0], classLoader); - } - return new TypeData<>(Object.class, List.of()); - } - return new TypeData<>(Object.class, List.of()); - } - /** * Parses a classfile signature string into a list of {@link TypeData} instances. * Uses the ClassFile API's {@code Signature.parseFrom()} for type argument extraction. From dba79047b146d08073e6e280a58153d685f3c628 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 16:53:59 -0400 Subject: [PATCH 23/31] Inline loop variable in toAnnotationValue array branch --- .../dev/morphia/critter/parser/generator/GenerationUtils.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 4479a2e3f95..5aef544b465 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -155,8 +155,7 @@ private static AnnotationValue toAnnotationValue(java.lang.reflect.Type genericT int len = Array.getLength(value); List values = new ArrayList<>(); for (int i = 0; i < len; i++) { - Object elem = Array.get(value, i); - values.add(toAnnotationValue(compType, compType, elem)); + values.add(toAnnotationValue(compType, compType, Array.get(value, i))); } return AnnotationValue.ofArray(values); } From 5debf9a3990d1492914dad83d8c4026e1abdc477 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Sun, 21 Jun 2026 17:06:02 -0400 Subject: [PATCH 24/31] use the descriptor rather than the name Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../dev/morphia/critter/parser/generator/GenerationUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 5aef544b465..35379354032 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -289,7 +289,7 @@ public static void emitClassRef(CodeBuilder cod, Class cls) { String wrapper = primitiveWrapperName(cls); cod.getstatic(ClassDesc.of(wrapper), "TYPE", ConstantDescs.CD_Class); } else if (Modifier.isPublic(cls.getModifiers())) { - cod.loadConstant(ClassDesc.of(cls.getName())); + cod.loadConstant(ClassDesc.ofDescriptor(cls.descriptorString())); } else { cod.ldc(cls.getName()); cod.iconst_0(); From 68d4d3e3347a79c472e5735804fe0c86651c7d9a Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Sun, 21 Jun 2026 17:07:07 -0400 Subject: [PATCH 25/31] use more typesafe alternatives Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../parser/generator/AddMethodAccessorMethods.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java index 925e2715fbc..0b6fec0b654 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -120,13 +120,8 @@ private static java.lang.reflect.Method findSetter(Class start, String name, default -> throw new IllegalArgumentException("Unknown primitive: " + paramName); }; } else { - String className = paramDesc.packageName().isEmpty() - ? paramDesc.displayName() - : paramDesc.packageName() + "." + paramDesc.displayName(); - paramType = Class.forName(className, false, start.getClassLoader() != null - ? start.getClassLoader() - : ClassLoader.getSystemClassLoader()); - } + String className = GenerationUtils.typeClassName(paramDesc); + paramType = Class.forName(className, false, GenerationUtils.safeClassLoader(start)); } catch (ClassNotFoundException e) { return null; } From 06b4b226b8a7afc1ba1ecc0b8428fa93ca9353ef Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 17:39:46 -0400 Subject: [PATCH 26/31] Fix Session 4 review findings: unify setter lookup, fix primitive signature, synthesize @Entity for @ExternalEntity - Unify findSetter/hasSetter into GenerationUtils.findSetterMethod(), fixing array-typed and inner-class setter parameter resolution that previously failed Class.forName - Fix malformed "Lint;" class signature for primitive properties in PropertyAccessorGenerator by using the wrapper type in the generic class signature - Exclude dev.morphia.annotations.internal.* from Morphia annotation collection in EntityModelGenerator to avoid constructing nonexistent builder class names - Synthesize @Entity from @ExternalEntity in EntityModelGenerator constructor, mirroring MorphiaDefaultsConvention, so getAnnotation(Entity.class) returns non-null for @ExternalEntity models with the critter mapper - Cache annotation descriptor key list in PropertyFinder instead of rebuilding per call --- .../critter/parser/PropertyFinder.java | 9 +++-- .../generator/AddMethodAccessorMethods.java | 40 ++----------------- .../generator/EntityModelGenerator.java | 15 +++++++ .../parser/generator/GenerationUtils.java | 27 +++++++++++++ .../generator/PropertyAccessorGenerator.java | 8 ++-- .../generator/VarHandleAccessorGenerator.java | 29 +++----------- 6 files changed, 61 insertions(+), 67 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 4ea87327968..c45d82a7665 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -34,6 +34,7 @@ public class PropertyFinder { private static final Logger LOG = LoggerFactory.getLogger(PropertyFinder.class); private final Map, Object> providerMap; + private final List annotationDescriptorKeys; private final CritterClassLoader classLoader; private final boolean runtimeMode; private final CritterGenerator critterGenerator; @@ -44,6 +45,9 @@ public PropertyFinder(Mapper mapper, CritterClassLoader classLoader, boolean run for (var provider : mapper.getConfig().propertyAnnotationProviders()) { providerMap.put(provider.provides(), provider); } + this.annotationDescriptorKeys = providerMap.keySet().stream() + .map(type -> "L" + type.getName().replace('.', '/') + ";") + .toList(); this.classLoader = classLoader; this.runtimeMode = runtimeMode; this.critterGenerator = new CritterGenerator(mapper); @@ -93,11 +97,8 @@ public List find(Class standinType, ClassModel classM private boolean isPropertyAnnotated(List annotations, boolean allowUnannotated) { List anns = annotations != null ? annotations : List.of(); - List keys = providerMap.keySet().stream() - .map(type -> "L" + type.getName().replace('.', '/') + ";") - .toList(); return allowUnannotated || anns.stream() - .anyMatch(a -> keys.contains(a.classSymbol().descriptorString())); + .anyMatch(a -> annotationDescriptorKeys.contains(a.classSymbol().descriptorString())); } private List discoverAllFields(Class entityType, ClassModel classModel) { diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java index 0b6fec0b654..c0b303f6fec 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -3,7 +3,6 @@ import java.lang.constant.ClassDesc; import java.lang.constant.ConstantDescs; import java.lang.constant.MethodTypeDesc; -import java.lang.reflect.Modifier; import java.util.List; import dev.morphia.critter.Critter; @@ -16,6 +15,8 @@ import io.github.dmlloyd.classfile.MethodModel; import io.github.dmlloyd.classfile.TypeKind; +import static dev.morphia.critter.parser.generator.GenerationUtils.findSetterMethod; + /** * Generates synthetic {@code __readXxx} and {@code __writeXxx} accessor methods into an entity class * bytecode for properties backed by getter/setter methods rather than direct fields. @@ -50,7 +51,7 @@ public byte[] emit() { String getterName = method.name(); String setterName = "set%s".formatted(Critter.titleCase(propertyName)); - boolean hasSetter = findSetter(entity, setterName, methodMtd.returnType()) != null; + boolean hasSetter = findSetterMethod(entity, setterName, methodMtd.returnType()) != null; // __readXxx(): returns Object for reference types (keeps non-public types // out of the accessor's constant pool; concrete type widens inside entity) @@ -103,39 +104,4 @@ public byte[] emit() { return ClassFile.of().transformClass(model, transform); } - private static java.lang.reflect.Method findSetter(Class start, String name, ClassDesc paramDesc) { - Class paramType; - try { - String paramName = paramDesc.descriptorString(); - if (paramName.length() == 1) { - paramType = switch (paramName) { - case "Z" -> boolean.class; - case "B" -> byte.class; - case "C" -> char.class; - case "S" -> short.class; - case "I" -> int.class; - case "J" -> long.class; - case "F" -> float.class; - case "D" -> double.class; - default -> throw new IllegalArgumentException("Unknown primitive: " + paramName); - }; - } else { - String className = GenerationUtils.typeClassName(paramDesc); - paramType = Class.forName(className, false, GenerationUtils.safeClassLoader(start)); - } catch (ClassNotFoundException e) { - return null; - } - Class current = start; - while (current != null && current != Object.class) { - try { - java.lang.reflect.Method m = current.getDeclaredMethod(name, paramType); - if (!Modifier.isPrivate(m.getModifiers()) && !Modifier.isStatic(m.getModifiers())) { - return m; - } - } catch (NoSuchMethodException ignored) { - } - current = current.getSuperclass(); - } - return null; - } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index 0aea8a69a16..e0c8ccd014f 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -18,6 +18,8 @@ import io.github.dmlloyd.classfile.ClassFile; +import static dev.morphia.annotations.internal.EntityBuilder.entityBuilder; + /** * Generates a ClassFile-based {@code CritterEntityModel} implementation for a Morphia entity class. */ @@ -61,12 +63,25 @@ public EntityModelGenerator(Mapper mapper, Class type, Class standinType, while (current != null && current != Object.class) { for (Annotation a : current.getAnnotations()) { if (a.annotationType().getName().startsWith("dev.morphia.annotations.") + && !a.annotationType().getName().contains(".internal.") && registered.add(a.annotationType().getName())) { allAnnotations.add(a); } } current = current.getSuperclass(); } + // MorphiaDefaultsConvention synthesizes @Entity from @ExternalEntity; mirror that here + // so that model.getAnnotation(Entity.class) returns non-null for @ExternalEntity models. + if (this.externalEntity != null && allAnnotations.stream().noneMatch(a -> a instanceof Entity)) { + allAnnotations.add(entityBuilder() + .cap(this.externalEntity.cap()) + .concern(this.externalEntity.concern()) + .discriminator(this.externalEntity.discriminator()) + .discriminatorKey(this.externalEntity.discriminatorKey()) + .value(this.externalEntity.value()) + .useDiscriminator(this.externalEntity.useDiscriminator()) + .build()); + } this.morphiaAnnotations = List.copyOf(allAnnotations); } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 35379354032..01e0c67ca74 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -8,6 +8,7 @@ import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Array; import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; import java.util.ArrayList; @@ -42,6 +43,32 @@ public class GenerationUtils { private GenerationUtils() { } + /** + * Walks the class hierarchy looking for a non-private, non-static method named {@code name} + * with a single parameter matching {@code paramDesc}. Returns the first such method found, + * or {@code null} if none exists (including when the parameter type cannot be resolved). + */ + public static Method findSetterMethod(Class start, String name, ClassDesc paramDesc) { + Class paramType; + try { + paramType = asClass(paramDesc, safeClassLoader(start)); + } catch (RuntimeException e) { + return null; + } + Class current = start; + while (current != null && current != Object.class) { + try { + Method m = current.getDeclaredMethod(name, paramType); + if (!Modifier.isPrivate(m.getModifiers()) && !Modifier.isStatic(m.getModifiers())) { + return m; + } + } catch (NoSuchMethodException ignored) { + } + current = current.getSuperclass(); + } + return null; + } + /** * Converts a ClassDesc to a type class name string suitable for Class.forName(). */ diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index 27fd71ef302..5d2b7d09af1 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -66,9 +66,11 @@ public PropertyAccessorGenerator emit() { cb.withInterfaceSymbols(accessorDesc); // Class signature: Ljava/lang/Object;Lorg/bson/codecs/pojo/PropertyAccessor; - String propDesc = propertyType.startsWith("[") - ? propertyType.replace('.', '/') - : "L" + propertyType.replace('.', '/') + ";"; + // Primitives must use their wrapper type in generic signatures (e.g. "int" → "Ljava/lang/Integer;") + String sigType = isPrimitive() ? getWrapperType() : propertyType; + String propDesc = sigType.startsWith("[") + ? sigType.replace('.', '/') + : "L" + sigType.replace('.', '/') + ";"; String sigStr = "Ljava/lang/Object;L" + accessorDesc.descriptorString().substring(1, accessorDesc.descriptorString().length() - 1) + "<" + propDesc + ">" + ";"; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index 5c2c39dc239..e3417b66e61 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -7,8 +7,6 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.invoke.VarHandle; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.util.Map; import dev.morphia.critter.Critter; @@ -22,6 +20,8 @@ import io.github.dmlloyd.classfile.TypeKind; import io.github.dmlloyd.classfile.attribute.SignatureAttribute; +import static dev.morphia.critter.parser.generator.GenerationUtils.findSetterMethod; + /** * Generates a ClassFile-based {@link org.bson.codecs.pojo.PropertyAccessor} implementation that uses * {@link java.lang.invoke.VarHandle} (for fields) or {@link java.lang.invoke.MethodHandle} (for getter/setter pairs) @@ -104,27 +104,10 @@ public String getWrapperType() { private boolean hasSetter() { if (isFieldBased || setterName == null) return false; - Class paramClass = isPrimitive() ? PRIMITIVE_CLASSES.get(propertyType) : null; - if (paramClass == null) { - try { - paramClass = Class.forName(propertyType, false, entityClassLoader()); - } catch (ClassNotFoundException e) { - return false; - } - } - Class current = entity; - while (current != null && current != Object.class) { - try { - Method m = current.getDeclaredMethod(setterName, paramClass); - if (!Modifier.isPrivate(m.getModifiers()) && !Modifier.isStatic(m.getModifiers())) { - return true; - } - } catch (NoSuchMethodException e) { - // walk up to superclass - } - current = current.getSuperclass(); - } - return false; + ClassDesc paramDesc = isPrimitive() + ? ClassDesc.ofDescriptor(PRIMITIVE_CLASSES.get(propertyType).descriptorString()) + : ClassDesc.of(propertyType); + return findSetterMethod(entity, setterName, paramDesc) != null; } /** From e245b06796320bf0d0b0f085dad54e03178d4c02 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 18:03:49 -0400 Subject: [PATCH 27/31] Fix Session 5 review findings: array hasSetter, dead classModel param, dead annotation(), duplication - VarHandleAccessorGenerator.hasSetter(): use propertyClassDesc() instead of ClassDesc.of(), which already handles primitives, arrays, and reference types; fixes IllegalArgumentException when a getter returns an array type in METHODS mode - Remove PRIMITIVE_CLASSES map from VarHandleAccessorGenerator; now fully redundant - Move primitiveUnboxMethod() to GenerationUtils as a shared static helper; remove the duplicate from both PropertyAccessorGenerator and VarHandleAccessorGenerator - CritterGenerator.entityModel(): remove dead classModel parameter from both overloads - EntityModelGenerator: remove dead annotation() method that read from the wrong type for @ExternalEntity models and had no callers - PropertyFinder.readClassModel(): catch RuntimeException from GenerationUtils.readClassModel() and log a warning, restoring the graceful-degradation behavior from the previous ASM implementation instead of crashing hierarchy traversal on parse failures --- .../critter/parser/PropertyFinder.java | 13 ++++++--- .../parser/generator/CritterGenerator.java | 7 ++--- .../generator/EntityModelGenerator.java | 8 ------ .../parser/generator/GenerationUtils.java | 17 +++++++++++ .../generator/PropertyAccessorGenerator.java | 12 +------- .../generator/VarHandleAccessorGenerator.java | 28 ++----------------- 6 files changed, 32 insertions(+), 53 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index c45d82a7665..154c1448134 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -123,11 +123,16 @@ private List discoverAllFields(Class entityType, ClassModel classM } private ClassModel readClassModel(Class type) { - ClassModel model = GenerationUtils.readClassModel(type); - if (model == null) { - LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); + try { + ClassModel model = GenerationUtils.readClassModel(type); + if (model == null) { + LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); + } + return model; + } catch (RuntimeException e) { + LOG.warn("Failed to read bytecode for {}; hierarchy traversal stops here: {}", type.getName(), e.getMessage()); + return null; } - return model; } private List discoverFields(ClassModel classModel) { diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index aa9c4f2d441..c34e592b90d 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -48,7 +48,7 @@ public EntityModelGenerator generate(Class type, CritterClassLoader critterCl } PropertyFinder propertyFinder = new PropertyFinder(mapper, critterClassLoader, runtimeMode); - return entityModel(targetType, standinType, critterClassLoader, classModel, + return entityModel(targetType, standinType, critterClassLoader, propertyFinder.find(standinType, classModel, targetType)); } @@ -179,12 +179,11 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Class< * * @param type the entity class * @param critterClassLoader the class loader that will receive the generated bytecode - * @param classModel the ClassModel for the entity * @param properties the list of property model generators for the entity's properties * @return the emitted entity model generator */ public EntityModelGenerator entityModel(Class type, CritterClassLoader critterClassLoader, - ClassModel classModel, List properties) { + List properties) { return new EntityModelGenerator(mapper, type, type, critterClassLoader, properties).emit(); } @@ -192,7 +191,7 @@ public EntityModelGenerator entityModel(Class type, CritterClassLoader critte * Generates and registers the entity model class for the given type, reading entity-level annotations from {@code standinType}. */ public EntityModelGenerator entityModel(Class type, Class standinType, CritterClassLoader critterClassLoader, - ClassModel classModel, List properties) { + List properties) { return new EntityModelGenerator(mapper, type, standinType, critterClassLoader, properties).emit(); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java index e0c8ccd014f..b80016d31b8 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -93,14 +93,6 @@ public EntityModelGenerator(Mapper mapper, Class type, CritterClassLoader cri this(mapper, type, type, critterClassLoader, properties); } - /** - * Returns the annotation of the given type from the entity class, or {@code null} if not present. - */ - @SuppressWarnings("unchecked") - public T annotation(Class type) { - return entity.getAnnotation(type); - } - /** * Returns the fully-qualified name of the generated entity model class. */ diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index 01e0c67ca74..e8749c5df48 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -367,6 +367,23 @@ public static void emitTypeData(TypeData data, CodeBuilder cod) { MethodTypeDesc.ofDescriptor("(Ljava/lang/Class;[Ldev/morphia/mapping/codec/pojo/TypeData;)V")); } + /** + * Returns the unboxing method name for the given primitive type name (e.g. {@code "int"} → {@code "intValue"}). + */ + public static String primitiveUnboxMethod(String primitiveTypeName) { + return switch (primitiveTypeName) { + case "boolean" -> "booleanValue"; + case "byte" -> "byteValue"; + case "char" -> "charValue"; + case "short" -> "shortValue"; + case "int" -> "intValue"; + case "long" -> "longValue"; + case "float" -> "floatValue"; + case "double" -> "doubleValue"; + default -> throw new IllegalArgumentException("Not a primitive: " + primitiveTypeName); + }; + } + /** * Resolves a ClassDesc to a Class using the given class loader. */ diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index 5d2b7d09af1..b6231f4315c 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -144,16 +144,6 @@ private String primitiveDescriptor() { } private String primitiveUnboxMethod() { - return switch (propertyType) { - case "boolean" -> "booleanValue"; - case "byte" -> "byteValue"; - case "char" -> "charValue"; - case "short" -> "shortValue"; - case "int" -> "intValue"; - case "long" -> "longValue"; - case "float" -> "floatValue"; - case "double" -> "doubleValue"; - default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); - }; + return GenerationUtils.primitiveUnboxMethod(propertyType); } } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index e3417b66e61..bf6188f88bf 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -7,7 +7,6 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.invoke.VarHandle; -import java.util.Map; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -28,16 +27,6 @@ * to access a single property of a Morphia entity class. */ public class VarHandleAccessorGenerator extends BaseGenerator { - private static final Map> PRIMITIVE_CLASSES = Map.of( - "boolean", boolean.class, - "byte", byte.class, - "char", char.class, - "short", short.class, - "int", int.class, - "long", long.class, - "float", float.class, - "double", double.class); - private final String propertyName; private final String propertyType; private final boolean isFieldBased; @@ -104,10 +93,7 @@ public String getWrapperType() { private boolean hasSetter() { if (isFieldBased || setterName == null) return false; - ClassDesc paramDesc = isPrimitive() - ? ClassDesc.ofDescriptor(PRIMITIVE_CLASSES.get(propertyType).descriptorString()) - : ClassDesc.of(propertyType); - return findSetterMethod(entity, setterName, paramDesc) != null; + return findSetterMethod(entity, setterName, propertyClassDesc()) != null; } /** @@ -407,16 +393,6 @@ private ClassDesc propertyClassDesc() { } private String primitiveUnboxMethod() { - return switch (propertyType) { - case "boolean" -> "booleanValue"; - case "byte" -> "byteValue"; - case "char" -> "charValue"; - case "short" -> "shortValue"; - case "int" -> "intValue"; - case "long" -> "longValue"; - case "float" -> "floatValue"; - case "double" -> "doubleValue"; - default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); - }; + return GenerationUtils.primitiveUnboxMethod(propertyType); } } From 574e5998e1403a09a7ffc38f9e4f05e8ffb177bf Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 18:57:19 -0400 Subject: [PATCH 28/31] Unify primitive ClassDesc lookup into GenerationUtils.primitiveClassDesc() Removes the private primitiveDescriptor() switch from PropertyAccessorGenerator and the duplicate switch in VarHandleAccessorGenerator.propertyClassDesc(), replacing both with a single GenerationUtils.primitiveClassDesc(String) helper that returns the appropriate ConstantDescs.CD_* value directly. --- .../parser/generator/GenerationUtils.java | 17 +++++++++++ .../generator/PropertyAccessorGenerator.java | 16 +--------- .../generator/VarHandleAccessorGenerator.java | 30 +++++++------------ 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java index e8749c5df48..34b254e2633 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -384,6 +384,23 @@ public static String primitiveUnboxMethod(String primitiveTypeName) { }; } + /** + * Returns the {@link ClassDesc} for a primitive type name (e.g. {@code "int"} → {@link ConstantDescs#CD_int}). + */ + public static ClassDesc primitiveClassDesc(String primitiveTypeName) { + return switch (primitiveTypeName) { + case "boolean" -> ConstantDescs.CD_boolean; + case "byte" -> ConstantDescs.CD_byte; + case "char" -> ConstantDescs.CD_char; + case "short" -> ConstantDescs.CD_short; + case "int" -> ConstantDescs.CD_int; + case "long" -> ConstantDescs.CD_long; + case "float" -> ConstantDescs.CD_float; + case "double" -> ConstantDescs.CD_double; + default -> throw new IllegalArgumentException("Not a primitive: " + primitiveTypeName); + }; + } + /** * Resolves a ClassDesc to a Class using the given class loader. */ diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index b6231f4315c..b0bdf879cce 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -50,7 +50,7 @@ public PropertyAccessorGenerator emit() { ClassDesc entityDesc = ClassDesc.of(entity.getName()); ClassDesc propertyDesc; if (isPrimitive()) { - propertyDesc = ClassDesc.ofDescriptor(primitiveDescriptor()); + propertyDesc = GenerationUtils.primitiveClassDesc(propertyType); } else if (propertyType.startsWith("[")) { propertyDesc = ClassDesc.ofDescriptor(propertyType.replace('.', '/')); } else { @@ -129,20 +129,6 @@ public PropertyAccessorGenerator emit() { return this; } - private String primitiveDescriptor() { - return switch (propertyType) { - case "boolean" -> "Z"; - case "byte" -> "B"; - case "char" -> "C"; - case "short" -> "S"; - case "int" -> "I"; - case "long" -> "J"; - case "float" -> "F"; - case "double" -> "D"; - default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); - }; - } - private String primitiveUnboxMethod() { return GenerationUtils.primitiveUnboxMethod(propertyType); } diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index bf6188f88bf..4ec1215b243 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -19,7 +19,11 @@ import io.github.dmlloyd.classfile.TypeKind; import io.github.dmlloyd.classfile.attribute.SignatureAttribute; +import static dev.morphia.critter.parser.generator.GenerationUtils.PRIMITIVE_TO_WRAPPER; +import static dev.morphia.critter.parser.generator.GenerationUtils.emitClassRef; import static dev.morphia.critter.parser.generator.GenerationUtils.findSetterMethod; +import static dev.morphia.critter.parser.generator.GenerationUtils.primitiveClassDesc; +import static dev.morphia.critter.parser.generator.GenerationUtils.typeClassName; /** * Generates a ClassFile-based {@link org.bson.codecs.pojo.PropertyAccessor} implementation that uses @@ -44,7 +48,7 @@ public class VarHandleAccessorGenerator extends BaseGenerator { public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.propertyName = field.name(); - this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); this.isFieldBased = true; this.isFinalField = (field.access() & ClassFile.ACC_FINAL) != 0; this.getterName = null; @@ -63,7 +67,7 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); String returnDesc = java.lang.constant.MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); - this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(returnDesc)); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); this.isFieldBased = false; this.isFinalField = false; this.getterName = method.name(); @@ -77,7 +81,7 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla * @return {@code true} if the property type is primitive */ public boolean isPrimitive() { - return GenerationUtils.PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); } /** @@ -87,7 +91,7 @@ public boolean isPrimitive() { * @return the wrapper type name */ public String getWrapperType() { - return GenerationUtils.PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } private boolean hasSetter() { @@ -281,7 +285,7 @@ public VarHandleAccessorGenerator emit() { // Use reflection to set final field ClassDesc fieldDesc2 = ClassDesc.of("java.lang.reflect.Field"); cod.trying(tryBody -> { - GenerationUtils.emitClassRef(tryBody, entity); + emitClassRef(tryBody, entity); tryBody.ldc(propertyName); tryBody.invokevirtual(ConstantDescs.CD_Class, "getDeclaredField", MethodTypeDesc.of(fieldDesc2, ConstantDescs.CD_String)); @@ -368,23 +372,9 @@ private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String t } } - private ClassLoader entityClassLoader() { - return GenerationUtils.safeClassLoader(entity); - } - private ClassDesc propertyClassDesc() { if (isPrimitive()) { - return switch (propertyType) { - case "boolean" -> ConstantDescs.CD_boolean; - case "byte" -> ConstantDescs.CD_byte; - case "char" -> ConstantDescs.CD_char; - case "short" -> ConstantDescs.CD_short; - case "int" -> ConstantDescs.CD_int; - case "long" -> ConstantDescs.CD_long; - case "float" -> ConstantDescs.CD_float; - case "double" -> ConstantDescs.CD_double; - default -> throw new IllegalArgumentException("Not a primitive: " + propertyType); - }; + return primitiveClassDesc(propertyType); } if (propertyType.startsWith("[")) { return ClassDesc.ofDescriptor(propertyType.replace('.', '/')); From 00445d1e799589db4ae5112f3be860c09832fa76 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Sun, 21 Jun 2026 18:58:15 -0400 Subject: [PATCH 29/31] static imports --- .../generator/PropertyAccessorGenerator.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java index b0bdf879cce..b573a2f1ffc 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -14,6 +14,10 @@ import io.github.dmlloyd.classfile.ClassSignature; import io.github.dmlloyd.classfile.attribute.SignatureAttribute; +import static dev.morphia.critter.parser.generator.GenerationUtils.PRIMITIVE_TO_WRAPPER; +import static dev.morphia.critter.parser.generator.GenerationUtils.primitiveClassDesc; +import static dev.morphia.critter.parser.generator.GenerationUtils.typeClassName; + /** * Generates a {@link org.bson.codecs.pojo.PropertyAccessor} implementation for a single * entity property, delegating to the synthetic {@code __readXxx}/{@code __writeXxx} methods. @@ -25,7 +29,7 @@ public class PropertyAccessorGenerator extends BaseGenerator { public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { super(entity, critterClassLoader); this.propertyName = field.name(); - this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } @@ -33,16 +37,16 @@ public PropertyAccessorGenerator(Class entity, CritterClassLoader critterClas super(entity, critterClassLoader); this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); String returnDesc = MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); - this.propertyType = GenerationUtils.typeClassName(ClassDesc.ofDescriptor(returnDesc)); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } public boolean isPrimitive() { - return GenerationUtils.PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); } public String getWrapperType() { - return GenerationUtils.PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } public PropertyAccessorGenerator emit() { @@ -50,7 +54,7 @@ public PropertyAccessorGenerator emit() { ClassDesc entityDesc = ClassDesc.of(entity.getName()); ClassDesc propertyDesc; if (isPrimitive()) { - propertyDesc = GenerationUtils.primitiveClassDesc(propertyType); + propertyDesc = primitiveClassDesc(propertyType); } else if (propertyType.startsWith("[")) { propertyDesc = ClassDesc.ofDescriptor(propertyType.replace('.', '/')); } else { From dcf9f3a1ba03e32bc8e2c6f7f563b208bf4dcbbf Mon Sep 17 00:00:00 2001 From: evanchooly Date: Mon, 22 Jun 2026 21:32:12 -0400 Subject: [PATCH 30/31] Fix Session 7 review findings: array VarHandle fallback, fluent setter, setter param-type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VarHandleAccessorGenerator: add explicit guard in emit() for array-typed properties with a documenting comment — the BSON codec provides Object[] at runtime which VarHandle rejects; explicit throw replaces the accidental ClassDesc.of() throw that was previously causing the reflection fallback - VarHandleAccessorGenerator: fix fluent setter NoSuchMethodException by storing the reflected setter Method at construction time and emitting its actual return type in the findVirtual lookup instead of hardcoded void; MethodHandle.invoke() silently discards the non-void return - PropertyModelGenerator: replace private findSetterMethod(Class,String) — which ignored parameter type and could pick a wrong overloaded setter — with GenerationUtils.findSetterMethod(Class,String,ClassDesc) which validates the parameter type against the getter's return type --- .../generator/PropertyModelGenerator.java | 22 +++++--------- .../generator/VarHandleAccessorGenerator.java | 29 +++++++++++++------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 70f19c6b8c3..527303573c9 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -98,7 +98,14 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class an this.annotationMap = buildAnnotationMap(reflectedMethod != null ? reflectedMethod.getAnnotations() : new Annotation[0]); // Also collect setter annotations — some annotations (e.g. @Version, @Text) live on the setter, not the getter String setterName = "set" + Critter.titleCase(this.propertyName); - Method reflectedSetter = findSetterMethod(annotationSource, setterName); + Method reflectedSetter = null; + if (reflectedMethod != null) { + Class paramType = reflectedMethod.getReturnType(); + ClassDesc paramDesc = paramType.isPrimitive() + ? GenerationUtils.primitiveClassDesc(paramType.getName()) + : ClassDesc.of(paramType.getName()); + reflectedSetter = GenerationUtils.findSetterMethod(annotationSource, setterName, paramDesc); + } if (reflectedSetter != null) { for (Annotation ann : reflectedSetter.getAnnotations()) { this.annotationMap.putIfAbsent(ann.annotationType().getName(), ann); @@ -140,19 +147,6 @@ private static Method findMethod(Class cls, String name) { return null; } - private static Method findSetterMethod(Class cls, String setterName) { - Class current = cls; - while (current != null && current != Object.class) { - for (Method m : current.getDeclaredMethods()) { - if (m.getName().equals(setterName) && m.getParameterCount() == 1 && !m.isBridge()) { - return m; - } - } - current = current.getSuperclass(); - } - return null; - } - private static Map buildAnnotationMap(Annotation[] annotations) { Map map = new LinkedHashMap<>(); for (Annotation ann : annotations) { diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java index 4ec1215b243..93f26e7e115 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -7,6 +7,7 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.invoke.VarHandle; +import java.lang.reflect.Method; import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; @@ -37,6 +38,7 @@ public class VarHandleAccessorGenerator extends BaseGenerator { private final boolean isFinalField; private final String getterName; private final String setterName; + private final Method setterMethod; /** * Creates a generator for a field-based property accessor using a {@link java.lang.invoke.VarHandle}. @@ -53,6 +55,7 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla this.isFinalField = (field.access() & ClassFile.ACC_FINAL) != 0; this.getterName = null; this.setterName = null; + this.setterMethod = null; generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } @@ -72,6 +75,7 @@ public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterCla this.isFinalField = false; this.getterName = method.name(); this.setterName = "set%s".formatted(Critter.titleCase(propertyName)); + this.setterMethod = findSetterMethod(entity, this.setterName, propertyClassDesc()); generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); } @@ -94,12 +98,6 @@ public String getWrapperType() { return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); } - private boolean hasSetter() { - if (isFieldBased || setterName == null) - return false; - return findSetterMethod(entity, setterName, propertyClassDesc()) != null; - } - /** * Emits the generated property accessor class and returns this generator. * @@ -109,6 +107,14 @@ public VarHandleAccessorGenerator emit() { ClassDesc thisDesc = ClassDesc.of(generatedType); ClassDesc accessorDesc = ClassDesc.of("org.bson.codecs.pojo.PropertyAccessor"); ClassDesc propertyDesc = propertyClassDesc(); + // Array-typed properties are not supported by VarHandle accessors because the BSON codec + // provides Object[] values at runtime which VarHandle rejects with a ClassCastException. + // Throwing here causes CritterMapper.tryRuntimeGeneration() to fall back to reflection + // for the entity, which handles array types correctly. + if (propertyType.startsWith("[")) { + throw new IllegalArgumentException( + "Array-typed properties cannot use VarHandle accessors (codec provides Object[]): " + propertyName); + } ClassDesc wrapperDesc = ClassDesc.of(getWrapperType()); ClassDesc varHandleDesc = ClassDesc.of(VarHandle.class.getName()); @@ -120,7 +126,7 @@ public VarHandleAccessorGenerator emit() { ClassDesc rteDesc = ClassDesc.of(RuntimeException.class.getName()); boolean useVarHandle = isFieldBased; - boolean useSetterHandle = !isFieldBased && hasSetter(); + boolean useSetterHandle = setterMethod != null; byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); @@ -211,11 +217,16 @@ public VarHandleAccessorGenerator emit() { tryBody.putfield(thisDesc, "getterHandle", methodHandleDesc); if (useSetterHandle) { - // privateLookup.findVirtual(entityClass, setterName, MethodType.methodType(void.class, paramType)) + // privateLookup.findVirtual(entityClass, setterName, MethodType.methodType(returnType, paramType)) + // Use the actual setter return type (void for normal setters, non-void for fluent setters) + Class setterReturnType = setterMethod.getReturnType(); + ClassDesc setterReturnDesc = setterReturnType == void.class ? ConstantDescs.CD_void + : setterReturnType.isPrimitive() ? primitiveClassDesc(setterReturnType.getName()) + : ClassDesc.of(setterReturnType.getName()); tryBody.aload(privateLookupSlot); tryBody.aload(entityClassSlot); tryBody.ldc(setterName); - tryBody.loadConstant(ConstantDescs.CD_void); + tryBody.loadConstant(setterReturnDesc); emitLoadClass(tryBody, propertyType, propertyDesc, tcclSlot); tryBody.invokestatic(methodTypeDesc2, "methodType", MethodTypeDesc.of(methodTypeDesc2, ConstantDescs.CD_Class, ConstantDescs.CD_Class)); From f8d3d28466500f5193c9503969144e8d3791ab2d Mon Sep 17 00:00:00 2001 From: evanchooly Date: Tue, 23 Jun 2026 21:33:52 -0400 Subject: [PATCH 31/31] Replace VarHandle accessors with hidden nestmate class approach Uses MethodHandles.privateLookupIn(declaringClass).defineHiddenClass(..., NESTMATE) to generate PropertyAccessor impls with direct getfield/putfield bytecode. This grants private-member access across the class hierarchy without requiring --add-opens or VarHandle setup overhead. Key fixes included: - Resolve declaring class via hierarchy walk so superclass private fields are accessible even when the accessor is instantiated from a subclass - Exclude static fields from property discovery to avoid IncompatibleClassChangeError - Guard PropertyModel.getValue against null proxy unwrap result (deleted referent) - Exclude NestmateAccessorRegistry from CritterClassLoader child-first loading so it remains a single shared instance across all classloaders --- .../morphia/critter/CritterClassLoader.java | 8 +- .../dev/morphia/critter/parser/FieldInfo.java | 7 +- .../critter/parser/PropertyFinder.java | 19 +- .../parser/generator/CritterGenerator.java | 72 +++-- .../generator/NestmateAccessorGenerator.java | 254 ++++++++++++++++++ .../generator/NestmateAccessorRegistry.java | 24 ++ .../generator/PropertyModelGenerator.java | 56 +++- .../dev/morphia/mapping/CritterMapper.java | 2 +- .../mapping/codec/pojo/PropertyModel.java | 3 + .../critter/parser/TestVarHandleAccessor.java | 49 ++-- 10 files changed, 428 insertions(+), 66 deletions(-) create mode 100644 core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorGenerator.java create mode 100644 core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorRegistry.java diff --git a/core/src/main/java/dev/morphia/critter/CritterClassLoader.java b/core/src/main/java/dev/morphia/critter/CritterClassLoader.java index 8ece70b2b65..5a752481ce9 100644 --- a/core/src/main/java/dev/morphia/critter/CritterClassLoader.java +++ b/core/src/main/java/dev/morphia/critter/CritterClassLoader.java @@ -90,8 +90,12 @@ protected Class findClass(String name) throws ClassNotFoundException { private boolean shouldRegister(String className) { // Only register classes from the dev.morphia.critter package - // This avoids SecurityException (java.*, javax.*) and LinkageError (third-party libs) - return className.startsWith("dev.morphia.critter."); + // This avoids SecurityException (java.*, javax.*) and LinkageError (third-party libs). + // NestmateAccessorRegistry must be excluded: it uses a static map that must be shared across + // classloaders (the generator registers via the parent CL; generated models read via this CL). + // Excluding it here lets ChildFirst delegation fall back to the parent for a single shared instance. + return className.startsWith("dev.morphia.critter.") + && !className.equals("dev.morphia.critter.parser.generator.NestmateAccessorRegistry"); } /** diff --git a/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java b/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java index 5eb3486dece..08094e6a3fa 100644 --- a/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java +++ b/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java @@ -13,5 +13,10 @@ public record FieldInfo( String desc, String signature, int access, - List visibleAnnotations) { + List visibleAnnotations, + Class declaringClass) { + + public FieldInfo(String name, String desc, String signature, int access, List visibleAnnotations) { + this(name, desc, signature, access, visibleAnnotations, null); + } } diff --git a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java index 154c1448134..0618af654c1 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -73,11 +73,11 @@ public List find(Class standinType, ClassModel classM } for (FieldInfo field : fields) { if (runtimeMode) { - critterGenerator.varHandleAccessor(targetType, classLoader, field); + critterGenerator.nestmateAccessor(targetType, field); } else { critterGenerator.propertyAccessor(targetType, classLoader, field); } - models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, field)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, field, runtimeMode)); } } else { if (!runtimeMode) { @@ -85,11 +85,11 @@ public List find(Class standinType, ClassModel classM } for (MethodInfo method : methods) { if (runtimeMode) { - critterGenerator.varHandleAccessor(targetType, classLoader, method); + critterGenerator.nestmateAccessor(targetType, method); } else { critterGenerator.propertyAccessor(targetType, classLoader, method); } - models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, method)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, method, runtimeMode)); } } return models; @@ -111,7 +111,8 @@ private List discoverAllFields(Class entityType, ClassModel classM ClassModel model = currentModel != null ? currentModel : readClassModel(current); if (model == null) break; - for (FieldInfo field : discoverFields(model)) { + final Class declaringClass = current; + for (FieldInfo field : discoverFields(model, declaringClass)) { if (seen.putIfAbsent(field.name(), Boolean.TRUE) == null) { fields.add(field); } @@ -135,14 +136,15 @@ private ClassModel readClassModel(Class type) { } } - private List discoverFields(ClassModel classModel) { + private List discoverFields(ClassModel classModel, Class declaringClass) { List transientDescs = CritterParser.INSTANCE.transientAnnotations(); List result = new ArrayList<>(); for (FieldModel field : classModel.fields()) { List visible = visibleAnnotations(field); boolean isTransient = (field.flags().flagsMask() & ClassFile.ACC_TRANSIENT) != 0 || visible.stream().map(a -> a.classSymbol().descriptorString()).anyMatch(transientDescs::contains); - if (!isTransient && isPropertyAnnotated(visible, true)) { + boolean isStatic = (field.flags().flagsMask() & ClassFile.ACC_STATIC) != 0; + if (!isTransient && !isStatic && isPropertyAnnotated(visible, true)) { String sig = field.findAttribute(signature()) .map(a -> a.signature().stringValue()) .orElse(null); @@ -151,7 +153,8 @@ private List discoverFields(ClassModel classModel) { field.fieldType().stringValue(), sig, field.flags().flagsMask(), - visible)); + visible, + declaringClass)); } } return result; diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java index c34e592b90d..78350dd6f54 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -1,5 +1,7 @@ package dev.morphia.critter.parser.generator; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; import java.util.List; import dev.morphia.annotations.ExternalEntity; @@ -9,6 +11,8 @@ import dev.morphia.critter.parser.PropertyFinder; import dev.morphia.mapping.Mapper; +import org.bson.codecs.pojo.PropertyAccessor; + import io.github.dmlloyd.classfile.ClassModel; /** @@ -32,7 +36,7 @@ public CritterGenerator(Mapper mapper) { * * @param type the entity class to process * @param critterClassLoader the class loader that will receive generated bytecode - * @param runtimeMode {@code true} to generate VarHandle-based accessors for runtime use + * @param runtimeMode {@code true} to generate hidden nestmate accessors for runtime use * @return the generated entity model generator */ public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader, boolean runtimeMode) { @@ -111,27 +115,45 @@ public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterCl } /** - * Generates and registers a VarHandle-based accessor for the given field (used in runtime mode). + * Generates a nestmate accessor for the given field and registers the instance in + * {@link NestmateAccessorRegistry}. The accessor is defined as a hidden nestmate of + * {@code entityType} so it can directly access private fields via {@code getfield}/{@code putfield}. * - * @param entityType the entity class that owns the field - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param field the field for which a VarHandle accessor should be generated - * @return the emitted VarHandle accessor generator + * @param entityType the entity class that owns the field + * @param field the field for which a nestmate accessor should be generated */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { - return new VarHandleAccessorGenerator(entityType, critterClassLoader, field).emit(); + public void nestmateAccessor(Class entityType, FieldInfo field) { + defineNestmate(entityType, new NestmateAccessorGenerator(entityType, field)); } /** - * Generates and registers a VarHandle-based accessor for the property exposed by the given getter method (used in runtime mode). + * Generates a nestmate accessor for the property exposed by the given getter method and registers + * the instance in {@link NestmateAccessorRegistry}. * - * @param entityType the entity class that owns the method - * @param critterClassLoader the class loader that will receive the generated bytecode - * @param method the getter method for which a VarHandle accessor should be generated - * @return the emitted VarHandle accessor generator + * @param entityType the entity class that owns the method + * @param method the getter method for which a nestmate accessor should be generated */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { - return new VarHandleAccessorGenerator(entityType, critterClassLoader, method).emit(); + public void nestmateAccessor(Class entityType, MethodInfo method) { + defineNestmate(entityType, new NestmateAccessorGenerator(entityType, method)); + } + + private void defineNestmate(Class entityType, NestmateAccessorGenerator gen) { + try { + byte[] bytes = gen.generate(); + // Use the declaring class for privateLookupIn so the hidden nestmate can access + // private fields declared in a superclass (not just in the leaf entity class). + Lookup privateLookup = MethodHandles.privateLookupIn(gen.declaringClass, MethodHandles.lookup()); + Class accessorClass = privateLookup + .defineHiddenClass(bytes, true, Lookup.ClassOption.NESTMATE) + .lookupClass(); + @SuppressWarnings("unchecked") + PropertyAccessor instance = (PropertyAccessor) accessorClass.getDeclaredConstructor().newInstance(); + NestmateAccessorRegistry.register(gen.registryKey, instance); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } } /** @@ -154,6 +176,16 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Class< return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, field).emit(); } + /** + * Generates and registers a {@link PropertyModelGenerator} for the given field, reading annotations from {@code annotationSource}. + * + * @param runtimeMode {@code true} to use the nestmate accessor registry instead of generating {@code new} bytecode + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, Class annotationSource, + CritterClassLoader critterClassLoader, FieldInfo field, boolean runtimeMode) { + return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, field, runtimeMode).emit(); + } + /** * Generates and registers a {@link PropertyModelGenerator} for the property exposed by the given getter method. * @@ -174,6 +206,16 @@ public PropertyModelGenerator propertyModelGenerator(Class entityType, Class< return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, method).emit(); } + /** + * Generates and registers a {@link PropertyModelGenerator} for the given method, reading annotations from {@code annotationSource}. + * + * @param runtimeMode {@code true} to use the nestmate accessor registry instead of generating {@code new} bytecode + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, Class annotationSource, + CritterClassLoader critterClassLoader, MethodInfo method, boolean runtimeMode) { + return new PropertyModelGenerator(mapper.getConfig(), entityType, annotationSource, critterClassLoader, method, runtimeMode).emit(); + } + /** * Generates and registers the entity model class for the given type. * diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorGenerator.java new file mode 100644 index 00000000000..bc7860c4f7e --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorGenerator.java @@ -0,0 +1,254 @@ +package dev.morphia.critter.parser.generator; + +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Method; + +import dev.morphia.critter.Critter; +import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.FieldInfo; +import dev.morphia.critter.parser.MethodInfo; + +import io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassSignature; +import io.github.dmlloyd.classfile.attribute.SignatureAttribute; + +import static dev.morphia.critter.parser.generator.GenerationUtils.PRIMITIVE_TO_WRAPPER; +import static dev.morphia.critter.parser.generator.GenerationUtils.findSetterMethod; +import static dev.morphia.critter.parser.generator.GenerationUtils.primitiveClassDesc; +import static dev.morphia.critter.parser.generator.GenerationUtils.primitiveUnboxMethod; +import static dev.morphia.critter.parser.generator.GenerationUtils.typeClassName; + +/** + * Generates a {@link org.bson.codecs.pojo.PropertyAccessor} implementation that uses direct + * {@code getfield}/{@code putfield} (for fields) or {@code invokevirtual} (for getter/setter methods) + * to access entity properties. The generated class must be defined as a hidden nestmate of the entity + * class so the JVM grants it private-member access. + * + *

+ * Array-typed properties are not supported: the BSON codec provides {@code Object[]} at runtime, + * which is incompatible with {@code putfield} to a typed array field. Callers should throw + * to trigger {@link dev.morphia.mapping.CritterMapper} fallback to reflection for such entities. + */ +public class NestmateAccessorGenerator { + private final Class entity; + // declaringClass: the class that actually declares the field (may be a superclass of entity) + final Class declaringClass; + private final String propertyName; + private final String propertyType; + private final boolean isFieldBased; + private final boolean isFinalField; + private final String fieldOrGetterName; + private final String setterName; + private final Method setterMethod; + // registryKey: stable key used for NestmateAccessorRegistry (critter subpackage name) + final String registryKey; + // bytecodeName: class name embedded in the bytecode; MUST be in the declaringClass's package for defineHiddenClass + private final String bytecodeName; + + public NestmateAccessorGenerator(Class entity, FieldInfo field) { + this.entity = entity; + this.declaringClass = findDeclaringClassInHierarchy(entity, field.name()); + this.propertyName = field.name(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.isFieldBased = true; + this.isFinalField = (field.access() & ClassFile.ACC_FINAL) != 0; + this.fieldOrGetterName = field.name(); + this.setterName = null; + this.setterMethod = null; + this.registryKey = "%s.%sAccessor".formatted(Critter.critterPackage(entity), Critter.titleCase(propertyName)); + this.bytecodeName = entityPackagePrefix(declaringClass) + declaringClass.getSimpleName() + "$$" + Critter.titleCase(propertyName) + + "Accessor"; + } + + public NestmateAccessorGenerator(Class entity, MethodInfo method) { + this.entity = entity; + this.declaringClass = entity; + this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); + String returnDesc = MethodTypeDesc.ofDescriptor(method.desc()).returnType().descriptorString(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(returnDesc)); + this.isFieldBased = false; + this.isFinalField = false; + this.fieldOrGetterName = method.name(); + this.setterName = "set%s".formatted(Critter.titleCase(propertyName)); + this.setterMethod = findSetterMethod(entity, this.setterName, propertyClassDesc()); + this.registryKey = "%s.%sAccessor".formatted(Critter.critterPackage(entity), Critter.titleCase(propertyName)); + this.bytecodeName = entityPackagePrefix(entity) + entity.getSimpleName() + "$$" + Critter.titleCase(propertyName) + "Accessor"; + } + + private static Class findDeclaringClassInHierarchy(Class entity, String fieldName) { + Class current = entity; + while (current != null && current != Object.class) { + try { + current.getDeclaredField(fieldName); + return current; + } catch (NoSuchFieldException ignored) { + current = current.getSuperclass(); + } + } + return entity; + } + + private static String entityPackagePrefix(Class entity) { + String pkg = entity.getPackageName(); + return pkg.isEmpty() ? "" : pkg + "."; + } + + boolean isPrimitive() { + return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + } + + String getWrapperType() { + return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + } + + /** + * Generates the accessor bytecode. + * + * @throws IllegalArgumentException if the property type is an array (BSON codec provides {@code Object[]} + * which is incompatible with typed array putfield at runtime) + */ + public byte[] generate() { + if (propertyType.startsWith("[")) { + throw new IllegalArgumentException( + "Array-typed properties cannot use nestmate accessors (codec provides Object[]): " + propertyName); + } + + ClassDesc thisDesc = ClassDesc.of(bytecodeName); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); + // For field access, use the declaring class (may differ from entity when field is in a superclass) + ClassDesc fieldOwnerDesc = ClassDesc.of(declaringClass.getName()); + ClassDesc propertyDesc = propertyClassDesc(); + ClassDesc wrapperDesc = ClassDesc.of(getWrapperType()); + ClassDesc accessorDesc = ClassDesc.of("org.bson.codecs.pojo.PropertyAccessor"); + + boolean useSetterHandle = !isFieldBased && setterMethod != null; + + return ClassFile.of().build(thisDesc, cb -> { + cb.withVersion(ClassFile.JAVA_17_VERSION, 0); + cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); + cb.withSuperclass(ConstantDescs.CD_Object); + cb.withInterfaceSymbols(accessorDesc); + + String sigType = isPrimitive() ? getWrapperType() : propertyType; + String propDesc = sigType.startsWith("[") + ? sigType.replace('.', '/') + : "L" + sigType.replace('.', '/') + ";"; + String sigStr = "Ljava/lang/Object;L" + + accessorDesc.descriptorString().substring(1, accessorDesc.descriptorString().length() - 1) + + "<" + propDesc + ">" + ";"; + cb.with(SignatureAttribute.of(ClassSignature.parseFrom(sigStr))); + + // no-arg constructor + cb.withMethodBody("", MethodTypeDesc.ofDescriptor("()V"), ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.invokespecial(ConstantDescs.CD_Object, "", MethodTypeDesc.ofDescriptor("()V")); + cod.return_(); + }); + + // get(Object model): Object + cb.withMethodBody("get", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;)Ljava/lang/Object;"), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(1); + // For field-based access, cast to the declaring class (not the leaf entity): + // the nestmate is defined in the declaring class's nest, so it can reference + // the declaring class but may not be able to reference a private nested leaf class. + cod.checkcast(isFieldBased ? fieldOwnerDesc : entityDesc); + if (isFieldBased) { + cod.getfield(fieldOwnerDesc, fieldOrGetterName, propertyDesc); + } else { + MethodTypeDesc getterMtd = MethodTypeDesc.of(propertyDesc); + cod.invokevirtual(entityDesc, fieldOrGetterName, getterMtd); + } + if (isPrimitive()) { + cod.invokestatic(wrapperDesc, "valueOf", MethodTypeDesc.of(wrapperDesc, propertyDesc)); + } + cod.areturn(); + }); + + // set(Object model, Object value): void + cb.withMethodBody("set", MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V"), + ClassFile.ACC_PUBLIC, cod -> { + if (!isFieldBased && !useSetterHandle) { + cod.new_(ClassDesc.of("java.lang.UnsupportedOperationException")); + cod.dup(); + cod.ldc("Property '%s' is read-only".formatted(propertyName)); + cod.invokespecial(ClassDesc.of("java.lang.UnsupportedOperationException"), "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + cod.athrow(); + return; + } + + if (isFinalField) { + // putfield cannot write to final fields outside ; use reflection + ClassDesc fieldDesc2 = ClassDesc.of("java.lang.reflect.Field"); + ClassDesc rteDesc = ClassDesc.of("java.lang.RuntimeException"); + cod.trying(tryBody -> { + GenerationUtils.emitClassRef(tryBody, declaringClass); + tryBody.ldc(fieldOrGetterName); + tryBody.invokevirtual(ConstantDescs.CD_Class, "getDeclaredField", + MethodTypeDesc.of(fieldDesc2, ConstantDescs.CD_String)); + int fieldSlot = tryBody.allocateLocal(io.github.dmlloyd.classfile.TypeKind.REFERENCE); + tryBody.astore(fieldSlot); + tryBody.aload(fieldSlot); + tryBody.iconst_1(); + tryBody.invokevirtual(fieldDesc2, "setAccessible", + MethodTypeDesc.ofDescriptor("(Z)V")); + tryBody.aload(fieldSlot); + tryBody.aload(1); + tryBody.aload(2); + tryBody.invokevirtual(fieldDesc2, "set", + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/Object;)V")); + tryBody.return_(); + }, 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(fieldOrGetterName)); + catchBody.aload(3); + catchBody.invokespecial(rteDesc, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;Ljava/lang/Throwable;)V")); + catchBody.athrow(); + })); + return; + } + + cod.aload(1); + cod.checkcast(isFieldBased ? fieldOwnerDesc : entityDesc); + if (isPrimitive()) { + cod.aload(2); + cod.checkcast(wrapperDesc); + cod.invokevirtual(wrapperDesc, primitiveUnboxMethod(propertyType), MethodTypeDesc.of(propertyDesc)); + } else { + cod.aload(2); + cod.checkcast(propertyDesc); + } + + if (isFieldBased) { + cod.putfield(fieldOwnerDesc, fieldOrGetterName, propertyDesc); + } else { + Class retType = setterMethod.getReturnType(); + ClassDesc retDesc = retType == void.class ? ConstantDescs.CD_void + : retType.isPrimitive() ? primitiveClassDesc(retType.getName()) + : ClassDesc.of(retType.getName()); + cod.invokevirtual(entityDesc, setterName, MethodTypeDesc.of(retDesc, propertyDesc)); + if (retType != void.class) { + cod.pop(); + } + } + cod.return_(); + }); + }); + } + + private ClassDesc propertyClassDesc() { + if (isPrimitive()) { + return primitiveClassDesc(propertyType); + } + if (propertyType.startsWith("[")) { + return ClassDesc.ofDescriptor(propertyType.replace('.', '/')); + } + return ClassDesc.of(propertyType); + } +} diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorRegistry.java b/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorRegistry.java new file mode 100644 index 00000000000..02227329198 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/NestmateAccessorRegistry.java @@ -0,0 +1,24 @@ +package dev.morphia.critter.parser.generator; + +import java.util.concurrent.ConcurrentHashMap; + +import org.bson.codecs.pojo.PropertyAccessor; + +/** + * Static registry mapping stable accessor names to pre-instantiated {@link PropertyAccessor} instances + * for hidden nestmate accessor classes (which cannot be looked up by name via {@link Class#forName}). + */ +public final class NestmateAccessorRegistry { + private static final ConcurrentHashMap> INSTANCES = new ConcurrentHashMap<>(); + + private NestmateAccessorRegistry() { + } + + public static void register(String key, PropertyAccessor accessor) { + INSTANCES.put(key, accessor); + } + + public static PropertyAccessor get(String key) { + return INSTANCES.get(key); + } +} diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java index 527303573c9..5d9ca8c4557 100644 --- a/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -43,6 +43,7 @@ public class PropertyModelGenerator extends BaseGenerator { private final String propertyName; private final String accessorType; private final boolean isFieldBased; + private final boolean runtimeMode; private final int accessFlags; private final java.lang.reflect.Type genericType; private final Map annotationMap; @@ -53,12 +54,14 @@ public class PropertyModelGenerator extends BaseGenerator { * Creates a generator for a field-based property. * * @param annotationSource the class to reflect on for field annotations (may differ from entity for @ExternalEntity stand-ins) + * @param runtimeMode {@code true} to use the nestmate accessor registry instead of generating {@code new} bytecode */ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, - CritterClassLoader critterClassLoader, FieldInfo field) { + CritterClassLoader critterClassLoader, FieldInfo field, boolean runtimeMode) { super(entity, critterClassLoader); this.config = config; this.isFieldBased = true; + this.runtimeMode = runtimeMode; this.propertyName = field.name(); generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); @@ -71,23 +74,33 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class an this.getterName = null; } + /** + * Creates a generator for a field-based property where the entity class is also the annotation source. + */ + public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, + CritterClassLoader critterClassLoader, FieldInfo field) { + this(config, entity, annotationSource, critterClassLoader, field, false); + } + /** * Creates a generator for a field-based property where the entity class is also the annotation source. */ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { - this(config, entity, entity, critterClassLoader, field); + this(config, entity, entity, critterClassLoader, field, false); } /** * Creates a generator for a method-based (getter) property. * * @param annotationSource the class to reflect on for method annotations (may differ from entity for @ExternalEntity stand-ins) + * @param runtimeMode {@code true} to use the nestmate accessor registry instead of generating {@code new} bytecode */ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, - CritterClassLoader critterClassLoader, MethodInfo method) { + CritterClassLoader critterClassLoader, MethodInfo method, boolean runtimeMode) { super(entity, critterClassLoader); this.config = config; this.isFieldBased = false; + this.runtimeMode = runtimeMode; this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); generatedType = "%s.%sModel".formatted(baseName, Critter.titleCase(propertyName)); accessorType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); @@ -115,11 +128,21 @@ public PropertyModelGenerator(MorphiaConfig config, Class entity, Class an this.getterName = method.name(); } + /** + * Creates a generator for a method-based (getter) property. + * + * @param annotationSource the class to reflect on for method annotations (may differ from entity for @ExternalEntity stand-ins) + */ + public PropertyModelGenerator(MorphiaConfig config, Class entity, Class annotationSource, + CritterClassLoader critterClassLoader, MethodInfo method) { + this(config, entity, annotationSource, critterClassLoader, method, false); + } + /** * Creates a generator for a method-based property where the entity class is also the annotation source. */ public PropertyModelGenerator(MorphiaConfig config, Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { - this(config, entity, entity, critterClassLoader, method); + this(config, entity, entity, critterClassLoader, method, false); } private static Field findField(Class cls, String name) { @@ -322,6 +345,10 @@ public PropertyModelGenerator emit() { .map(GenerationUtils::toClassfileAnnotation) .toList(); + // For runtime mode, the accessor is a hidden nestmate class retrieved from the registry. + // For AOT mode, the accessor is a normal class registered in CritterClassLoader by name. + ClassDesc accessorFieldDesc = runtimeMode ? accessorDesc : accessorImplDesc; + byte[] bytes = ClassFile.of().build(thisDesc, cb -> { cb.withVersion(ClassFile.JAVA_17_VERSION, 0); cb.withFlags(ClassFile.ACC_PUBLIC | ClassFile.ACC_SUPER); @@ -331,7 +358,7 @@ public PropertyModelGenerator emit() { } cb.withField("entityModel", entityModelDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); - cb.withField("accessor", accessorImplDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); + cb.withField("accessor", accessorFieldDesc, ClassFile.ACC_PRIVATE | ClassFile.ACC_FINAL); // Constructor: (EntityModel) -> void cb.withMethodBody("", MethodTypeDesc.of(ConstantDescs.CD_void, entityModelDesc), @@ -345,10 +372,19 @@ public PropertyModelGenerator emit() { cod.putfield(thisDesc, "entityModel", entityModelDesc); cod.aload(0); - cod.new_(accessorImplDesc); - cod.dup(); - cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); - cod.putfield(thisDesc, "accessor", accessorImplDesc); + if (runtimeMode) { + // Retrieve pre-instantiated nestmate accessor from registry + ClassDesc registryDesc = ClassDesc.of(NestmateAccessorRegistry.class.getName()); + cod.ldc(accessorType); + cod.invokestatic(registryDesc, "get", + MethodTypeDesc.of(accessorDesc, ConstantDescs.CD_String)); + cod.putfield(thisDesc, "accessor", accessorFieldDesc); + } else { + cod.new_(accessorImplDesc); + cod.dup(); + cod.invokespecial(accessorImplDesc, "", MethodTypeDesc.ofDescriptor("()V")); + cod.putfield(thisDesc, "accessor", accessorFieldDesc); + } // Morphia annotations: builder with hardcoded values — no reflection for (Annotation ann : morphiaAnnotations) { @@ -392,7 +428,7 @@ public PropertyModelGenerator emit() { cb.withMethodBody("getAccessor", MethodTypeDesc.of(accessorDesc), ClassFile.ACC_PUBLIC, cod -> { cod.aload(0); - cod.getfield(thisDesc, "accessor", accessorImplDesc); + cod.getfield(thisDesc, "accessor", accessorFieldDesc); cod.areturn(); }); diff --git a/core/src/main/java/dev/morphia/mapping/CritterMapper.java b/core/src/main/java/dev/morphia/mapping/CritterMapper.java index 4a30532fe8f..1a57d361cae 100644 --- a/core/src/main/java/dev/morphia/mapping/CritterMapper.java +++ b/core/src/main/java/dev/morphia/mapping/CritterMapper.java @@ -174,7 +174,7 @@ private EntityModel tryRuntimeGeneration(Class type) { } catch (Exception e) { if (fallbackTypes.add(type.getName())) { LOG.warn("Runtime bytecode generation failed for {}; falling back to reflection: {}", - type.getName(), e.getMessage()); + type.getName(), e.getMessage(), e); } return null; } diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java index e4d69d3333a..83d224ffb4b 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/PropertyModel.java @@ -275,6 +275,9 @@ public Object getValue(Object instance) { Object target = instance; if (target instanceof MorphiaProxy) { target = ((MorphiaProxy) instance).unwrap(); + if (target == null) { + return null; + } } return getAccessor().get(target); } diff --git a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index c2c60e4b260..989150cdf65 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -16,6 +16,7 @@ import dev.morphia.critter.Critter; import dev.morphia.critter.CritterClassLoader; import dev.morphia.critter.parser.generator.CritterGenerator; +import dev.morphia.critter.parser.generator.NestmateAccessorRegistry; import dev.morphia.critter.parser.generator.VarHandleAccessorGenerator; import dev.morphia.critter.sources.Example; import dev.morphia.mapping.PropertyDiscovery; @@ -92,38 +93,32 @@ public void testLongBoxedField() throws Exception { } @Test - public void testAccessorsInstantiatable() throws Exception { + public void testAccessorsInstantiatable() { for (String field : List.of("name", "age", "salary")) { - Class cls = classLoader.loadClass( - Critter.critterPackage(Example.class) + "." + Critter.titleCase(field) + "Accessor"); - cls.getConstructor().newInstance(); + String key = Critter.critterPackage(Example.class) + "." + Critter.titleCase(field) + "Accessor"; + Assertions.assertNotNull(NestmateAccessorRegistry.get(key), + "Nestmate accessor must be registered in registry for field: " + field); } } /** - * Verifies the code path for {@code final} fields in the runtime-generated VarHandle accessor. - * {@code VarHandle.set()} does not support final fields; the generated {@code set()} method - * falls back to reflection ({@code Field.setAccessible + Field.set}). In practice the - * reflection-based set may also fail in Java 17+ due to JVM final-field restrictions, so this - * test verifies that (a) {@code get()} returns the correct initial value, and (b) the - * reflection fallback code path IS taken (the RuntimeException message contains the field name). + * Verifies the code path for {@code final} fields in the runtime-generated nestmate accessor. + * The JVM does not allow {@code putfield} to a final field outside {@code }; the generated + * {@code set()} method falls back to reflection ({@code Field.setAccessible + Field.set}). + * In practice the reflection-based set may also fail in Java 17+ due to JVM final-field + * restrictions, so this test verifies that (a) {@code get()} returns the correct initial value, + * and (b) the reflection fallback code path IS taken (the RuntimeException message contains the + * field name). */ @Test public void testFinalFieldReflectionFallback() throws Exception { CritterClassLoader loader = new CritterClassLoader(); new CritterGenerator(defaultMapper()).generate(FinalFieldEntity.class, loader, true); - // Static check: the generated accessor must reference java/lang/reflect/Field - String accessorName = Critter.critterPackage(FinalFieldEntity.class) + "." + Critter.titleCase("label") + "Accessor"; - byte[] accessorBytes = loader.getTypeDefinitions().get(accessorName); - Assertions.assertNotNull(accessorBytes, "LabelAccessor bytecode should be registered"); - String bytecodeStr = new String(accessorBytes, StandardCharsets.ISO_8859_1); - Assertions.assertTrue(bytecodeStr.contains("java/lang/reflect/Field"), - "Generated LabelAccessor should reference java/lang/reflect/Field for the final-field fallback"); - FinalFieldEntity entity = new FinalFieldEntity(); PropertyAccessor accessor = loadAccessor(loader, FinalFieldEntity.class, "label"); + Assertions.assertNotNull(accessor, "LabelAccessor must be registered in nestmate registry"); Assertions.assertEquals("original", accessor.get(entity), "get() must return the correct final field value"); try { @@ -145,7 +140,7 @@ public void testFinalFieldReflectionFallback() throws Exception { * field. * * After the fix: the static setter is filtered by PropertyFinder, the property falls back to - * field-based VarHandle discovery, and set() works correctly. + * field-based nestmate accessor, and set() works correctly. */ @Test public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exception { @@ -154,16 +149,14 @@ public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exceptio CritterClassLoader loader = new CritterClassLoader(); new CritterGenerator(methodsMapper).generate(StaticSetterEntity.class, loader, true); - String accessorName = Critter.critterPackage(StaticSetterEntity.class) + ".ValueAccessor"; @SuppressWarnings("unchecked") - PropertyAccessor accessor = (PropertyAccessor) loader - .loadClass(accessorName).getConstructor().newInstance(); + PropertyAccessor accessor = (PropertyAccessor) loadAccessor(loader, StaticSetterEntity.class, "value"); StaticSetterEntity entity = new StaticSetterEntity(); Assertions.assertNull(accessor.get(entity), "initial value should be null"); // Bug: set() threw UnsupportedOperationException because the static setter was found by // PropertyFinder, making the property method-based, then hasSetter() rejected it. - // Fix: PropertyFinder ignores static setters; property falls back to field VarHandle. + // Fix: PropertyFinder ignores static setters; property falls back to field nestmate accessor. accessor.set(entity, "hello"); Assertions.assertEquals("hello", accessor.get(entity), "set() must write through to the backing field"); } @@ -263,16 +256,14 @@ public static class FinalFieldEntity { } @SuppressWarnings("unchecked") - private PropertyAccessor loadAccessor(Class entityType, String fieldName) throws Exception { + private PropertyAccessor loadAccessor(Class entityType, String fieldName) { return loadAccessor(classLoader, entityType, fieldName); } @SuppressWarnings("unchecked") - private PropertyAccessor loadAccessor(CritterClassLoader loader, Class entityType, String fieldName) - throws Exception { - Class> cls = (Class>) loader.loadClass( - Critter.critterPackage(entityType) + "." + Critter.titleCase(fieldName) + "Accessor"); - return cls.getConstructor().newInstance(); + private PropertyAccessor loadAccessor(CritterClassLoader loader, Class entityType, String fieldName) { + String key = Critter.critterPackage(entityType) + "." + Critter.titleCase(fieldName) + "Accessor"; + return (PropertyAccessor) NestmateAccessorRegistry.get(key); } /**