From a701b359b9dabe0654630f7cfea7462f66f3b843 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Fri, 21 Aug 2026 16:11:27 +0200
Subject: [PATCH 01/23] Add MyBatis native metadata and improve native-image
test support
---
gradle/libs.versions.toml | 2 +
mybatis-processor/build.gradle.kts | 16 ++
.../processor/MyBatisMapperScanVisitor.java | 107 +++++++++++
...icronaut.inject.visitor.TypeElementVisitor | 1 +
.../MyBatisMapperScanVisitorTest.java | 166 ++++++++++++++++++
mybatis/build.gradle.kts | 2 +
.../mybatis/MyBatisMapperScan.java | 53 ++++++
.../mybatis/MyBatisMapperScanInterceptor.java | 72 ++++++++
.../micronaut-mybatis/reflect-config.json | 83 +++++++++
settings.gradle | 1 +
.../guide/mybatis/mybatis-customizers.adoc | 28 ++-
tests/mybatis-groovy/build.gradle.kts | 1 +
.../CustomConfigurationCustomizer.groovy | 11 +-
tests/mybatis-java/build.gradle.kts | 17 +-
.../reachability-metadata.json | 11 ++
.../CustomConfigurationCustomizer.java | 13 +-
.../java/example/micronaut/domain/Genre.java | 2 +
tests/mybatis-kotlin/build.gradle.kts | 1 +
.../CustomConfigurationCustomizer.kt | 10 +-
19 files changed, 570 insertions(+), 27 deletions(-)
create mode 100644 mybatis-processor/build.gradle.kts
create mode 100644 mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitor.java
create mode 100644 mybatis-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor
create mode 100644 mybatis-processor/src/test/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitorTest.java
create mode 100644 mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
create mode 100644 mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java
create mode 100644 mybatis/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis/reflect-config.json
create mode 100644 tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 17b94331e..892bc32d3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ managed-hibernate = "7.4.5.Final"
managed-hibernate-reactive = "4.5.3.Final"
managed-jasync = "2.2.4"
managed-mybatis = "3.5.19"
+managed-javassist = "3.30.2-GA"
managed-jdbi = "3.54.0"
# JDBC Drivers
@@ -131,6 +132,7 @@ dms = { module = "com.oracle.database.observability:dms", version.ref = "managed
# MyBatis
managed-mybatis = { module = "org.mybatis:mybatis", version.ref = "managed-mybatis" }
+managed-javassist = { module = "org.javassist:javassist", version.ref = "managed-javassist" }
# JDBC drivers
diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts
new file mode 100644
index 000000000..2f8b9b12b
--- /dev/null
+++ b/mybatis-processor/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+ id("io.micronaut.build.internal.sql-module")
+}
+
+dependencies {
+ implementation(mn.micronaut.core.processor)
+ api(projects.micronautMybatis)
+ testImplementation(mn.micronaut.inject.java)
+ testImplementation(mnTest.junit.jupiter.api)
+ testRuntimeOnly(mnTest.junit.jupiter.engine)
+ testImplementation(mnTest.junit.platform.launcher)
+}
+
+tasks.withType {
+ useJUnitPlatform()
+}
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..ebf2d2ef2
--- /dev/null
+++ b/mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitor.java
@@ -0,0 +1,107 @@
+/*
+ * 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.inject.ast.ClassElement;
+import io.micronaut.inject.visitor.TypeElementVisitor;
+import io.micronaut.inject.visitor.VisitorContext;
+import jakarta.inject.Named;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Adds compile-time discovered mapper types to {@link MyBatisMapperScan} metadata.
+ */
+public final class MyBatisMapperScanVisitor implements TypeElementVisitor
+ *
+ * The GraalVM native image metadata of the mappers is handled by {@link MyBatisMapperScanReflectionVisitor}.
*/
@Internal
public final class MyBatisMapperScanVisitor implements TypeElementVisitor {
@@ -86,41 +80,28 @@ public VisitorKind getVisitorKind() {
@Override
public void visitClass(ClassElement element, VisitorContext context) {
- collectInterfaces(element);
- AnnotationValue annotation = element.getAnnotation(MyBatisMapperScan.class);
- if (annotation != null) {
- List mappers = new ArrayList<>();
- for (AnnotationClassValue> mapper : annotation.annotationClassValues("mappers")) {
- mappers.add(mapper.getName());
- }
- scans.put(element.getName(), new Scan(
- element.getName(),
- List.of(annotation.stringValues("value")),
- mappers,
- annotation.stringValue("datasource").orElse("default"),
- annotation.booleanValue("nativeImageMetadata").orElse(true)
- ));
+ 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) {
- NativeImageMetadata nativeImageMetadata = new NativeImageMetadata();
for (Scan scan : scans.values()) {
- if (written.add(scan.elementName())) {
- context.getClassElement(scan.elementName())
- .ifPresent(element -> generateRegistration(context, element, scan, nativeImageMetadata));
+ if (written.add(scan.element().getName())) {
+ generateRegistration(context, scan);
}
}
- if (nativeImageMetadata.originatingElement != null) {
- writeNativeImageMetadata(context, nativeImageMetadata);
- }
}
- private void generateRegistration(VisitorContext context,
- ClassElement element,
- Scan scan,
- NativeImageMetadata nativeImageMetadata) {
+ private void generateRegistration(VisitorContext context, Scan scan) {
+ ClassElement element = scan.element();
Set mapperTypes = new TreeSet<>(scan.mappers());
List unresolvedPackages = new ArrayList<>();
for (String packageName : scan.packages()) {
@@ -142,15 +123,6 @@ private void generateRegistration(VisitorContext context,
ClassDef registration = registrationDefinition(element, scan.datasource(), mapperTypes, unresolvedPackages);
writeClass(context, element, registration);
context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element);
-
- if (scan.nativeImageMetadata()) {
- nativeImageMetadata.originatingElement = element;
- nativeImageMetadata.proxyTypes.addAll(mapperTypes);
- for (String mapperType : mapperTypes) {
- context.getClassElement(mapperType)
- .ifPresent(mapper -> collectReflectiveTypes(mapper, nativeImageMetadata.reflectiveTypes));
- }
- }
}
private static void writeClass(VisitorContext context, ClassElement originatingElement, ClassDef classDef) {
@@ -161,117 +133,10 @@ private static void writeClass(VisitorContext context, ClassElement originatingE
}
}
- /**
- * MyBatis implements mapper interfaces with {@link java.lang.reflect.Proxy} and instantiates and populates
- * result and parameter objects reflectively. Both need GraalVM metadata, which is written next to the
- * generated registration so that users do not have to declare it by hand.
- */
- private static void writeNativeImageMetadata(VisitorContext context, NativeImageMetadata metadata) {
- ClassElement originatingElement = metadata.originatingElement;
- Set proxyTypes = metadata.proxyTypes;
- Set reflectiveTypes = metadata.reflectiveTypes;
- Map options = context.getOptions();
- String group = options.getOrDefault(VisitorContext.MICRONAUT_PROCESSING_GROUP, originatingElement.getPackageName());
- String module = options.getOrDefault(VisitorContext.MICRONAUT_PROCESSING_MODULE, "mybatis-mapper-scan");
- String directory = "native-image/" + group + "/" + module + "/";
-
- StringBuilder proxyConfig = new StringBuilder("[\n");
- for (String proxyType : proxyTypes) {
- proxyConfig.append(" {\"interfaces\": [\"").append(proxyType).append("\"]},\n");
- }
- writeMetaInfFile(context, originatingElement, directory + "proxy-config.json", closeJsonArray(proxyConfig));
-
- if (!reflectiveTypes.isEmpty()) {
- StringBuilder reflectConfig = new StringBuilder("[\n");
- for (String reflectiveType : reflectiveTypes) {
- reflectConfig.append(" {\"name\": \"").append(reflectiveType).append("\", ")
- .append("\"allDeclaredConstructors\": true, \"allPublicConstructors\": true, ")
- .append("\"allDeclaredMethods\": true, \"allPublicMethods\": true, ")
- .append("\"allDeclaredFields\": true, \"allPublicFields\": true},\n");
- }
- writeMetaInfFile(context, originatingElement, directory + "reflect-config.json", closeJsonArray(reflectConfig));
- }
- }
-
- private static String closeJsonArray(StringBuilder json) {
- int trailingComma = json.lastIndexOf(",");
- if (trailingComma > 0) {
- json.deleteCharAt(trailingComma);
- }
- return json.append("]\n").toString();
- }
-
- private static void writeMetaInfFile(VisitorContext context, ClassElement originatingElement, String path, String content) {
- try {
- GeneratedFile file = context.visitMetaInfFile(path, originatingElement).orElse(null);
- if (file == null) {
- return;
- }
- try (Writer writer = file.openWriter()) {
- writer.write(content);
- }
- } catch (IOException e) {
- context.warn("Unable to write GraalVM metadata file [META-INF/" + path + "]: " + e.getMessage(), originatingElement);
- }
- }
-
- /**
- * Collects the result and parameter types of the mapper methods, unwrapping containers.
- */
- private 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);
- }
-
- /**
- * Collects the element and its nested types when they are interfaces. Nested interfaces are included
- * because MyBatis runtime package scanning registers them as well.
- */
- private void collectInterfaces(ClassElement element) {
- if (element.isInterface() && !element.isAssignable(Annotation.class)) {
- interfaceTypes.add(element.getName());
- }
- for (ClassElement inner : element.getEnclosedElements(ElementQuery.ALL_INNER_CLASSES)) {
- collectInterfaces(inner);
- }
- }
-
private Set discoverMappers(String packageName) {
Set discovered = new TreeSet<>();
for (String interfaceType : interfaceTypes) {
- String interfacePackage = packageOf(interfaceType);
- if (interfacePackage.equals(packageName) || interfacePackage.startsWith(packageName + ".")) {
+ if (MapperScanSupport.isInPackage(interfaceType, packageName)) {
discovered.add(interfaceType);
}
}
@@ -313,25 +178,4 @@ private static ClassDef registrationDefinition(ClassElement element,
}))
.build();
}
-
- private static String packageOf(String typeName) {
- int lastDot = typeName.lastIndexOf('.');
- return lastDot > 0 ? typeName.substring(0, lastDot) : "";
- }
-
- /**
- * GraalVM metadata collected across all scans of a compilation round.
- */
- private static final class NativeImageMetadata {
- private final Set proxyTypes = new TreeSet<>();
- private final Set reflectiveTypes = new TreeSet<>();
- private ClassElement originatingElement;
- }
-
- private record Scan(String elementName,
- List packages,
- List mappers,
- String datasource,
- boolean nativeImageMetadata) {
- }
}
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
index 14fc48e6e..fb7b01959 100644
--- 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
@@ -1 +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
index 8bbdb2200..e05496c78 100644
--- 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
@@ -17,7 +17,9 @@
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;
@@ -31,13 +33,18 @@
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;
@@ -156,19 +163,23 @@ public interface NotScannedMapper {
registration.register(configuration);
}
- // no micronaut.processing.group/module options in this test: falls back to the annotated type's package
- Path nativeImage = compilation.classes().resolve("META-INF/native-image/example.config/mybatis-mapper-scan");
- String proxyConfig = Files.readString(nativeImage.resolve("proxy-config.json"));
- assertTrue(proxyConfig.contains("{\"interfaces\": [\"example.mappers.GenreMapper\"]}"), proxyConfig);
- assertTrue(proxyConfig.contains("example.mappers.Mappers$Helper$DeepMapper"), proxyConfig);
- assertTrue(proxyConfig.contains("example.other.OtherMapper"), proxyConfig);
- assertFalse(proxyConfig.contains("NotScannedMapper"), proxyConfig);
-
- String reflectConfig = Files.readString(nativeImage.resolve("reflect-config.json"));
- assertTrue(reflectConfig.contains("{\"name\": \"example.domain.Genre\", \"allDeclaredConstructors\": true"), reflectConfig);
- assertTrue(reflectConfig.contains("\"example.domain.Book\""), reflectConfig);
- assertFalse(reflectConfig.contains("java.lang.String"), reflectConfig);
- assertFalse(reflectConfig.contains("java.util"), reflectConfig);
+ 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.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
@@ -220,8 +231,8 @@ public interface NotListedMapper {
assertFalse(configuration.hasMapper(classLoader.loadClass("example.other.NotListedMapper")));
}
- // nativeImageMetadata = false: no GraalVM metadata is generated
- assertFalse(Files.exists(compilation.classes().resolve("META-INF/native-image")));
+ // nativeImageMetadata = false: no reflection configuration is generated
+ assertFalse(Files.exists(compilation.classes().resolve("example/config/$MapperConfiguration$ReflectConfig.class")));
}
@Test
@@ -250,6 +261,12 @@ void visitsAllClassesAndAggregates() {
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 {
@@ -272,7 +289,12 @@ private static Compilation compile(Path temporaryDirectory, List
null,
sources
);
- task.setProcessors(List.of(new TestTypeElementVisitorProcessor()));
+ 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)
@@ -288,15 +310,79 @@ URLClassLoader classLoader() throws Exception {
}
}
+ /**
+ * 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 extends TypeElementVisitor, ?>> findTypeElementVisitors() {
- return List.of(new MyBatisMapperScanVisitor());
+ return visitors;
}
@Override
protected TypeElementVisitor.VisitorKind getIncrementalProcessorKind() {
- return TypeElementVisitor.VisitorKind.AGGREGATING;
+ return kind;
}
}
diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
index 0813e7043..50a637aec 100644
--- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
+++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
@@ -57,8 +57,9 @@
/**
* Whether to generate the GraalVM native image metadata (dynamic proxy entries for the mapper interfaces
- * and reflection entries for their result and parameter types). 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.
+ * 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
*/
diff --git a/src/main/docs/guide/mybatis/mybatis-graalvm.adoc b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
index b87602539..adb513b80 100644
--- a/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
+++ b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
@@ -3,7 +3,7 @@ When you use api:configuration.mybatis.MyBatisMapperScan[] (see <//proxy-config.json` and `reflect-config.json` of the compiled module, so no manual configuration is needed for the common case. It is ignored when running on the JVM. Set `nativeImageMetadata = false` on the annotation if you prefer to manage the native image configuration yourself.
+The metadata is generated through the same mechanism as `@ReflectiveAccess` and 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`.
From fc272e722447bcae069a3ccc92c028c3739da967 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 23 Sep 2026 17:52:06 +0200
Subject: [PATCH 19/23] Refactor MyBatis mapper scan to support package-based
mapper grouping, idempotent registration, and enhanced test coverage
---
.../processor/MyBatisMapperScanVisitor.java | 71 ++++++++++++---
.../MyBatisMapperScanVisitorTest.java | 90 +++++++++++++++++--
.../MyBatisMapperScanRegistration.java | 13 ++-
.../MyBatisMapperScanRegistrationTest.java | 51 +++++++++++
.../docs/guide/mybatis/mybatis-graalvm.adoc | 2 +-
.../MapperScanRegistrationSpec.groovy | 2 +-
.../micronaut/MapperScanRegistrationTest.java | 2 +-
.../micronaut/MapperScanRegistrationTest.kt | 2 +-
8 files changed, 210 insertions(+), 23 deletions(-)
create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistrationTest.java
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
index 3e839415d..d216567de 100644
--- 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
@@ -42,6 +42,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.TreeSet;
/**
@@ -72,6 +73,7 @@ public final class MyBatisMapperScanVisitor implements TypeElementVisitor 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() {
@@ -102,7 +104,11 @@ public void finish(VisitorContext context) {
private void generateRegistration(VisitorContext context, Scan scan) {
ClassElement element = scan.element();
- Set mapperTypes = new TreeSet<>(scan.mappers());
+ // 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);
@@ -112,17 +118,62 @@ private void generateRegistration(VisitorContext context, Scan scan) {
+ "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);
- } else {
- mapperTypes.addAll(discovered);
+ }
+ for (String mapperType : discovered) {
+ mapperTypesByPackage.computeIfAbsent(packageOf(mapperType), ignored -> new TreeSet<>()).add(mapperType);
}
}
- if (mapperTypes.isEmpty() && unresolvedPackages.isEmpty()) {
+ if (mapperTypesByPackage.values().stream().allMatch(Set::isEmpty) && unresolvedPackages.isEmpty()) {
context.warn("@" + MyBatisMapperScan.class.getSimpleName() + " declares neither packages nor mappers", element);
return;
}
- ClassDef registration = registrationDefinition(element, scan.datasource(), mapperTypes, unresolvedPackages);
- writeClass(context, element, registration);
- context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element);
+ 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 = packageName.equals(elementPackage)
+ ? element.getName().substring(elementPackage.isEmpty() ? 0 : elementPackage.length() + 1)
+ : element.getName();
+ 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) {
@@ -144,13 +195,11 @@ private Set discoverMappers(String packageName) {
}
private static ClassDef registrationDefinition(ClassElement element,
+ String packageName,
String datasource,
Set mapperTypes,
List unresolvedPackages) {
- String packageName = element.getPackageName();
- String simpleName = element.getName().substring(packageName.isEmpty() ? 0 : packageName.length() + 1)
- .replace('$', '_') + REGISTRATION_SUFFIX;
- String registrationName = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
+ String registrationName = registrationName(element, packageName);
return ClassDef.builder(registrationName)
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addSuperinterface(ClassTypeDef.of(MyBatisMapperScanRegistration.class))
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
index e05496c78..7f4cc0f62 100644
--- 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
@@ -111,12 +111,20 @@ public interface GenreMapper {
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 {
}
@@ -138,29 +146,39 @@ 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(1, registrations.size());
- MyBatisMapperScanRegistration registration = registrations.get(0);
- assertEquals("orders", registration.getDatasourceName());
-
+ assertEquals(3, registrations.size());
Configuration configuration = new Configuration();
- registration.register(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
- registration.register(configuration);
+ for (MyBatisMapperScanRegistration registration : registrations) {
+ registration.register(configuration);
+ }
}
try (URLClassLoader classLoader = compilation.classLoader()) {
@@ -169,6 +187,8 @@ public interface NotScannedMapper {
"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);
@@ -235,6 +255,64 @@ public interface NotListedMapper {
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(
diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
index 6ee7696db..70aeb1321 100644
--- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
+++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
@@ -15,6 +15,7 @@
*/
package io.micronaut.configuration.mybatis;
+import org.apache.ibatis.io.ResolverUtil;
import org.apache.ibatis.session.Configuration;
/**
@@ -54,12 +55,20 @@ default void addMapper(Configuration configuration, Class> 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.
+ * 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) {
- configuration.addMappers(packageName);
+ ResolverUtil> resolverUtil = new ResolverUtil<>();
+ resolverUtil.find(new ResolverUtil.IsA(Object.class), packageName);
+ for (Class extends Class>> mapperType : resolverUtil.getClasses()) {
+ if (mapperType.isInterface()) {
+ addMapper(configuration, mapperType);
+ }
+ }
}
}
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/src/main/docs/guide/mybatis/mybatis-graalvm.adoc b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
index adb513b80..ab8509ad8 100644
--- a/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
+++ b/src/main/docs/guide/mybatis/mybatis-graalvm.adoc
@@ -3,7 +3,7 @@ When you use api:configuration.mybatis.MyBatisMapperScan[] (see <
Date: Wed, 23 Sep 2026 20:01:23 +0200
Subject: [PATCH 20/23] Update GraalVM distribution to version 25.4 in GitHub
Actions workflow
---
.github/workflows/python.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 4070b4f4a..52d646309 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -82,7 +82,7 @@ jobs:
with:
distribution: 'graalvm'
java-version: '25'
- version: '25.3'
+ version: '25.4'
github-token: ${{ secrets.GITHUB_TOKEN }}
native-image-job-reports: true
From 728e32695710571c7ea9919eace0e6ae696e5687 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 23 Sep 2026 22:36:48 +0200
Subject: [PATCH 21/23] Refactor MyBatis mapper scan: improve registration name
calculation and adjust mapper type resolution logic
---
.../mybatis/processor/MyBatisMapperScanVisitor.java | 8 +++++---
.../mybatis/MyBatisMapperScanRegistration.java | 4 ++--
2 files changed, 7 insertions(+), 5 deletions(-)
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
index d216567de..a3707ba3f 100644
--- 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
@@ -154,9 +154,11 @@ private void generateRegistration(VisitorContext context, Scan scan) {
*/
private static String registrationName(ClassElement element, String packageName) {
String elementPackage = element.getPackageName();
- String qualifier = packageName.equals(elementPackage)
- ? element.getName().substring(elementPackage.isEmpty() ? 0 : elementPackage.length() + 1)
- : element.getName();
+ 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;
}
diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
index 70aeb1321..8fea7075b 100644
--- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
+++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
@@ -63,9 +63,9 @@ default void addMapper(Configuration configuration, Class> mapperType) {
* @param packageName The package name
*/
default void addMappers(Configuration configuration, String packageName) {
- ResolverUtil> resolverUtil = new ResolverUtil<>();
+ ResolverUtil resolverUtil = new ResolverUtil<>();
resolverUtil.find(new ResolverUtil.IsA(Object.class), packageName);
- for (Class extends Class>> mapperType : resolverUtil.getClasses()) {
+ for (Class> mapperType : resolverUtil.getClasses()) {
if (mapperType.isInterface()) {
addMapper(configuration, mapperType);
}
From 82a9182dc798e3a8a8a57c093fcffdbdd9fc4808 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Tue, 29 Sep 2026 22:30:33 +0200
Subject: [PATCH 22/23] Enable the processor binary compatibility checks after
its first release in 7.3.0
---
mybatis-processor/build.gradle.kts | 2 +-
.../io/micronaut/configuration/mybatis/MyBatisMapperScan.java | 2 +-
.../configuration/mybatis/MyBatisMapperScanRegistration.java | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts
index c481dae72..27bbdcbbc 100644
--- a/mybatis-processor/build.gradle.kts
+++ b/mybatis-processor/build.gradle.kts
@@ -23,6 +23,6 @@ tasks.withType {
micronautBuild {
binaryCompatibility {
- enabledAfter("7.2.0")
+ enabledAfter("7.3.0")
}
}
diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
index 50a637aec..921af7826 100644
--- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
+++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java
@@ -33,7 +33,7 @@
* compiled together with the annotated type are discovered by package; mapper interfaces from other
* modules must be listed in {@link #mappers()}.
*
- * @since 7.2.0
+ * @since 7.3.0
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
index 8fea7075b..ed6d0d870 100644
--- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
+++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java
@@ -25,7 +25,7 @@
* {@link MyBatisMapperScan} and discovered through {@code META-INF/services}. They are applied
* by {@link MyBatisFactory} after all {@link MyBatisConfigurationCustomizer} beans.
*
- * @since 7.2.0
+ * @since 7.3.0
*/
public interface MyBatisMapperScanRegistration {
From db4b11dfde477749a68d41f191ce6308a9b75b7c Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Tue, 29 Sep 2026 22:30:35 +0200
Subject: [PATCH 23/23] Load the mapper scan registrations with the application
context class loader
---
.../configuration/mybatis/MyBatisFactory.java | 12 +++++--
.../mybatis/MyBatisFactoryTest.java | 33 +++++++++++++++++
...hildClassLoaderMapperScanRegistration.java | 35 +++++++++++++++++++
3 files changed, 77 insertions(+), 3 deletions(-)
create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestChildClassLoaderMapperScanRegistration.java
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 e0889634e..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,6 +15,7 @@
*/
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;
@@ -69,7 +70,7 @@ public Configuration myBatisConfiguration(
}
// 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()) {
+ for (MyBatisMapperScanRegistration registration : mapperScanRegistrations(beanLocator)) {
if (name.equals(registration.getDatasourceName())) {
registration.register(configuration);
}
@@ -77,9 +78,14 @@ public Configuration myBatisConfiguration(
return configuration;
}
- private static List mapperScanRegistrations() {
+ 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, MyBatisFactory.class.getClassLoader())
+ SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader)
.collectAll(registrations);
return registrations;
}
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 1cc97e03a..08b28734d 100644
--- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java
+++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java
@@ -26,10 +26,17 @@
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;
@@ -119,6 +126,32 @@ void appliesMapperScanRegistrationsPerDataSource() {
}
}
+ @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/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);
+ }
+}