diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts new file mode 100644 index 000000000..27bbdcbbc --- /dev/null +++ b/mybatis-processor/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("io.micronaut.build.internal.sql-module") +} + +dependencies { + implementation(projects.micronautMybatis) + implementation(mn.micronaut.core.processor) + implementation(mn.micronaut.sourcegen.model) + implementation(mn.micronaut.sourcegen.bytecode.writer) + + testImplementation(mn.micronaut.inject.java) + // Runs the GraalTypeElementVisitor in the tests to verify the generated reflection configuration + testImplementation(mn.micronaut.graal) + testImplementation(mnTest.junit.jupiter.api) + testImplementation(mnTest.junit.platform.launcher) + + testRuntimeOnly(mnTest.junit.jupiter.engine) +} + +tasks.withType { + useJUnitPlatform() +} + +micronautBuild { + binaryCompatibility { + enabledAfter("7.3.0") + } +} diff --git a/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MapperScanSupport.java b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MapperScanSupport.java new file mode 100644 index 000000000..c7a887034 --- /dev/null +++ b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MapperScanSupport.java @@ -0,0 +1,156 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.processor; + +import io.micronaut.configuration.mybatis.MyBatisMapperScan; +import io.micronaut.core.annotation.AnnotationClassValue; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.Internal; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.ElementQuery; +import io.micronaut.inject.ast.MethodElement; +import io.micronaut.inject.ast.ParameterElement; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Shared logic of the {@link MyBatisMapperScan} visitors. + */ +@Internal +final class MapperScanSupport { + + private MapperScanSupport() { + } + + /** + * Reads the members of a {@link MyBatisMapperScan} annotation. + * + * @param element The annotated element + * @return The scan, or {@code null} if the element is not annotated + */ + static Scan readScan(ClassElement element) { + AnnotationValue annotation = element.getAnnotation(MyBatisMapperScan.class); + if (annotation == null) { + return null; + } + List mappers = new ArrayList<>(); + for (AnnotationClassValue mapper : annotation.annotationClassValues("mappers")) { + mappers.add(mapper.getName()); + } + return new Scan( + element, + List.of(annotation.stringValues("value")), + mappers, + annotation.stringValue("datasource").orElse("default"), + annotation.booleanValue("nativeImageMetadata").orElse(true) + ); + } + + /** + * Collects the element and its nested types when they are interfaces. Nested interfaces are included + * because MyBatis runtime package scanning registers them as well. + * + * @param element The element + * @param interfaces The collected interfaces + */ + static void collectInterfaces(ClassElement element, List interfaces) { + if (element.isInterface() && !element.isAssignable(Annotation.class)) { + interfaces.add(element); + } + for (ClassElement inner : element.getEnclosedElements(ElementQuery.ALL_INNER_CLASSES)) { + collectInterfaces(inner, interfaces); + } + } + + /** + * @param typeName A type name + * @param packageName A package name + * @return Whether the type is in the package or one of its sub-packages + */ + static boolean isInPackage(String typeName, String packageName) { + int lastDot = typeName.lastIndexOf('.'); + String typePackage = lastDot > 0 ? typeName.substring(0, lastDot) : ""; + return typePackage.equals(packageName) || typePackage.startsWith(packageName + "."); + } + + /** + * Collects the result and parameter types of the mapper methods, unwrapping containers. These are the + * types MyBatis instantiates and populates reflectively. + * + * @param mapper The mapper interface + * @param reflectiveTypes The collected type names + */ + static void collectReflectiveTypes(ClassElement mapper, Set reflectiveTypes) { + for (MethodElement method : mapper.getEnclosedElements(ElementQuery.ALL_METHODS)) { + addReflectiveType(method.getGenericReturnType(), reflectiveTypes); + for (ParameterElement parameter : method.getParameters()) { + addReflectiveType(parameter.getGenericType(), reflectiveTypes); + } + } + } + + private static void addReflectiveType(ClassElement type, Set reflectiveTypes) { + if (type == null || type.isPrimitive() || type.isEnum()) { + return; + } + if (type.isArray()) { + addReflectiveType(type.fromArray(), reflectiveTypes); + return; + } + if (type.isAssignable(Iterable.class) + || type.isAssignable(Map.class) + || type.isAssignable(Optional.class) + || type.isAssignable("java.util.stream.Stream") + || type.isAssignable("org.reactivestreams.Publisher")) { + for (ClassElement typeArgument : type.getTypeArguments().values()) { + addReflectiveType(typeArgument, reflectiveTypes); + } + return; + } + String name = type.getName(); + if (name.startsWith("java.") || name.startsWith("javax.") || name.startsWith("jakarta.") + || name.startsWith("kotlin.") || name.startsWith("groovy.") + || name.startsWith("org.apache.ibatis.")) { + return; + } + reflectiveTypes.add(name); + } + + /** + * The members of a {@link MyBatisMapperScan} annotation. + * + * @param element The annotated element + * @param packages The scanned packages + * @param mappers The explicitly listed mappers + * @param datasource The datasource name + * @param nativeImageMetadata Whether to generate GraalVM metadata + */ + record Scan(ClassElement element, + List packages, + List mappers, + String datasource, + boolean nativeImageMetadata) { + + boolean covers(String typeName) { + return packages.stream().anyMatch(packageName -> isInPackage(typeName, packageName)); + } + } +} diff --git a/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanReflectionVisitor.java b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanReflectionVisitor.java new file mode 100644 index 000000000..856d49607 --- /dev/null +++ b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanReflectionVisitor.java @@ -0,0 +1,133 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.processor; + +import io.micronaut.configuration.mybatis.MyBatisMapperScan; +import io.micronaut.configuration.mybatis.processor.MapperScanSupport.Scan; +import io.micronaut.core.annotation.AnnotationClassValue; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.ReflectionConfig; +import io.micronaut.core.annotation.TypeHint; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.visitor.VisitorContext; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * Adds the GraalVM native image metadata of the mappers registered by {@link MyBatisMapperScan} as + * {@link ReflectionConfig} values to the annotated type: MyBatis implements mapper interfaces with + * {@link java.lang.reflect.Proxy}, and instantiates and populates their result and parameter types reflectively. + * + *

The metadata itself is generated by the Micronaut {@code GraalTypeElementVisitor} from those values, + * exactly like for {@code @ReflectiveAccess}. That visitor is {@link VisitorKind#ISOLATING} and runs at + * order {@code -200}, so this visitor must be isolating as well (isolating and aggregating visitors run in + * different annotation processors on javac) and ordered before it, see {@link #getOrder()}.

+ * + *

Like {@link MyBatisMapperScanVisitor}, the visitor visits every class because it has to see the + * unannotated mapper interfaces. Visitors process all classes before the next visitor runs, so the values + * added here are complete by the time the Graal visitor reaches the annotated type.

+ */ +@Internal +public final class MyBatisMapperScanReflectionVisitor implements TypeElementVisitor { + + /** + * {@code io.micronaut.graal.reflect.GraalTypeElementVisitor#POSITION}. + */ + private static final int GRAAL_TYPE_ELEMENT_VISITOR_POSITION = -200; + + private static final TypeHint.AccessType[] REFLECTIVE_TYPE_ACCESS = { + TypeHint.AccessType.ALL_DECLARED_CONSTRUCTORS, + TypeHint.AccessType.ALL_PUBLIC_CONSTRUCTORS, + TypeHint.AccessType.ALL_DECLARED_METHODS, + TypeHint.AccessType.ALL_PUBLIC_METHODS, + TypeHint.AccessType.ALL_DECLARED_FIELDS, + TypeHint.AccessType.ALL_PUBLIC_FIELDS + }; + + private final Map interfaceTypes = new LinkedHashMap<>(); + private final Map> scans = new LinkedHashMap<>(); + + @Override + public VisitorKind getVisitorKind() { + return VisitorKind.ISOLATING; + } + + @Override + public int getOrder() { + return GRAAL_TYPE_ELEMENT_VISITOR_POSITION + 100; + } + + @Override + public void visitClass(ClassElement element, VisitorContext context) { + List interfaces = new ArrayList<>(); + MapperScanSupport.collectInterfaces(element, interfaces); + for (ClassElement anInterface : interfaces) { + interfaceTypes.put(anInterface.getName(), anInterface); + for (Map.Entry> entry : scans.entrySet()) { + if (entry.getKey().covers(anInterface.getName())) { + register(entry.getKey(), entry.getValue(), anInterface); + } + } + } + + Scan scan = MapperScanSupport.readScan(element); + if (scan == null || !scan.nativeImageMetadata()) { + return; + } + Set registered = new HashSet<>(); + scans.put(scan, registered); + for (String mapper : scan.mappers()) { + context.getClassElement(mapper).ifPresent(mapperElement -> register(scan, registered, mapperElement)); + } + for (ClassElement anInterface : interfaceTypes.values()) { + if (scan.covers(anInterface.getName())) { + register(scan, registered, anInterface); + } + } + } + + @Override + public void finish(VisitorContext visitorContext) { + interfaceTypes.clear(); + scans.clear(); + } + + private static void register(Scan scan, Set registered, ClassElement mapper) { + if (!registered.add(mapper.getName())) { + return; + } + ClassElement element = scan.element(); + element.annotate(ReflectionConfig.class, builder -> builder + .member("type", new AnnotationClassValue<>(mapper.getName())) + .member("accessType", TypeHint.AccessType.DYNAMIC_PROXY)); + Set reflectiveTypes = new TreeSet<>(); + MapperScanSupport.collectReflectiveTypes(mapper, reflectiveTypes); + for (String reflectiveType : reflectiveTypes) { + if (registered.add(reflectiveType)) { + element.annotate(ReflectionConfig.class, builder -> builder + .member("type", new AnnotationClassValue<>(reflectiveType)) + .member("accessType", REFLECTIVE_TYPE_ACCESS)); + } + } + } +} diff --git a/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitor.java b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitor.java new file mode 100644 index 000000000..a3707ba3f --- /dev/null +++ b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitor.java @@ -0,0 +1,232 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.processor; + +import io.micronaut.configuration.mybatis.MyBatisMapperScan; +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; +import io.micronaut.configuration.mybatis.processor.MapperScanSupport.Scan; +import io.micronaut.core.annotation.Internal; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.processing.ProcessingException; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.sourcegen.bytecode.ByteCodeWriter; +import io.micronaut.sourcegen.model.ClassDef; +import io.micronaut.sourcegen.model.ClassTypeDef; +import io.micronaut.sourcegen.model.ExpressionDef; +import io.micronaut.sourcegen.model.MethodDef; +import io.micronaut.sourcegen.model.StatementDef; +import io.micronaut.sourcegen.model.TypeDef; +import org.apache.ibatis.session.Configuration; + +import javax.lang.model.element.Modifier; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Generates a {@link MyBatisMapperScanRegistration} for every type annotated with {@link MyBatisMapperScan}. + * + *

The visitor deliberately visits every class (the default {@code "*"} of + * {@link #getSupportedAnnotationNames()}) because it has to see the mapper interfaces, which carry no + * annotation, in addition to the annotated types. Only interfaces compiled in the same compilation + * unit can be discovered by package; for packages without any discovered interface a warning is + * emitted and the generated registration falls back to MyBatis runtime scanning.

+ * + *

The GraalVM native image metadata of the mappers is handled by {@link MyBatisMapperScanReflectionVisitor}.

+ */ +@Internal +public final class MyBatisMapperScanVisitor implements TypeElementVisitor { + + private static final String REGISTRATION_SUFFIX = "$MyBatisMapperScanRegistration"; + + /** + * The bytecode writer is used directly instead of {@code ByteCodeGenerator} from + * {@code micronaut-sourcegen-generator-bytecode}: that module registers a {@code SourceGenerator} service for + * Java, which other processors (e.g. Serde) on the same classpath may pick up with a mismatching version. The + * writer is already a dependency of {@code micronaut-core-processor}, so no additional sourcegen artifact is + * put on the annotation processor classpath. + */ + private static final ByteCodeWriter BYTE_CODE_WRITER = new ByteCodeWriter(false, true); + + private final Set interfaceTypes = new LinkedHashSet<>(); + private final Map scans = new LinkedHashMap<>(); + private final Set written = new HashSet<>(); + private final Set generatedRegistrations = new HashSet<>(); + + @Override + public VisitorKind getVisitorKind() { + return VisitorKind.AGGREGATING; + } + + @Override + public void visitClass(ClassElement element, VisitorContext context) { + List interfaces = new ArrayList<>(); + MapperScanSupport.collectInterfaces(element, interfaces); + for (ClassElement anInterface : interfaces) { + interfaceTypes.add(anInterface.getName()); + } + Scan scan = MapperScanSupport.readScan(element); + if (scan != null) { + scans.put(element.getName(), scan); + } + } + + @Override + public void finish(VisitorContext context) { + for (Scan scan : scans.values()) { + if (written.add(scan.element().getName())) { + generateRegistration(context, scan); + } + } + } + + private void generateRegistration(VisitorContext context, Scan scan) { + ClassElement element = scan.element(); + // Mappers are grouped by package and every group is written to a registration class in that package, + // because a class literal of a non-public interface can only be used from the same package. Explicitly + // listed mappers are referenced from the annotation, so they are accessible from the annotated type. + Map> mapperTypesByPackage = new TreeMap<>(); + mapperTypesByPackage.computeIfAbsent(element.getPackageName(), ignored -> new TreeSet<>()).addAll(scan.mappers()); + List unresolvedPackages = new ArrayList<>(); + for (String packageName : scan.packages()) { + Set discovered = discoverMappers(packageName); + if (discovered.isEmpty()) { + unresolvedPackages.add(packageName); + context.warn("No mapper interface found in package [" + packageName + "] during compilation. " + + "Mapper interfaces from other modules must be listed in the `mappers` member of @" + + MyBatisMapperScan.class.getSimpleName() + + "; MyBatis runtime scanning is used as a fallback, which is not supported in GraalVM native images.", element); + } + for (String mapperType : discovered) { + mapperTypesByPackage.computeIfAbsent(packageOf(mapperType), ignored -> new TreeSet<>()).add(mapperType); + } + } + if (mapperTypesByPackage.values().stream().allMatch(Set::isEmpty) && unresolvedPackages.isEmpty()) { + context.warn("@" + MyBatisMapperScan.class.getSimpleName() + " declares neither packages nor mappers", element); + return; + } + for (Map.Entry> entry : mapperTypesByPackage.entrySet()) { + String packageName = entry.getKey(); + Set mapperTypes = entry.getValue(); + List packages = packageName.equals(element.getPackageName()) ? unresolvedPackages : List.of(); + if (mapperTypes.isEmpty() && packages.isEmpty()) { + continue; + } + ClassDef registration = registrationDefinition(element, packageName, scan.datasource(), mapperTypes, packages); + if (!generatedRegistrations.add(registration.getName())) { + context.fail("The generated registration [" + registration.getName() + "] of @" + + MyBatisMapperScan.class.getSimpleName() + " on [" + element.getName() + + "] clashes with the registration of another annotated type. Rename one of the annotated types.", element); + return; + } + writeClass(context, element, registration); + context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element); + } + } + + /** + * The name of the registration class of an annotated type written to the given package. Registrations written + * to the package of the annotated type are named after its simple name, those written to a mapper package after + * its fully qualified name, both {@link #encode(String) encoded} so that distinct types always yield distinct + * registration names. + */ + private static String registrationName(ClassElement element, String packageName) { + String elementPackage = element.getPackageName(); + String qualifier = element.getName(); + if (packageName.equals(elementPackage)) { + int simpleNameStart = elementPackage.isEmpty() ? 0 : elementPackage.length() + 1; + qualifier = qualifier.substring(simpleNameStart); + } + String simpleName = encode(qualifier) + REGISTRATION_SUFFIX; + return packageName.isEmpty() ? simpleName : packageName + "." + simpleName; + } + + /** + * Turns a type name into an identifier fragment with a prefix-free, and therefore injective, encoding: + * {@code _} becomes {@code _u}, {@code .} becomes {@code _p} and {@code $} becomes {@code _d}. Every + * underscore of the result starts an escape sequence, so e.g. {@code a.b_.c} and {@code a.b._c}, or + * {@code Outer$Inner} and {@code Outer_Inner}, are encoded differently. + */ + private static String encode(String typeName) { + return typeName.replace("_", "_u").replace(".", "_p").replace("$", "_d"); + } + + private static String packageOf(String typeName) { + int lastDot = typeName.lastIndexOf('.'); + return lastDot > 0 ? typeName.substring(0, lastDot) : ""; + } + + private static void writeClass(VisitorContext context, ClassElement originatingElement, ClassDef classDef) { + try (OutputStream outputStream = context.visitClass(classDef.getName(), originatingElement)) { + outputStream.write(BYTE_CODE_WRITER.write(classDef, null)); + } catch (IOException e) { + throw new ProcessingException(originatingElement, "Failed to generate '" + classDef.getName() + "': " + e.getMessage(), e); + } + } + + private Set discoverMappers(String packageName) { + Set discovered = new TreeSet<>(); + for (String interfaceType : interfaceTypes) { + if (MapperScanSupport.isInPackage(interfaceType, packageName)) { + discovered.add(interfaceType); + } + } + return discovered; + } + + private static ClassDef registrationDefinition(ClassElement element, + String packageName, + String datasource, + Set mapperTypes, + List unresolvedPackages) { + String registrationName = registrationName(element, packageName); + return ClassDef.builder(registrationName) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) + .addSuperinterface(ClassTypeDef.of(MyBatisMapperScanRegistration.class)) + .addMethod(MethodDef.builder("getDatasourceName") + .overrides() + .addModifiers(Modifier.PUBLIC) + .returns(String.class) + .build((aThis, parameters) -> ExpressionDef.constant(datasource).returning())) + .addMethod(MethodDef.builder("register") + .overrides() + .addModifiers(Modifier.PUBLIC) + .addParameter("configuration", Configuration.class) + .build((aThis, parameters) -> { + ExpressionDef configuration = parameters.get(0); + List statements = new ArrayList<>(); + for (String mapperType : mapperTypes) { + statements.add(aThis.invoke("addMapper", TypeDef.VOID, + configuration, ExpressionDef.constant(ClassTypeDef.of(mapperType)))); + } + for (String unresolvedPackage : unresolvedPackages) { + statements.add(aThis.invoke("addMappers", TypeDef.VOID, + configuration, ExpressionDef.constant(unresolvedPackage))); + } + return StatementDef.multi(statements); + })) + .build(); + } +} diff --git a/mybatis-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor b/mybatis-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor new file mode 100644 index 000000000..fb7b01959 --- /dev/null +++ b/mybatis-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor @@ -0,0 +1,2 @@ +io.micronaut.configuration.mybatis.processor.MyBatisMapperScanVisitor +io.micronaut.configuration.mybatis.processor.MyBatisMapperScanReflectionVisitor diff --git a/mybatis-processor/src/test/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitorTest.java b/mybatis-processor/src/test/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitorTest.java new file mode 100644 index 000000000..7f4cc0f62 --- /dev/null +++ b/mybatis-processor/src/test/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitorTest.java @@ -0,0 +1,480 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.processor; + +import io.micronaut.annotation.processing.TypeElementVisitorProcessor; +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; +import io.micronaut.core.graal.GraalReflectionConfigurer; +import io.micronaut.core.io.service.SoftServiceLoader; +import io.micronaut.graal.reflect.GraalTypeElementVisitor; +import io.micronaut.inject.visitor.TypeElementVisitor; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MyBatisMapperScanVisitorTest { + + @Test + void generatesRegistrationForDiscoveredMappers(@TempDir Path temporaryDirectory) throws Exception { + Compilation compilation = compile(temporaryDirectory, List.of( + new InMemoryJavaFileObject("example.config.MapperConfiguration", """ + package example.config; + + import io.micronaut.configuration.mybatis.MyBatisMapperScan; + + @MyBatisMapperScan(value = "example.mappers", datasource = "orders", mappers = example.other.OtherMapper.class) + class MapperConfiguration { + } + """), + new InMemoryJavaFileObject("example.domain.Genre", """ + package example.domain; + + public class Genre { + private Long id; + private String name; + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """), + new InMemoryJavaFileObject("example.domain.Book", """ + package example.domain; + + public record Book(Long id, String title) { + } + """), + new InMemoryJavaFileObject("example.mappers.GenreMapper", """ + package example.mappers; + + import example.domain.Book; + import example.domain.Genre; + import org.apache.ibatis.annotations.Select; + import java.util.List; + import java.util.Map; + import java.util.Optional; + + public interface GenreMapper { + @Select("select 1") + int findOne(); + @Select("select * from genre") + List findAll(); + @Select("select * from book") + Optional> findBooks(); + void save(Genre genre, String[] tags, long id); + } + """), + new InMemoryJavaFileObject("example.mappers.nested.NestedMapper", """ + package example.mappers.nested; + + public interface NestedMapper { + } + """), + new InMemoryJavaFileObject("example.mappers.PackagePrivateMapper", """ + package example.mappers; + + interface PackagePrivateMapper { + } + """), + new InMemoryJavaFileObject("example.mappers.Mappers", """ + package example.mappers; + + public final class Mappers { + public interface InnerMapper { + } + private interface PrivateMapper { + } + public static class Helper { + public interface DeepMapper { + } + } + } + """), + new InMemoryJavaFileObject("example.other.OtherMapper", """ + package example.other; + + public interface OtherMapper { + } + """), + new InMemoryJavaFileObject("example.other.NotScannedMapper", """ + package example.other; + + public interface NotScannedMapper { + } + """) + )); + + assertTrue(compilation.success(), compilation.diagnostics()); + // one registration per mapper package, so that non-public mappers can be referenced, plus the + // explicitly listed mappers in the package of the annotated type + assertTrue(Files.exists(compilation.classes().resolve( + "example/config/MapperConfiguration$MyBatisMapperScanRegistration.class"))); + assertTrue(Files.exists(compilation.classes().resolve( + "example/mappers/example_pconfig_pMapperConfiguration$MyBatisMapperScanRegistration.class"))); + assertTrue(Files.exists(compilation.classes().resolve( + "example/mappers/nested/example_pconfig_pMapperConfiguration$MyBatisMapperScanRegistration.class"))); + + try (URLClassLoader classLoader = compilation.classLoader()) { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader).collectAll(registrations); + + assertEquals(3, registrations.size()); + Configuration configuration = new Configuration(); + for (MyBatisMapperScanRegistration registration : registrations) { + assertEquals("orders", registration.getDatasourceName()); + registration.register(configuration); + } + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.GenreMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.PackagePrivateMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.nested.NestedMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.Mappers$InnerMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.Mappers$PrivateMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.Mappers$Helper$DeepMapper"))); + assertFalse(configuration.hasMapper(classLoader.loadClass("example.mappers.Mappers"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.other.OtherMapper"))); + assertFalse(configuration.hasMapper(classLoader.loadClass("example.other.NotScannedMapper"))); + + // registering twice must not fail with a MyBatis "already known" error + for (MyBatisMapperScanRegistration registration : registrations) { + registration.register(configuration); + } + } + + try (URLClassLoader classLoader = compilation.classLoader()) { + RecordingReflectionContext reflection = reflectionConfiguration(classLoader, "example.config.$MapperConfiguration"); + assertEquals(Set.of( + "example.mappers.GenreMapper", + "example.mappers.Mappers$Helper$DeepMapper", + "example.mappers.Mappers$InnerMapper", + "example.mappers.Mappers$PrivateMapper", + "example.mappers.PackagePrivateMapper", + "example.mappers.nested.NestedMapper", + "example.other.OtherMapper" + ), reflection.proxies); + // GraalReflectionConfigurer registers every configured type, the mappers included + assertTrue(reflection.types.containsAll(Set.of("example.domain.Book", "example.domain.Genre")), reflection.types.toString()); + assertFalse(reflection.types.contains("example.other.NotScannedMapper"), reflection.types.toString()); + assertFalse(reflection.types.contains("java.lang.String"), reflection.types.toString()); + assertTrue(reflection.members.contains("example.domain.Genre#setName"), reflection.members.toString()); + assertTrue(reflection.members.contains("example.domain.Genre#"), reflection.members.toString()); + assertTrue(reflection.members.contains("example.domain.Genre.name"), reflection.members.toString()); + } + } + + @Test + void registersExplicitMappersWithoutPackages(@TempDir Path temporaryDirectory) throws Exception { + Compilation compilation = compile(temporaryDirectory, List.of( + new InMemoryJavaFileObject("example.config.MapperConfiguration", """ + package example.config; + + import io.micronaut.configuration.mybatis.MyBatisMapperScan; + + @MyBatisMapperScan(mappers = {example.other.OtherMapper.class, example.other.SecondMapper.class}, nativeImageMetadata = false) + class MapperConfiguration { + } + """), + new InMemoryJavaFileObject("example.other.OtherMapper", """ + package example.other; + + public interface OtherMapper { + } + """), + new InMemoryJavaFileObject("example.other.SecondMapper", """ + package example.other; + + public interface SecondMapper { + } + """), + new InMemoryJavaFileObject("example.other.NotListedMapper", """ + package example.other; + + public interface NotListedMapper { + } + """) + )); + + assertTrue(compilation.success(), compilation.diagnostics()); + assertFalse(compilation.diagnostics().contains("No mapper interface found")); + + try (URLClassLoader classLoader = compilation.classLoader()) { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader).collectAll(registrations); + + assertEquals(1, registrations.size()); + assertEquals("default", registrations.get(0).getDatasourceName()); + + Configuration configuration = new Configuration(); + registrations.get(0).register(configuration); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.other.OtherMapper"))); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.other.SecondMapper"))); + assertFalse(configuration.hasMapper(classLoader.loadClass("example.other.NotListedMapper"))); + } + + // nativeImageMetadata = false: no reflection configuration is generated + assertFalse(Files.exists(compilation.classes().resolve("example/config/$MapperConfiguration$ReflectConfig.class"))); + } + + @Test + void registrationNamesOfSimilarTypesDoNotCollide(@TempDir Path temporaryDirectory) throws Exception { + Compilation compilation = compile(temporaryDirectory, List.of( + new InMemoryJavaFileObject("a.b_.c.Config", """ + package a.b_.c; + + @io.micronaut.configuration.mybatis.MyBatisMapperScan("example.mappers") + class Config { + } + """), + new InMemoryJavaFileObject("a.b._c.Config", """ + package a.b._c; + + @io.micronaut.configuration.mybatis.MyBatisMapperScan(value = "example.mappers", datasource = "second") + class Config { + } + """), + new InMemoryJavaFileObject("example.mappers.Outer$Inner", """ + package example.mappers; + + // a top-level type whose name contains a dollar, as a nested type's binary name does + @io.micronaut.configuration.mybatis.MyBatisMapperScan(value = "example.mappers", datasource = "third") + class Outer$Inner { + } + """), + new InMemoryJavaFileObject("example.mappers.Outer_Inner", """ + package example.mappers; + + @io.micronaut.configuration.mybatis.MyBatisMapperScan(value = "example.mappers", datasource = "fourth") + class Outer_Inner { + } + """), + new InMemoryJavaFileObject("example.mappers.GenreMapper", """ + package example.mappers; + + public interface GenreMapper { + } + """) + )); + + assertTrue(compilation.success(), compilation.diagnostics()); + for (String registration : List.of( + "a_pb_u_pc_pConfig", + "a_pb_p_uc_pConfig", + "Outer_dInner", + "Outer_uInner")) { + assertTrue(Files.exists(compilation.classes().resolve( + "example/mappers/" + registration + "$MyBatisMapperScanRegistration.class")), registration); + } + + try (URLClassLoader classLoader = compilation.classLoader()) { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader).collectAll(registrations); + assertEquals(Set.of("default", "second", "third", "fourth"), + registrations.stream().map(MyBatisMapperScanRegistration::getDatasourceName).collect(Collectors.toSet())); + } + } + + @Test + void warnsAboutPackagesWithoutMappers(@TempDir Path temporaryDirectory) throws Exception { + Compilation compilation = compile(temporaryDirectory, List.of( + new InMemoryJavaFileObject("example.config.MapperConfiguration", """ + package example.config; + + import io.micronaut.configuration.mybatis.MyBatisMapperScan; + + @MyBatisMapperScan("example.missing") + class MapperConfiguration { + } + """) + )); + + assertTrue(compilation.success(), compilation.diagnostics()); + assertTrue(compilation.diagnostics().contains("No mapper interface found in package [example.missing]")); + assertTrue(Files.exists(compilation.classes().resolve( + "example/config/MapperConfiguration$MyBatisMapperScanRegistration.class"))); + } + + @Test + void visitsAllClassesAndAggregates() { + MyBatisMapperScanVisitor visitor = new MyBatisMapperScanVisitor(); + + assertEquals(Set.of("*"), visitor.getSupportedAnnotationNames()); + assertEquals(TypeElementVisitor.VisitorKind.AGGREGATING, visitor.getVisitorKind()); + + MyBatisMapperScanReflectionVisitor reflectionVisitor = new MyBatisMapperScanReflectionVisitor(); + assertEquals(Set.of("*"), reflectionVisitor.getSupportedAnnotationNames()); + assertEquals(TypeElementVisitor.VisitorKind.ISOLATING, reflectionVisitor.getVisitorKind()); + assertTrue(reflectionVisitor.getOrder() > GraalTypeElementVisitor.POSITION, + "must run before the GraalTypeElementVisitor so that the @ReflectionConfig values are picked up"); + } + + private static Compilation compile(Path temporaryDirectory, List sources) throws Exception { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + Path classes = temporaryDirectory.resolve("classes"); + Path generatedSources = temporaryDirectory.resolve("generated-sources"); + Files.createDirectories(classes); + Files.createDirectories(generatedSources); + + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null, null)) { + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, List.of(classes.toFile())); + fileManager.setLocation(StandardLocation.SOURCE_OUTPUT, List.of(generatedSources.toFile())); + + JavaCompiler.CompilationTask task = compiler.getTask( + null, + fileManager, + diagnostics, + List.of("-classpath", System.getProperty("java.class.path")), + null, + sources + ); + task.setProcessors(List.of( + new TestTypeElementVisitorProcessor(TypeElementVisitor.VisitorKind.ISOLATING, + new MyBatisMapperScanReflectionVisitor(), new GraalTypeElementVisitor()), + new TestTypeElementVisitorProcessor(TypeElementVisitor.VisitorKind.AGGREGATING, + new MyBatisMapperScanVisitor()) + )); + boolean success = task.call(); + String messages = diagnostics.getDiagnostics().stream() + .map(Diagnostic::toString) + .collect(Collectors.joining(System.lineSeparator())); + return new Compilation(success, messages, classes); + } + } + + private record Compilation(boolean success, String diagnostics, Path classes) { + + URLClassLoader classLoader() throws Exception { + return new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader()); + } + } + + /** + * Loads the generated {@code $ReflectConfig} class of the given type and records what it registers. + */ + private static RecordingReflectionContext reflectionConfiguration(URLClassLoader classLoader, String typeName) throws Exception { + Class configurerClass = classLoader.loadClass(typeName + GraalReflectionConfigurer.CLASS_SUFFIX); + GraalReflectionConfigurer configurer = (GraalReflectionConfigurer) configurerClass.getDeclaredConstructor().newInstance(); + RecordingReflectionContext context = new RecordingReflectionContext(classLoader); + configurer.configure(context); + return context; + } + + private static final class RecordingReflectionContext implements GraalReflectionConfigurer.ReflectionConfigurationContext { + private final ClassLoader classLoader; + private final Set proxies = new LinkedHashSet<>(); + private final Set types = new LinkedHashSet<>(); + private final Set members = new LinkedHashSet<>(); + + private RecordingReflectionContext(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + @Override + public Class findClassByName(String name) { + try { + return classLoader.loadClass(name); + } catch (ClassNotFoundException e) { + return null; + } + } + + @Override + public void register(Class... classes) { + Arrays.stream(classes).map(Class::getName).forEach(types::add); + } + + @Override + public void register(Method... methods) { + Arrays.stream(methods).map(m -> m.getDeclaringClass().getName() + "#" + m.getName()).forEach(members::add); + } + + @Override + public void register(Field... fields) { + Arrays.stream(fields).map(f -> f.getDeclaringClass().getName() + "." + f.getName()).forEach(members::add); + } + + @Override + public void register(Constructor... constructors) { + Arrays.stream(constructors).map(c -> c.getDeclaringClass().getName() + "#").forEach(members::add); + } + + @Override + public void registerDynamicProxy(Class... interfaces) { + proxies.add(Arrays.stream(interfaces).map(Class::getName).collect(Collectors.joining(","))); + } + } + + private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { + private final TypeElementVisitor.VisitorKind kind; + private final List> visitors; + + private TestTypeElementVisitorProcessor(TypeElementVisitor.VisitorKind kind, TypeElementVisitor... visitors) { + this.kind = kind; + this.visitors = List.of(visitors); + } + + @Override + protected Collection> findTypeElementVisitors() { + return visitors; + } + + @Override + protected TypeElementVisitor.VisitorKind getIncrementalProcessorKind() { + return kind; + } + } + + private static final class InMemoryJavaFileObject extends SimpleJavaFileObject { + private final String source; + + private InMemoryJavaFileObject(String className, String source) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.source = source; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return source; + } + } +} diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java index aa4a6968a..57c00b4c3 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java @@ -15,11 +15,13 @@ */ package io.micronaut.configuration.mybatis; +import io.micronaut.context.BeanContext; import io.micronaut.context.BeanLocator; import io.micronaut.context.annotation.Bean; import io.micronaut.context.annotation.EachBean; import io.micronaut.context.annotation.Factory; import io.micronaut.context.annotation.Parameter; +import io.micronaut.core.io.service.SoftServiceLoader; import io.micronaut.inject.qualifiers.Qualifiers; import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.session.Configuration; @@ -31,6 +33,8 @@ import org.jspecify.annotations.Nullable; import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.List; /** * Configures MyBatis beans from Micronaut {@link DataSource} beans. @@ -64,9 +68,28 @@ public Configuration myBatisConfiguration( )) { customizer.customize(configuration); } + // Mapper registrations run after the customizers so that type aliases, type handlers etc. + // configured by customizers are available when the mapper interfaces are parsed. + for (MyBatisMapperScanRegistration registration : mapperScanRegistrations(beanLocator)) { + if (name.equals(registration.getDatasourceName())) { + registration.register(configuration); + } + } return configuration; } + private static List mapperScanRegistrations(BeanLocator beanLocator) { + // The generated registrations live with the application classes, which may be loaded by a + // child of the class loader that loaded this factory, so use the context's class loader. + ClassLoader classLoader = beanLocator instanceof BeanContext beanContext + ? beanContext.getClassLoader() + : MyBatisFactory.class.getClassLoader(); + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader) + .collectAll(registrations); + return registrations; + } + /** * Creates the MyBatis {@link SqlSessionFactory} for a datasource. * diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java new file mode 100644 index 000000000..921af7826 --- /dev/null +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Requests compile-time discovery of MyBatis mapper interfaces. + * + *

The {@code micronaut-mybatis-processor} generates a {@link MyBatisMapperScanRegistration} + * for every annotated type. The registration adds the mapper interfaces found in {@link #value()} + * and listed in {@link #mappers()} to the MyBatis {@code Configuration} of the {@link #datasource()} + * without runtime classpath scanning, which makes the registration work in GraalVM native images.

+ * + *

The annotation can be placed on any type, it does not have to be a bean. Only mapper interfaces + * compiled together with the annotated type are discovered by package; mapper interfaces from other + * modules must be listed in {@link #mappers()}.

+ * + * @since 7.3.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface MyBatisMapperScan { + + /** + * @return Packages containing MyBatis mapper interfaces. Sub-packages and nested interfaces are included. + */ + String[] value() default {}; + + /** + * @return The datasource name to which the discovered mappers are registered + */ + String datasource() default "default"; + + /** + * @return Mapper interfaces to register directly + */ + Class[] mappers() default {}; + + /** + * Whether to generate the GraalVM native image metadata (dynamic proxy entries for the mapper interfaces + * and reflection entries for their result and parameter types), using the same mechanism as + * {@code @ReflectiveAccess}. The metadata is ignored on the JVM, so it only needs to be disabled when a + * project wants to manage the native image configuration itself. + * + * @return Whether to generate GraalVM native image metadata + */ + boolean nativeImageMetadata() default true; +} diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java new file mode 100644 index 000000000..ed6d0d870 --- /dev/null +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java @@ -0,0 +1,74 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import org.apache.ibatis.io.ResolverUtil; +import org.apache.ibatis.session.Configuration; + +/** + * Registers mapper interfaces with the MyBatis {@link Configuration} of a datasource. + * + *

Implementations are generated by the {@code micronaut-mybatis-processor} for every + * {@link MyBatisMapperScan} and discovered through {@code META-INF/services}. They are applied + * by {@link MyBatisFactory} after all {@link MyBatisConfigurationCustomizer} beans.

+ * + * @since 7.3.0 + */ +public interface MyBatisMapperScanRegistration { + + /** + * @return The name of the datasource whose configuration receives the mappers + */ + String getDatasourceName(); + + /** + * Registers the mapper interfaces. + * + * @param configuration The MyBatis configuration + */ + void register(Configuration configuration); + + /** + * Adds a mapper interface unless it is already known to the configuration. + * + * @param configuration The MyBatis configuration + * @param mapperType The mapper interface + */ + default void addMapper(Configuration configuration, Class mapperType) { + if (!configuration.hasMapper(mapperType)) { + configuration.addMapper(mapperType); + } + } + + /** + * Adds all mapper interfaces of a package using MyBatis runtime scanning. This is the JVM fallback + * for packages in which the annotation processor could not discover any mapper interface. Unlike + * {@link Configuration#addMappers(String)}, interfaces already known to the configuration (for example + * because they are also listed explicitly) are skipped. + * + * @param configuration The MyBatis configuration + * @param packageName The package name + */ + default void addMappers(Configuration configuration, String packageName) { + ResolverUtil resolverUtil = new ResolverUtil<>(); + resolverUtil.find(new ResolverUtil.IsA(Object.class), packageName); + for (Class mapperType : resolverUtil.getClasses()) { + if (mapperType.isInterface()) { + addMapper(configuration, mapperType); + } + } + } +} diff --git a/mybatis/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis/reflect-config.json b/mybatis/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis/reflect-config.json new file mode 100644 index 000000000..4e34af3cf --- /dev/null +++ b/mybatis/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis/reflect-config.json @@ -0,0 +1,83 @@ +[ + { + "name": "org.apache.ibatis.javassist.util.proxy.ProxyFactory", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "org.apache.ibatis.scripting.xmltags.XMLLanguageDriver", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "name": "org.apache.ibatis.scripting.defaults.RawLanguageDriver", + "allDeclaredConstructors": true, + "allDeclaredMethods": true + }, + { + "name": "org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.logging.jdk14.Jdk14LoggingImpl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.logging.log4j.Log4jImpl" + }, + { + "name": "org.apache.ibatis.logging.log4j2.Log4j2Impl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.logging.slf4j.Slf4jImpl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.logging.stdout.StdOutImpl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.logging.nologging.NoLoggingImpl", + "methods": [ + { + "name": "", + "parameterTypes": ["java.lang.String"] + } + ] + }, + { + "name": "org.apache.ibatis.io.JBoss6VFS", + "allDeclaredConstructors": true + }, + { + "name": "org.apache.ibatis.io.DefaultVFS", + "allDeclaredConstructors": true + } +] diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java index ae5189539..08b28734d 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java @@ -15,19 +15,28 @@ */ package io.micronaut.configuration.mybatis; +import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; +import io.micronaut.configuration.mybatis.generated.TestOtherDataSourceMapper; +import io.micronaut.configuration.mybatis.support.TestTransactionFactory; import io.micronaut.context.ApplicationContext; import io.micronaut.inject.qualifiers.Qualifiers; -import io.micronaut.configuration.mybatis.support.TestTransactionFactory; import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.session.SqlSessionManager; import org.apache.ibatis.transaction.TransactionFactory; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import javax.sql.DataSource; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.Statement; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -61,6 +70,8 @@ void createsMyBatisBeansForTheDefaultDataSource() throws Exception { Configuration configuration = applicationContext.getBean(Configuration.class); assertTrue(configuration.hasMapper(TestMapper.class)); + assertTrue(configuration.hasMapper(TestGeneratedMapper.class)); + assertFalse(configuration.hasMapper(TestOtherDataSourceMapper.class)); assertTrue(configuration.isMapUnderscoreToCamelCase()); assertInstanceOf(TestTransactionFactory.class, configuration.getEnvironment().getTransactionFactory()); assertSame( @@ -94,10 +105,53 @@ void ignoresWronglyQualifiedBeansAndFallsBackToJdbcTransactions() { Configuration configuration = applicationContext.getBean(Configuration.class); assertFalse(configuration.hasMapper(TestMapper.class)); + assertFalse(configuration.hasMapper(TestGeneratedMapper.class)); + assertFalse(configuration.hasMapper(TestOtherDataSourceMapper.class)); assertInstanceOf(JdbcTransactionFactory.class, configuration.getEnvironment().getTransactionFactory()); } } + @Test + void appliesMapperScanRegistrationsPerDataSource() { + try (ApplicationContext applicationContext = ApplicationContext.builder("test") + .properties(Map.of("datasources.default", Map.of(), "datasources.other", Map.of())) + .start()) { + Configuration defaultConfiguration = applicationContext.getBean(Configuration.class, Qualifiers.byName("default")); + assertTrue(defaultConfiguration.hasMapper(TestGeneratedMapper.class)); + assertFalse(defaultConfiguration.hasMapper(TestOtherDataSourceMapper.class)); + + Configuration otherConfiguration = applicationContext.getBean(Configuration.class, Qualifiers.byName("other")); + assertTrue(otherConfiguration.hasMapper(TestOtherDataSourceMapper.class)); + assertFalse(otherConfiguration.hasMapper(TestGeneratedMapper.class)); + } + } + + @Test + void loadsMapperScanRegistrationsFromTheContextClassLoader(@TempDir Path tempDir) throws Exception { + Path serviceFile = tempDir.resolve("META-INF/services/" + MyBatisMapperScanRegistration.class.getName()); + Files.createDirectories(serviceFile.getParent()); + Files.writeString(serviceFile, TestChildClassLoaderMapperScanRegistration.class.getName()); + URL serviceUrl = serviceFile.toUri().toURL(); + // Only the child sees the service descriptor, like application classes loaded by a child of the framework loader + ClassLoader childClassLoader = new ClassLoader(MyBatisFactory.class.getClassLoader()) { + @Override + protected Enumeration findResources(String name) { + return name.equals("META-INF/services/" + MyBatisMapperScanRegistration.class.getName()) + ? Collections.enumeration(List.of(serviceUrl)) + : Collections.emptyEnumeration(); + } + }; + + try (ApplicationContext applicationContext = ApplicationContext.builder("test") + .classLoader(childClassLoader) + .properties(Map.of("datasources.default", Map.of())) + .start()) { + Configuration configuration = applicationContext.getBean(Configuration.class); + assertTrue(configuration.hasMapper(TestGeneratedMapper.class)); + assertTrue(configuration.hasMapper(TestOtherDataSourceMapper.class)); + } + } + private static void initializeSchema(DataSource dataSource) throws Exception { try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistrationTest.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistrationTest.java new file mode 100644 index 000000000..972bd9cb2 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistrationTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; +import io.micronaut.configuration.mybatis.generated.TestOtherDataSourceMapper; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MyBatisMapperScanRegistrationTest { + + private final MyBatisMapperScanRegistration registration = new MyBatisMapperScanRegistration() { + @Override + public String getDatasourceName() { + return "default"; + } + + @Override + public void register(Configuration configuration) { + // an explicitly listed mapper that is also found by the package fallback + addMapper(configuration, TestGeneratedMapper.class); + addMappers(configuration, TestGeneratedMapper.class.getPackageName()); + } + }; + + @Test + void packageFallbackSkipsAlreadyRegisteredMappers() { + Configuration configuration = new Configuration(); + registration.register(configuration); + assertTrue(configuration.hasMapper(TestGeneratedMapper.class)); + assertTrue(configuration.hasMapper(TestOtherDataSourceMapper.class)); + + // and is idempotent + registration.register(configuration); + } +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestChildClassLoaderMapperScanRegistration.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestChildClassLoaderMapperScanRegistration.java new file mode 100644 index 000000000..13f3f62bf --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestChildClassLoaderMapperScanRegistration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import io.micronaut.configuration.mybatis.generated.TestOtherDataSourceMapper; +import org.apache.ibatis.session.Configuration; + +/** + * A registration that is only listed in a service descriptor visible to a child class loader. + */ +public final class TestChildClassLoaderMapperScanRegistration implements MyBatisMapperScanRegistration { + + @Override + public String getDatasourceName() { + return "default"; + } + + @Override + public void register(Configuration configuration) { + addMapper(configuration, TestOtherDataSourceMapper.class); + } +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java new file mode 100644 index 000000000..81d55995a --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; +import org.apache.ibatis.session.Configuration; + +/** + * Stands in for a registration generated by the annotation processor, registered through {@code META-INF/services}. + */ +public final class TestGeneratedMapperScanRegistration implements MyBatisMapperScanRegistration { + + @Override + public String getDatasourceName() { + return "default"; + } + + @Override + public void register(Configuration configuration) { + // TestMapper is also added by TestMyBatisConfigurationCustomizer; addMapper must tolerate that + addMapper(configuration, TestMapper.class); + addMapper(configuration, TestGeneratedMapper.class); + } +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java new file mode 100644 index 000000000..c51d59a87 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis; + +import io.micronaut.configuration.mybatis.generated.TestOtherDataSourceMapper; +import org.apache.ibatis.session.Configuration; + +/** + * A registration for a datasource that is not the default one. + */ +public final class TestOtherDataSourceMapperScanRegistration implements MyBatisMapperScanRegistration { + + @Override + public String getDatasourceName() { + return "other"; + } + + @Override + public void register(Configuration configuration) { + addMapper(configuration, TestOtherDataSourceMapper.class); + } +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestGeneratedMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestGeneratedMapper.java new file mode 100644 index 000000000..ae33c1687 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestGeneratedMapper.java @@ -0,0 +1,19 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.generated; + +public interface TestGeneratedMapper { +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java new file mode 100644 index 000000000..ffe574839 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java @@ -0,0 +1,19 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.configuration.mybatis.generated; + +public interface TestOtherDataSourceMapper { +} diff --git a/mybatis/src/test/resources/META-INF/services/io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration b/mybatis/src/test/resources/META-INF/services/io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration new file mode 100644 index 000000000..0131116ec --- /dev/null +++ b/mybatis/src/test/resources/META-INF/services/io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration @@ -0,0 +1,2 @@ +io.micronaut.configuration.mybatis.TestGeneratedMapperScanRegistration +io.micronaut.configuration.mybatis.TestOtherDataSourceMapperScanRegistration diff --git a/settings.gradle b/settings.gradle index 538eb5cdf..547c4f2b5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -45,6 +45,7 @@ include 'vertx-mysql-client' include 'vertx-pg-client' include 'jdbi' include 'mybatis' +include 'mybatis-processor' include 'tests:common' include 'tests:common-reactive' diff --git a/src/main/docs/guide/mybatis/mybatis-customizers.adoc b/src/main/docs/guide/mybatis/mybatis-customizers.adoc index 75045175f..7a5b78cb3 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -5,7 +5,26 @@ For example, you can register mapper interfaces and tune MyBatis settings for a snippet::example.micronaut.CustomConfigurationCustomizer[tags="imports,clazz", project-base="tests/mybatis"] -This hook is also the right place to configure other MyBatis features that belong on the `Configuration`, such as type aliases, type handlers, interceptors, or additional mapper registrations. +The `@Named("default")` qualifier applies the customizer to the `default` datasource. The `customize` method is the place for any MyBatis feature that belongs on the `Configuration`, such as type aliases, type handlers, interceptors, or additional mapper registrations. The `@MyBatisMapperScan` annotation registers the mapper interfaces of the `example.micronaut.mappers` package at compile time, see below. Python has no annotation processor, so the Python example uses MyBatis runtime scanning with `configuration.addMappers(...)` instead. + +==== Compile-time Mapper Registration + +MyBatis normally discovers mapper interfaces by scanning the classpath at runtime (`configuration.addMappers("com.example.mappers")`). Classpath scanning does not work in GraalVM native images. Instead, annotate any class with api:configuration.mybatis.MyBatisMapperScan[] and add the `micronaut-mybatis-processor` to your build: + +dependency:micronaut-mybatis-processor[groupId="io.micronaut.sql", scope="annotationProcessor"] + +NOTE: For Kotlin, add the `micronaut-mybatis-processor` dependency in https://docs.micronaut.io/latest/guide/#kaptOrKsp[kapt or ksp scope], and for Groovy add `micronaut-mybatis-processor` in compileOnly scope. + +The processor generates a api:configuration.mybatis.MyBatisMapperScanRegistration[] for every annotated class. It registers the mapper interfaces found in the given packages (including sub-packages) and any listed in the `mappers` member with the `Configuration` of the datasource named by the `datasource` member (`default` if not set). Registrations are applied after all customizers, so type aliases and type handlers set up by a customizer are available when the mapper interfaces are parsed. The processor also generates the GraalVM metadata for the mappers, see <>. The annotation does not need to be placed on a bean; the customizer above is just a convenient place. + +Only mapper interfaces compiled together with the annotated class are discovered by package. For mapper interfaces from other modules, list them explicitly: + +[source,java] +---- +@MyBatisMapperScan(value = "com.example.mappers", mappers = OtherModuleMapper.class, datasource = "orders") +---- + +If no mapper interface is found in a package at compile time, the processor emits a warning and the generated registration falls back to MyBatis runtime scanning for that package. That fallback works on the JVM but not in a native image. ==== TransactionFactory Customization diff --git a/src/main/docs/guide/mybatis/mybatis-graalvm.adoc b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc new file mode 100644 index 000000000..ab8509ad8 --- /dev/null +++ b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc @@ -0,0 +1,10 @@ +When you use api:configuration.mybatis.MyBatisMapperScan[] (see <>), the `micronaut-mybatis-processor` generates the GraalVM metadata for your mappers: + +* every mapper interface is registered as a dynamic proxy, because MyBatis implements mappers with `java.lang.reflect.Proxy` +* the result and parameter types of the mapper methods (unwrapping arrays, collections, maps, `Optional` and reactive publishers) are registered for reflection, because MyBatis instantiates and populates them reflectively + +The metadata is generated through the same mechanism as `@ReflectiveAccess`, which requires `micronaut-graal` on the annotation processor path; the Micronaut Gradle and Maven plugins add it automatically for native image builds. It is ignored when running on the JVM, so no manual configuration is needed for the common case. Set `nativeImageMetadata = false` on the annotation if you prefer to manage the native image configuration yourself. + +Types that MyBatis reaches only through mapping metadata rather than method signatures still have to be registered manually, for example the `javaType` of a `@Result`, association or collection types of nested result maps, or custom type handlers and object factories. Annotate such classes with `@ReflectiveAccess`, or declare them in your own `reflect-config.json`. + +Mapper interfaces registered by MyBatis runtime package scanning (`configuration.addMappers(String)`) are not supported in native images. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 222cddba9..174bd4c9d 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -39,6 +39,7 @@ mybatis: title: Configuring MyBatis mybatis-session-access: Injecting MyBatis runtime beans mybatis-customizers: Configuring MyBatis customizers + mybatis-graalvm: GraalVM native image vertxmysqlclient: title: Configuring Reactive MySQL Client vertxmysqlclient-healthchecks: MySQL Health Checks diff --git a/tests/mybatis-groovy/build.gradle.kts b/tests/mybatis-groovy/build.gradle.kts index ac4dc19d6..12ffa6a92 100644 --- a/tests/mybatis-groovy/build.gradle.kts +++ b/tests/mybatis-groovy/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { testCompileOnly(mn.micronaut.inject.groovy) + testCompileOnly(projects.micronautMybatisProcessor) testCompileOnly(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) testCompileOnly(mnValidation.micronaut.validation.processor) diff --git a/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy b/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy index c7b4fa0f6..2d0b61318 100644 --- a/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy +++ b/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy @@ -2,18 +2,19 @@ package example.micronaut // tag::imports[] import io.micronaut.configuration.mybatis.MyBatisConfigurationCustomizer +import io.micronaut.configuration.mybatis.MyBatisMapperScan import jakarta.inject.Named import jakarta.inject.Singleton import org.apache.ibatis.session.Configuration // end::imports[] // tag::clazz[] +@MyBatisMapperScan("example.micronaut.mappers") @Named("default") @Singleton class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { @Override void customize(Configuration configuration) { - configuration.addMappers("example.micronaut.mappers") configuration.mapUnderscoreToCamelCase = true } } diff --git a/tests/mybatis-groovy/src/test/groovy/example/micronaut/MapperScanRegistrationSpec.groovy b/tests/mybatis-groovy/src/test/groovy/example/micronaut/MapperScanRegistrationSpec.groovy new file mode 100644 index 000000000..47aa9eaf1 --- /dev/null +++ b/tests/mybatis-groovy/src/test/groovy/example/micronaut/MapperScanRegistrationSpec.groovy @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.micronaut + +import example.micronaut.mappers.GenreMapper +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration +import io.micronaut.core.io.service.SoftServiceLoader +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.apache.ibatis.session.Configuration +import spock.lang.Specification + +/** + * Verifies that the mapper registration was generated at compile time instead of relying on runtime package scanning. + */ +@MicronautTest +class MapperScanRegistrationSpec extends Specification { + + @Inject + Configuration configuration + + void "registration is generated at compile time"() { + given: + List registrations = [] + SoftServiceLoader.load(MyBatisMapperScanRegistration).collectAll(registrations) + + expect: + registrations.size() == 1 + registrations[0].datasourceName == "default" + registrations[0].class.name == 'example.micronaut.mappers.example_pmicronaut_pCustomConfigurationCustomizer$MyBatisMapperScanRegistration' + configuration.hasMapper(GenreMapper) + configuration.mapUnderscoreToCamelCase + } +} diff --git a/tests/mybatis-java/build.gradle.kts b/tests/mybatis-java/build.gradle.kts index 2b5cb5523..f102239be 100644 --- a/tests/mybatis-java/build.gradle.kts +++ b/tests/mybatis-java/build.gradle.kts @@ -1,8 +1,10 @@ plugins { - `java-library` + id("io.micronaut.build.internal.test-application") } dependencies { + implementation(projects.micronautTests.micronautCommon) testAnnotationProcessor(mn.micronaut.inject.java) + testAnnotationProcessor(projects.micronautMybatisProcessor) testAnnotationProcessor(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) testAnnotationProcessor(mnValidation.micronaut.validation.processor) @@ -19,3 +21,19 @@ dependencies { tasks.named("test") { useJUnitPlatform() } + +val isMacOsArm = System.getProperty("os.name") == "Mac OS X" && + System.getProperty("os.arch") in setOf("aarch64", "arm64") + +if (isMacOsArm) { + graalvmNative { + binaries { + all { + // Full native test compilation for this H2 module fails locally on macOS ARM with GraalVM 25.0.3, + // while CI Linux passes. Keep the workaround scoped to the known failing module and platform, + // same as tests/hibernate/hibernate-h2 and tests/jdbc-hikari-tests/jdbc-hikari-h2. + quickBuild.set(true) + } + } + } +} diff --git a/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java b/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java index 01dc58c91..026a59646 100644 --- a/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java +++ b/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java @@ -2,18 +2,19 @@ // tag::imports[] import io.micronaut.configuration.mybatis.MyBatisConfigurationCustomizer; +import io.micronaut.configuration.mybatis.MyBatisMapperScan; import jakarta.inject.Named; import jakarta.inject.Singleton; import org.apache.ibatis.session.Configuration; // end::imports[] // tag::clazz[] +@MyBatisMapperScan("example.micronaut.mappers") @Named("default") @Singleton public class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { @Override public void customize(Configuration configuration) { - configuration.addMappers("example.micronaut.mappers"); configuration.setMapUnderscoreToCamelCase(true); } } diff --git a/tests/mybatis-java/src/test/java/example/micronaut/MapperScanRegistrationTest.java b/tests/mybatis-java/src/test/java/example/micronaut/MapperScanRegistrationTest.java new file mode 100644 index 000000000..55b834d9f --- /dev/null +++ b/tests/mybatis-java/src/test/java/example/micronaut/MapperScanRegistrationTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.micronaut; + +import example.micronaut.mappers.GenreMapper; +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; +import io.micronaut.core.io.service.SoftServiceLoader; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the mapper registration was generated at compile time instead of relying on runtime package scanning. + */ +@MicronautTest +class MapperScanRegistrationTest { + + @Inject + Configuration configuration; + + @Test + void registrationIsGeneratedAtCompileTime() { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class).collectAll(registrations); + + assertEquals(1, registrations.size()); + assertEquals("default", registrations.get(0).getDatasourceName()); + assertEquals("example.micronaut.mappers.example_pmicronaut_pCustomConfigurationCustomizer$MyBatisMapperScanRegistration", + registrations.get(0).getClass().getName()); + assertTrue(configuration.hasMapper(GenreMapper.class)); + assertTrue(configuration.isMapUnderscoreToCamelCase()); + } +} diff --git a/tests/mybatis-kotlin/build.gradle.kts b/tests/mybatis-kotlin/build.gradle.kts index 208d9f368..1a71566b4 100644 --- a/tests/mybatis-kotlin/build.gradle.kts +++ b/tests/mybatis-kotlin/build.gradle.kts @@ -3,6 +3,7 @@ plugins { } dependencies { kspTest(mn.micronaut.inject.kotlin) + kspTest(projects.micronautMybatisProcessor) kspTest(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) kspTest(mnValidation.micronaut.validation.processor) diff --git a/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt index dc6730300..07f294176 100644 --- a/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt +++ b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt @@ -2,17 +2,18 @@ package example.micronaut // tag::imports[] import io.micronaut.configuration.mybatis.MyBatisConfigurationCustomizer +import io.micronaut.configuration.mybatis.MyBatisMapperScan import jakarta.inject.Named import jakarta.inject.Singleton import org.apache.ibatis.session.Configuration // end::imports[] // tag::clazz[] +@MyBatisMapperScan("example.micronaut.mappers") @Named("default") @Singleton class CustomConfigurationCustomizer : MyBatisConfigurationCustomizer { override fun customize(configuration: Configuration) { - configuration.addMappers("example.micronaut.mappers") configuration.isMapUnderscoreToCamelCase = true } } diff --git a/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/MapperScanRegistrationTest.kt b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/MapperScanRegistrationTest.kt new file mode 100644 index 000000000..a6a9c76a1 --- /dev/null +++ b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/MapperScanRegistrationTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2026 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package example.micronaut + +import example.micronaut.mappers.GenreMapper +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration +import io.micronaut.core.io.service.SoftServiceLoader +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import jakarta.inject.Inject +import org.apache.ibatis.session.Configuration +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Verifies that the mapper registration was generated at compile time instead of relying on runtime package scanning. + */ +@MicronautTest +class MapperScanRegistrationTest { + + @Inject + lateinit var configuration: Configuration + + @Test + fun registrationIsGeneratedAtCompileTime() { + val registrations = ArrayList() + SoftServiceLoader.load(MyBatisMapperScanRegistration::class.java).collectAll(registrations) + + assertEquals(1, registrations.size) + assertEquals("default", registrations[0].datasourceName) + assertEquals( + "example.micronaut.mappers.example_pmicronaut_pCustomConfigurationCustomizer\$MyBatisMapperScanRegistration", + registrations[0].javaClass.name + ) + assertTrue(configuration.hasMapper(GenreMapper::class.java)) + assertTrue(configuration.isMapUnderscoreToCamelCase) + } +}