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 { + + private final Set mapperTypes = new LinkedHashSet<>(); + private final Map scans = new LinkedHashMap<>(); + private boolean processed; + + @Override + public VisitorKind getVisitorKind() { + return VisitorKind.AGGREGATING; + } + + @Override + public void visitClass(ClassElement element, VisitorContext context) { + if (element.isInterface()) { + mapperTypes.add(element.getName()); + } + + AnnotationValue scan = element.getAnnotation(MyBatisMapperScan.class); + if (scan != null) { + scans.put(element.getName(), new Scan( + element.getName(), + scan.stringValues("value"), + scan.stringValue("datasource").orElse("default") + )); + } + } + + @Override + public void finish(VisitorContext context) { + if (processed || scans.isEmpty()) { + return; + } + processed = true; + + for (Scan scan : scans.values()) { + ClassElement element = context.getClassElement(scan.elementName()).orElse(null); + if (element == null) { + continue; + } + Set packages = new TreeSet<>(); + for (String packageName : scan.packages()) { + packages.add(packageName); + } + + Set selectedMapperTypes = new TreeSet<>(); + for (String mapperType : mapperTypes) { + if (isInScannedPackage(mapperType, packages)) { + selectedMapperTypes.add(mapperType); + } + } + + AnnotationClassValue[] mapperClassValues = selectedMapperTypes.stream() + .map(AnnotationClassValue::new) + .toArray(AnnotationClassValue[]::new); + element.annotate(AnnotationValue.builder(MyBatisMapperScan.class) + .member("value", scan.packages()) + .member("datasource", scan.datasource()) + .member("mappers", mapperClassValues) + .build()); + element.annotate(Named.class, builder -> builder.value(scan.datasource())); + } + } + + private boolean isInScannedPackage(String mapperType, Set packages) { + int lastDot = mapperType.lastIndexOf('.'); + String packageName = lastDot > 0 ? mapperType.substring(0, lastDot) : ""; + return packages.stream().anyMatch(scanPackage -> + packageName.equals(scanPackage) || packageName.startsWith(scanPackage + ".")); + } + + private record Scan(String elementName, String[] packages, String datasource) { + } +} 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..14fc48e6e --- /dev/null +++ b/mybatis-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor @@ -0,0 +1 @@ +io.micronaut.configuration.mybatis.processor.MyBatisMapperScanVisitor 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..268e57dad --- /dev/null +++ b/mybatis-processor/src/test/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanVisitorTest.java @@ -0,0 +1,166 @@ +/* + * 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.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 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.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MyBatisMapperScanVisitorTest { + + @Test + void addsDiscoveredMappersToAnnotationMetadata(@TempDir Path temporaryDirectory) 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, + List.of(new InMemoryJavaFileObject("example.config.MapperConfiguration", """ + package example.config; + + import io.micronaut.configuration.mybatis.MyBatisMapperScan; + + @MyBatisMapperScan("example.mappers") + interface MapperConfiguration { + void customize(org.apache.ibatis.session.Configuration configuration); + } + """), new InMemoryJavaFileObject("example.mappers.GenreMapper", """ + package example.mappers; + + import org.apache.ibatis.annotations.Select; + + public interface GenreMapper { + @Select("select 1") + int findOne(); + } + """)) + ); + TestTypeElementVisitorProcessor processor = new TestTypeElementVisitorProcessor(); + task.setProcessors(List.of(processor)); + + assertTrue(task.call(), diagnosticsToString(diagnostics)); + assertEquals(List.of("example.mappers.GenreMapper"), processor.mapperNames); + } + } + + private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { + private final MapperMetadataVisitor mapperMetadataVisitor = new MapperMetadataVisitor(); + + @Override + protected Collection> findTypeElementVisitors() { + return List.of(new MyBatisMapperScanVisitor(), mapperMetadataVisitor); + } + + @Override + protected TypeElementVisitor.VisitorKind getIncrementalProcessorKind() { + return TypeElementVisitor.VisitorKind.AGGREGATING; + } + + private final List mapperNames = mapperMetadataVisitor.mapperNames; + } + + private static final class MapperMetadataVisitor implements TypeElementVisitor { + private final List mapperNames = new java.util.ArrayList<>(); + private String configurationElementName; + private boolean processed; + + @Override + public void visitClass(ClassElement element, VisitorContext context) { + if (element.getName().equals("example.config.MapperConfiguration")) { + configurationElementName = element.getName(); + } + } + + @Override + public void finish(VisitorContext context) { + if (processed) { + return; + } + processed = true; + ClassElement configurationElement = context.getClassElement(configurationElementName).orElseThrow(); + AnnotationValue scan = configurationElement.getAnnotation(MyBatisMapperScan.class); + if (scan != null) { + for (AnnotationClassValue mapper : scan.annotationClassValues("mappers")) { + mapperNames.add(mapper.getName()); + } + } + } + + @Override + public VisitorKind getVisitorKind() { + return VisitorKind.AGGREGATING; + } + + @Override + public int getOrder() { + return -100; + } + } + + private String diagnosticsToString(DiagnosticCollector diagnostics) { + return diagnostics.getDiagnostics().stream() + .map(Diagnostic::toString) + .reduce("", (left, right) -> left + System.lineSeparator() + right); + } + + private static final class InMemoryJavaFileObject extends SimpleJavaFileObject { + private final String source; + + private InMemoryJavaFileObject(String className, String source) { + super(java.net.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/build.gradle.kts b/mybatis/build.gradle.kts index 486404018..20f419b4a 100644 --- a/mybatis/build.gradle.kts +++ b/mybatis/build.gradle.kts @@ -3,9 +3,11 @@ plugins { } dependencies { api(projects.micronautJdbc) + api(mn.micronaut.aop) api(mn.micronaut.context) api(mn.micronaut.inject) api(libs.managed.mybatis) + runtimeOnly(libs.managed.javassist) testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mnTest.micronaut.test.junit5) testRuntimeOnly(projects.micronautJdbcHikari) 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..6690b4c32 --- /dev/null +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.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 io.micronaut.configuration.mybatis; + +import io.micronaut.aop.Introduction; +import jakarta.inject.Singleton; + +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 in the given packages. + * + * @since 7.2.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Introduction +@Singleton +public @interface MyBatisMapperScan { + + /** + * @return Packages containing MyBatis mapper interfaces + */ + String[] value(); + + /** + * @return The datasource name to which the discovered mappers are registered + */ + String datasource() default "default"; + + /** + * @return Mapper interfaces discovered by the compile-time processor + */ + Class[] mappers() default {}; +} diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java new file mode 100644 index 000000000..b9ad2f516 --- /dev/null +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java @@ -0,0 +1,72 @@ +/* + * 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.aop.InterceptorBean; +import io.micronaut.aop.MethodInterceptor; +import io.micronaut.aop.MethodInvocationContext; +import io.micronaut.core.annotation.Internal; +import org.apache.ibatis.session.Configuration; + +import java.util.HashSet; +import java.util.Set; + +/** + * Registers mapper types discovered by {@link MyBatisMapperScan}. + */ +@InterceptorBean(MyBatisMapperScan.class) +@Internal +final class MyBatisMapperScanInterceptor implements MethodInterceptor { + + @Override + public Object intercept(MethodInvocationContext context) { + Object[] parameterValues = context.getParameterValues(); + if (parameterValues.length == 1 && parameterValues[0] instanceof Configuration configuration) { + Class[] mappers = context.classValues(MyBatisMapperScan.class, "mappers"); + if (mappers.length > 0) { + for (Class mapper : mappers) { + configuration.addMapper(mapper); + } + } else { + MyBatisMapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); + String[] packages = mapperScan == null + ? context.stringValues(MyBatisMapperScan.class, "value") + : mapperScan.value(); + for (String packageName : packages) { + configuration.addMappers(packageName); + } + } + } + return null; + } + + private static MyBatisMapperScan findMapperScan(Class type, Set> visited) { + if (type == null || !visited.add(type)) { + return null; + } + MyBatisMapperScan mapperScan = type.getAnnotation(MyBatisMapperScan.class); + if (mapperScan != null) { + return mapperScan; + } + for (Class interfaceType : type.getInterfaces()) { + mapperScan = findMapperScan(interfaceType, visited); + if (mapperScan != null) { + return mapperScan; + } + } + return findMapperScan(type.getSuperclass(), visited); + } +} 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/settings.gradle b/settings.gradle index 50e1ddd46..bf6fd2495 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..ecc200bf0 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -1,5 +1,7 @@ You can define additional beans which will be used when the MyBatis `Configuration` is created. -Only beans of type api:configuration.mybatis.MyBatisConfigurationCustomizer[] with an `@Named` qualifier matching the datasource name will be applied. +Only beans of type api:configuration.mybatis.MyBatisConfigurationCustomizer[] with an `@Named` qualifier matching the datasource name will be applied. When the mapper-scan processor is used with Java, it adds this qualifier to the generated bean metadata. + +For the default datasource, Groovy and Kotlin customizers must explicitly declare `@Named("default")`. For a named datasource, set the `datasource` member of `@MyBatisMapperScan` and qualify the customizer with the same name, for example `@Named("orders")` with `datasources.orders.url`. Java users may omit `@Named` when using the processor because it adds the qualifier to the generated bean metadata; Groovy and Kotlin must declare it explicitly. For example, you can register mapper interfaces and tune MyBatis settings for a specific datasource: @@ -7,6 +9,30 @@ snippet::example.micronaut.CustomConfigurationCustomizer[tags="imports,clazz", p 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. +To register mapper interfaces by package, annotate the customizer with api:configuration.mybatis.MyBatisMapperScan[]. This replaces a customizer call such as `configuration.addMappers("com.example.mappers")`. The language-specific examples above show the annotation and customizer declaration. + +On the JVM, MyBatis package scanning is available as a fallback. For native Java applications, the `micronaut-mybatis-processor` discovers mapper interfaces at compile time and registers them without runtime package scanning. Groovy and Kotlin use the declared packages as a JVM fallback. + +Add the processor for the language you use: + +[source,groovy] +.Java +---- +annotationProcessor("io.micronaut.sql:micronaut-mybatis-processor") +---- + +[source,groovy] +.Groovy +---- +compileOnly("io.micronaut.sql:micronaut-mybatis-processor") +---- + +[source,kotlin] +.Kotlin +---- +ksp("io.micronaut.sql:micronaut-mybatis-processor") +---- + ==== TransactionFactory Customization Micronaut also looks for a link:https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/transaction/TransactionFactory.html[TransactionFactory] bean qualified with the datasource name. If none is present, Micronaut falls back to link:https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/transaction/jdbc/JdbcTransactionFactory.html[JdbcTransactionFactory]. 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 7a510fa4d..38be3cf97 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,13 @@ 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") - } +interface CustomConfigurationCustomizer extends MyBatisConfigurationCustomizer { } // end::clazz[] diff --git a/tests/mybatis-java/build.gradle.kts b/tests/mybatis-java/build.gradle.kts index 2b5cb5523..2cb3841ac 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,16 @@ 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 { + quickBuild.set(true) + } + } + } +} diff --git a/tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json b/tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json new file mode 100644 index 000000000..457b67cb2 --- /dev/null +++ b/tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json @@ -0,0 +1,11 @@ +{ + "reflection": [ + { + "type": { + "proxy": [ + "example.micronaut.mappers.GenreMapper" + ] + } + } + ] +} 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 264660766..1defb2513 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,11 @@ // tag::imports[] import io.micronaut.configuration.mybatis.MyBatisConfigurationCustomizer; -import jakarta.inject.Named; -import jakarta.inject.Singleton; -import org.apache.ibatis.session.Configuration; +import io.micronaut.configuration.mybatis.MyBatisMapperScan; // end::imports[] // tag::clazz[] -@Named("default") -@Singleton -public class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { - @Override - public void customize(Configuration configuration) { - configuration.addMappers("example.micronaut.mappers"); - } +@MyBatisMapperScan("example.micronaut.mappers") +public interface CustomConfigurationCustomizer extends MyBatisConfigurationCustomizer { } // end::clazz[] diff --git a/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java b/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java index 2f1bf82ab..84d992726 100644 --- a/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java +++ b/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.NotBlank; @@ -25,6 +26,7 @@ import java.util.Set; @Serdeable +@ReflectiveAccess public class Genre { @Nullable 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 d5f15d086..3c274f6ca 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,13 @@ 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") - } +interface CustomConfigurationCustomizer : MyBatisConfigurationCustomizer { } // end::clazz[] From 07f56e93f991452141e913a1b504f2324ddc9dc2 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Fri, 21 Aug 2026 16:11:51 +0200 Subject: [PATCH 02/23] Add branch `mybatis-native-tests` to GitHub Actions workflows --- .github/workflows/graalvm-latest.yml | 1 + .github/workflows/gradle.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/graalvm-latest.yml b/.github/workflows/graalvm-latest.yml index fdc5eefc5..da762747e 100644 --- a/.github/workflows/graalvm-latest.yml +++ b/.github/workflows/graalvm-latest.yml @@ -9,6 +9,7 @@ on: branches: - master - '[0-9]+.[0-9]+.x' + - mybatis-native-tests pull_request: branches: - master diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 7d4055cd8..493dcf411 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -9,6 +9,7 @@ on: branches: - master - '[0-9]+.[0-9]+.x' + - mybatis-native-tests pull_request: branches: - master From e8c2f5b16c734d87e686a7091c27852dc0e27141 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Fri, 21 Aug 2026 16:33:19 +0200 Subject: [PATCH 03/23] Enable binary compatibility checks for mybatis-processor starting from version 7.2.0 --- mybatis-processor/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 2f8b9b12b..58bbc54d3 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -14,3 +14,9 @@ dependencies { tasks.withType { useJUnitPlatform() } + +micronautBuild { + binaryCompatibility { + enabledAfter("7.2.0") + } +} From 062595a5c4a3b70bca8f3e35564622722cb76460 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Fri, 21 Aug 2026 16:45:48 +0200 Subject: [PATCH 04/23] Remove `mybatis-native-tests` branch from GitHub Actions workflows --- .github/workflows/graalvm-latest.yml | 1 - .github/workflows/gradle.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/graalvm-latest.yml b/.github/workflows/graalvm-latest.yml index da762747e..fdc5eefc5 100644 --- a/.github/workflows/graalvm-latest.yml +++ b/.github/workflows/graalvm-latest.yml @@ -9,7 +9,6 @@ on: branches: - master - '[0-9]+.[0-9]+.x' - - mybatis-native-tests pull_request: branches: - master diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 5b6f5af0e..e631f1daa 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -9,7 +9,6 @@ on: branches: - master - '[0-9]+.[0-9]+.x' - - mybatis-native-tests pull_request: branches: - master From bfd40841db992b4c5f5189ed8059fbbde4b5d12c Mon Sep 17 00:00:00 2001 From: radovanradic Date: Fri, 21 Aug 2026 17:11:59 +0200 Subject: [PATCH 05/23] Fix Sonar warnings. --- mybatis-processor/build.gradle.kts | 4 +-- .../processor/MyBatisMapperScanVisitor.java | 34 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 58bbc54d3..931ec800a 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -3,12 +3,12 @@ plugins { } dependencies { - implementation(mn.micronaut.core.processor) api(projects.micronautMybatis) + implementation(mn.micronaut.core.processor) testImplementation(mn.micronaut.inject.java) testImplementation(mnTest.junit.jupiter.api) - testRuntimeOnly(mnTest.junit.jupiter.engine) testImplementation(mnTest.junit.platform.launcher) + testRuntimeOnly(mnTest.junit.jupiter.engine) } tasks.withType { 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 ebf2d2ef2..a75c1da07 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 @@ -23,6 +23,7 @@ import io.micronaut.inject.visitor.VisitorContext; import jakarta.inject.Named; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -71,10 +72,7 @@ public void finish(VisitorContext context) { if (element == null) { continue; } - Set packages = new TreeSet<>(); - for (String packageName : scan.packages()) { - packages.add(packageName); - } + Set packages = new TreeSet<>(Arrays.asList(scan.packages())); Set selectedMapperTypes = new TreeSet<>(); for (String mapperType : mapperTypes) { @@ -103,5 +101,33 @@ private boolean isInScannedPackage(String mapperType, Set packages) { } private record Scan(String elementName, String[] packages, String datasource) { + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Scan scan)) { + return false; + } + return elementName.equals(scan.elementName) + && Arrays.equals(packages, scan.packages) + && datasource.equals(scan.datasource); + } + + @Override + public int hashCode() { + int result = elementName.hashCode(); + result = 31 * result + Arrays.hashCode(packages); + result = 31 * result + datasource.hashCode(); + return result; + } + + @Override + public String toString() { + return "Scan[elementName=" + elementName + + ", packages=" + Arrays.toString(packages) + + ", datasource=" + datasource + "]"; + } } } From c997feeea8a100b23cc237842a9b52c8cd0a7837 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Fri, 21 Aug 2026 17:16:24 +0200 Subject: [PATCH 06/23] Fix Sonar warnings. --- .../mybatis/MyBatisFactoryTest.java | 6 ++++- .../TestExplicitMapperScanCustomizer.java | 24 +++++++++++++++++++ .../TestPackageMapperScanCustomizer.java | 23 ++++++++++++++++++ .../mybatis/explicit/TestExplicitMapper.java | 19 +++++++++++++++ .../mybatis/support/TestPackageMapper.java | 19 +++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java 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..d557c5327 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java @@ -15,9 +15,11 @@ */ package io.micronaut.configuration.mybatis; +import io.micronaut.configuration.mybatis.explicit.TestExplicitMapper; +import io.micronaut.configuration.mybatis.support.TestPackageMapper; +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; @@ -61,6 +63,8 @@ void createsMyBatisBeansForTheDefaultDataSource() throws Exception { Configuration configuration = applicationContext.getBean(Configuration.class); assertTrue(configuration.hasMapper(TestMapper.class)); + assertTrue(configuration.hasMapper(TestPackageMapper.class)); + assertTrue(configuration.hasMapper(TestExplicitMapper.class)); assertTrue(configuration.isMapUnderscoreToCamelCase()); assertInstanceOf(TestTransactionFactory.class, configuration.getEnvironment().getTransactionFactory()); assertSame( diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java new file mode 100644 index 000000000..8cc225b55 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java @@ -0,0 +1,24 @@ +/* + * 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.explicit.TestExplicitMapper; +import jakarta.inject.Named; + +@Named("default") +@MyBatisMapperScan(value = "unused", mappers = TestExplicitMapper.class) +interface TestExplicitMapperScanCustomizer extends MyBatisConfigurationCustomizer { +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java new file mode 100644 index 000000000..fb8859a8d --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java @@ -0,0 +1,23 @@ +/* + * 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 jakarta.inject.Named; + +@Named("default") +@MyBatisMapperScan("io.micronaut.configuration.mybatis.support") +interface TestPackageMapperScanCustomizer extends MyBatisConfigurationCustomizer { +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.java new file mode 100644 index 000000000..bd41b8d34 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.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.explicit; + +public interface TestExplicitMapper { +} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java new file mode 100644 index 000000000..004a048a5 --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.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.support; + +public interface TestPackageMapper { +} From 2d72f579c02c47a26e4b206dea2c4eb4dcd23b20 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Mon, 24 Aug 2026 09:39:43 +0200 Subject: [PATCH 07/23] Ensure `Scan` can be used as a top-level record and add tests for equality and string representation --- .../processor/MyBatisMapperScanVisitor.java | 2 +- .../MyBatisMapperScanVisitorTest.java | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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 a75c1da07..f0bda4879 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 @@ -100,7 +100,7 @@ private boolean isInScannedPackage(String mapperType, Set packages) { packageName.equals(scanPackage) || packageName.startsWith(scanPackage + ".")); } - private record Scan(String elementName, String[] packages, String datasource) { + record Scan(String elementName, String[] packages, String datasource) { @Override public boolean equals(Object o) { 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 268e57dad..6c3e4a0ed 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 @@ -39,6 +39,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; class MyBatisMapperScanVisitorTest { @@ -90,6 +91,36 @@ public interface GenreMapper { } } + @Test + void scanUsesArrayContentsForEqualityAndStringValues() { + MyBatisMapperScanVisitor.Scan scan = new MyBatisMapperScanVisitor.Scan( + "example.config.MapperConfiguration", + new String[]{"example.mappers"}, + "default" + ); + MyBatisMapperScanVisitor.Scan equalScan = new MyBatisMapperScanVisitor.Scan( + "example.config.MapperConfiguration", + new String[]{"example.mappers"}, + "default" + ); + MyBatisMapperScanVisitor.Scan differentScan = new MyBatisMapperScanVisitor.Scan( + "example.config.MapperConfiguration", + new String[]{"example.other"}, + "default" + ); + + assertEquals(scan, scan); + assertEquals(scan, equalScan); + assertEquals(scan.hashCode(), equalScan.hashCode()); + assertEquals( + "Scan[elementName=example.config.MapperConfiguration, packages=[example.mappers], datasource=default]", + scan.toString() + ); + assertNotEquals(scan, differentScan); + assertNotEquals(scan, null); + assertNotEquals(scan, "not a scan"); + } + private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { private final MapperMetadataVisitor mapperMetadataVisitor = new MapperMetadataVisitor(); From a20f91adfaefbef3bfb39502662fb39b7706dfef Mon Sep 17 00:00:00 2001 From: radovanradic Date: Mon, 24 Aug 2026 09:58:06 +0200 Subject: [PATCH 08/23] Refactor `Scan` equality checks to use pattern matching and fix related unit tests --- .../mybatis/processor/MyBatisMapperScanVisitor.java | 8 ++++---- .../mybatis/processor/MyBatisMapperScanVisitorTest.java | 4 ++-- 2 files changed, 6 insertions(+), 6 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 f0bda4879..8138d8744 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 @@ -107,12 +107,12 @@ public boolean equals(Object o) { if (this == o) { return true; } - if (!(o instanceof Scan scan)) { + if (!(o instanceof Scan(String otherElementName, String[] otherPackages, String otherDatasource))) { return false; } - return elementName.equals(scan.elementName) - && Arrays.equals(packages, scan.packages) - && datasource.equals(scan.datasource); + return elementName.equals(otherElementName) + && Arrays.equals(packages, otherPackages) + && datasource.equals(otherDatasource); } @Override 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 6c3e4a0ed..07fc8e7c1 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 @@ -117,8 +117,8 @@ void scanUsesArrayContentsForEqualityAndStringValues() { scan.toString() ); assertNotEquals(scan, differentScan); - assertNotEquals(scan, null); - assertNotEquals(scan, "not a scan"); + assertNotEquals(null, scan); + assertNotEquals("not a scan", scan); } private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { From 69240ccafc94abcd5e463f6cbbcc132bc3831272 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 25 Aug 2026 14:24:27 +0200 Subject: [PATCH 09/23] Remove `javassist` dependency from MyBatis build configuration --- gradle/libs.versions.toml | 2 -- mybatis/build.gradle.kts | 1 - 2 files changed, 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a767217a8..304eec873 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,6 @@ managed-hibernate = "7.4.5.Final" managed-hibernate-reactive = "4.5.4.Final" managed-jasync = "2.2.4" managed-mybatis = "3.5.19" -managed-javassist = "3.30.2-GA" managed-jdbi = "3.54.0" # JDBC Drivers @@ -132,7 +131,6 @@ 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/build.gradle.kts b/mybatis/build.gradle.kts index 20f419b4a..a87897269 100644 --- a/mybatis/build.gradle.kts +++ b/mybatis/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { api(mn.micronaut.context) api(mn.micronaut.inject) api(libs.managed.mybatis) - runtimeOnly(libs.managed.javassist) testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mnTest.micronaut.test.junit5) testRuntimeOnly(projects.micronautJdbcHikari) From ec04acead0a6dab908f69c42c83581e91d8eac38 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 1 Sep 2026 09:21:17 +0200 Subject: [PATCH 10/23] Generate aggregating MyBatis mapper registrations --- mybatis-processor/build.gradle.kts | 5 ++ .../processor/MyBatisMapperScanVisitor.java | 83 ++++++++++++++--- .../MyBatisMapperScanVisitorTest.java | 89 ++++++++----------- .../mybatis/MyBatisMapperScan.java | 2 +- .../mybatis/MyBatisMapperScanInterceptor.java | 46 ++++++++-- .../MyBatisMapperScanRegistration.java | 40 +++++++++ .../mybatis/MyBatisFactoryTest.java | 2 + .../TestGeneratedMapperScanCustomizer.java | 23 +++++ .../TestGeneratedMapperScanRegistration.java | 34 +++++++ .../generated/TestGeneratedMapper.java | 19 ++++ .../guide/mybatis/mybatis-customizers.adoc | 2 +- 11 files changed, 273 insertions(+), 72 deletions(-) create mode 100644 mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java create mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestGeneratedMapper.java diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 931ec800a..8f35d8d2d 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -4,10 +4,15 @@ plugins { dependencies { api(projects.micronautMybatis) + api(mn.micronaut.sourcegen.annotations) + api(mn.micronaut.sourcegen.generator.bytecode) + implementation(mn.micronaut.core.processor) + testImplementation(mn.micronaut.inject.java) testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.junit.platform.launcher) + testRuntimeOnly(mnTest.junit.jupiter.engine) } 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 8138d8744..c72a62732 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 @@ -16,25 +16,37 @@ package io.micronaut.configuration.mybatis.processor; import io.micronaut.configuration.mybatis.MyBatisMapperScan; -import io.micronaut.core.annotation.AnnotationClassValue; +import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; 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 io.micronaut.sourcegen.generator.bytecode.ByteCodeGenerator; +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 jakarta.inject.Named; +import org.apache.ibatis.session.Configuration; +import javax.lang.model.element.Modifier; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; /** - * Adds compile-time discovered mapper types to {@link MyBatisMapperScan} metadata. + * Generates registrations for mapper types discovered at compile time. */ public final class MyBatisMapperScanVisitor implements TypeElementVisitor { + private static final ByteCodeGenerator BYTE_CODE_GENERATOR = new ByteCodeGenerator(); + private final Set mapperTypes = new LinkedHashSet<>(); private final Map scans = new LinkedHashMap<>(); private boolean processed; @@ -44,6 +56,11 @@ public VisitorKind getVisitorKind() { return VisitorKind.AGGREGATING; } + @Override + public Set getSupportedAnnotationNames() { + return Set.of(MyBatisMapperScan.class.getName()); + } + @Override public void visitClass(ClassElement element, VisitorContext context) { if (element.isInterface()) { @@ -81,18 +98,64 @@ public void finish(VisitorContext context) { } } - AnnotationClassValue[] mapperClassValues = selectedMapperTypes.stream() - .map(AnnotationClassValue::new) - .toArray(AnnotationClassValue[]::new); - element.annotate(AnnotationValue.builder(MyBatisMapperScan.class) - .member("value", scan.packages()) - .member("datasource", scan.datasource()) - .member("mappers", mapperClassValues) - .build()); element.annotate(Named.class, builder -> builder.value(scan.datasource())); + writeRegistrations(context, element, scan, selectedMapperTypes); } } + private void writeRegistrations(VisitorContext context, + ClassElement element, + Scan scan, + Set selectedMapperTypes) { + Map> mapperTypesByPackage = new TreeMap<>(); + for (String mapperType : selectedMapperTypes) { + int lastDot = mapperType.lastIndexOf('.'); + String packageName = lastDot > 0 ? mapperType.substring(0, lastDot) : ""; + mapperTypesByPackage.computeIfAbsent(packageName, ignored -> new TreeSet<>()).add(mapperType); + } + + for (Map.Entry> entry : mapperTypesByPackage.entrySet()) { + String packageName = entry.getKey(); + String className = "MyBatisMapperScanRegistration_" + + scan.elementName().replace('.', '_').replace('$', '_'); + String registrationName = packageName.isEmpty() ? className : packageName + "." + className; + BYTE_CODE_GENERATOR.write(registrationDefinition( + packageName, + className, + scan.elementName(), + entry.getValue() + ), context); + context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registrationName, element); + } + } + + private ClassDef registrationDefinition(String packageName, + String className, + String customizerType, + Set mapperTypes) { + String registrationName = packageName.isEmpty() ? className : packageName + "." + className; + return ClassDef.builder(registrationName) + .addModifiers(Modifier.FINAL) + .addSuperinterface(ClassTypeDef.of(MyBatisMapperScanRegistration.class)) + .addMethod(MethodDef.builder("getCustomizerType") + .overrides() + .addModifiers(Modifier.PUBLIC) + .returns(String.class) + .build((aThis, parameters) -> ExpressionDef.constant(customizerType).returning())) + .addMethod(MethodDef.builder("register") + .overrides() + .addModifiers(Modifier.PUBLIC) + .addParameter("configuration", Configuration.class) + .build((aThis, parameters) -> StatementDef.multi(mapperTypes.stream() + .map(mapperType -> (StatementDef) parameters.get(0).invoke( + "addMapper", + TypeDef.VOID, + ExpressionDef.constant(ClassTypeDef.of(mapperType)) + )) + .toList()))) + .build(); + } + private boolean isInScannedPackage(String mapperType, Set packages) { int lastDot = mapperType.lastIndexOf('.'); String packageName = lastDot > 0 ? mapperType.substring(0, lastDot) : ""; 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 07fc8e7c1..a6790deb7 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,11 +17,10 @@ import io.micronaut.annotation.processing.TypeElementVisitorProcessor; 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.configuration.mybatis.MyBatisMapperScanRegistration; +import io.micronaut.core.io.service.SoftServiceLoader; import io.micronaut.inject.visitor.TypeElementVisitor; -import io.micronaut.inject.visitor.VisitorContext; +import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -33,10 +32,13 @@ import javax.tools.StandardJavaFileManager; import javax.tools.StandardLocation; import javax.tools.ToolProvider; +import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -45,7 +47,7 @@ class MyBatisMapperScanVisitorTest { @Test - void addsDiscoveredMappersToAnnotationMetadata(@TempDir Path temporaryDirectory) throws Exception { + void generatesRegistrationForDiscoveredMappers(@TempDir Path temporaryDirectory) throws Exception { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); Path classes = temporaryDirectory.resolve("classes"); @@ -83,14 +85,41 @@ public interface GenreMapper { } """)) ); - TestTypeElementVisitorProcessor processor = new TestTypeElementVisitorProcessor(); - task.setProcessors(List.of(processor)); + task.setProcessors(List.of(new TestTypeElementVisitorProcessor())); assertTrue(task.call(), diagnosticsToString(diagnostics)); - assertEquals(List.of("example.mappers.GenreMapper"), processor.mapperNames); + Path generatedRegistration = classes.resolve( + "example/mappers/MyBatisMapperScanRegistration_example_config_MapperConfiguration.class" + ); + assertTrue(Files.exists(generatedRegistration)); + try (URLClassLoader classLoader = new URLClassLoader( + new java.net.URL[]{classes.toUri().toURL()}, + getClass().getClassLoader() + )) { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader).collectAll(registrations); + + assertEquals(1, registrations.size()); + MyBatisMapperScanRegistration registration = registrations.get(0); + assertEquals("example.config.MapperConfiguration", registration.getCustomizerType()); + Configuration configuration = new Configuration(); + registration.register(configuration); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.GenreMapper"))); + } } } + @Test + void declaresSupportedAnnotationAndAggregatingKind() { + MyBatisMapperScanVisitor visitor = new MyBatisMapperScanVisitor(); + + assertEquals( + Set.of(MyBatisMapperScan.class.getName()), + visitor.getSupportedAnnotationNames() + ); + assertEquals(TypeElementVisitor.VisitorKind.AGGREGATING, visitor.getVisitorKind()); + } + @Test void scanUsesArrayContentsForEqualityAndStringValues() { MyBatisMapperScanVisitor.Scan scan = new MyBatisMapperScanVisitor.Scan( @@ -122,57 +151,15 @@ void scanUsesArrayContentsForEqualityAndStringValues() { } private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { - private final MapperMetadataVisitor mapperMetadataVisitor = new MapperMetadataVisitor(); - @Override protected Collection> findTypeElementVisitors() { - return List.of(new MyBatisMapperScanVisitor(), mapperMetadataVisitor); + return List.of(new MyBatisMapperScanVisitor()); } @Override protected TypeElementVisitor.VisitorKind getIncrementalProcessorKind() { return TypeElementVisitor.VisitorKind.AGGREGATING; } - - private final List mapperNames = mapperMetadataVisitor.mapperNames; - } - - private static final class MapperMetadataVisitor implements TypeElementVisitor { - private final List mapperNames = new java.util.ArrayList<>(); - private String configurationElementName; - private boolean processed; - - @Override - public void visitClass(ClassElement element, VisitorContext context) { - if (element.getName().equals("example.config.MapperConfiguration")) { - configurationElementName = element.getName(); - } - } - - @Override - public void finish(VisitorContext context) { - if (processed) { - return; - } - processed = true; - ClassElement configurationElement = context.getClassElement(configurationElementName).orElseThrow(); - AnnotationValue scan = configurationElement.getAnnotation(MyBatisMapperScan.class); - if (scan != null) { - for (AnnotationClassValue mapper : scan.annotationClassValues("mappers")) { - mapperNames.add(mapper.getName()); - } - } - } - - @Override - public VisitorKind getVisitorKind() { - return VisitorKind.AGGREGATING; - } - - @Override - public int getOrder() { - return -100; - } } private String diagnosticsToString(DiagnosticCollector diagnostics) { 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 6690b4c32..88c6efe70 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java @@ -47,7 +47,7 @@ String datasource() default "default"; /** - * @return Mapper interfaces discovered by the compile-time processor + * @return Mapper interfaces to register directly */ Class[] mappers() default {}; } diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java index b9ad2f516..be0144807 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java @@ -19,9 +19,12 @@ import io.micronaut.aop.MethodInterceptor; import io.micronaut.aop.MethodInvocationContext; import io.micronaut.core.annotation.Internal; +import io.micronaut.core.io.service.SoftServiceLoader; import org.apache.ibatis.session.Configuration; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; /** @@ -31,6 +34,16 @@ @Internal final class MyBatisMapperScanInterceptor implements MethodInterceptor { + private final List registrations; + + MyBatisMapperScanInterceptor(List registrations) { + this.registrations = new ArrayList<>(registrations); + SoftServiceLoader.load( + MyBatisMapperScanRegistration.class, + MyBatisMapperScanInterceptor.class.getClassLoader() + ).collectAll(this.registrations); + } + @Override public Object intercept(MethodInvocationContext context) { Object[] parameterValues = context.getParameterValues(); @@ -41,32 +54,47 @@ public Object intercept(MethodInvocationContext context) { configuration.addMapper(mapper); } } else { - MyBatisMapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); + MapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); + String customizerType = mapperScan == null + ? context.getDeclaringType().getName() + : mapperScan.type().getName(); + boolean registered = false; + for (MyBatisMapperScanRegistration registration : registrations) { + if (registration.getCustomizerType().equals(customizerType)) { + registration.register(configuration); + registered = true; + } + } String[] packages = mapperScan == null ? context.stringValues(MyBatisMapperScan.class, "value") - : mapperScan.value(); - for (String packageName : packages) { - configuration.addMappers(packageName); + : mapperScan.annotation().value(); + if (!registered) { + for (String packageName : packages) { + configuration.addMappers(packageName); + } } } } return null; } - private static MyBatisMapperScan findMapperScan(Class type, Set> visited) { + private static MapperScan findMapperScan(Class type, Set> visited) { if (type == null || !visited.add(type)) { return null; } MyBatisMapperScan mapperScan = type.getAnnotation(MyBatisMapperScan.class); if (mapperScan != null) { - return mapperScan; + return new MapperScan(type, mapperScan); } for (Class interfaceType : type.getInterfaces()) { - mapperScan = findMapperScan(interfaceType, visited); - if (mapperScan != null) { - return mapperScan; + MapperScan interfaceMapperScan = findMapperScan(interfaceType, visited); + if (interfaceMapperScan != null) { + return interfaceMapperScan; } } return findMapperScan(type.getSuperclass(), visited); } + + private record MapperScan(Class type, MyBatisMapperScan annotation) { + } } 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..71b78653a --- /dev/null +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java @@ -0,0 +1,40 @@ +/* + * 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.core.annotation.Internal; +import org.apache.ibatis.session.Configuration; + +/** + * Registers mapper interfaces discovered by the MyBatis annotation processor. + * + * @since 7.2.0 + */ +@Internal +public interface MyBatisMapperScanRegistration { + + /** + * @return The customizer type that declared the mapper scan + */ + String getCustomizerType(); + + /** + * Registers the discovered mapper interfaces. + * + * @param configuration The MyBatis configuration + */ + void register(Configuration configuration); +} 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 d557c5327..a7f57a760 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java @@ -16,6 +16,7 @@ package io.micronaut.configuration.mybatis; import io.micronaut.configuration.mybatis.explicit.TestExplicitMapper; +import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; import io.micronaut.configuration.mybatis.support.TestPackageMapper; import io.micronaut.configuration.mybatis.support.TestTransactionFactory; import io.micronaut.context.ApplicationContext; @@ -65,6 +66,7 @@ void createsMyBatisBeansForTheDefaultDataSource() throws Exception { assertTrue(configuration.hasMapper(TestMapper.class)); assertTrue(configuration.hasMapper(TestPackageMapper.class)); assertTrue(configuration.hasMapper(TestExplicitMapper.class)); + assertTrue(configuration.hasMapper(TestGeneratedMapper.class)); assertTrue(configuration.isMapUnderscoreToCamelCase()); assertInstanceOf(TestTransactionFactory.class, configuration.getEnvironment().getTransactionFactory()); assertSame( diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java new file mode 100644 index 000000000..aab01861c --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java @@ -0,0 +1,23 @@ +/* + * 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 jakarta.inject.Named; + +@Named("default") +@MyBatisMapperScan("unused") +interface TestGeneratedMapperScanCustomizer extends MyBatisConfigurationCustomizer { +} 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..13c97d8af --- /dev/null +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java @@ -0,0 +1,34 @@ +/* + * 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 jakarta.inject.Singleton; +import org.apache.ibatis.session.Configuration; + +@Singleton +final class TestGeneratedMapperScanRegistration implements MyBatisMapperScanRegistration { + + @Override + public String getCustomizerType() { + return TestGeneratedMapperScanCustomizer.class.getName(); + } + + @Override + public void register(Configuration configuration) { + configuration.addMapper(TestGeneratedMapper.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/src/main/docs/guide/mybatis/mybatis-customizers.adoc b/src/main/docs/guide/mybatis/mybatis-customizers.adoc index ecc200bf0..479810c99 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -11,7 +11,7 @@ This hook is also the right place to configure other MyBatis features that belon To register mapper interfaces by package, annotate the customizer with api:configuration.mybatis.MyBatisMapperScan[]. This replaces a customizer call such as `configuration.addMappers("com.example.mappers")`. The language-specific examples above show the annotation and customizer declaration. -On the JVM, MyBatis package scanning is available as a fallback. For native Java applications, the `micronaut-mybatis-processor` discovers mapper interfaces at compile time and registers them without runtime package scanning. Groovy and Kotlin use the declared packages as a JVM fallback. +The `micronaut-mybatis-processor` discovers mapper interfaces at compile time and generates direct registrations for Java, Groovy, and Kotlin. This avoids runtime package scanning and works with native images. On the JVM, MyBatis package scanning remains available as a fallback when no compile-time registration is generated. Add the processor for the language you use: From 0496d2c9d82cbd3d058bf0ca580f93a5844f2889 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Thu, 3 Sep 2026 15:00:13 +0200 Subject: [PATCH 11/23] Refactor MyBatis mapper registration logic for improved readability and maintainability --- .../mybatis/MyBatisMapperScanInterceptor.java | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java index be0144807..9979cae42 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java @@ -47,37 +47,56 @@ final class MyBatisMapperScanInterceptor implements MethodInterceptor context) { Object[] parameterValues = context.getParameterValues(); - if (parameterValues.length == 1 && parameterValues[0] instanceof Configuration configuration) { - Class[] mappers = context.classValues(MyBatisMapperScan.class, "mappers"); - if (mappers.length > 0) { - for (Class mapper : mappers) { - configuration.addMapper(mapper); - } - } else { - MapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); - String customizerType = mapperScan == null - ? context.getDeclaringType().getName() - : mapperScan.type().getName(); - boolean registered = false; - for (MyBatisMapperScanRegistration registration : registrations) { - if (registration.getCustomizerType().equals(customizerType)) { - registration.register(configuration); - registered = true; - } - } - String[] packages = mapperScan == null - ? context.stringValues(MyBatisMapperScan.class, "value") - : mapperScan.annotation().value(); - if (!registered) { - for (String packageName : packages) { - configuration.addMappers(packageName); - } - } - } + if (parameterValues.length != 1 || !(parameterValues[0] instanceof Configuration configuration)) { + return null; } + registerMappers(context, configuration); return null; } + private void registerMappers(MethodInvocationContext context, Configuration configuration) { + Class[] mappers = context.classValues(MyBatisMapperScan.class, "mappers"); + if (mappers.length > 0) { + registerMappers(configuration, mappers); + return; + } + + MapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); + String customizerType = mapperScan == null + ? context.getDeclaringType().getName() + : mapperScan.type().getName(); + if (registerMappers(configuration, customizerType)) { + return; + } + String[] packages = mapperScan == null + ? context.stringValues(MyBatisMapperScan.class, "value") + : mapperScan.annotation().value(); + registerMappers(configuration, packages); + } + + private static void registerMappers(Configuration configuration, Class[] mappers) { + for (Class mapper : mappers) { + configuration.addMapper(mapper); + } + } + + private boolean registerMappers(Configuration configuration, String customizerType) { + boolean registered = false; + for (MyBatisMapperScanRegistration registration : registrations) { + if (registration.getCustomizerType().equals(customizerType)) { + registration.register(configuration); + registered = true; + } + } + return registered; + } + + private static void registerMappers(Configuration configuration, String[] packages) { + for (String packageName : packages) { + configuration.addMappers(packageName); + } + } + private static MapperScan findMapperScan(Class type, Set> visited) { if (type == null || !visited.add(type)) { return null; From df5955290804b5838e4eace960f3944abf19b9b7 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Thu, 3 Sep 2026 16:47:24 +0200 Subject: [PATCH 12/23] Ensure `intercept` proceeds when conditions are unmet in MyBatisMapperScanInterceptor --- .../configuration/mybatis/MyBatisMapperScanInterceptor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java index 9979cae42..63e0cdf32 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java @@ -48,7 +48,7 @@ final class MyBatisMapperScanInterceptor implements MethodInterceptor context) { Object[] parameterValues = context.getParameterValues(); if (parameterValues.length != 1 || !(parameterValues[0] instanceof Configuration configuration)) { - return null; + return context.proceed(); } registerMappers(context, configuration); return null; From d03473bd20fdf8e43863785639d6846702b2ced5 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 22 Sep 2026 23:14:53 +0200 Subject: [PATCH 13/23] Remove runtime-based MyBatisMapperScan functionality and introduce compile-time mapper registration with generated tests. --- mybatis-processor/build.gradle.kts | 7 +- .../processor/MyBatisMapperScanVisitor.java | 316 ++++++++++++------ .../MyBatisMapperScanVisitorTest.java | 304 ++++++++++++----- mybatis/build.gradle.kts | 1 - .../configuration/mybatis/MyBatisFactory.java | 17 + .../mybatis/MyBatisMapperScan.java | 29 +- .../mybatis/MyBatisMapperScanInterceptor.java | 119 ------- .../MyBatisMapperScanRegistration.java | 37 +- .../mybatis/MyBatisFactoryTest.java | 23 +- .../TestExplicitMapperScanCustomizer.java | 24 -- .../TestGeneratedMapperScanCustomizer.java | 23 -- .../TestGeneratedMapperScanRegistration.java | 15 +- ...therDataSourceMapperScanRegistration.java} | 20 +- .../TestOtherDataSourceMapper.java} | 4 +- .../mybatis/support/TestPackageMapper.java | 19 -- ...tion.mybatis.MyBatisMapperScanRegistration | 2 + .../guide/mybatis/mybatis-customizers.adoc | 35 +- .../docs/guide/mybatis/mybatis-graalvm.adoc | 10 + src/main/docs/guide/toc.yml | 1 + .../CustomConfigurationCustomizer.groovy | 13 +- .../MapperScanRegistrationSpec.groovy | 47 +++ tests/mybatis-java/build.gradle.kts | 3 + .../reachability-metadata.json | 11 - .../CustomConfigurationCustomizer.java | 13 +- .../micronaut/MapperScanRegistrationTest.java | 53 +++ .../java/example/micronaut/domain/Genre.java | 2 - .../CustomConfigurationCustomizer.kt | 12 +- .../micronaut/MapperScanRegistrationTest.kt | 51 +++ 28 files changed, 762 insertions(+), 449 deletions(-) delete mode 100644 mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java delete mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java delete mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java rename mybatis/src/test/java/io/micronaut/configuration/mybatis/{TestPackageMapperScanCustomizer.java => TestOtherDataSourceMapperScanRegistration.java} (55%) rename mybatis/src/test/java/io/micronaut/configuration/mybatis/{explicit/TestExplicitMapper.java => generated/TestOtherDataSourceMapper.java} (85%) delete mode 100644 mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java create mode 100644 mybatis/src/test/resources/META-INF/services/io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration create mode 100644 src/main/docs/guide/mybatis/mybatis-graalvm.adoc create mode 100644 tests/mybatis-groovy/src/test/groovy/example/micronaut/MapperScanRegistrationSpec.groovy delete mode 100644 tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json create mode 100644 tests/mybatis-java/src/test/java/example/micronaut/MapperScanRegistrationTest.java create mode 100644 tests/mybatis-kotlin/src/test/kotlin/example/micronaut/MapperScanRegistrationTest.kt diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 8f35d8d2d..8d0d96d8f 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -3,11 +3,10 @@ plugins { } dependencies { - api(projects.micronautMybatis) - api(mn.micronaut.sourcegen.annotations) - api(mn.micronaut.sourcegen.generator.bytecode) - + // The annotation and the registration interface referenced by the generated classes + implementation(projects.micronautMybatis) implementation(mn.micronaut.core.processor) + implementation(mn.micronaut.sourcegen.generator.bytecode) testImplementation(mn.micronaut.inject.java) testImplementation(mnTest.junit.jupiter.api) 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 c72a62732..631eb6b97 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 @@ -17,10 +17,16 @@ import io.micronaut.configuration.mybatis.MyBatisMapperScan; import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; +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 io.micronaut.inject.visitor.TypeElementVisitor; import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.inject.writer.GeneratedFile; import io.micronaut.sourcegen.generator.bytecode.ByteCodeGenerator; import io.micronaut.sourcegen.model.ClassDef; import io.micronaut.sourcegen.model.ClassTypeDef; @@ -28,169 +34,275 @@ import io.micronaut.sourcegen.model.MethodDef; import io.micronaut.sourcegen.model.StatementDef; import io.micronaut.sourcegen.model.TypeDef; -import jakarta.inject.Named; import org.apache.ibatis.session.Configuration; import javax.lang.model.element.Modifier; -import java.util.Arrays; +import java.io.IOException; +import java.io.Writer; +import java.lang.annotation.Annotation; +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.Optional; import java.util.Set; -import java.util.TreeMap; import java.util.TreeSet; /** - * Generates registrations for mapper types discovered at compile time. + * 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.

