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/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 47a314b9481..00000000000 --- a/build-plugins/src/main/kotlin/util/AnnotationNodeExtensions.kt +++ /dev/null @@ -1,277 +0,0 @@ -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 -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.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() { - 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( - """ - 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("}") - - 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()) } - } - - 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..b5625a4df7d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -111,12 +111,6 @@ generate-sources - - morphia-annotations-asm - - morphia-annotation-node - - @@ -166,16 +160,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/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/ExtensionFunctions.java b/core/src/main/java/dev/morphia/critter/parser/ExtensionFunctions.java index f19aa198d6e..eebc77a1cb2 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,12 @@ 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)) { + if (field.getName().equals(methodName) && field.getType().descriptorString().equals(returnDesc)) { 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..08094e6a3fa --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/FieldInfo.java @@ -0,0 +1,22 @@ +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, + 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/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..0618af654c1 100644 --- a/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java +++ b/core/src/main/java/dev/morphia/critter/parser/PropertyFinder.java @@ -1,259 +1,282 @@ 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; 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.GenerationUtils; +import dev.morphia.critter.parser.generator.PropertyModelGenerator; import dev.morphia.critter.parser.java.CritterParser; 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 { 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 CritterGizmoGenerator critterGizmoGenerator; + private final CritterGenerator critterGenerator; 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()) { providerMap.put(provider.provides(), provider); } + this.annotationDescriptorKeys = providerMap.keySet().stream() + .map(type -> "L" + type.getName().replace('.', '/') + ";") + .toList(); this.classLoader = classLoader; this.runtimeMode = runtimeMode; - this.critterGizmoGenerator = new CritterGizmoGenerator(mapper); + this.critterGenerator = new CritterGenerator(mapper); this.propertyDiscovery = mapper.getConfig().propertyDiscovery(); } + public List find(Class entityType, ClassModel classModel) { + return find(entityType, classModel, entityType); + } + /** - * 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 + * 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 entityType, ClassNode classNode) { + public List find(Class standinType, ClassModel classModel, Class targetType) { List models = new ArrayList<>(); - List methods = discoverPropertyMethods(entityType, classNode); + List methods = discoverPropertyMethods(standinType, classModel); if (methods.isEmpty()) { - List fields = discoverAllFields(entityType, classNode); + List fields = discoverAllFields(standinType, classModel); if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGizmoGenerator.fieldAccessors(entityType, fields)); + classLoader.register(targetType.getName(), critterGenerator.fieldAccessors(targetType, fields)); } - for (FieldNode field : fields) { + for (FieldInfo field : fields) { if (runtimeMode) { - critterGizmoGenerator.varHandleAccessor(entityType, classLoader, field); + critterGenerator.nestmateAccessor(targetType, field); } else { - critterGizmoGenerator.propertyAccessor(entityType, classLoader, field); + critterGenerator.propertyAccessor(targetType, classLoader, field); } - models.add(critterGizmoGenerator.propertyModelGenerator(entityType, classLoader, field)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, field, runtimeMode)); } } else { if (!runtimeMode) { - classLoader.register(entityType.getName(), critterGizmoGenerator.methodAccessors(entityType, methods)); + classLoader.register(targetType.getName(), critterGenerator.methodAccessors(targetType, methods)); } - for (MethodNode method : methods) { + for (MethodInfo method : methods) { if (runtimeMode) { - critterGizmoGenerator.varHandleAccessor(entityType, classLoader, method); + critterGenerator.nestmateAccessor(targetType, method); } else { - critterGizmoGenerator.propertyAccessor(entityType, classLoader, method); + critterGenerator.propertyAccessor(targetType, classLoader, method); } - models.add(critterGizmoGenerator.propertyModelGenerator(entityType, classLoader, method)); + models.add(critterGenerator.propertyModelGenerator(targetType, standinType, classLoader, method, runtimeMode)); } } return models; } - private boolean isPropertyAnnotated(List annotationNodes, boolean allowUnannotated) { - List annotations = annotationNodes != null ? annotationNodes : List.of(); - List keys = providerMap.keySet().stream() - .map(type -> Type.getType(type).getDescriptor()) - .toList(); - return allowUnannotated || annotations.stream().anyMatch(a -> keys.contains(a.desc)); + private boolean isPropertyAnnotated(List annotations, boolean allowUnannotated) { + List anns = annotations != null ? annotations : List.of(); + return allowUnannotated || anns.stream() + .anyMatch(a -> annotationDescriptorKeys.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) { + final Class declaringClass = current; + for (FieldInfo field : discoverFields(model, declaringClass)) { + 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) { - String resourceName = "%s.class".formatted(type.getName().replace('.', '/')); - InputStream inputStream = type.getClassLoader().getResourceAsStream(resourceName); - if (inputStream == null) { - LOG.debug("Bytecode resource not found for {}; hierarchy traversal stops here", type.getName()); - return null; - } - ClassNode node = new ClassNode(); + private ClassModel readClassModel(Class type) { try { - new ClassReader(inputStream).accept(node, 0); - } catch (IOException e) { - LOG.warn("Failed to read bytecode for {}: {}", type.getName(), e.getMessage()); + 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 node; } - private List discoverFields(ClassNode classNode) { + private List discoverFields(ClassModel classModel, Class declaringClass) { 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); + 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); + result.add(new FieldInfo( + field.fieldName().stringValue(), + field.fieldType().stringValue(), + sig, + field.flags().flagsMask(), + visible, + declaringClass)); } } 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 (name.equals("get") || name.equals("is")) + return false; + 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()); + 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) + && (method.flags().flagsMask() & ClassFile.ACC_STATIC) == 0) { + 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 + && (setter.access() & ClassFile.ACC_STATIC) == 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 deleted file mode 100644 index 3a4e2dfa177..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddFieldAccessorMethods.java +++ /dev/null @@ -1,98 +0,0 @@ -package dev.morphia.critter.parser.asm; - -import java.util.List; - -import dev.morphia.critter.Critter; - -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; - -/** - * 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; - - /** - * 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) { - 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(); - } - - 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(); - } - - 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(); - } -} 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 deleted file mode 100644 index b62d33fa7ed..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/asm/AddMethodAccessorMethods.java +++ /dev/null @@ -1,144 +0,0 @@ -package dev.morphia.critter.parser.asm; - -import java.util.List; - -import dev.morphia.critter.Critter; -import dev.morphia.critter.parser.ExtensionFunctions; - -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; - -/** - * 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; - - /** - * 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) { - 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(); - } - - 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; - } - } - - 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); - - 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); - } - - mv.visitMaxs(2, 2); - mv.visitEnd(); - } - - 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(); - } -} 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 deleted file mode 100644 index 846241ffc8f..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/asm/BaseGenerator.java +++ /dev/null @@ -1,151 +0,0 @@ -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 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; - -/** - * Base class for ASM-based bytecode generators used by Critter to produce accessor and model classes. - */ -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; - - /** - * 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); - } - - /** - * 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 - */ - 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) { - String resourceName = "%s.class".formatted(entity.getName().replace('.', '/')); - java.io.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) { - 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/generator/AccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java new file mode 100644 index 00000000000..5f3040123ca --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AccessorMethods.java @@ -0,0 +1,36 @@ +package dev.morphia.critter.parser.generator; + +import dev.morphia.mapping.MappingException; + +import io.github.dmlloyd.classfile.ClassModel; + +/** + * Base class for bytecode generators that read and transform existing class files. + */ +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 AccessorMethods(Class entity) { + this.entity = entity; + } + + /** + * Emits the generated or augmented class bytecode. + */ + public abstract byte[] emit(); + + /** + * Reads the class file bytes for the given entity. + */ + protected ClassModel readClassFiltering() { + 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/AddFieldAccessorMethods.java b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java new file mode 100644 index 00000000000..e6f05234544 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddFieldAccessorMethods.java @@ -0,0 +1,83 @@ +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; + +import dev.morphia.critter.Critter; +import dev.morphia.critter.parser.FieldInfo; + +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 AccessorMethods { + private final List fields; + + /** + * Creates a generator that will add accessor methods for the given fields to the entity class. + */ + public AddFieldAccessorMethods(Class entity, List fields) { + super(entity); + this.fields = fields; + } + + @Override + public byte[] emit() { + ClassModel model = readClassFiltering(); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); + + 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 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)); + 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 -> { + cod.aload(0); + cod.getfield(entityDesc, name, fieldDesc); + cod.return_(kind); + }); + + // __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"), + isPrimitive ? fieldDesc : ConstantDescs.CD_Object); + classBuilder.withMethodBody(writerName, writerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + if (isPrimitive) { + cod.loadLocal(kind, 1); + } else { + cod.aload(1); + cod.checkcast(fieldDesc); + } + cod.putfield(entityDesc, name, fieldDesc); + cod.return_(); + }); + } + })); + + return ClassFile.of().transformClass(model, transform); + } +} 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 new file mode 100644 index 00000000000..c0b303f6fec --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/AddMethodAccessorMethods.java @@ -0,0 +1,107 @@ +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; + +import dev.morphia.critter.Critter; +import dev.morphia.critter.parser.ExtensionFunctions; +import dev.morphia.critter.parser.MethodInfo; + +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; + +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. + */ +public class AddMethodAccessorMethods extends AccessorMethods { + private final List methods; + + /** + * Creates a generator that will add accessor methods for the given getter methods to the entity class. + */ + public AddMethodAccessorMethods(Class entity, List methods) { + super(entity); + this.methods = methods; + } + + @Override + public byte[] emit() { + ClassModel model = readClassFiltering(); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); + + 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)); + + 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) + boolean isPrimitive = returnKind != TypeKind.REFERENCE; + String readerName = "__read%s".formatted(Critter.titleCase(propertyName)); + MethodTypeDesc readerMtd = MethodTypeDesc.of(isPrimitive ? returnDesc : ConstantDescs.CD_Object); + classBuilder.withMethodBody(readerName, readerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + cod.invokevirtual(entityDesc, getterName, MethodTypeDesc.of(returnDesc)); + cod.return_(returnKind); + }); + + // __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"), + isPrimitive ? returnDesc : ConstantDescs.CD_Object); + if (hasSetter) { + classBuilder.withMethodBody(writerName, writerMtd, + ClassFile.ACC_PUBLIC | ClassFile.ACC_SYNTHETIC, + cod -> { + cod.aload(0); + 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_(); + }); + } 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(); + }); + } + } + })); + + return ClassFile.of().transformClass(model, transform); + } + +} diff --git a/core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java new file mode 100644 index 00000000000..a6c46b184c8 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/BaseGenerator.java @@ -0,0 +1,24 @@ +package dev.morphia.critter.parser.generator; + +import dev.morphia.critter.Critter; +import dev.morphia.critter.CritterClassLoader; + +/** + * Base class for ClassFile-based code generators that produce Critter accessor and model classes. + */ +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. */ + 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; + + 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/generator/CritterGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java new file mode 100644 index 00000000000..78350dd6f54 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/CritterGenerator.java @@ -0,0 +1,239 @@ +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; +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.mapping.Mapper; + +import org.bson.codecs.pojo.PropertyAccessor; + +import io.github.dmlloyd.classfile.ClassModel; + +/** + * 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 CritterGenerator { + private final Mapper mapper; + + /** + * Creates a new CritterGenerator with the given mapper. + * + * @param mapper the Morphia mapper + */ + public CritterGenerator(Mapper mapper) { + this.mapper = mapper; + } + + /** + * Reads the entity class bytecode, discovers its properties, and generates all required Critter support classes. + * + * @param type the entity class to process + * @param critterClassLoader the class loader that will receive generated bytecode + * @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) { + // 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(standinType.getName())); + } + PropertyFinder propertyFinder = new PropertyFinder(mapper, critterClassLoader, runtimeMode); + + return entityModel(targetType, standinType, critterClassLoader, + propertyFinder.find(standinType, classModel, targetType)); + } + + /** + * Reads the entity class bytecode, discovers its properties, and generates all required Critter support classes + * using build-time (non-runtime) mode. + * + * @param type the entity class to process + * @param critterClassLoader the class loader that will receive generated bytecode + * @return the generated entity model generator + */ + public EntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader) { + return generate(type, critterClassLoader, false); + } + + /** + * Generates bytecode augmenting the entity with synthetic {@code __readXxx}/{@code __writeXxx} methods for its fields. + * + * @param entityType the entity class to augment + * @param fields the fields for which accessor methods should be generated + * @return the augmented class bytecode + */ + public byte[] fieldAccessors(Class entityType, List fields) { + return new AddFieldAccessorMethods(entityType, fields).emit(); + } + + /** + * Generates bytecode augmenting the entity with synthetic {@code __readXxx}/{@code __writeXxx} methods for its getter methods. + * + * @param entityType the entity class to augment + * @param methods the getter methods for which accessor methods should be generated + * @return the augmented class bytecode + */ + public byte[] methodAccessors(Class entityType, List methods) { + return new AddMethodAccessorMethods(entityType, methods).emit(); + } + + /** + * Generates and registers a {@link PropertyAccessorGenerator} for the given field. + * + * @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 property accessor should be generated + * @return the emitted property accessor generator + */ + public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { + return new PropertyAccessorGenerator(entityType, critterClassLoader, field).emit(); + } + + /** + * Generates and registers a {@link PropertyAccessorGenerator} for the given getter method. + * + * @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 property accessor should be generated + * @return the emitted property accessor generator + */ + public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { + return new PropertyAccessorGenerator(entityType, critterClassLoader, method).emit(); + } + + /** + * 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 field the field for which a nestmate accessor should be generated + */ + public void nestmateAccessor(Class entityType, FieldInfo field) { + defineNestmate(entityType, new NestmateAccessorGenerator(entityType, field)); + } + + /** + * 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 method the getter method for which a nestmate accessor should be generated + */ + 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); + } + } + + /** + * Generates and registers a {@link PropertyModelGenerator} for the given field. + * + * @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 property model should be generated + * @return the emitted property model generator + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, FieldInfo field) { + 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 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. + * + * @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 property model should be generated + * @return the emitted property model generator + */ + public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, MethodInfo method) { + 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 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. + * + * @param type the entity class + * @param critterClassLoader the class loader that will receive the generated bytecode + * @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, + List properties) { + 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, + 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 new file mode 100644 index 00000000000..b80016d31b8 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/EntityModelGenerator.java @@ -0,0 +1,224 @@ +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; +import java.lang.reflect.Modifier; +import java.util.LinkedHashSet; +import java.util.List; +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; +import dev.morphia.mapping.codec.pojo.critter.CritterEntityModel; + +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. + */ +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, reading entity-level annotations + * from {@code standinType} (which differs from {@code type} only for {@code @ExternalEntity}). + * + * @param mapper the Morphia mapper + * @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 neither {@code @Entity} nor {@code @ExternalEntity} is present on {@code standinType} + */ + 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()); + + 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())); + } + + // 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 = 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); + } + + /** + * 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 fully-qualified name of the generated entity model class. + */ + public String getGeneratedType() { + return generatedType; + } + + /** + * Emits the generated entity model class and returns this generator. + */ + public EntityModelGenerator emit() { + 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 != null ? entityAnnotation.useDiscriminator() + : externalEntity.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); + GenerationUtils.emitClassRef(cod, entity); + cod.invokespecial(superDesc, "", + MethodTypeDesc.of(ConstantDescs.CD_void, mapperDesc, ConstantDescs.CD_Class)); + + // setType(entityClass) + cod.aload(0); + GenerationUtils.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(); + } + + // Morphia annotations via builders — no reflection + for (Annotation ann : morphiaAnnotations) { + cod.aload(0); + GenerationUtils.emitAnnotationViaBuilder(cod, ann); + cod.invokevirtual(entityModelDesc, "annotation", + MethodTypeDesc.of(ConstantDescs.CD_void, annotationDesc)); + } + + // 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 + GenerationUtils.emitBooleanMethod(cb, "isAbstract", isAbstractFlag); + // isInterface(): boolean + GenerationUtils.emitBooleanMethod(cb, "isInterface", isInterfaceFlag); + // useDiscriminator(): boolean + GenerationUtils.emitBooleanMethod(cb, "useDiscriminator", useDiscriminatorFlag); + }); + + critterClassLoader.register(generatedType, bytes); + return this; + } + + private String computeCollectionName() { + String key = entityAnnotation != null ? entityAnnotation.value() : externalEntity.value(); + return Mapper.IGNORED_FIELDNAME.equals(key) + ? mapper.getConfig().collectionNaming().apply(entity.getSimpleName()) + : key; + } + + private String computeDiscriminator() { + String disc = entityAnnotation != null ? entityAnnotation.discriminator() : externalEntity.discriminator(); + return mapper.getConfig().discriminator().apply(entity, disc); + } + + private String computeDiscriminatorKey() { + 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/GenerationUtils.java b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java new file mode 100644 index 00000000000..34b254e2633 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/GenerationUtils.java @@ -0,0 +1,431 @@ +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.Method; +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.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.ClassModel; +import io.github.dmlloyd.classfile.CodeBuilder; +import io.github.dmlloyd.classfile.TypeKind; + +/** + * Static utility methods bridging annotation introspection and Morphia type data with the ClassFile API. + */ +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() { + } + + /** + * 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(). + */ + 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(); + } + + /** + * 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}. + * All element values are captured at generation time. + */ + public static io.github.dmlloyd.classfile.Annotation toClassfileAnnotation(Annotation ann) { + Class annType = ann.annotationType(); + 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); + } + + @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(); + int len = Array.getLength(value); + List values = new ArrayList<>(); + for (int i = 0; i < len; i++) { + values.add(toAnnotationValue(compType, compType, Array.get(value, i))); + } + 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); + } + + /** + * 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"; + + cod.invokestatic(builderDesc, factoryMethod, MethodTypeDesc.of(builderDesc)); + + 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); + } + } + + cod.invokevirtual(builderDesc, "build", MethodTypeDesc.of(annDesc)); + } + + @SuppressWarnings("rawtypes") + 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) { + cod.loadConstant(((Boolean) value) ? 1 : 0); + } 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 (rawType == float.class) { + cod.loadConstant((float) value); + } else if (rawType == double.class) { + cod.loadConstant((double) value); + } else if (rawType == Class.class || (genericType instanceof ParameterizedType pt && pt.getRawType() == Class.class)) { + emitClassRef(cod, (Class) value); + } else if (rawType.isEnum()) { + Enum e = (Enum) value; + ClassDesc enumDesc = ClassDesc.of(e.getDeclaringClass().getName()); + cod.getstatic(enumDesc, e.name(), enumDesc); + } else if (rawType.isAnnotation()) { + emitAnnotationViaBuilder(cod, (Annotation) value); + } else if (rawType.isArray()) { + Class compType = rawType.getComponentType(); + 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(); + Class compClass = (compGeneric instanceof ParameterizedType pt) + ? (Class) pt.getRawType() + : (Class) compGeneric; + Object[] arr = (Object[]) value; + cod.loadConstant(arr.length); + cod.anewarray(ClassDesc.of(compClass.getName())); + for (int i = 0; i < arr.length; i++) { + cod.dup(); + cod.loadConstant(i); + emitBuilderElementValue(cod, compGeneric, compClass, arr[i]); + cod.aastore(); + } + } else { + throw new UnsupportedOperationException("Unsupported annotation element type: " + rawType); + } + } + + /** + * Emits bytecode that loads a Class reference. Non-public classes use Class.forName(). + */ + public static void emitClassRef(CodeBuilder cod, Class cls) { + 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.ofDescriptor(cls.descriptorString())); + } else { + 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;")); + } + } + + private static String primitiveWrapperName(Class primitive) { + if (primitive == void.class) { + return "java.lang.Void"; + } + String wrapper = PRIMITIVE_TO_WRAPPER.get(primitive.getName()); + if (wrapper == null) { + throw new IllegalArgumentException("Not a primitive: " + primitive); + } + return wrapper; + } + + /** + * Emits bytecode that constructs a TypeData instance. + */ + 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")); + } + + /** + * 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); + }; + } + + /** + * 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. + */ + 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); + } + } + +} 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/PropertyAccessorGenerator.java b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java new file mode 100644 index 00000000000..b573a2f1ffc --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyAccessorGenerator.java @@ -0,0 +1,139 @@ +package dev.morphia.critter.parser.generator; + +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.MethodTypeDesc; + +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 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.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. + */ +public class PropertyAccessorGenerator extends BaseGenerator { + 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())); + generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); + } + + 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)); + generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); + } + + public boolean isPrimitive() { + return PRIMITIVE_TO_WRAPPER.containsKey(propertyType); + } + + public String getWrapperType() { + return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); + } + + public PropertyAccessorGenerator emit() { + ClassDesc thisDesc = ClassDesc.of(generatedType); + ClassDesc entityDesc = ClassDesc.of(entity.getName()); + ClassDesc propertyDesc; + if (isPrimitive()) { + propertyDesc = primitiveClassDesc(propertyType); + } 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"); + + 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; + // 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 + ">" + ";"; + 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 + // __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); + 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(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); + 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)); + cod.invokevirtual(entityDesc, writeName, + MethodTypeDesc.of(ConstantDescs.CD_void, propertyDesc)); + } else { + cod.aload(2); + cod.invokevirtual(entityDesc, writeName, + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_Object)); + } + cod.return_(); + }); + }); + + critterClassLoader.register(generatedType, bytes); + return this; + } + + private String primitiveUnboxMethod() { + return GenerationUtils.primitiveUnboxMethod(propertyType); + } +} 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 new file mode 100644 index 00000000000..5d9ca8c4557 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/PropertyModelGenerator.java @@ -0,0 +1,521 @@ +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; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.TypeVariable; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.mongodb.DBRef; + +import dev.morphia.annotations.AlsoLoad; +import dev.morphia.annotations.Reference; +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 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 + * for a single property of a Morphia entity class. + */ +public class PropertyModelGenerator extends BaseGenerator { + private final MorphiaConfig config; + 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; + private final TypeData typeData; + private final String getterName; + + /** + * 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, 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)); + + 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 = TypeData.get(resolveGenericType(this.genericType, field.name(), 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, 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, 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, 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)); + + 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 = 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); + } + } + this.typeData = TypeData.get(resolveGenericType(this.genericType, this.propertyName, annotationSource)); + 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, false); + } + + private static Field findField(Class cls, String name) { + Class current = cls; + while (current != null && current != Object.class) { + try { + return current.getDeclaredField(name); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } + + 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 && !m.isBridge()) { + return m; + } + } + current = current.getSuperclass(); + } + return null; + } + + private static Map buildAnnotationMap(Annotation[] annotations) { + Map map = new LinkedHashMap<>(); + for (Annotation ann : annotations) { + map.put(ann.annotationType().getName(), ann); + } + 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(); + } + } + // 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; + } + + 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; + } + + /** + * 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 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 == null || input.isEmpty()) + return java.util.Collections.emptyList(); + try { + 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 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 = GenerationUtils.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 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()); + } + + /** + * 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]; + + 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(); + + // 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); + cb.withSuperclass(superDesc); + if (!cfAnnotations.isEmpty()) { + cb.with(RuntimeVisibleAnnotationsAttribute.of(cfAnnotations)); + } + + cb.withField("entityModel", entityModelDesc, 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), + 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); + 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) { + cod.aload(0); + 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;")); + // 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_(); + }); + + // getAccessor(): PropertyAccessor + cb.withMethodBody("getAccessor", MethodTypeDesc.of(accessorDesc), + ClassFile.ACC_PUBLIC, cod -> { + cod.aload(0); + cod.getfield(thisDesc, "accessor", accessorFieldDesc); + 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 -> { + GenerationUtils.emitClassRef(cod, normalizedType); + cod.areturn(); + }); + + // getType(): Class + cb.withMethodBody("getType", MethodTypeDesc.of(ConstantDescs.CD_Class), + ClassFile.ACC_PUBLIC, cod -> { + GenerationUtils.emitClassRef(cod, typeData.getType()); + cod.areturn(); + }); + + // getTypeData(): TypeData + cb.withMethodBody("getTypeData", MethodTypeDesc.of(typeDataDesc), + ClassFile.ACC_PUBLIC, cod -> { + GenerationUtils.emitTypeData(typeData, cod); + cod.areturn(); + }); + + // isArray(): boolean + GenerationUtils.emitBooleanMethod(cb, "isArray", isArrayFlag); + // isFinal(): boolean + GenerationUtils.emitBooleanMethod(cb, "isFinal", isFinalFlag); + // isReference(): boolean + GenerationUtils.emitBooleanMethod(cb, "isReference", isReferenceFlag); + // isTransient(): boolean + GenerationUtils.emitBooleanMethod(cb, "isTransient", isTransientFlag); + // isMap(): boolean + GenerationUtils.emitBooleanMethod(cb, "isMap", isMapFlag); + // isSet(): boolean + GenerationUtils.emitBooleanMethod(cb, "isSet", isSetFlag); + // isCollection(): boolean + GenerationUtils.emitBooleanMethod(cb, "isCollection", isCollectionFlag); + }); + + critterClassLoader.register(generatedType, bytes); + return this; + } + +} 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 new file mode 100644 index 00000000000..93f26e7e115 --- /dev/null +++ b/core/src/main/java/dev/morphia/critter/parser/generator/VarHandleAccessorGenerator.java @@ -0,0 +1,399 @@ +package dev.morphia.critter.parser.generator; + +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.Method; + +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 io.github.dmlloyd.classfile.ClassFile; +import io.github.dmlloyd.classfile.ClassSignature; +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 + * {@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 BaseGenerator { + private final String propertyName; + private final String propertyType; + private final boolean isFieldBased; + 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}. + * + * @param entity the entity class that owns the field + * @param critterClassLoader the class loader that will receive the generated bytecode + * @param field the field info representing the property + */ + public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldInfo field) { + super(entity, critterClassLoader); + this.propertyName = field.name(); + this.propertyType = typeClassName(ClassDesc.ofDescriptor(field.desc())); + this.isFieldBased = true; + 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)); + } + + /** + * Creates a generator for a method-based property accessor using {@link java.lang.invoke.MethodHandle}s. + * + * @param entity the entity class that owns the getter + * @param critterClassLoader the class loader that will receive the generated bytecode + * @param method the method info representing the getter + */ + public VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodInfo method) { + 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.isFieldBased = false; + 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)); + } + + /** + * 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); + } + + /** + * Returns the fully-qualified name of the wrapper class for the property type, + * or the property type itself if it is not a 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 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()); + 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 = setterMethod != null; + + 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 wrapperInternalName = getWrapperType().replace('.', '/'); + String accInternal = "org/bson/codecs/pojo/PropertyAccessor"; + 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 + 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); + } + } + + // 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, tcclSlot); + 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, tcclSlot); + 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(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(setterReturnDesc); + emitLoadClass(tryBody, propertyType, propertyDesc, tcclSlot); + 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 -> { + 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.aload(3); + catchBody.invokespecial(rteDesc, "", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;Ljava/lang/Throwable;)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 { + // 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")); + } + } 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 { + // 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")); + } + } + cod.return_(); + }); + }); + + critterClassLoader.register(generatedType, bytes); + return this; + } + + private void emitLoadClass(io.github.dmlloyd.classfile.CodeBuilder cod, String typeName, ClassDesc desc, + int tcclSlot) { + if (isPrimitive()) { + cod.loadConstant(desc); + } else { + // 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 ClassDesc propertyClassDesc() { + if (isPrimitive()) { + return primitiveClassDesc(propertyType); + } + if (propertyType.startsWith("[")) { + return ClassDesc.ofDescriptor(propertyType.replace('.', '/')); + } + return ClassDesc.of(propertyType); + } + + private String primitiveUnboxMethod() { + return GenerationUtils.primitiveUnboxMethod(propertyType); + } +} 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 deleted file mode 100644 index 7013cf679da..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/BaseGizmoGenerator.java +++ /dev/null @@ -1,62 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -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. - */ -public abstract class BaseGizmoGenerator { - /** The entity class for which code is being generated. */ - protected final Class entity; - /** The class loader used to register generated class bytecode. */ - 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 deleted file mode 100644 index e850c976859..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/CritterGizmoGenerator.java +++ /dev/null @@ -1,177 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -import java.io.IOException; -import java.util.List; - -import dev.morphia.critter.CritterClassLoader; -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; - -/** - * Facade that orchestrates the full Gizmo-based code generation pipeline for a Morphia entity, - * including field/method accessor injection and property/entity model generation. - */ -public class CritterGizmoGenerator { - private final Mapper mapper; - - /** - * Creates a new CritterGizmoGenerator with the given mapper. - * - * @param mapper the Morphia mapper - */ - public CritterGizmoGenerator(Mapper mapper) { - this.mapper = mapper; - } - - /** - * Reads the entity class bytecode, discovers its properties, and generates all required Critter support classes. - * - * @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 - * @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); - if (inputStream == null) { - throw new IllegalArgumentException("Could not find class file for %s".formatted(type.getName())); - } - try { - new ClassReader(inputStream).accept(classNode, 0); - } 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)); - } - - /** - * Reads the entity class bytecode, discovers its properties, and generates all required Critter support classes - * using build-time (non-runtime) mode. - * - * @param type the entity class to process - * @param critterClassLoader the class loader that will receive generated bytecode - * @return the generated entity model generator - */ - public GizmoEntityModelGenerator generate(Class type, CritterClassLoader critterClassLoader) { - return generate(type, critterClassLoader, false); - } - - /** - * Generates bytecode augmenting the entity with synthetic {@code __readXxx}/{@code __writeXxx} methods for its fields. - * - * @param entityType the entity class to augment - * @param fields the fields for which accessor methods should be generated - * @return the augmented class bytecode - */ - public byte[] fieldAccessors(Class entityType, List fields) { - return new AddFieldAccessorMethods(entityType, fields).emit(); - } - - /** - * Generates bytecode augmenting the entity with synthetic {@code __readXxx}/{@code __writeXxx} methods for its getter methods. - * - * @param entityType the entity class to augment - * @param methods the getter methods for which accessor methods should be generated - * @return the augmented class bytecode - */ - public byte[] methodAccessors(Class entityType, List methods) { - return new AddMethodAccessorMethods(entityType, methods).emit(); - } - - /** - * Generates and registers a {@link PropertyAccessorGenerator} for the given field. - * - * @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 property accessor should be generated - * @return the emitted property accessor generator - */ - public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { - return new PropertyAccessorGenerator(entityType, critterClassLoader, field).emit(); - } - - /** - * Generates and registers a {@link PropertyAccessorGenerator} for the given getter method. - * - * @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 property accessor should be generated - * @return the emitted property accessor generator - */ - public PropertyAccessorGenerator propertyAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { - return new PropertyAccessorGenerator(entityType, critterClassLoader, method).emit(); - } - - /** - * Generates and registers a VarHandle-based accessor for the given field (used in runtime mode). - * - * @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 - */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { - return new VarHandleAccessorGenerator(entityType, critterClassLoader, field).emit(); - } - - /** - * Generates and registers a VarHandle-based accessor for the property exposed by the given getter method (used in runtime mode). - * - * @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 - */ - public VarHandleAccessorGenerator varHandleAccessor(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { - return new VarHandleAccessorGenerator(entityType, critterClassLoader, method).emit(); - } - - /** - * Generates and registers a {@link PropertyModelGenerator} for the given field. - * - * @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 property model should be generated - * @return the emitted property model generator - */ - public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, FieldNode field) { - return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, field).emit(); - } - - /** - * Generates and registers a {@link PropertyModelGenerator} for the property exposed by the given getter method. - * - * @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 property model should be generated - * @return the emitted property model generator - */ - public PropertyModelGenerator propertyModelGenerator(Class entityType, CritterClassLoader critterClassLoader, MethodNode method) { - return new PropertyModelGenerator(mapper.getConfig(), entityType, critterClassLoader, method).emit(); - } - - /** - * Generates and registers the entity model class for the given type. - * - * @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 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(); - } -} 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 deleted file mode 100644 index 1e1fd913287..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoEntityModelGenerator.java +++ /dev/null @@ -1,241 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -import java.io.IOException; -import java.io.InputStream; -import java.lang.annotation.Annotation; -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; - -/** - * Generates a Gizmo-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; - - /** - * Creates a new entity model generator for the given entity. - * - * @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) { - 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) { - throw new IllegalStateException("Class %s does not have @Entity annotation".formatted(type.getName())); - } - 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)); - } - } - } - - /** - * 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) { - return entity.getAnnotation(type); - } - - /** - * Returns the fully-qualified name of the generated entity model class. - * - * @return the generated class name - */ - public String getGeneratedType() { - return generatedType; - } - - /** - * 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()))); - } - } - - 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)); - } - } - - 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)); - } - } - - 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 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 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 static boolean isMorphiaAnnotation(AnnotationNode annotation) { - return annotation.desc.startsWith("Ldev/morphia/annotations/"); - } -} 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 deleted file mode 100644 index 0bf5742ebb6..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/GizmoExtensions.java +++ /dev/null @@ -1,273 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -import java.lang.reflect.GenericArrayType; -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; - -/** - * Static utility methods that bridge ASM annotation nodes and Morphia type data with the Gizmo bytecode generation 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 - */ - 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); - - setBuilderValues(annotationNode, creator, local); - - return creator.invokeVirtualMethod(ofMethod(builderType.getClassName(), "build", type.getClassName()), local); - } - - /** - * 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); - } - } - }; - } - - /** - * 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); - } - - /** - * 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); - } - 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)); - } - 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 - */ - 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(); - } else { - return Type.getType((Class) type).getDescriptor(); - } - } - - /** - * 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 - */ - 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); - } - } - - /** - * 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 - */ - @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(); - } - 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)); - } - return newArray; - } else { - throw new UnsupportedOperationException("Unknown type: %s".formatted(type)); - } - } - - /** - * 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 - */ - @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)); - } - } - - /** - * 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 - */ - public static TypeData typeDataFromType(Type type, ClassLoader classLoader, List> typeParameters) { - return new TypeData<>(asClass(type, 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 - */ - public static TypeData typeDataFromType(Type type, ClassLoader classLoader) { - return typeDataFromType(type, 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 deleted file mode 100644 index a58769709a8..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyAccessorGenerator.java +++ /dev/null @@ -1,160 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -import java.util.Map; - -import dev.morphia.critter.Critter; -import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.ExtensionFunctions; - -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; - -/** - * Generates a Gizmo-based {@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 { - 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; - - /** - * 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) { - super(entity, critterClassLoader); - this.propertyName = field.name; - this.propertyType = Type.getType(field.desc).getClassName(); - 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) { - super(entity, critterClassLoader); - this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - this.propertyType = Type.getReturnType(method.desc).getClassName(); - 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(); - } - - 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 void ctor() { - var constructor = getCreator().getConstructorCreator(new String[0]); - constructor.invokeSpecialMethod(ofConstructor(Object.class), constructor.getThis()); - constructor.returnVoid(); - constructor.close(); - } -} 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 deleted file mode 100644 index d7eae341bc9..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/PropertyModelGenerator.java +++ /dev/null @@ -1,584 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -import java.lang.annotation.Annotation; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.TypeVariable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import com.mongodb.DBRef; - -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.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; - -/** - * Generates a Gizmo-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; - - /** - * 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) { - 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); - } - 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(); - } - - /** - * 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) { - super(entity, critterClassLoader); - this.config = config; - this.method = method; - 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; - } - - 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; - while (current != null && current != Object.class) { - try { - current.getDeclaredField(fieldName); - return current; - } catch (NoSuchFieldException e) { - current = current.getSuperclass(); - } - } - 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; - 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); - } - } - // 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; - } - return null; - } - - private io.quarkus.gizmo.FieldCreator getModelField() { - if (modelField == null) { - modelField = getCreator().getFieldCreator("entityModel", EntityModel.class); - } - return modelField; - } - - private io.quarkus.gizmo.FieldCreator getAccessorField() { - if (accessorField == null) { - accessorField = getCreator().getFieldCreator("accessor", accessorType); - } - return accessorField; - } - - /** - * Emits the generated property model class and returns this generator. - * - * @return this generator after emitting - */ - 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])); - } - } - 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)); - } - } - - 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. - * - * @param input the ASM type signature to parse (field/return-type signature) - * @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(); - try { - TypeDataVisitor visitor = new TypeDataVisitor(classLoader); - new SignatureReader(input).acceptType(visitor); - return visitor.result != null ? List.of(visitor.result) : Collections.emptyList(); - } catch (Exception e) { - return Collections.emptyList(); - } - } - - 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; - } - - @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); - } - } - - @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; - } - } - } -} 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 deleted file mode 100644 index f2dcf1c3fa2..00000000000 --- a/core/src/main/java/dev/morphia/critter/parser/gizmo/VarHandleAccessorGenerator.java +++ /dev/null @@ -1,399 +0,0 @@ -package dev.morphia.critter.parser.gizmo; - -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; - -import dev.morphia.critter.Critter; -import dev.morphia.critter.CritterClassLoader; -import dev.morphia.critter.parser.ExtensionFunctions; - -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; - -/** - * Generates a Gizmo-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. - */ -public class VarHandleAccessorGenerator extends BaseGizmoGenerator { - 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, - "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; - private final boolean isFinalField; - private final String getterName; - private final String setterName; - - /** - * Creates a generator for a field-based property accessor using a {@link java.lang.invoke.VarHandle}. - * - * @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 VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, FieldNode field) { - super(entity, critterClassLoader); - this.propertyName = field.name; - this.propertyType = Type.getType(field.desc).getClassName(); - this.isFieldBased = true; - this.isFinalField = (field.access & Opcodes.ACC_FINAL) != 0; - this.getterName = null; - this.setterName = null; - generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - } - - /** - * Creates a generator for a method-based property accessor using {@link java.lang.invoke.MethodHandle}s. - * - * @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 VarHandleAccessorGenerator(Class entity, CritterClassLoader critterClassLoader, MethodNode method) { - super(entity, critterClassLoader); - this.propertyName = ExtensionFunctions.getterToPropertyName(method, entity); - this.propertyType = Type.getReturnType(method.desc).getClassName(); - this.isFieldBased = false; - this.isFinalField = false; - this.getterName = method.name; - this.setterName = "set%s".formatted(Critter.titleCase(propertyName)); - generatedType = "%s.%sAccessor".formatted(baseName, Critter.titleCase(propertyName)); - } - - /** - * 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); - } - - /** - * Returns the fully-qualified name of the wrapper class for the property type, - * or the property type itself if it is not a primitive. - * - * @return the wrapper type name - */ - public String getWrapperType() { - return PRIMITIVE_TO_WRAPPER.getOrDefault(propertyType, propertyType); - } - - 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); - } 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) { - // expected: this class does not declare the setter; walk up to the superclass - } - current = current.getSuperclass(); - } - return false; - } - - /** - * Emits the generated property accessor class and returns this generator. - * - * @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(); - } - } - - try (var creator = getCreator()) { - ctor(handleDesc, setterHandleDesc); - get(handleDesc); - set(handleDesc, setterHandleDesc); - } - - 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); - } else { - emitGetterHandleLookup(tryBlock, privateLookup, entityClass, handleDesc); - if (setterHandleDesc != null) { - emitSetterHandleLookup(tryBlock, privateLookup, entityClass, setterHandleDesc); - } - } - - var catchBlock = tryBlock.addCatch(ReflectiveOperationException.class); - ResultHandle ex = catchBlock.getCaughtException(); - ResultHandle wrapped = catchBlock.newInstance( - ofConstructor(RuntimeException.class, Throwable.class), ex); - catchBlock.throwException(wrapped); - - constructor.returnVoid(); - constructor.close(); - } - - /** - * Emits bytecode to obtain a private {@link MethodHandles.Lookup} for the entity class. - * The entity class is loaded via the thread context class loader to avoid classloader mismatches. - * - * @return a two-element array: {@code [entityClass, privateLookup]} - */ - private ResultHandle[] emitPrivateLookup(TryBlock tryBlock) { - ResultHandle callerLookup = tryBlock.invokeStaticMethod( - ofMethod(MethodHandles.class, "lookup", MethodHandles.Lookup.class)); - - // Load entity class via TCCL to avoid classloader mismatch - ResultHandle currentThread = tryBlock.invokeStaticMethod( - ofMethod(Thread.class, "currentThread", Thread.class)); - ResultHandle tccl = tryBlock.invokeVirtualMethod( - ofMethod(Thread.class, "getContextClassLoader", ClassLoader.class), - currentThread); - ResultHandle entityClass = tryBlock.invokeStaticMethod( - ofMethod(Class.class, "forName", Class.class, String.class, boolean.class, ClassLoader.class), - tryBlock.load(entity.getName()), - tryBlock.load(true), - tccl); - ResultHandle privateLookup = tryBlock.invokeStaticMethod( - ofMethod(MethodHandles.class, "privateLookupIn", MethodHandles.Lookup.class, Class.class, MethodHandles.Lookup.class), - entityClass, - callerLookup); - return new ResultHandle[] { entityClass, privateLookup }; - } - - private void emitVarHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass, - FieldDescriptor handleDesc) { - ResultHandle fieldTypeClass = tryBlock.loadClass(propertyType); - ResultHandle handle = tryBlock.invokeVirtualMethod( - ofMethod(MethodHandles.Lookup.class, "findVarHandle", VarHandle.class, Class.class, String.class, Class.class), - privateLookup, - entityClass, - tryBlock.load(propertyName), - fieldTypeClass); - tryBlock.writeInstanceField(handleDesc, tryBlock.getThis(), handle); - } - - private void emitGetterHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass, - FieldDescriptor handleDesc) { - ResultHandle returnTypeClass = tryBlock.loadClass(propertyType); - ResultHandle getterMethodType = tryBlock.invokeStaticMethod( - ofMethod(MethodType.class, "methodType", MethodType.class, Class.class), - returnTypeClass); - ResultHandle getterHandle = tryBlock.invokeVirtualMethod( - ofMethod(MethodHandles.Lookup.class, "findVirtual", MethodHandle.class, Class.class, String.class, MethodType.class), - privateLookup, - entityClass, - tryBlock.load(getterName), - getterMethodType); - tryBlock.writeInstanceField(handleDesc, tryBlock.getThis(), getterHandle); - } - - private void emitSetterHandleLookup(TryBlock tryBlock, ResultHandle privateLookup, ResultHandle entityClass, - FieldDescriptor setterHandleDesc) { - ResultHandle voidClass = tryBlock.loadClass(void.class); - ResultHandle paramTypeClass = tryBlock.loadClass(propertyType); - ResultHandle setterMethodType = tryBlock.invokeStaticMethod( - ofMethod(MethodType.class, "methodType", MethodType.class, Class.class, Class.class), - voidClass, - paramTypeClass); - ResultHandle setterHandle = tryBlock.invokeVirtualMethod( - ofMethod(MethodHandles.Lookup.class, "findVirtual", MethodHandle.class, Class.class, String.class, MethodType.class), - privateLookup, - entityClass, - tryBlock.load(setterName), - setterMethodType); - tryBlock.writeInstanceField(setterHandleDesc, tryBlock.getThis(), setterHandle); - } - - private void get(FieldDescriptor handleDesc) { - var method = getCreator().getMethodCreator( - ofMethod(generatedType, "get", Object.class.getName(), Object.class.getName())); - method.setSignature( - forMethod() - .addTypeParameter(typeVariable("S")) - .setReturnType(classType(propertyType)) - .addParameterType(typeVariable("S")) - .build()); - method.setParameterNames(new String[] { "model" }); - - ResultHandle model = method.getMethodParam(0); - - // Guard against null model (e.g. unwrapped lazy proxy for a missing/deleted reference) - BranchResult nullCheck = method.ifNull(model); - try (BytecodeCreator nullBranch = nullCheck.trueBranch()) { - nullBranch.returnValue(nullBranch.loadNull()); - } - - ResultHandle handleRef = method.readInstanceField(handleDesc, method.getThis()); - - ResultHandle result; - if (isFieldBased) { - result = method.invokeVirtualMethod( - ofMethod(VarHandle.class, "get", Object.class.getName(), Object.class.getName()), - handleRef, - model); - } else { - result = method.invokeVirtualMethod( - ofMethod(MethodHandle.class, "invoke", Object.class.getName(), Object.class.getName()), - handleRef, - model); - } - - 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; - 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); - } - - method.returnValue(null); - } -} 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..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,65 +1,31 @@ package dev.morphia.critter.parser.java; -import java.io.File; -import java.io.PrintWriter; -import java.io.StringWriter; +import java.util.Collections; 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 Collections.unmodifiableList(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 Collections.unmodifiableList(Critter.transientAnnotations); } } diff --git a/core/src/main/java/dev/morphia/mapping/CritterMapper.java b/core/src/main/java/dev/morphia/mapping/CritterMapper.java index 0d2ac1e3aba..1a57d361cae 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,20 +161,20 @@ 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); } 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/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/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/TestVarHandleAccessor.java b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java index f61e36570bb..989150cdf65 100644 --- a/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java +++ b/core/src/test/java/dev/morphia/critter/parser/TestVarHandleAccessor.java @@ -1,16 +1,26 @@ 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; +import dev.morphia.annotations.EntityListeners; 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.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; +import dev.morphia.mapping.ReflectiveMapper; import org.bson.codecs.pojo.PropertyAccessor; import org.bson.types.ObjectId; @@ -19,6 +29,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) @@ -28,7 +40,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 @@ -81,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 CritterGizmoGenerator(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"); + new CritterGenerator(defaultMapper()).generate(FinalFieldEntity.class, loader, true); 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 { @@ -124,6 +130,124 @@ 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 nestmate accessor, and set() works correctly. + */ + @Test + public void testStaticSetterMethodIsNotTreatedAsPropertySetter() throws Exception { + var methodsMapper = new ReflectiveMapper( + new ManualMorphiaConfig().propertyDiscovery(PropertyDiscovery.METHODS)); + CritterClassLoader loader = new CritterClassLoader(); + new CritterGenerator(methodsMapper).generate(StaticSetterEntity.class, loader, true); + + @SuppressWarnings("unchecked") + 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 nestmate accessor. + accessor.set(entity, "hello"); + 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. + * + * 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 + 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 @@ -132,15 +256,113 @@ 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); + } + + /** + * 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); + } } } 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..5a7e45dc4f9 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; @@ -7,13 +8,12 @@ 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; 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); - Class actual = GizmoExtensions.asClass(type, Thread.currentThread().getContextClassLoader()); - Assertions.assertEquals(expected, actual, "Type " + type.getDescriptor() + " should convert to " + expected.getName()); + ClassDesc type = ClassDesc.ofDescriptor(classToDescriptor(expected)); + 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/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/gizmo/TestGizmoGeneration.java b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java similarity index 61% 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 79b58116ee6..19b389bfe76 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; @@ -19,9 +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.asm.AddMethodAccessorMethods; +import dev.morphia.critter.parser.MethodInfo; import dev.morphia.critter.sources.Example; import dev.morphia.critter.sources.MethodExample; import dev.morphia.mapping.codec.pojo.EntityModel; @@ -31,31 +30,21 @@ 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 { +public class TestGeneration { private final CritterClassLoader critterClassLoader = new CritterClassLoader(); @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 +52,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 +60,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 +74,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,45 +81,13 @@ 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); + 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); @@ -213,77 +156,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()); @@ -293,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"); @@ -312,16 +228,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/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; - } -} 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-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..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 @@ -4,6 +4,7 @@ import dev.morphia.annotations.Id; import java.util.List; +import java.util.Objects; @Entity("hotels") public class Hotel { @@ -12,4 +13,25 @@ public class Hotel { private String name; private int stars; private List tags; + + @SafeVarargs + private String foo(String... bob) { + return ""; + } + + @Override + 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 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 e30c7d32f06..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 @@ -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 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 c0933a593bf..326063d35b9 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 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 1.5.3 @@ -371,9 +371,9 @@ compile - io.quarkus.gizmo - gizmo - ${gizmo.version} + io.github.dmlloyd + jdk-classfile-backport + ${classfile.backport.version} net.bytebuddy 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 }