*/ +@Internal public final class MyBatisMapperScanVisitor implements TypeElementVisitor { - private static final ByteCodeGenerator BYTE_CODE_GENERATOR = new ByteCodeGenerator(); + private static final String REGISTRATION_SUFFIX = "$MyBatisMapperScanRegistration"; - private final Set mapperTypes = new LinkedHashSet<>(); + private final ByteCodeGenerator byteCodeGenerator = new ByteCodeGenerator(); + private final Set interfaceTypes = new LinkedHashSet<>(); private final Map scans = new LinkedHashMap<>(); - private boolean processed; + private final Set written = new HashSet<>(); @Override public VisitorKind getVisitorKind() { return VisitorKind.AGGREGATING; } - @Override - public Set getSupportedAnnotationNames() { - return Set.of(MyBatisMapperScan.class.getName()); - } - @Override public void visitClass(ClassElement element, VisitorContext context) { - if (element.isInterface()) { - mapperTypes.add(element.getName()); - } - - AnnotationValue scan = element.getAnnotation(MyBatisMapperScan.class); - if (scan != null) { + 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(), - scan.stringValues("value"), - scan.stringValue("datasource").orElse("default") + List.of(annotation.stringValues("value")), + mappers, + annotation.stringValue("datasource").orElse("default"), + annotation.booleanValue("nativeImageMetadata").orElse(true) )); } } @Override public void finish(VisitorContext context) { - if (processed || scans.isEmpty()) { - return; - } - processed = true; - + Set proxyTypes = new TreeSet<>(); + Set reflectiveTypes = new TreeSet<>(); + ClassElement originatingElement = null; for (Scan scan : scans.values()) { + if (!written.add(scan.elementName())) { + continue; + } ClassElement element = context.getClassElement(scan.elementName()).orElse(null); if (element == null) { continue; } - Set packages = new TreeSet<>(Arrays.asList(scan.packages())); + Set mapperTypes = new TreeSet<>(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); + } else { + mapperTypes.addAll(discovered); + } + } + if (mapperTypes.isEmpty() && unresolvedPackages.isEmpty()) { + context.warn("@" + MyBatisMapperScan.class.getSimpleName() + " declares neither packages nor mappers", element); + continue; + } + ClassDef registration = registrationDefinition(element, scan.datasource(), mapperTypes, unresolvedPackages); + byteCodeGenerator.write(registration, context, element); + context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element); - Set selectedMapperTypes = new TreeSet<>(); - for (String mapperType : mapperTypes) { - if (isInScannedPackage(mapperType, packages)) { - selectedMapperTypes.add(mapperType); + if (scan.nativeImageMetadata()) { + originatingElement = element; + proxyTypes.addAll(mapperTypes); + for (String mapperType : mapperTypes) { + context.getClassElement(mapperType).ifPresent(mapper -> collectReflectiveTypes(mapper, reflectiveTypes)); } } + } + if (originatingElement != null) { + writeNativeImageMetadata(context, originatingElement, proxyTypes, reflectiveTypes); + } + } + + /** + * 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, + ClassElement originatingElement, + Set proxyTypes, + Set 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)); - element.annotate(Named.class, builder -> builder.value(scan.datasource())); - writeRegistrations(context, element, scan, selectedMapperTypes); + 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 void writeRegistrations(VisitorContext context, - ClassElement element, - Scan scan, - Set selectedMapperTypes) { - Map> mapperTypesByPackage = new TreeMap<>(); - for (String mapperType : selectedMapperTypes) { - int lastDot = mapperType.lastIndexOf('.'); - String packageName = lastDot > 0 ? mapperType.substring(0, lastDot) : ""; - mapperTypesByPackage.computeIfAbsent(packageName, ignored -> new TreeSet<>()).add(mapperType); + private static String closeJsonArray(StringBuilder json) { + int trailingComma = json.lastIndexOf(","); + if (trailingComma > 0) { + json.deleteCharAt(trailingComma); } + return json.append("]\n").toString(); + } - for (Map.Entry> entry : mapperTypesByPackage.entrySet()) { - String packageName = entry.getKey(); - String className = "MyBatisMapperScanRegistration_" - + scan.elementName().replace('.', '_').replace('$', '_'); - String registrationName = packageName.isEmpty() ? className : packageName + "." + className; - BYTE_CODE_GENERATOR.write(registrationDefinition( - packageName, - className, - scan.elementName(), - entry.getValue() - ), context); - context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registrationName, element); + 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); } } - private ClassDef registrationDefinition(String packageName, - String className, - String customizerType, - Set mapperTypes) { - String registrationName = packageName.isEmpty() ? className : packageName + "." + className; + /** + * 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 + ".")) { + discovered.add(interfaceType); + } + } + return discovered; + } + + private static ClassDef registrationDefinition(ClassElement element, + 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; return ClassDef.builder(registrationName) - .addModifiers(Modifier.FINAL) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addSuperinterface(ClassTypeDef.of(MyBatisMapperScanRegistration.class)) - .addMethod(MethodDef.builder("getCustomizerType") + .addMethod(MethodDef.builder("getDatasourceName") .overrides() .addModifiers(Modifier.PUBLIC) .returns(String.class) - .build((aThis, parameters) -> ExpressionDef.constant(customizerType).returning())) + .build((aThis, parameters) -> ExpressionDef.constant(datasource).returning())) .addMethod(MethodDef.builder("register") .overrides() .addModifiers(Modifier.PUBLIC) .addParameter("configuration", Configuration.class) - .build((aThis, parameters) -> StatementDef.multi(mapperTypes.stream() - .map(mapperType -> (StatementDef) parameters.get(0).invoke( - "addMapper", - TypeDef.VOID, - ExpressionDef.constant(ClassTypeDef.of(mapperType)) - )) - .toList()))) + .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(); } - private boolean isInScannedPackage(String mapperType, Set packages) { - int lastDot = mapperType.lastIndexOf('.'); - String packageName = lastDot > 0 ? mapperType.substring(0, lastDot) : ""; - return packages.stream().anyMatch(scanPackage -> - packageName.equals(scanPackage) || packageName.startsWith(scanPackage + ".")); + private static String packageOf(String typeName) { + int lastDot = typeName.lastIndexOf('.'); + return lastDot > 0 ? typeName.substring(0, lastDot) : ""; } - record Scan(String elementName, String[] packages, String datasource) { - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof Scan(String otherElementName, String[] otherPackages, String otherDatasource))) { - return false; - } - return elementName.equals(otherElementName) - && Arrays.equals(packages, otherPackages) - && datasource.equals(otherDatasource); - } - - @Override - public int hashCode() { - int result = elementName.hashCode(); - result = 31 * result + Arrays.hashCode(packages); - result = 31 * result + datasource.hashCode(); - return result; - } - - @Override - public String toString() { - return "Scan[elementName=" + elementName - + ", packages=" + Arrays.toString(packages) - + ", datasource=" + datasource + "]"; - } + private record Scan(String elementName, + List packages, + List mappers, + String datasource, + boolean nativeImageMetadata) { } } 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 a6790deb7..8bbdb2200 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 @@ -16,7 +16,6 @@ package io.micronaut.configuration.mybatis.processor; import io.micronaut.annotation.processing.TypeElementVisitorProcessor; -import io.micronaut.configuration.mybatis.MyBatisMapperScan; import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; import io.micronaut.core.io.service.SoftServiceLoader; import io.micronaut.inject.visitor.TypeElementVisitor; @@ -32,6 +31,8 @@ import javax.tools.StandardJavaFileManager; import javax.tools.StandardLocation; import javax.tools.ToolProvider; +import java.net.URI; +import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; @@ -39,15 +40,219 @@ import java.util.Collection; 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.assertNotEquals; +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.Mappers", """ + package example.mappers; + + public final class Mappers { + public interface InnerMapper { + } + 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()); + assertTrue(Files.exists(compilation.classes().resolve( + "example/config/MapperConfiguration$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()); + + Configuration configuration = new Configuration(); + registration.register(configuration); + assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.GenreMapper"))); + 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$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); + } + + // 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); + } + + @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 GraalVM metadata is generated + assertFalse(Files.exists(compilation.classes().resolve("META-INF/native-image"))); + } + + @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()); + } + + private static Compilation compile(Path temporaryDirectory, List sources) throws Exception { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); DiagnosticCollector diagnostics = new DiagnosticCollector<>(); Path classes = temporaryDirectory.resolve("classes"); @@ -65,89 +270,22 @@ void generatesRegistrationForDiscoveredMappers(@TempDir Path temporaryDirectory) diagnostics, List.of("-classpath", System.getProperty("java.class.path")), null, - List.of(new InMemoryJavaFileObject("example.config.MapperConfiguration", """ - package example.config; - - import io.micronaut.configuration.mybatis.MyBatisMapperScan; - - @MyBatisMapperScan("example.mappers") - interface MapperConfiguration { - void customize(org.apache.ibatis.session.Configuration configuration); - } - """), new InMemoryJavaFileObject("example.mappers.GenreMapper", """ - package example.mappers; - - import org.apache.ibatis.annotations.Select; - - public interface GenreMapper { - @Select("select 1") - int findOne(); - } - """)) + sources ); task.setProcessors(List.of(new TestTypeElementVisitorProcessor())); - - assertTrue(task.call(), diagnosticsToString(diagnostics)); - Path generatedRegistration = classes.resolve( - "example/mappers/MyBatisMapperScanRegistration_example_config_MapperConfiguration.class" - ); - assertTrue(Files.exists(generatedRegistration)); - try (URLClassLoader classLoader = new URLClassLoader( - new java.net.URL[]{classes.toUri().toURL()}, - getClass().getClassLoader() - )) { - List registrations = new ArrayList<>(); - SoftServiceLoader.load(MyBatisMapperScanRegistration.class, classLoader).collectAll(registrations); - - assertEquals(1, registrations.size()); - MyBatisMapperScanRegistration registration = registrations.get(0); - assertEquals("example.config.MapperConfiguration", registration.getCustomizerType()); - Configuration configuration = new Configuration(); - registration.register(configuration); - assertTrue(configuration.hasMapper(classLoader.loadClass("example.mappers.GenreMapper"))); - } + boolean success = task.call(); + String messages = diagnostics.getDiagnostics().stream() + .map(Diagnostic::toString) + .collect(Collectors.joining(System.lineSeparator())); + return new Compilation(success, messages, classes); } } - @Test - void declaresSupportedAnnotationAndAggregatingKind() { - MyBatisMapperScanVisitor visitor = new MyBatisMapperScanVisitor(); - - assertEquals( - Set.of(MyBatisMapperScan.class.getName()), - visitor.getSupportedAnnotationNames() - ); - assertEquals(TypeElementVisitor.VisitorKind.AGGREGATING, visitor.getVisitorKind()); - } + private record Compilation(boolean success, String diagnostics, Path classes) { - @Test - void scanUsesArrayContentsForEqualityAndStringValues() { - MyBatisMapperScanVisitor.Scan scan = new MyBatisMapperScanVisitor.Scan( - "example.config.MapperConfiguration", - new String[]{"example.mappers"}, - "default" - ); - MyBatisMapperScanVisitor.Scan equalScan = new MyBatisMapperScanVisitor.Scan( - "example.config.MapperConfiguration", - new String[]{"example.mappers"}, - "default" - ); - MyBatisMapperScanVisitor.Scan differentScan = new MyBatisMapperScanVisitor.Scan( - "example.config.MapperConfiguration", - new String[]{"example.other"}, - "default" - ); - - assertEquals(scan, scan); - assertEquals(scan, equalScan); - assertEquals(scan.hashCode(), equalScan.hashCode()); - assertEquals( - "Scan[elementName=example.config.MapperConfiguration, packages=[example.mappers], datasource=default]", - scan.toString() - ); - assertNotEquals(scan, differentScan); - assertNotEquals(null, scan); - assertNotEquals("not a scan", scan); + URLClassLoader classLoader() throws Exception { + return new URLClassLoader(new URL[]{classes.toUri().toURL()}, getClass().getClassLoader()); + } } private static final class TestTypeElementVisitorProcessor extends TypeElementVisitorProcessor { @@ -162,17 +300,11 @@ protected TypeElementVisitor.VisitorKind getIncrementalProcessorKind() { } } - private String diagnosticsToString(DiagnosticCollector diagnostics) { - return diagnostics.getDiagnostics().stream() - .map(Diagnostic::toString) - .reduce("", (left, right) -> left + System.lineSeparator() + right); - } - private static final class InMemoryJavaFileObject extends SimpleJavaFileObject { private final String source; private InMemoryJavaFileObject(String className, String source) { - super(java.net.URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); this.source = source; } diff --git a/mybatis/build.gradle.kts b/mybatis/build.gradle.kts index a87897269..486404018 100644 --- a/mybatis/build.gradle.kts +++ b/mybatis/build.gradle.kts @@ -3,7 +3,6 @@ plugins { } dependencies { api(projects.micronautJdbc) - api(mn.micronaut.aop) api(mn.micronaut.context) api(mn.micronaut.inject) api(libs.managed.mybatis) 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..e0889634e 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisFactory.java @@ -20,6 +20,7 @@ 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 +32,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 +67,23 @@ 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()) { + if (name.equals(registration.getDatasourceName())) { + registration.register(configuration); + } + } return configuration; } + private static List mapperScanRegistrations() { + List registrations = new ArrayList<>(); + SoftServiceLoader.load(MyBatisMapperScanRegistration.class, MyBatisFactory.class.getClassLoader()) + .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 index 88c6efe70..0813e7043 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScan.java @@ -15,9 +15,6 @@ */ package io.micronaut.configuration.mybatis; -import io.micronaut.aop.Introduction; -import jakarta.inject.Singleton; - import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -25,21 +22,28 @@ import java.lang.annotation.Target; /** - * Requests compile-time discovery of MyBatis mapper interfaces in the given packages. + * 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.2.0 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) -@Introduction -@Singleton public @interface MyBatisMapperScan { /** - * @return Packages containing MyBatis mapper interfaces + * @return Packages containing MyBatis mapper interfaces. Sub-packages and nested interfaces are included. */ - String[] value(); + String[] value() default {}; /** * @return The datasource name to which the discovered mappers are registered @@ -50,4 +54,13 @@ * @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). 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/MyBatisMapperScanInterceptor.java b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java deleted file mode 100644 index 63e0cdf32..000000000 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanInterceptor.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * 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.aop.InterceptorBean; -import io.micronaut.aop.MethodInterceptor; -import io.micronaut.aop.MethodInvocationContext; -import io.micronaut.core.annotation.Internal; -import io.micronaut.core.io.service.SoftServiceLoader; -import org.apache.ibatis.session.Configuration; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Registers mapper types discovered by {@link MyBatisMapperScan}. - */ -@InterceptorBean(MyBatisMapperScan.class) -@Internal -final class MyBatisMapperScanInterceptor implements MethodInterceptor { - - private final List registrations; - - MyBatisMapperScanInterceptor(List registrations) { - this.registrations = new ArrayList<>(registrations); - SoftServiceLoader.load( - MyBatisMapperScanRegistration.class, - MyBatisMapperScanInterceptor.class.getClassLoader() - ).collectAll(this.registrations); - } - - @Override - public Object intercept(MethodInvocationContext context) { - Object[] parameterValues = context.getParameterValues(); - if (parameterValues.length != 1 || !(parameterValues[0] instanceof Configuration configuration)) { - return context.proceed(); - } - registerMappers(context, configuration); - return null; - } - - private void registerMappers(MethodInvocationContext context, Configuration configuration) { - Class[] mappers = context.classValues(MyBatisMapperScan.class, "mappers"); - if (mappers.length > 0) { - registerMappers(configuration, mappers); - return; - } - - MapperScan mapperScan = findMapperScan(context.getTarget().getClass(), new HashSet<>()); - String customizerType = mapperScan == null - ? context.getDeclaringType().getName() - : mapperScan.type().getName(); - if (registerMappers(configuration, customizerType)) { - return; - } - String[] packages = mapperScan == null - ? context.stringValues(MyBatisMapperScan.class, "value") - : mapperScan.annotation().value(); - registerMappers(configuration, packages); - } - - private static void registerMappers(Configuration configuration, Class[] mappers) { - for (Class mapper : mappers) { - configuration.addMapper(mapper); - } - } - - private boolean registerMappers(Configuration configuration, String customizerType) { - boolean registered = false; - for (MyBatisMapperScanRegistration registration : registrations) { - if (registration.getCustomizerType().equals(customizerType)) { - registration.register(configuration); - registered = true; - } - } - return registered; - } - - private static void registerMappers(Configuration configuration, String[] packages) { - for (String packageName : packages) { - configuration.addMappers(packageName); - } - } - - private static MapperScan findMapperScan(Class type, Set> visited) { - if (type == null || !visited.add(type)) { - return null; - } - MyBatisMapperScan mapperScan = type.getAnnotation(MyBatisMapperScan.class); - if (mapperScan != null) { - return new MapperScan(type, mapperScan); - } - for (Class interfaceType : type.getInterfaces()) { - MapperScan interfaceMapperScan = findMapperScan(interfaceType, visited); - if (interfaceMapperScan != null) { - return interfaceMapperScan; - } - } - return findMapperScan(type.getSuperclass(), visited); - } - - private record MapperScan(Class type, MyBatisMapperScan annotation) { - } -} 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 71b78653a..6ee7696db 100644 --- a/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java +++ b/mybatis/src/main/java/io/micronaut/configuration/mybatis/MyBatisMapperScanRegistration.java @@ -15,26 +15,51 @@ */ package io.micronaut.configuration.mybatis; -import io.micronaut.core.annotation.Internal; import org.apache.ibatis.session.Configuration; /** - * Registers mapper interfaces discovered by the MyBatis annotation processor. + * 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.2.0 */ -@Internal public interface MyBatisMapperScanRegistration { /** - * @return The customizer type that declared the mapper scan + * @return The name of the datasource whose configuration receives the mappers */ - String getCustomizerType(); + String getDatasourceName(); /** - * Registers the discovered mapper interfaces. + * 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. + * + * @param configuration The MyBatis configuration + * @param packageName The package name + */ + default void addMappers(Configuration configuration, String packageName) { + configuration.addMappers(packageName); + } } 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 a7f57a760..1cc97e03a 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/MyBatisFactoryTest.java @@ -15,9 +15,8 @@ */ package io.micronaut.configuration.mybatis; -import io.micronaut.configuration.mybatis.explicit.TestExplicitMapper; import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; -import io.micronaut.configuration.mybatis.support.TestPackageMapper; +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; @@ -64,9 +63,8 @@ void createsMyBatisBeansForTheDefaultDataSource() throws Exception { Configuration configuration = applicationContext.getBean(Configuration.class); assertTrue(configuration.hasMapper(TestMapper.class)); - assertTrue(configuration.hasMapper(TestPackageMapper.class)); - assertTrue(configuration.hasMapper(TestExplicitMapper.class)); assertTrue(configuration.hasMapper(TestGeneratedMapper.class)); + assertFalse(configuration.hasMapper(TestOtherDataSourceMapper.class)); assertTrue(configuration.isMapUnderscoreToCamelCase()); assertInstanceOf(TestTransactionFactory.class, configuration.getEnvironment().getTransactionFactory()); assertSame( @@ -100,10 +98,27 @@ 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)); + } + } + 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/TestExplicitMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java deleted file mode 100644 index 8cc225b55..000000000 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestExplicitMapperScanCustomizer.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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.explicit.TestExplicitMapper; -import jakarta.inject.Named; - -@Named("default") -@MyBatisMapperScan(value = "unused", mappers = TestExplicitMapper.class) -interface TestExplicitMapperScanCustomizer extends MyBatisConfigurationCustomizer { -} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java deleted file mode 100644 index aab01861c..000000000 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanCustomizer.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 jakarta.inject.Named; - -@Named("default") -@MyBatisMapperScan("unused") -interface TestGeneratedMapperScanCustomizer extends MyBatisConfigurationCustomizer { -} diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java index 13c97d8af..81d55995a 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestGeneratedMapperScanRegistration.java @@ -16,19 +16,22 @@ package io.micronaut.configuration.mybatis; import io.micronaut.configuration.mybatis.generated.TestGeneratedMapper; -import jakarta.inject.Singleton; import org.apache.ibatis.session.Configuration; -@Singleton -final class TestGeneratedMapperScanRegistration implements MyBatisMapperScanRegistration { +/** + * 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 getCustomizerType() { - return TestGeneratedMapperScanCustomizer.class.getName(); + public String getDatasourceName() { + return "default"; } @Override public void register(Configuration configuration) { - configuration.addMapper(TestGeneratedMapper.class); + // 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/TestPackageMapperScanCustomizer.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java similarity index 55% rename from mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java rename to mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java index fb8859a8d..c51d59a87 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestPackageMapperScanCustomizer.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/TestOtherDataSourceMapperScanRegistration.java @@ -15,9 +15,21 @@ */ package io.micronaut.configuration.mybatis; -import jakarta.inject.Named; +import io.micronaut.configuration.mybatis.generated.TestOtherDataSourceMapper; +import org.apache.ibatis.session.Configuration; -@Named("default") -@MyBatisMapperScan("io.micronaut.configuration.mybatis.support") -interface TestPackageMapperScanCustomizer extends MyBatisConfigurationCustomizer { +/** + * 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/explicit/TestExplicitMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java similarity index 85% rename from mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.java rename to mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java index bd41b8d34..ffe574839 100644 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/explicit/TestExplicitMapper.java +++ b/mybatis/src/test/java/io/micronaut/configuration/mybatis/generated/TestOtherDataSourceMapper.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.micronaut.configuration.mybatis.explicit; +package io.micronaut.configuration.mybatis.generated; -public interface TestExplicitMapper { +public interface TestOtherDataSourceMapper { } diff --git a/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java b/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java deleted file mode 100644 index 004a048a5..000000000 --- a/mybatis/src/test/java/io/micronaut/configuration/mybatis/support/TestPackageMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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.support; - -public interface TestPackageMapper { -} 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/src/main/docs/guide/mybatis/mybatis-customizers.adoc b/src/main/docs/guide/mybatis/mybatis-customizers.adoc index 479810c99..c276c2d68 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -1,38 +1,33 @@ You can define additional beans which will be used when the MyBatis `Configuration` is created. -Only beans of type api:configuration.mybatis.MyBatisConfigurationCustomizer[] with an `@Named` qualifier matching the datasource name will be applied. When the mapper-scan processor is used with Java, it adds this qualifier to the generated bean metadata. - -For the default datasource, Groovy and Kotlin customizers must explicitly declare `@Named("default")`. For a named datasource, set the `datasource` member of `@MyBatisMapperScan` and qualify the customizer with the same name, for example `@Named("orders")` with `datasources.orders.url`. Java users may omit `@Named` when using the processor because it adds the qualifier to the generated bean metadata; Groovy and Kotlin must declare it explicitly. +Only beans of type api:configuration.mybatis.MyBatisConfigurationCustomizer[] with an `@Named` qualifier matching the datasource name will be applied. For example, you can register mapper interfaces and tune MyBatis settings for a specific datasource: 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. +<1> Mapper interfaces in the `example.micronaut.mappers` package are registered at compile time (see below) +<2> The customizer is applied to the `default` datasource +<3> The customizer can configure any other MyBatis feature that belongs on the `Configuration`, such as type aliases, type handlers, interceptors, or additional mapper registrations -To register mapper interfaces by package, annotate the customizer with api:configuration.mybatis.MyBatisMapperScan[]. This replaces a customizer call such as `configuration.addMappers("com.example.mappers")`. The language-specific examples above show the annotation and customizer declaration. +==== Compile-time Mapper Registration -The `micronaut-mybatis-processor` discovers mapper interfaces at compile time and generates direct registrations for Java, Groovy, and Kotlin. This avoids runtime package scanning and works with native images. On the JVM, MyBatis package scanning remains available as a fallback when no compile-time registration is generated. +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: -Add the processor for the language you use: +dependency:micronaut-mybatis-processor[groupId="io.micronaut.sql", scope="annotationProcessor"] -[source,groovy] -.Java ----- -annotationProcessor("io.micronaut.sql:micronaut-mybatis-processor") ----- +NOTE: For Kotlin add the processor to the `ksp` (or `kapt`) configuration, for Groovy to the `compileOnly` configuration. -[source,groovy] -.Groovy ----- -compileOnly("io.micronaut.sql:micronaut-mybatis-processor") ----- +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,kotlin] -.Kotlin +[source,java] ---- -ksp("io.micronaut.sql:micronaut-mybatis-processor") +@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 Micronaut also looks for a link:https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/transaction/TransactionFactory.html[TransactionFactory] bean qualified with the datasource name. If none is present, Micronaut falls back to link:https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/transaction/jdbc/JdbcTransactionFactory.html[JdbcTransactionFactory]. 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..b87602539 --- /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 written to `META-INF/native-image///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. + +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/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy b/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy index 38be3cf97..3f0cb1e92 100644 --- a/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy +++ b/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy @@ -4,11 +4,18 @@ package example.micronaut 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") -interface CustomConfigurationCustomizer extends MyBatisConfigurationCustomizer { +@MyBatisMapperScan("example.micronaut.mappers") // <1> +@Named("default") // <2> +@Singleton +class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { + @Override + void customize(Configuration configuration) { // <3> + configuration.mapUnderscoreToCamelCase = true + } } // end::clazz[] 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..7e33913f9 --- /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.CustomConfigurationCustomizer$MyBatisMapperScanRegistration' + configuration.hasMapper(GenreMapper) + configuration.mapUnderscoreToCamelCase + } +} diff --git a/tests/mybatis-java/build.gradle.kts b/tests/mybatis-java/build.gradle.kts index 2cb3841ac..f102239be 100644 --- a/tests/mybatis-java/build.gradle.kts +++ b/tests/mybatis-java/build.gradle.kts @@ -29,6 +29,9 @@ 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/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json b/tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json deleted file mode 100644 index 457b67cb2..000000000 --- a/tests/mybatis-java/src/main/resources/META-INF/native-image/io.micronaut.sql/micronaut-mybatis-test/reachability-metadata.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "reflection": [ - { - "type": { - "proxy": [ - "example.micronaut.mappers.GenreMapper" - ] - } - } - ] -} 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 1defb2513..25509db4e 100644 --- a/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java +++ b/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java @@ -3,10 +3,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") -public interface CustomConfigurationCustomizer extends MyBatisConfigurationCustomizer { +@MyBatisMapperScan("example.micronaut.mappers") // <1> +@Named("default") // <2> +@Singleton +public class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { + @Override + public void customize(Configuration configuration) { // <3> + configuration.setMapUnderscoreToCamelCase(true); + } } // end::clazz[] 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..881203e6f --- /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.CustomConfigurationCustomizer$MyBatisMapperScanRegistration", + registrations.get(0).getClass().getName()); + assertTrue(configuration.hasMapper(GenreMapper.class)); + assertTrue(configuration.isMapUnderscoreToCamelCase()); + } +} diff --git a/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java b/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java index 84d992726..2f1bf82ab 100644 --- a/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java +++ b/tests/mybatis-java/src/test/java/example/micronaut/domain/Genre.java @@ -18,7 +18,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.annotation.ReflectiveAccess; import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.NotBlank; @@ -26,7 +25,6 @@ import java.util.Set; @Serdeable -@ReflectiveAccess public class Genre { @Nullable 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 3c274f6ca..314a447db 100644 --- a/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt +++ b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt @@ -4,11 +4,17 @@ package example.micronaut 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") -interface CustomConfigurationCustomizer : MyBatisConfigurationCustomizer { +@MyBatisMapperScan("example.micronaut.mappers") // <1> +@Named("default") // <2> +@Singleton +class CustomConfigurationCustomizer : MyBatisConfigurationCustomizer { + override fun customize(configuration: Configuration) { // <3> + configuration.isMapUnderscoreToCamelCase = true + } } // end::clazz[] 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..f7ae8949c --- /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.CustomConfigurationCustomizer\$MyBatisMapperScanRegistration", + registrations[0].javaClass.name + ) + assertTrue(configuration.hasMapper(GenreMapper::class.java)) + assertTrue(configuration.isMapUnderscoreToCamelCase) + } +} From 6d116c9c875a84de531e0db886fc82d82872f298 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 22 Sep 2026 23:56:35 +0200 Subject: [PATCH 14/23] Switch from `ByteCodeGenerator` to `ByteCodeWriter` for improved dependency isolation and update build dependencies accordingly --- mybatis-processor/build.gradle.kts | 4 ++-- .../processor/MyBatisMapperScanVisitor.java | 24 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 8d0d96d8f..85a010069 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -3,10 +3,10 @@ plugins { } dependencies { - // The annotation and the registration interface referenced by the generated classes implementation(projects.micronautMybatis) implementation(mn.micronaut.core.processor) - implementation(mn.micronaut.sourcegen.generator.bytecode) + implementation(mn.micronaut.sourcegen.model) + implementation(mn.micronaut.sourcegen.bytecode.writer) testImplementation(mn.micronaut.inject.java) testImplementation(mnTest.junit.jupiter.api) 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 631eb6b97..da841b37e 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 @@ -25,9 +25,10 @@ import io.micronaut.inject.ast.MethodElement; import io.micronaut.inject.ast.ParameterElement; import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.processing.ProcessingException; import io.micronaut.inject.visitor.VisitorContext; import io.micronaut.inject.writer.GeneratedFile; -import io.micronaut.sourcegen.generator.bytecode.ByteCodeGenerator; +import io.micronaut.sourcegen.bytecode.ByteCodeWriter; import io.micronaut.sourcegen.model.ClassDef; import io.micronaut.sourcegen.model.ClassTypeDef; import io.micronaut.sourcegen.model.ExpressionDef; @@ -38,6 +39,7 @@ import javax.lang.model.element.Modifier; import java.io.IOException; +import java.io.OutputStream; import java.io.Writer; import java.lang.annotation.Annotation; import java.util.ArrayList; @@ -64,7 +66,15 @@ public final class MyBatisMapperScanVisitor implements TypeElementVisitor interfaceTypes = new LinkedHashSet<>(); private final Map scans = new LinkedHashMap<>(); private final Set written = new HashSet<>(); @@ -125,7 +135,7 @@ public void finish(VisitorContext context) { continue; } ClassDef registration = registrationDefinition(element, scan.datasource(), mapperTypes, unresolvedPackages); - byteCodeGenerator.write(registration, context, element); + writeClass(context, element, registration); context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element); if (scan.nativeImageMetadata()) { @@ -141,6 +151,14 @@ public void finish(VisitorContext context) { } } + 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); + } + } + /** * 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 From cbb7fbc8bf758a495cbe0fa6777ba8afa987b1a2 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Wed, 23 Sep 2026 00:13:39 +0200 Subject: [PATCH 15/23] Update MyBatis documentation for kotlin and groovy processor configuration links --- src/main/docs/guide/mybatis/mybatis-customizers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/docs/guide/mybatis/mybatis-customizers.adoc b/src/main/docs/guide/mybatis/mybatis-customizers.adoc index c276c2d68..ee346cb76 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -15,7 +15,7 @@ MyBatis normally discovers mapper interfaces by scanning the classpath at runtim dependency:micronaut-mybatis-processor[groupId="io.micronaut.sql", scope="annotationProcessor"] -NOTE: For Kotlin add the processor to the `ksp` (or `kapt`) configuration, for Groovy to the `compileOnly` configuration. +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. From 58eb4465891164e9a7baa0dd36e2c696e89723c8 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Wed, 23 Sep 2026 09:01:10 +0200 Subject: [PATCH 16/23] Fix Sonar: Refactor MyBatis mapper scan to extract registration logic and improve native image metadata handling --- .../processor/MyBatisMapperScanVisitor.java | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 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 da841b37e..067623454 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 @@ -105,49 +105,51 @@ public void visitClass(ClassElement element, VisitorContext context) { @Override public void finish(VisitorContext context) { - Set proxyTypes = new TreeSet<>(); - Set reflectiveTypes = new TreeSet<>(); - ClassElement originatingElement = null; + NativeImageMetadata nativeImageMetadata = new NativeImageMetadata(); for (Scan scan : scans.values()) { - if (!written.add(scan.elementName())) { - continue; + if (written.add(scan.elementName())) { + context.getClassElement(scan.elementName()) + .ifPresent(element -> generateRegistration(context, element, scan, nativeImageMetadata)); } - ClassElement element = context.getClassElement(scan.elementName()).orElse(null); - if (element == null) { - continue; - } - Set mapperTypes = new TreeSet<>(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); - } else { - mapperTypes.addAll(discovered); - } - } - if (mapperTypes.isEmpty() && unresolvedPackages.isEmpty()) { - context.warn("@" + MyBatisMapperScan.class.getSimpleName() + " declares neither packages nor mappers", element); - continue; - } - ClassDef registration = registrationDefinition(element, scan.datasource(), mapperTypes, unresolvedPackages); - writeClass(context, element, registration); - context.visitServiceDescriptor(MyBatisMapperScanRegistration.class, registration.getName(), element); + } + if (nativeImageMetadata.originatingElement != null) { + writeNativeImageMetadata(context, nativeImageMetadata); + } + } - if (scan.nativeImageMetadata()) { - originatingElement = element; - proxyTypes.addAll(mapperTypes); - for (String mapperType : mapperTypes) { - context.getClassElement(mapperType).ifPresent(mapper -> collectReflectiveTypes(mapper, reflectiveTypes)); - } + private void generateRegistration(VisitorContext context, + ClassElement element, + Scan scan, + NativeImageMetadata nativeImageMetadata) { + Set mapperTypes = new TreeSet<>(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); + } else { + mapperTypes.addAll(discovered); } } - if (originatingElement != null) { - writeNativeImageMetadata(context, originatingElement, proxyTypes, reflectiveTypes); + if (mapperTypes.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); + + if (scan.nativeImageMetadata()) { + nativeImageMetadata.originatingElement = element; + nativeImageMetadata.proxyTypes.addAll(mapperTypes); + for (String mapperType : mapperTypes) { + context.getClassElement(mapperType) + .ifPresent(mapper -> collectReflectiveTypes(mapper, nativeImageMetadata.reflectiveTypes)); + } } } @@ -164,10 +166,10 @@ private static void writeClass(VisitorContext context, ClassElement originatingE * 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, - ClassElement originatingElement, - Set proxyTypes, - Set reflectiveTypes) { + 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"); @@ -317,6 +319,15 @@ private static String packageOf(String typeName) { 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, From 57ba5c87987ef9bfede4d7577a0364d5cc56f46b Mon Sep 17 00:00:00 2001 From: radovanradic Date: Wed, 23 Sep 2026 16:08:15 +0200 Subject: [PATCH 17/23] Refactor MyBatis documentation and examples to clarify `@Named("default")` usage and streamline comments --- src/main/docs/guide/mybatis/mybatis-customizers.adoc | 4 +--- .../example/micronaut/CustomConfigurationCustomizer.groovy | 6 +++--- .../example/micronaut/CustomConfigurationCustomizer.java | 6 +++--- .../example/micronaut/CustomConfigurationCustomizer.kt | 6 +++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/main/docs/guide/mybatis/mybatis-customizers.adoc b/src/main/docs/guide/mybatis/mybatis-customizers.adoc index ee346cb76..7a5b78cb3 100644 --- a/src/main/docs/guide/mybatis/mybatis-customizers.adoc +++ b/src/main/docs/guide/mybatis/mybatis-customizers.adoc @@ -5,9 +5,7 @@ For example, you can register mapper interfaces and tune MyBatis settings for a snippet::example.micronaut.CustomConfigurationCustomizer[tags="imports,clazz", project-base="tests/mybatis"] -<1> Mapper interfaces in the `example.micronaut.mappers` package are registered at compile time (see below) -<2> The customizer is applied to the `default` datasource -<3> The customizer can configure any other MyBatis feature that belongs 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 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 3f0cb1e92..2d0b61318 100644 --- a/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy +++ b/tests/mybatis-groovy/src/test/groovy/example/micronaut/CustomConfigurationCustomizer.groovy @@ -9,12 +9,12 @@ import org.apache.ibatis.session.Configuration // end::imports[] // tag::clazz[] -@MyBatisMapperScan("example.micronaut.mappers") // <1> -@Named("default") // <2> +@MyBatisMapperScan("example.micronaut.mappers") +@Named("default") @Singleton class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { @Override - void customize(Configuration configuration) { // <3> + void customize(Configuration configuration) { configuration.mapUnderscoreToCamelCase = 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 25509db4e..026a59646 100644 --- a/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java +++ b/tests/mybatis-java/src/test/java/example/micronaut/CustomConfigurationCustomizer.java @@ -9,12 +9,12 @@ // end::imports[] // tag::clazz[] -@MyBatisMapperScan("example.micronaut.mappers") // <1> -@Named("default") // <2> +@MyBatisMapperScan("example.micronaut.mappers") +@Named("default") @Singleton public class CustomConfigurationCustomizer implements MyBatisConfigurationCustomizer { @Override - public void customize(Configuration configuration) { // <3> + public void customize(Configuration configuration) { configuration.setMapUnderscoreToCamelCase(true); } } 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 314a447db..07f294176 100644 --- a/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt +++ b/tests/mybatis-kotlin/src/test/kotlin/example/micronaut/CustomConfigurationCustomizer.kt @@ -9,11 +9,11 @@ import org.apache.ibatis.session.Configuration // end::imports[] // tag::clazz[] -@MyBatisMapperScan("example.micronaut.mappers") // <1> -@Named("default") // <2> +@MyBatisMapperScan("example.micronaut.mappers") +@Named("default") @Singleton class CustomConfigurationCustomizer : MyBatisConfigurationCustomizer { - override fun customize(configuration: Configuration) { // <3> + override fun customize(configuration: Configuration) { configuration.isMapUnderscoreToCamelCase = true } } From 50c8f42c591e39dfedf9dcb2ed0c28246646bb0a Mon Sep 17 00:00:00 2001 From: radovanradic Date: Wed, 23 Sep 2026 16:42:03 +0200 Subject: [PATCH 18/23] Refactor MyBatis mapper scan: implement `MapperScanSupport` for shared logic, add `MyBatisMapperScanReflectionVisitor`, and streamline native image metadata generation --- mybatis-processor/build.gradle.kts | 2 + .../mybatis/processor/MapperScanSupport.java | 156 +++++++++++++++ .../MyBatisMapperScanReflectionVisitor.java | 133 +++++++++++++ .../processor/MyBatisMapperScanVisitor.java | 188 ++---------------- ...icronaut.inject.visitor.TypeElementVisitor | 1 + .../MyBatisMapperScanVisitorTest.java | 122 ++++++++++-- .../mybatis/MyBatisMapperScan.java | 5 +- .../docs/guide/mybatis/mybatis-graalvm.adoc | 2 +- 8 files changed, 416 insertions(+), 193 deletions(-) create mode 100644 mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MapperScanSupport.java create mode 100644 mybatis-processor/src/main/java/io/micronaut/configuration/mybatis/processor/MyBatisMapperScanReflectionVisitor.java diff --git a/mybatis-processor/build.gradle.kts b/mybatis-processor/build.gradle.kts index 85a010069..c481dae72 100644 --- a/mybatis-processor/build.gradle.kts +++ b/mybatis-processor/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { 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) 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 index 067623454..3e839415d 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 @@ -17,17 +17,12 @@ import io.micronaut.configuration.mybatis.MyBatisMapperScan; import io.micronaut.configuration.mybatis.MyBatisMapperScanRegistration; -import io.micronaut.core.annotation.AnnotationClassValue; -import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.configuration.mybatis.processor.MapperScanSupport.Scan; 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 io.micronaut.inject.visitor.TypeElementVisitor; import io.micronaut.inject.processing.ProcessingException; import io.micronaut.inject.visitor.VisitorContext; -import io.micronaut.inject.writer.GeneratedFile; import io.micronaut.sourcegen.bytecode.ByteCodeWriter; import io.micronaut.sourcegen.model.ClassDef; import io.micronaut.sourcegen.model.ClassTypeDef; @@ -40,15 +35,12 @@ import javax.lang.model.element.Modifier; import java.io.IOException; import java.io.OutputStream; -import java.io.Writer; -import java.lang.annotation.Annotation; 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.Optional; import java.util.Set; import java.util.TreeSet; @@ -60,6 +52,8 @@ * 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 { @@ -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> 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> 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> 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); + } +}