From 9e27786c75bfe6d68ba67b0faa24b50d5700c487 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Thu, 28 May 2026 15:42:06 +0200
Subject: [PATCH 01/15] Investigate MariaDB batch inserts
---
.../DefaultJdbcRepositoryOperations.java | 47 ++++-
.../jdbc/mariadb/MariaBatchInsertSpec.groovy | 91 ++++++++++
.../legacy/MariaLegacyBatchInsertSpec.groovy | 115 +++++++++++++
.../sql/AbstractSqlRepositoryOperations.java | 19 +-
.../internal/sql/SqlBatchSupport.java | 162 ++++++++++++++++++
.../internal/sql/SqlBatchSupportSpec.groovy | 65 +++++++
6 files changed, 477 insertions(+), 22 deletions(-)
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy
create mode 100644 data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java
create mode 100644 data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index 93ae930e8db..ab36f92ae5c 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -101,6 +101,7 @@
import io.micronaut.data.runtime.operations.internal.query.BindableParametersStoredQuery;
import io.micronaut.data.runtime.operations.internal.sql.AbstractSqlRepositoryOperations;
import io.micronaut.data.runtime.operations.internal.sql.DefaultSqlPreparedQuery;
+import io.micronaut.data.runtime.operations.internal.sql.SqlBatchSupport;
import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider;
import io.micronaut.data.runtime.operations.internal.sql.SqlPreparedQuery;
import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery;
@@ -850,7 +851,8 @@ public Iterable persistAll(@NonNull InsertBatchOperation operation) {
final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery());
final RuntimePersistentEntity persistentEntity = storedQuery.getPersistentEntity();
JdbcOperationContext ctx = createContext(operation, connection, storedQuery);
- if (!isSupportsBatchInsert(persistentEntity, storedQuery)) {
+ boolean requiresGeneratedKeys = SqlBatchSupport.requiresBatchGeneratedKeys(persistentEntity, operation);
+ if (!isSupportsBatchInsert(ctx, persistentEntity, storedQuery, requiresGeneratedKeys)) {
return operation.split().stream()
.map(persistOp -> {
JdbcEntityOperations op = new JdbcEntityOperations<>(ctx, storedQuery, persistentEntity, persistOp.getEntity(), true);
@@ -859,7 +861,7 @@ public Iterable persistAll(@NonNull InsertBatchOperation operation) {
})
.toList();
} else {
- JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery, true);
+ JdbcEntitiesOperations op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery, true, requiresGeneratedKeys);
op.persist();
return op.getEntities();
}
@@ -1158,6 +1160,30 @@ public boolean isSupportsBatchInsert(JdbcOperationContext jdbcOperationContext,
return isSupportsBatchInsert(persistentEntity, jdbcOperationContext.dialect);
}
+ private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
+ RuntimePersistentEntity> persistentEntity,
+ SqlStoredQuery, ?> storedQuery,
+ boolean requiresGeneratedKeys) {
+ if (storedQuery.getOperationType() == StoredQuery.OperationType.INSERT_RETURNING) {
+ return false;
+ }
+ return SqlBatchSupport.isSupportsBatchInsert(
+ persistentEntity,
+ storedQuery.getDialect(),
+ databaseProductName(ctx.connection),
+ requiresGeneratedKeys
+ );
+ }
+
+ @Nullable
+ private String databaseProductName(Connection connection) {
+ try {
+ return connection.getMetaData().getDatabaseProductName();
+ } catch (SQLException ignored) {
+ return null;
+ }
+ }
+
@SuppressWarnings({"rawtypes", "unchecked"})
private RuntimePersistentEntity> resolveOracleReturningEntity(Class> type) {
return getEntity((Class) type);
@@ -1420,6 +1446,7 @@ private void executeUpdate() throws SQLException {
private final class JdbcEntitiesOperations extends AbstractSyncEntitiesOperations {
private final SqlStoredQuery storedQuery;
+ private final boolean requiresGeneratedKeys;
private int rowsUpdated;
private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) {
@@ -1427,11 +1454,21 @@ private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity
}
private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery, boolean insert) {
+ this(ctx, persistentEntity, entities, storedQuery, insert, insert && persistentEntity.hasIdentity() && persistentEntity.getIdentity().isGenerated());
+ }
+
+ private JdbcEntitiesOperations(JdbcOperationContext ctx,
+ RuntimePersistentEntity persistentEntity,
+ Iterable entities,
+ SqlStoredQuery storedQuery,
+ boolean insert,
+ boolean requiresGeneratedKeys) {
super(ctx,
DefaultJdbcRepositoryOperations.this.cascadeOperations,
DefaultJdbcRepositoryOperations.this.conversionService,
entityEventRegistry, persistentEntity, entities, insert);
this.storedQuery = storedQuery;
+ this.requiresGeneratedKeys = requiresGeneratedKeys;
}
@Override
@@ -1447,7 +1484,7 @@ protected void collectAutoPopulatedPreviousValues() {
private PreparedStatement prepare(Connection connection) throws SQLException {
if (insert) {
Dialect dialect = storedQuery.getDialect();
- if (hasGeneratedId && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) {
+ if (requiresGeneratedKeys && (dialect == Dialect.ORACLE || dialect == Dialect.SQL_SERVER)) {
if (isJsonEntityGeneratedId(storedQuery, persistentEntity)) {
// This is being closed in try with resources from where it is being called
@SuppressWarnings({"java:S2095"})
@@ -1458,7 +1495,7 @@ private PreparedStatement prepare(Connection connection) throws SQLException {
}
return connection.prepareStatement(storedQuery.getQuery(), new String[]{persistentEntity.getIdentity().getPersistedName()});
} else {
- return connection.prepareStatement(storedQuery.getQuery(), hasGeneratedId ? Statement.RETURN_GENERATED_KEYS : Statement.NO_GENERATED_KEYS);
+ return connection.prepareStatement(storedQuery.getQuery(), requiresGeneratedKeys ? Statement.RETURN_GENERATED_KEYS : Statement.NO_GENERATED_KEYS);
}
} else {
return connection.prepareStatement(storedQuery.getQuery());
@@ -1487,7 +1524,7 @@ protected void execute() {
try (PreparedStatement ps = prepare(ctx.connection)) {
setParameters(ps, storedQuery);
rowsUpdated = Arrays.stream(ps.executeBatch()).sum();
- if (hasGeneratedId) {
+ if (requiresGeneratedKeys) {
RuntimePersistentProperty identity = persistentEntity.getIdentity();
List
*
- * @author Denis Stepanov
- * @since 5.0.3
+ * @since 5.1.0
*/
@Internal
public final class SqlBatchSupport {
@@ -74,7 +73,9 @@ public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity,
Dialect dialect) {
return switch (dialect) {
case SQL_SERVER -> false;
- case MYSQL, ORACLE -> supportsBatchWithGeneratedIds(persistentEntity);
+ // Preserve the generic SQL/R2DBC rule for dialects where generated IDs cannot be
+ // assumed to come back reliably from a batch insert.
+ case MYSQL, ORACLE -> hasNonGeneratedIdentity(persistentEntity);
default -> true;
};
}
@@ -119,10 +120,14 @@ public static boolean isSupportsJdbcBatchInsert(PersistentEntity persistentEntit
boolean requiresGeneratedKeys) {
if (dialect == Dialect.MYSQL) {
if (isMariaDb(databaseProductName, driverName)) {
- // MariaDB generated keys for batched inserts are driver-option dependent.
+ // MariaDB reports generated-key support generally, but complete generated keys for
+ // batched multi-value inserts depend on driver options. Only batch when the caller
+ // does not need generated keys back.
return !requiresGeneratedKeys && Boolean.TRUE.equals(supportsBatchUpdates);
}
if (isMySql(databaseProductName, driverName)) {
+ // MySQL Connector/J can return generated keys for JDBC batches when both metadata
+ // capabilities are reported, so generated-key batches can be enabled there.
return Boolean.TRUE.equals(supportsBatchUpdates)
&& (!requiresGeneratedKeys || Boolean.TRUE.equals(supportsGetGeneratedKeys));
}
@@ -148,11 +153,8 @@ public static boolean requiresBatchGeneratedKeys(RuntimePersistentEntity> pers
return returnsEntities(operation.getResultArgument());
}
- private static boolean supportsBatchWithGeneratedIds(PersistentEntity persistentEntity) {
- if (persistentEntity.hasIdentity()) {
- return !persistentEntity.getIdentity().isGenerated();
- }
- return false;
+ private static boolean hasNonGeneratedIdentity(PersistentEntity persistentEntity) {
+ return persistentEntity.hasIdentity() && !persistentEntity.getIdentity().isGenerated();
}
private static boolean isMySqlFamily(@Nullable String databaseProductName) {
From f4e6a47f6b86627c3ba5456b1e34a352009e6a86 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Tue, 2 Jun 2026 16:33:59 +0200
Subject: [PATCH 08/15] Mirror conservative batch insert handling for R2DBC
---
.../DefaultJdbcRepositoryOperations.java | 5 +
.../jdbc/mariadb/MariaBatchInsertSpec.groovy | 6 +-
.../jdbc/mysql/MySqlBatchInsertSpec.groovy | 2 +-
data-r2dbc/build.gradle | 1 +
.../DefaultR2dbcRepositoryOperations.java | 37 +++++-
.../mariadb/MariaR2dbcBatchInsertSpec.groovy | 109 ++++++++++++++++++
.../mysql/MySqlR2dbcBatchInsertSpec.groovy | 109 ++++++++++++++++++
.../r2dbc/mariadb/MariaR2dbcBatchRecord.java | 30 +++++
.../r2dbc/mysql/MySqlR2dbcBatchRecord.java | 30 +++++
9 files changed, 321 insertions(+), 8 deletions(-)
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
create mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index 4dd65ba633c..c5dcb11f2a4 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -1175,6 +1175,11 @@ private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
RuntimePersistentEntity> persistentEntity,
boolean requiresGeneratedKeys) {
+ // JDBC metadata is only needed for the MySQL dialect, where Micronaut Data must
+ // distinguish MySQL from MariaDB and account for their generated-key batch behavior.
+ if (ctx.dialect != Dialect.MYSQL) {
+ return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect);
+ }
try {
DatabaseMetaData metaData = ctx.connection.getMetaData();
return SqlBatchSupport.isSupportsJdbcBatchInsert(
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
index 44c3dc2648c..316ec18c554 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
@@ -84,7 +84,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
def savedBooks = repository.findAll()
then:
- repository.count() == 2
+ savedBooks.size() == 2
books*.id == [null, null]
savedBooks*.id.every { it != null }
savedBooks*.title as Set == ["The Left Hand", "The Dispossessed"] as Set
@@ -103,7 +103,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
then:
inserted == 2
- repository.count() == 2
+ savedBooks.size() == 2
books*.id == [null, null]
savedBooks*.id.every { it != null }
savedBooks*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set
@@ -149,7 +149,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
then:
records.collect { it.id() }.every { it == 0L }
- recordRepository.count() == 100
+ savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
insertQueryExecutions("maria_batch_record") == 1
}
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
index 66c3aca77b7..fd4d14ab413 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
@@ -88,7 +88,7 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro
then:
records.collect { it.id() }.every { it == 0L }
- repository.count() == 100
+ savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
insertQueryExecutions("mysql_batch_record") == 1
}
diff --git a/data-r2dbc/build.gradle b/data-r2dbc/build.gradle
index 4daf5fe9958..ffd0cd2e3f5 100644
--- a/data-r2dbc/build.gradle
+++ b/data-r2dbc/build.gradle
@@ -39,6 +39,7 @@ dependencies {
testImplementation mnTest.micronaut.test.junit5
testImplementation libs.managed.jakarta.data.api
testImplementation mn.jackson.databind
+ testImplementation mnLogging.logback.classic
testImplementation(libs.managed.javax.persistence.api)
testImplementation(mnSql.jakarta.persistence.api)
testImplementation libs.groovy.sql
diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
index 11aa59b237e..243c4be14c5 100644
--- a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
+++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
@@ -105,6 +105,7 @@
import io.micronaut.data.runtime.operations.internal.sql.AbstractSqlRepositoryOperations;
import io.micronaut.data.runtime.operations.internal.sql.DefaultSqlPreparedQuery;
import io.micronaut.data.runtime.operations.internal.sql.OracleReturningMetadata;
+import io.micronaut.data.runtime.operations.internal.sql.SqlBatchSupport;
import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider;
import io.micronaut.data.runtime.operations.internal.sql.SqlPreparedQuery;
import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery;
@@ -978,7 +979,8 @@ public Flux persistAll(@NonNull InsertBatchOperation operation) {
final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery());
final RuntimePersistentEntity persistentEntity = storedQuery.getPersistentEntity();
final R2dbcOperationContext ctx = createContext(operation, status, storedQuery);
- if (!isSupportsBatchInsert(persistentEntity, storedQuery)) {
+ boolean requiresGeneratedKeys = SqlBatchSupport.requiresBatchGeneratedKeys(persistentEntity, operation);
+ if (!isSupportsBatchInsert(ctx, persistentEntity, storedQuery, requiresGeneratedKeys)) {
return concatMono(
operation.split().stream()
.map(persistOp -> {
@@ -988,7 +990,7 @@ public Flux persistAll(@NonNull InsertBatchOperation operation) {
})
);
} else {
- R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, operation, true);
+ R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, operation, true, requiresGeneratedKeys);
op.persist();
return op.getEntities();
}
@@ -1145,6 +1147,22 @@ private R2dbcOperationContext createContext(EntityOperation operation, Co
return new R2dbcOperationContext(operation.getAnnotationMetadata(), operation.getInvocationContext(), operation.getRepositoryType(), storedQuery.getDialect(), connection);
}
+ private boolean isSupportsBatchInsert(R2dbcOperationContext ctx,
+ RuntimePersistentEntity> persistentEntity,
+ SqlStoredQuery, ?> storedQuery,
+ boolean requiresGeneratedKeys) {
+ if (storedQuery.getOperationType() == OperationType.INSERT_RETURNING) {
+ return false;
+ }
+ if (ctx.dialect != Dialect.MYSQL) {
+ return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect);
+ }
+ // R2DBC metadata does not expose JDBC-style generated-key capability flags.
+ // For MySQL-family drivers, only use the batch path when generated keys are not needed.
+ String databaseProductName = ctx.connection.getMetadata().getDatabaseProductName();
+ return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect, databaseProductName, requiresGeneratedKeys);
+ }
+
@NonNull
@Override
public Mono findOptional(@NonNull Class type, @NonNull Object id) {
@@ -1427,18 +1445,29 @@ protected void execute() throws RuntimeException {
private final class R2dbcEntitiesOperations extends AbstractReactiveEntitiesOperations {
private final SqlStoredQuery storedQuery;
+ private final boolean requiresGeneratedKeys;
private R2dbcEntitiesOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) {
this(ctx, storedQuery, persistentEntity, entities, false);
}
private R2dbcEntitiesOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, Iterable entities, boolean insert) {
+ this(ctx, storedQuery, persistentEntity, entities, insert, insert && persistentEntity.hasIdentity() && persistentEntity.getIdentity().isGenerated());
+ }
+
+ private R2dbcEntitiesOperations(R2dbcOperationContext ctx,
+ SqlStoredQuery storedQuery,
+ RuntimePersistentEntity persistentEntity,
+ Iterable entities,
+ boolean insert,
+ boolean requiresGeneratedKeys) {
super(ctx,
DefaultR2dbcRepositoryOperations.this.cascadeOperations,
DefaultR2dbcRepositoryOperations.this.conversionService,
entityEventRegistry,
persistentEntity, entities, insert);
this.storedQuery = storedQuery;
+ this.requiresGeneratedKeys = requiresGeneratedKeys;
}
@Override
@@ -1524,7 +1553,7 @@ protected void execute() throws RuntimeException {
return;
}
Statement statement;
- if (hasGeneratedId) {
+ if (requiresGeneratedKeys) {
statement = ctx.connection.createStatement(storedQuery.getQuery());
if (isJsonEntityGeneratedId(storedQuery, persistentEntity)) {
statement.bind(getJsonGeneratedIdOutParameterIndex(storedQuery), Parameters.out(R2dbcType.NUMERIC));
@@ -1535,7 +1564,7 @@ protected void execute() throws RuntimeException {
statement = ctx.connection.createStatement(storedQuery.getQuery());
}
setParameters(statement, storedQuery);
- if (hasGeneratedId) {
+ if (requiresGeneratedKeys) {
entities = entities
.flatMap(list -> {
List notVetoedEntities = list.stream().filter(this::notVetoed).toList();
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
new file mode 100644
index 00000000000..bacfba4ea8c
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
@@ -0,0 +1,109 @@
+/*
+ * 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.data.r2dbc.mariadb
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.r2dbc.annotation.R2dbcRepository
+import io.micronaut.data.repository.CrudRepository
+import org.slf4j.LoggerFactory
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class MariaR2dbcBatchInsertSpec extends Specification implements MariaDbTestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ MariaR2dbcBatchRecordRepository repository = context.getBean(MariaR2dbcBatchRecordRepository)
+
+ @Shared
+ Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
+
+ @Shared
+ Level previousQueryLogLevel
+
+ @Shared
+ ListAppender queryLogAppender = new ListAppender<>()
+
+ void setupSpec() {
+ previousQueryLogLevel = queryLogger.level
+ queryLogger.level = Level.DEBUG
+ queryLogAppender.start()
+ queryLogger.addAppender(queryLogAppender)
+ }
+
+ void cleanupSpec() {
+ queryLogger.detachAppender(queryLogAppender)
+ queryLogger.level = previousQueryLogLevel
+ queryLogAppender.stop()
+ }
+
+ void setup() {
+ repository.deleteAll()
+ queryLogAppender.list.clear()
+ }
+
+ void "saveAll falls back to generated-key record inserts and populates ids"() {
+ given:
+ def records = (0..<100).collect { new MariaR2dbcBatchRecord(0L, "name-$it") }
+
+ when:
+ List saved = repository.saveAll(records)
+
+ then:
+ saved.size() == 100
+ saved.collect { it.id() }.every { it != null && it != 0L }
+ records.collect { it.id() }.every { it == 0L }
+ insertQueryExecutions("maria_r2dbc_batch_record") == 100
+ }
+
+ void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
+ given:
+ def records = (0..<100).collect { new MariaR2dbcBatchRecord(0L, "name-$it") }
+
+ when:
+ repository.insertAll(records)
+ def savedRecords = repository.findAll()
+
+ then:
+ records.collect { it.id() }.every { it == 0L }
+ savedRecords.size() == 100
+ savedRecords.every { it.id() != null && it.id() != 0L }
+ insertQueryExecutions("maria_r2dbc_batch_record") == 1
+ }
+
+ private long insertQueryExecutions(String tableName) {
+ queryLogAppender.list.count { event ->
+ String message = event.formattedMessage
+ message.contains("Executing SQL query: INSERT INTO")
+ && message.contains("`${tableName}`")
+ }
+ }
+}
+
+@R2dbcRepository(dialect = Dialect.MYSQL)
+interface MariaR2dbcBatchRecordRepository extends CrudRepository {
+
+ void insertAll(List entities)
+}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
new file mode 100644
index 00000000000..1cecb4a0d80
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
@@ -0,0 +1,109 @@
+/*
+ * 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.data.r2dbc.mysql
+
+import ch.qos.logback.classic.Level
+import ch.qos.logback.classic.Logger
+import ch.qos.logback.classic.spi.ILoggingEvent
+import ch.qos.logback.core.read.ListAppender
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.r2dbc.annotation.R2dbcRepository
+import io.micronaut.data.repository.CrudRepository
+import org.slf4j.LoggerFactory
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class MySqlR2dbcBatchInsertSpec extends Specification implements MySqlTestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ MySqlR2dbcBatchRecordRepository repository = context.getBean(MySqlR2dbcBatchRecordRepository)
+
+ @Shared
+ Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
+
+ @Shared
+ Level previousQueryLogLevel
+
+ @Shared
+ ListAppender queryLogAppender = new ListAppender<>()
+
+ void setupSpec() {
+ previousQueryLogLevel = queryLogger.level
+ queryLogger.level = Level.DEBUG
+ queryLogAppender.start()
+ queryLogger.addAppender(queryLogAppender)
+ }
+
+ void cleanupSpec() {
+ queryLogger.detachAppender(queryLogAppender)
+ queryLogger.level = previousQueryLogLevel
+ queryLogAppender.stop()
+ }
+
+ void setup() {
+ repository.deleteAll()
+ queryLogAppender.list.clear()
+ }
+
+ void "saveAll falls back to generated-key record inserts and populates ids"() {
+ given:
+ def records = (0..<100).collect { new MySqlR2dbcBatchRecord(0L, "name-$it") }
+
+ when:
+ List saved = repository.saveAll(records)
+
+ then:
+ saved.size() == 100
+ saved.collect { it.id() }.every { it != null && it != 0L }
+ records.collect { it.id() }.every { it == 0L }
+ insertQueryExecutions("mysql_r2dbc_batch_record") == 100
+ }
+
+ void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
+ given:
+ def records = (0..<100).collect { new MySqlR2dbcBatchRecord(0L, "name-$it") }
+
+ when:
+ repository.insertAll(records)
+ def savedRecords = repository.findAll()
+
+ then:
+ records.collect { it.id() }.every { it == 0L }
+ savedRecords.size() == 100
+ savedRecords.every { it.id() != null && it.id() != 0L }
+ insertQueryExecutions("mysql_r2dbc_batch_record") == 1
+ }
+
+ private long insertQueryExecutions(String tableName) {
+ queryLogAppender.list.count { event ->
+ String message = event.formattedMessage
+ message.contains("Executing SQL query: INSERT INTO")
+ && message.contains("`${tableName}`")
+ }
+ }
+}
+
+@R2dbcRepository(dialect = Dialect.MYSQL)
+interface MySqlR2dbcBatchRecordRepository extends CrudRepository {
+
+ void insertAll(List entities)
+}
diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
new file mode 100644
index 00000000000..7fd87742a1e
--- /dev/null
+++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
@@ -0,0 +1,30 @@
+/*
+ * 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.data.r2dbc.mariadb;
+
+import io.micronaut.data.annotation.GeneratedValue;
+import io.micronaut.data.annotation.Id;
+import io.micronaut.data.annotation.MappedEntity;
+
+/**
+ * R2DBC batch insert test record.
+ *
+ * @param id The id
+ * @param name The name
+ */
+@MappedEntity("maria_r2dbc_batch_record")
+public record MariaR2dbcBatchRecord(@Id @GeneratedValue(GeneratedValue.Type.AUTO) Long id, String name) {
+}
diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
new file mode 100644
index 00000000000..1d3ff3e4643
--- /dev/null
+++ b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
@@ -0,0 +1,30 @@
+/*
+ * 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.data.r2dbc.mysql;
+
+import io.micronaut.data.annotation.GeneratedValue;
+import io.micronaut.data.annotation.Id;
+import io.micronaut.data.annotation.MappedEntity;
+
+/**
+ * R2DBC batch insert test record.
+ *
+ * @param id The id
+ * @param name The name
+ */
+@MappedEntity("mysql_r2dbc_batch_record")
+public record MySqlR2dbcBatchRecord(@Id @GeneratedValue(GeneratedValue.Type.AUTO) Long id, String name) {
+}
From 4ff1883fc1484c84a994284afbab079e9734b42f Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Tue, 2 Jun 2026 17:00:43 +0200
Subject: [PATCH 09/15] Conservatively fix MySQL-family batch inserts
---
data-jdbc/build.gradle | 1 -
.../data/jdbc/h2/H2BatchInsertSpec.groovy | 99 +++++++++++++++++++
.../jdbc/mariadb/MariaBatchInsertSpec.groovy | 57 +----------
.../jdbc/mysql/MySqlBatchInsertSpec.groovy | 54 +---------
.../postgres/PostgresBatchInsertSpec.groovy | 99 +++++++++++++++++++
data-r2dbc/build.gradle | 1 -
.../data/r2dbc/h2/H2BatchInsertSpec.groovy | 99 +++++++++++++++++++
.../mariadb/MariaR2dbcBatchInsertSpec.groovy | 40 +-------
.../mysql/MySqlR2dbcBatchInsertSpec.groovy | 40 +-------
.../postgres/PostgresBatchInsertSpec.groovy | 99 +++++++++++++++++++
10 files changed, 401 insertions(+), 188 deletions(-)
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2BatchInsertSpec.groovy
create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresBatchInsertSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
diff --git a/data-jdbc/build.gradle b/data-jdbc/build.gradle
index 68501de21ce..5b659c65001 100644
--- a/data-jdbc/build.gradle
+++ b/data-jdbc/build.gradle
@@ -39,7 +39,6 @@ dependencies {
testImplementation(libs.managed.javax.persistence.api)
testImplementation(mnSql.jakarta.persistence.api)
testImplementation libs.groovy.sql
- testImplementation mnLogging.logback.classic
testImplementation mnValidation.micronaut.validation
testImplementation mnValidation.micronaut.validation.processor
testImplementation mn.micronaut.http.client
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2BatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2BatchInsertSpec.groovy
new file mode 100644
index 00000000000..acbd940bc6c
--- /dev/null
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/h2/H2BatchInsertSpec.groovy
@@ -0,0 +1,99 @@
+/*
+ * 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.data.jdbc.h2
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.annotation.GeneratedValue
+import io.micronaut.data.annotation.Id
+import io.micronaut.data.annotation.Insert
+import io.micronaut.data.annotation.MappedEntity
+import io.micronaut.data.jdbc.annotation.JdbcRepository
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.repository.CrudRepository
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class H2BatchInsertSpec extends Specification implements H2TestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ H2BatchInsertBookRepository repository = context.getBean(H2BatchInsertBookRepository)
+
+ void setup() {
+ repository.deleteAll()
+ }
+
+ void "custom void insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new H2BatchInsertBook(title: "Solaris"),
+ new H2BatchInsertBook(title: "Eden")
+ ]
+
+ when:
+ repository.customInsertAll(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Solaris", "Eden"] as Set
+ }
+
+ void "custom count insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new H2BatchInsertBook(title: "Fiasco"),
+ new H2BatchInsertBook(title: "The Invincible")
+ ]
+
+ when:
+ long inserted = repository.customInsertAllCount(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ inserted == 2
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
+ }
+}
+
+@MappedEntity("h2_batch_insert_book")
+class H2BatchInsertBook {
+
+ @Id
+ @GeneratedValue
+ Long id
+
+ String title
+}
+
+@JdbcRepository(dialect = Dialect.H2)
+interface H2BatchInsertBookRepository extends CrudRepository {
+
+ @Insert
+ void customInsertAll(List entities)
+
+ @Insert
+ long customInsertAllCount(List entities)
+}
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
index 316ec18c554..8a7a50701d8 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
@@ -15,10 +15,6 @@
*/
package io.micronaut.data.jdbc.mariadb
-import ch.qos.logback.classic.Level
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.spi.ILoggingEvent
-import ch.qos.logback.core.read.ListAppender
import io.micronaut.context.ApplicationContext
import io.micronaut.data.annotation.GeneratedValue
import io.micronaut.data.annotation.Id
@@ -27,7 +23,6 @@ import io.micronaut.data.annotation.MappedEntity
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
-import org.slf4j.LoggerFactory
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
@@ -44,32 +39,9 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
@Shared
MariaBatchRecordRepository recordRepository = context.getBean(MariaBatchRecordRepository)
- @Shared
- Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
-
- @Shared
- Level previousQueryLogLevel
-
- @Shared
- ListAppender queryLogAppender = new ListAppender<>()
-
- void setupSpec() {
- previousQueryLogLevel = queryLogger.level
- queryLogger.level = Level.DEBUG
- queryLogAppender.start()
- queryLogger.addAppender(queryLogAppender)
- }
-
- void cleanupSpec() {
- queryLogger.detachAppender(queryLogAppender)
- queryLogger.level = previousQueryLogLevel
- queryLogAppender.stop()
- }
-
void setup() {
repository.deleteAll()
recordRepository.deleteAll()
- queryLogAppender.list.clear()
}
void "custom void insertAll batches generated-id inserts without mutating input ids"() {
@@ -109,7 +81,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
savedBooks*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set
}
- void "saveAll falls back to generated-key inserts for generated identities"() {
+ void "saveAll generated-key inserts populate ids"() {
given:
def books = [
new MariaBatchBook(title: "A Wizard of Earthsea"),
@@ -122,10 +94,9 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
then:
saved*.id.every { it != null }
repository.count() == 2
- insertQueryExecutions("maria_batch_book") == 2
}
- void "saveAll falls back to generated-key record inserts and populates ids"() {
+ void "saveAll generated-key record inserts populate ids"() {
given:
def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") }
@@ -136,7 +107,6 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
saved.size() == 100
saved.collect { it.id() }.every { it != null && it != 0L }
records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("maria_batch_record") == 100
}
void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
@@ -151,29 +121,6 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
records.collect { it.id() }.every { it == 0L }
savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
- insertQueryExecutions("maria_batch_record") == 1
- }
-
- void "save one by one does not batch generated-id record inserts"() {
- given:
- def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") }
-
- when:
- List saved = records.collect { recordRepository.save(it) }
-
- then:
- saved.size() == 100
- saved.collect { it.id() }.every { it != null && it != 0L }
- records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("maria_batch_record") == 100
- }
-
- private long insertQueryExecutions(String tableName) {
- queryLogAppender.list.count { event ->
- String message = event.formattedMessage
- message.contains("Executing SQL query: INSERT INTO")
- && message.contains("`${tableName}`")
- }
}
}
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
index fd4d14ab413..c700d4c5fd1 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
@@ -15,15 +15,10 @@
*/
package io.micronaut.data.jdbc.mysql
-import ch.qos.logback.classic.Level
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.spi.ILoggingEvent
-import ch.qos.logback.core.read.ListAppender
import io.micronaut.context.ApplicationContext
import io.micronaut.data.jdbc.annotation.JdbcRepository
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.repository.CrudRepository
-import org.slf4j.LoggerFactory
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
@@ -37,34 +32,11 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro
@Shared
MySqlBatchRecordRepository repository = context.getBean(MySqlBatchRecordRepository)
- @Shared
- Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
-
- @Shared
- Level previousQueryLogLevel
-
- @Shared
- ListAppender queryLogAppender = new ListAppender<>()
-
- void setupSpec() {
- previousQueryLogLevel = queryLogger.level
- queryLogger.level = Level.DEBUG
- queryLogAppender.start()
- queryLogger.addAppender(queryLogAppender)
- }
-
- void cleanupSpec() {
- queryLogger.detachAppender(queryLogAppender)
- queryLogger.level = previousQueryLogLevel
- queryLogAppender.stop()
- }
-
void setup() {
repository.deleteAll()
- queryLogAppender.list.clear()
}
- void "saveAll batches generated-id record inserts and populates ids"() {
+ void "saveAll generated-id record inserts populate ids"() {
given:
def records = (0..<100).collect { new MySqlBatchRecord(0L, "name-$it") }
@@ -75,7 +47,6 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro
saved.size() == 100
saved.collect { it.id() }.every { it != null && it != 0L }
records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("mysql_batch_record") == 1
}
void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
@@ -90,29 +61,6 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro
records.collect { it.id() }.every { it == 0L }
savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
- insertQueryExecutions("mysql_batch_record") == 1
- }
-
- void "save one by one does not batch generated-id record inserts"() {
- given:
- def records = (0..<100).collect { new MySqlBatchRecord(0L, "name-$it") }
-
- when:
- List saved = records.collect { repository.save(it) }
-
- then:
- saved.size() == 100
- saved.collect { it.id() }.every { it != null && it != 0L }
- records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("mysql_batch_record") == 100
- }
-
- private long insertQueryExecutions(String tableName) {
- queryLogAppender.list.count { event ->
- String message = event.formattedMessage
- message.contains("Executing SQL query: INSERT INTO")
- && message.contains("`${tableName}`")
- }
}
}
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresBatchInsertSpec.groovy
new file mode 100644
index 00000000000..7672abf88d3
--- /dev/null
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/postgres/PostgresBatchInsertSpec.groovy
@@ -0,0 +1,99 @@
+/*
+ * 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.data.jdbc.postgres
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.annotation.GeneratedValue
+import io.micronaut.data.annotation.Id
+import io.micronaut.data.annotation.Insert
+import io.micronaut.data.annotation.MappedEntity
+import io.micronaut.data.jdbc.annotation.JdbcRepository
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.repository.CrudRepository
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class PostgresBatchInsertSpec extends Specification implements PostgresTestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ PostgresBatchInsertBookRepository repository = context.getBean(PostgresBatchInsertBookRepository)
+
+ void setup() {
+ repository.deleteAll()
+ }
+
+ void "custom void insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new PostgresBatchInsertBook(title: "Solaris"),
+ new PostgresBatchInsertBook(title: "Eden")
+ ]
+
+ when:
+ repository.customInsertAll(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Solaris", "Eden"] as Set
+ }
+
+ void "custom count insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new PostgresBatchInsertBook(title: "Fiasco"),
+ new PostgresBatchInsertBook(title: "The Invincible")
+ ]
+
+ when:
+ long inserted = repository.customInsertAllCount(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ inserted == 2
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
+ }
+}
+
+@MappedEntity("postgres_batch_insert_book")
+class PostgresBatchInsertBook {
+
+ @Id
+ @GeneratedValue
+ Long id
+
+ String title
+}
+
+@JdbcRepository(dialect = Dialect.POSTGRES)
+interface PostgresBatchInsertBookRepository extends CrudRepository {
+
+ @Insert
+ void customInsertAll(List entities)
+
+ @Insert
+ long customInsertAllCount(List entities)
+}
diff --git a/data-r2dbc/build.gradle b/data-r2dbc/build.gradle
index ffd0cd2e3f5..4daf5fe9958 100644
--- a/data-r2dbc/build.gradle
+++ b/data-r2dbc/build.gradle
@@ -39,7 +39,6 @@ dependencies {
testImplementation mnTest.micronaut.test.junit5
testImplementation libs.managed.jakarta.data.api
testImplementation mn.jackson.databind
- testImplementation mnLogging.logback.classic
testImplementation(libs.managed.javax.persistence.api)
testImplementation(mnSql.jakarta.persistence.api)
testImplementation libs.groovy.sql
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
new file mode 100644
index 00000000000..8fc0cad4290
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
@@ -0,0 +1,99 @@
+/*
+ * 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.data.r2dbc.h2
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.annotation.GeneratedValue
+import io.micronaut.data.annotation.Id
+import io.micronaut.data.annotation.Insert
+import io.micronaut.data.annotation.MappedEntity
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.r2dbc.annotation.R2dbcRepository
+import io.micronaut.data.repository.CrudRepository
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class H2BatchInsertSpec extends Specification implements H2TestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ H2BatchInsertBookRepository repository = context.getBean(H2BatchInsertBookRepository)
+
+ void setup() {
+ repository.deleteAll()
+ }
+
+ void "custom void insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new H2R2dbcBatchInsertBook(title: "Solaris"),
+ new H2R2dbcBatchInsertBook(title: "Eden")
+ ]
+
+ when:
+ repository.customInsertAll(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Solaris", "Eden"] as Set
+ }
+
+ void "custom count insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new H2R2dbcBatchInsertBook(title: "Fiasco"),
+ new H2R2dbcBatchInsertBook(title: "The Invincible")
+ ]
+
+ when:
+ long inserted = repository.customInsertAllCount(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ inserted == 2
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
+ }
+}
+
+@MappedEntity("h2_r2dbc_batch_insert_book")
+class H2R2dbcBatchInsertBook {
+
+ @Id
+ @GeneratedValue
+ Long id
+
+ String title
+}
+
+@R2dbcRepository(dialect = Dialect.H2)
+interface H2BatchInsertBookRepository extends CrudRepository {
+
+ @Insert
+ void customInsertAll(List entities)
+
+ @Insert
+ long customInsertAllCount(List entities)
+}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
index bacfba4ea8c..127b8193e3e 100644
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
@@ -15,15 +15,10 @@
*/
package io.micronaut.data.r2dbc.mariadb
-import ch.qos.logback.classic.Level
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.spi.ILoggingEvent
-import ch.qos.logback.core.read.ListAppender
import io.micronaut.context.ApplicationContext
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.r2dbc.annotation.R2dbcRepository
import io.micronaut.data.repository.CrudRepository
-import org.slf4j.LoggerFactory
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
@@ -37,34 +32,11 @@ class MariaR2dbcBatchInsertSpec extends Specification implements MariaDbTestProp
@Shared
MariaR2dbcBatchRecordRepository repository = context.getBean(MariaR2dbcBatchRecordRepository)
- @Shared
- Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
-
- @Shared
- Level previousQueryLogLevel
-
- @Shared
- ListAppender queryLogAppender = new ListAppender<>()
-
- void setupSpec() {
- previousQueryLogLevel = queryLogger.level
- queryLogger.level = Level.DEBUG
- queryLogAppender.start()
- queryLogger.addAppender(queryLogAppender)
- }
-
- void cleanupSpec() {
- queryLogger.detachAppender(queryLogAppender)
- queryLogger.level = previousQueryLogLevel
- queryLogAppender.stop()
- }
-
void setup() {
repository.deleteAll()
- queryLogAppender.list.clear()
}
- void "saveAll falls back to generated-key record inserts and populates ids"() {
+ void "saveAll generated-key record inserts populate ids"() {
given:
def records = (0..<100).collect { new MariaR2dbcBatchRecord(0L, "name-$it") }
@@ -75,7 +47,6 @@ class MariaR2dbcBatchInsertSpec extends Specification implements MariaDbTestProp
saved.size() == 100
saved.collect { it.id() }.every { it != null && it != 0L }
records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("maria_r2dbc_batch_record") == 100
}
void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
@@ -90,15 +61,6 @@ class MariaR2dbcBatchInsertSpec extends Specification implements MariaDbTestProp
records.collect { it.id() }.every { it == 0L }
savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
- insertQueryExecutions("maria_r2dbc_batch_record") == 1
- }
-
- private long insertQueryExecutions(String tableName) {
- queryLogAppender.list.count { event ->
- String message = event.formattedMessage
- message.contains("Executing SQL query: INSERT INTO")
- && message.contains("`${tableName}`")
- }
}
}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
index 1cecb4a0d80..a131b871559 100644
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
@@ -15,15 +15,10 @@
*/
package io.micronaut.data.r2dbc.mysql
-import ch.qos.logback.classic.Level
-import ch.qos.logback.classic.Logger
-import ch.qos.logback.classic.spi.ILoggingEvent
-import ch.qos.logback.core.read.ListAppender
import io.micronaut.context.ApplicationContext
import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.r2dbc.annotation.R2dbcRepository
import io.micronaut.data.repository.CrudRepository
-import org.slf4j.LoggerFactory
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
@@ -37,34 +32,11 @@ class MySqlR2dbcBatchInsertSpec extends Specification implements MySqlTestProper
@Shared
MySqlR2dbcBatchRecordRepository repository = context.getBean(MySqlR2dbcBatchRecordRepository)
- @Shared
- Logger queryLogger = LoggerFactory.getLogger("io.micronaut.data.query") as Logger
-
- @Shared
- Level previousQueryLogLevel
-
- @Shared
- ListAppender queryLogAppender = new ListAppender<>()
-
- void setupSpec() {
- previousQueryLogLevel = queryLogger.level
- queryLogger.level = Level.DEBUG
- queryLogAppender.start()
- queryLogger.addAppender(queryLogAppender)
- }
-
- void cleanupSpec() {
- queryLogger.detachAppender(queryLogAppender)
- queryLogger.level = previousQueryLogLevel
- queryLogAppender.stop()
- }
-
void setup() {
repository.deleteAll()
- queryLogAppender.list.clear()
}
- void "saveAll falls back to generated-key record inserts and populates ids"() {
+ void "saveAll generated-key record inserts populate ids"() {
given:
def records = (0..<100).collect { new MySqlR2dbcBatchRecord(0L, "name-$it") }
@@ -75,7 +47,6 @@ class MySqlR2dbcBatchInsertSpec extends Specification implements MySqlTestProper
saved.size() == 100
saved.collect { it.id() }.every { it != null && it != 0L }
records.collect { it.id() }.every { it == 0L }
- insertQueryExecutions("mysql_r2dbc_batch_record") == 100
}
void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
@@ -90,15 +61,6 @@ class MySqlR2dbcBatchInsertSpec extends Specification implements MySqlTestProper
records.collect { it.id() }.every { it == 0L }
savedRecords.size() == 100
savedRecords.every { it.id() != null && it.id() != 0L }
- insertQueryExecutions("mysql_r2dbc_batch_record") == 1
- }
-
- private long insertQueryExecutions(String tableName) {
- queryLogAppender.list.count { event ->
- String message = event.formattedMessage
- message.contains("Executing SQL query: INSERT INTO")
- && message.contains("`${tableName}`")
- }
}
}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
new file mode 100644
index 00000000000..53a422ec034
--- /dev/null
+++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
@@ -0,0 +1,99 @@
+/*
+ * 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.data.r2dbc.postgres
+
+import io.micronaut.context.ApplicationContext
+import io.micronaut.data.annotation.GeneratedValue
+import io.micronaut.data.annotation.Id
+import io.micronaut.data.annotation.Insert
+import io.micronaut.data.annotation.MappedEntity
+import io.micronaut.data.model.query.builder.sql.Dialect
+import io.micronaut.data.r2dbc.annotation.R2dbcRepository
+import io.micronaut.data.repository.CrudRepository
+import spock.lang.AutoCleanup
+import spock.lang.Shared
+import spock.lang.Specification
+
+class PostgresBatchInsertSpec extends Specification implements PostgresTestPropertyProvider {
+
+ @AutoCleanup
+ @Shared
+ ApplicationContext context = ApplicationContext.run(properties)
+
+ @Shared
+ PostgresBatchInsertBookRepository repository = context.getBean(PostgresBatchInsertBookRepository)
+
+ void setup() {
+ repository.deleteAll()
+ }
+
+ void "custom void insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new PostgresR2dbcBatchInsertBook(title: "Solaris"),
+ new PostgresR2dbcBatchInsertBook(title: "Eden")
+ ]
+
+ when:
+ repository.customInsertAll(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Solaris", "Eden"] as Set
+ }
+
+ void "custom count insertAll stores generated-id inserts without mutating input ids"() {
+ given:
+ def books = [
+ new PostgresR2dbcBatchInsertBook(title: "Fiasco"),
+ new PostgresR2dbcBatchInsertBook(title: "The Invincible")
+ ]
+
+ when:
+ long inserted = repository.customInsertAllCount(books)
+ def savedBooks = repository.findAll()
+
+ then:
+ inserted == 2
+ books*.id == [null, null]
+ savedBooks.size() == 2
+ savedBooks*.id.every { it != null }
+ savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
+ }
+}
+
+@MappedEntity("postgres_r2dbc_batch_insert_book")
+class PostgresR2dbcBatchInsertBook {
+
+ @Id
+ @GeneratedValue
+ Long id
+
+ String title
+}
+
+@R2dbcRepository(dialect = Dialect.POSTGRES)
+interface PostgresBatchInsertBookRepository extends CrudRepository {
+
+ @Insert
+ void customInsertAll(List entities)
+
+ @Insert
+ long customInsertAllCount(List entities)
+}
From 6016e9e1cb378b322dd38de33ea1630fa2133146 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 09:56:01 +0200
Subject: [PATCH 10/15] Remove workflow trigger
---
.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 3a023c909a6..fe93fca26d7 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'
- - mariadb-batch
pull_request:
branches:
- master
diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index 7d823b609c1..268cd6a9a70 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -9,7 +9,6 @@ on:
branches:
- master
- '[0-9]+.[0-9]+.x'
- - mariadb-batch
pull_request:
branches:
- master
From 218ece32ed0b5f690e27819a3874a0004d6243d0 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 10:24:52 +0200
Subject: [PATCH 11/15] Update test names and add more verification. Don't
change r2dbc behavior
---
.../jdbc/mariadb/MariaBatchInsertSpec.groovy | 10 +-
.../jdbc/mysql/MySqlBatchInsertSpec.groovy | 2 +-
.../DefaultR2dbcRepositoryOperations.java | 37 +------
.../data/r2dbc/h2/H2BatchInsertSpec.groovy | 99 -------------------
.../mariadb/MariaR2dbcBatchInsertSpec.groovy | 71 -------------
.../mysql/MySqlR2dbcBatchInsertSpec.groovy | 71 -------------
.../postgres/PostgresBatchInsertSpec.groovy | 99 -------------------
.../r2dbc/mariadb/MariaR2dbcBatchRecord.java | 30 ------
.../r2dbc/mysql/MySqlR2dbcBatchRecord.java | 30 ------
.../internal/sql/SqlBatchSupport.java | 24 -----
.../internal/sql/SqlBatchSupportSpec.groovy | 64 ++++++++----
11 files changed, 56 insertions(+), 481 deletions(-)
delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
delete mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
delete mode 100644 data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
index 8a7a50701d8..6e9b1a05744 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy
@@ -44,7 +44,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
recordRepository.deleteAll()
}
- void "custom void insertAll batches generated-id inserts without mutating input ids"() {
+ void "custom void insertAll stores generated-id inserts without mutating input ids"() {
given:
def books = [
new MariaBatchBook(title: "The Left Hand"),
@@ -62,7 +62,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
savedBooks*.title as Set == ["The Left Hand", "The Dispossessed"] as Set
}
- void "custom count insertAll batches generated-id inserts without mutating input ids"() {
+ void "custom count insertAll stores generated-id inserts without mutating input ids"() {
given:
def books = [
new MariaBatchBook(title: "The Lathe of Heaven"),
@@ -81,7 +81,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
savedBooks*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set
}
- void "saveAll generated-key inserts populate ids"() {
+ void "saveAll generated-key inserts populate ids through fallback path"() {
given:
def books = [
new MariaBatchBook(title: "A Wizard of Earthsea"),
@@ -96,7 +96,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
repository.count() == 2
}
- void "saveAll generated-key record inserts populate ids"() {
+ void "saveAll generated-key record inserts populate ids through fallback path"() {
given:
def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") }
@@ -109,7 +109,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro
records.collect { it.id() }.every { it == 0L }
}
- void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
+ void "custom void insertAll stores generated-id record inserts without mutating input ids"() {
given:
def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") }
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
index c700d4c5fd1..59c46a0a6f8 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy
@@ -49,7 +49,7 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro
records.collect { it.id() }.every { it == 0L }
}
- void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
+ void "custom void insertAll stores generated-id record inserts without mutating input ids"() {
given:
def records = (0..<100).collect { new MySqlBatchRecord(0L, "name-$it") }
diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
index 243c4be14c5..11aa59b237e 100644
--- a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
+++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/DefaultR2dbcRepositoryOperations.java
@@ -105,7 +105,6 @@
import io.micronaut.data.runtime.operations.internal.sql.AbstractSqlRepositoryOperations;
import io.micronaut.data.runtime.operations.internal.sql.DefaultSqlPreparedQuery;
import io.micronaut.data.runtime.operations.internal.sql.OracleReturningMetadata;
-import io.micronaut.data.runtime.operations.internal.sql.SqlBatchSupport;
import io.micronaut.data.runtime.operations.internal.sql.SqlJsonColumnMapperProvider;
import io.micronaut.data.runtime.operations.internal.sql.SqlPreparedQuery;
import io.micronaut.data.runtime.operations.internal.sql.SqlStoredQuery;
@@ -979,8 +978,7 @@ public Flux persistAll(@NonNull InsertBatchOperation operation) {
final SqlStoredQuery storedQuery = getSqlStoredQuery(operation.getStoredQuery());
final RuntimePersistentEntity persistentEntity = storedQuery.getPersistentEntity();
final R2dbcOperationContext ctx = createContext(operation, status, storedQuery);
- boolean requiresGeneratedKeys = SqlBatchSupport.requiresBatchGeneratedKeys(persistentEntity, operation);
- if (!isSupportsBatchInsert(ctx, persistentEntity, storedQuery, requiresGeneratedKeys)) {
+ if (!isSupportsBatchInsert(persistentEntity, storedQuery)) {
return concatMono(
operation.split().stream()
.map(persistOp -> {
@@ -990,7 +988,7 @@ public Flux persistAll(@NonNull InsertBatchOperation operation) {
})
);
} else {
- R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, operation, true, requiresGeneratedKeys);
+ R2dbcEntitiesOperations op = new R2dbcEntitiesOperations<>(ctx, storedQuery, persistentEntity, operation, true);
op.persist();
return op.getEntities();
}
@@ -1147,22 +1145,6 @@ private R2dbcOperationContext createContext(EntityOperation operation, Co
return new R2dbcOperationContext(operation.getAnnotationMetadata(), operation.getInvocationContext(), operation.getRepositoryType(), storedQuery.getDialect(), connection);
}
- private boolean isSupportsBatchInsert(R2dbcOperationContext ctx,
- RuntimePersistentEntity> persistentEntity,
- SqlStoredQuery, ?> storedQuery,
- boolean requiresGeneratedKeys) {
- if (storedQuery.getOperationType() == OperationType.INSERT_RETURNING) {
- return false;
- }
- if (ctx.dialect != Dialect.MYSQL) {
- return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect);
- }
- // R2DBC metadata does not expose JDBC-style generated-key capability flags.
- // For MySQL-family drivers, only use the batch path when generated keys are not needed.
- String databaseProductName = ctx.connection.getMetadata().getDatabaseProductName();
- return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect, databaseProductName, requiresGeneratedKeys);
- }
-
@NonNull
@Override
public Mono findOptional(@NonNull Class type, @NonNull Object id) {
@@ -1445,29 +1427,18 @@ protected void execute() throws RuntimeException {
private final class R2dbcEntitiesOperations extends AbstractReactiveEntitiesOperations {
private final SqlStoredQuery storedQuery;
- private final boolean requiresGeneratedKeys;
private R2dbcEntitiesOperations(R2dbcOperationContext ctx, RuntimePersistentEntity persistentEntity, Iterable entities, SqlStoredQuery storedQuery) {
this(ctx, storedQuery, persistentEntity, entities, false);
}
private R2dbcEntitiesOperations(R2dbcOperationContext ctx, SqlStoredQuery storedQuery, RuntimePersistentEntity persistentEntity, Iterable entities, boolean insert) {
- this(ctx, storedQuery, persistentEntity, entities, insert, insert && persistentEntity.hasIdentity() && persistentEntity.getIdentity().isGenerated());
- }
-
- private R2dbcEntitiesOperations(R2dbcOperationContext ctx,
- SqlStoredQuery storedQuery,
- RuntimePersistentEntity persistentEntity,
- Iterable entities,
- boolean insert,
- boolean requiresGeneratedKeys) {
super(ctx,
DefaultR2dbcRepositoryOperations.this.cascadeOperations,
DefaultR2dbcRepositoryOperations.this.conversionService,
entityEventRegistry,
persistentEntity, entities, insert);
this.storedQuery = storedQuery;
- this.requiresGeneratedKeys = requiresGeneratedKeys;
}
@Override
@@ -1553,7 +1524,7 @@ protected void execute() throws RuntimeException {
return;
}
Statement statement;
- if (requiresGeneratedKeys) {
+ if (hasGeneratedId) {
statement = ctx.connection.createStatement(storedQuery.getQuery());
if (isJsonEntityGeneratedId(storedQuery, persistentEntity)) {
statement.bind(getJsonGeneratedIdOutParameterIndex(storedQuery), Parameters.out(R2dbcType.NUMERIC));
@@ -1564,7 +1535,7 @@ protected void execute() throws RuntimeException {
statement = ctx.connection.createStatement(storedQuery.getQuery());
}
setParameters(statement, storedQuery);
- if (requiresGeneratedKeys) {
+ if (hasGeneratedId) {
entities = entities
.flatMap(list -> {
List notVetoedEntities = list.stream().filter(this::notVetoed).toList();
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
deleted file mode 100644
index 8fc0cad4290..00000000000
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/h2/H2BatchInsertSpec.groovy
+++ /dev/null
@@ -1,99 +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.data.r2dbc.h2
-
-import io.micronaut.context.ApplicationContext
-import io.micronaut.data.annotation.GeneratedValue
-import io.micronaut.data.annotation.Id
-import io.micronaut.data.annotation.Insert
-import io.micronaut.data.annotation.MappedEntity
-import io.micronaut.data.model.query.builder.sql.Dialect
-import io.micronaut.data.r2dbc.annotation.R2dbcRepository
-import io.micronaut.data.repository.CrudRepository
-import spock.lang.AutoCleanup
-import spock.lang.Shared
-import spock.lang.Specification
-
-class H2BatchInsertSpec extends Specification implements H2TestPropertyProvider {
-
- @AutoCleanup
- @Shared
- ApplicationContext context = ApplicationContext.run(properties)
-
- @Shared
- H2BatchInsertBookRepository repository = context.getBean(H2BatchInsertBookRepository)
-
- void setup() {
- repository.deleteAll()
- }
-
- void "custom void insertAll stores generated-id inserts without mutating input ids"() {
- given:
- def books = [
- new H2R2dbcBatchInsertBook(title: "Solaris"),
- new H2R2dbcBatchInsertBook(title: "Eden")
- ]
-
- when:
- repository.customInsertAll(books)
- def savedBooks = repository.findAll()
-
- then:
- books*.id == [null, null]
- savedBooks.size() == 2
- savedBooks*.id.every { it != null }
- savedBooks*.title as Set == ["Solaris", "Eden"] as Set
- }
-
- void "custom count insertAll stores generated-id inserts without mutating input ids"() {
- given:
- def books = [
- new H2R2dbcBatchInsertBook(title: "Fiasco"),
- new H2R2dbcBatchInsertBook(title: "The Invincible")
- ]
-
- when:
- long inserted = repository.customInsertAllCount(books)
- def savedBooks = repository.findAll()
-
- then:
- inserted == 2
- books*.id == [null, null]
- savedBooks.size() == 2
- savedBooks*.id.every { it != null }
- savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
- }
-}
-
-@MappedEntity("h2_r2dbc_batch_insert_book")
-class H2R2dbcBatchInsertBook {
-
- @Id
- @GeneratedValue
- Long id
-
- String title
-}
-
-@R2dbcRepository(dialect = Dialect.H2)
-interface H2BatchInsertBookRepository extends CrudRepository {
-
- @Insert
- void customInsertAll(List entities)
-
- @Insert
- long customInsertAllCount(List entities)
-}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
deleted file mode 100644
index 127b8193e3e..00000000000
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchInsertSpec.groovy
+++ /dev/null
@@ -1,71 +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.data.r2dbc.mariadb
-
-import io.micronaut.context.ApplicationContext
-import io.micronaut.data.model.query.builder.sql.Dialect
-import io.micronaut.data.r2dbc.annotation.R2dbcRepository
-import io.micronaut.data.repository.CrudRepository
-import spock.lang.AutoCleanup
-import spock.lang.Shared
-import spock.lang.Specification
-
-class MariaR2dbcBatchInsertSpec extends Specification implements MariaDbTestPropertyProvider {
-
- @AutoCleanup
- @Shared
- ApplicationContext context = ApplicationContext.run(properties)
-
- @Shared
- MariaR2dbcBatchRecordRepository repository = context.getBean(MariaR2dbcBatchRecordRepository)
-
- void setup() {
- repository.deleteAll()
- }
-
- void "saveAll generated-key record inserts populate ids"() {
- given:
- def records = (0..<100).collect { new MariaR2dbcBatchRecord(0L, "name-$it") }
-
- when:
- List saved = repository.saveAll(records)
-
- then:
- saved.size() == 100
- saved.collect { it.id() }.every { it != null && it != 0L }
- records.collect { it.id() }.every { it == 0L }
- }
-
- void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
- given:
- def records = (0..<100).collect { new MariaR2dbcBatchRecord(0L, "name-$it") }
-
- when:
- repository.insertAll(records)
- def savedRecords = repository.findAll()
-
- then:
- records.collect { it.id() }.every { it == 0L }
- savedRecords.size() == 100
- savedRecords.every { it.id() != null && it.id() != 0L }
- }
-}
-
-@R2dbcRepository(dialect = Dialect.MYSQL)
-interface MariaR2dbcBatchRecordRepository extends CrudRepository {
-
- void insertAll(List entities)
-}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
deleted file mode 100644
index a131b871559..00000000000
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchInsertSpec.groovy
+++ /dev/null
@@ -1,71 +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.data.r2dbc.mysql
-
-import io.micronaut.context.ApplicationContext
-import io.micronaut.data.model.query.builder.sql.Dialect
-import io.micronaut.data.r2dbc.annotation.R2dbcRepository
-import io.micronaut.data.repository.CrudRepository
-import spock.lang.AutoCleanup
-import spock.lang.Shared
-import spock.lang.Specification
-
-class MySqlR2dbcBatchInsertSpec extends Specification implements MySqlTestPropertyProvider {
-
- @AutoCleanup
- @Shared
- ApplicationContext context = ApplicationContext.run(properties)
-
- @Shared
- MySqlR2dbcBatchRecordRepository repository = context.getBean(MySqlR2dbcBatchRecordRepository)
-
- void setup() {
- repository.deleteAll()
- }
-
- void "saveAll generated-key record inserts populate ids"() {
- given:
- def records = (0..<100).collect { new MySqlR2dbcBatchRecord(0L, "name-$it") }
-
- when:
- List saved = repository.saveAll(records)
-
- then:
- saved.size() == 100
- saved.collect { it.id() }.every { it != null && it != 0L }
- records.collect { it.id() }.every { it == 0L }
- }
-
- void "custom void insertAll batches generated-id record inserts without mutating input ids"() {
- given:
- def records = (0..<100).collect { new MySqlR2dbcBatchRecord(0L, "name-$it") }
-
- when:
- repository.insertAll(records)
- def savedRecords = repository.findAll()
-
- then:
- records.collect { it.id() }.every { it == 0L }
- savedRecords.size() == 100
- savedRecords.every { it.id() != null && it.id() != 0L }
- }
-}
-
-@R2dbcRepository(dialect = Dialect.MYSQL)
-interface MySqlR2dbcBatchRecordRepository extends CrudRepository {
-
- void insertAll(List entities)
-}
diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
deleted file mode 100644
index 53a422ec034..00000000000
--- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/postgres/PostgresBatchInsertSpec.groovy
+++ /dev/null
@@ -1,99 +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.data.r2dbc.postgres
-
-import io.micronaut.context.ApplicationContext
-import io.micronaut.data.annotation.GeneratedValue
-import io.micronaut.data.annotation.Id
-import io.micronaut.data.annotation.Insert
-import io.micronaut.data.annotation.MappedEntity
-import io.micronaut.data.model.query.builder.sql.Dialect
-import io.micronaut.data.r2dbc.annotation.R2dbcRepository
-import io.micronaut.data.repository.CrudRepository
-import spock.lang.AutoCleanup
-import spock.lang.Shared
-import spock.lang.Specification
-
-class PostgresBatchInsertSpec extends Specification implements PostgresTestPropertyProvider {
-
- @AutoCleanup
- @Shared
- ApplicationContext context = ApplicationContext.run(properties)
-
- @Shared
- PostgresBatchInsertBookRepository repository = context.getBean(PostgresBatchInsertBookRepository)
-
- void setup() {
- repository.deleteAll()
- }
-
- void "custom void insertAll stores generated-id inserts without mutating input ids"() {
- given:
- def books = [
- new PostgresR2dbcBatchInsertBook(title: "Solaris"),
- new PostgresR2dbcBatchInsertBook(title: "Eden")
- ]
-
- when:
- repository.customInsertAll(books)
- def savedBooks = repository.findAll()
-
- then:
- books*.id == [null, null]
- savedBooks.size() == 2
- savedBooks*.id.every { it != null }
- savedBooks*.title as Set == ["Solaris", "Eden"] as Set
- }
-
- void "custom count insertAll stores generated-id inserts without mutating input ids"() {
- given:
- def books = [
- new PostgresR2dbcBatchInsertBook(title: "Fiasco"),
- new PostgresR2dbcBatchInsertBook(title: "The Invincible")
- ]
-
- when:
- long inserted = repository.customInsertAllCount(books)
- def savedBooks = repository.findAll()
-
- then:
- inserted == 2
- books*.id == [null, null]
- savedBooks.size() == 2
- savedBooks*.id.every { it != null }
- savedBooks*.title as Set == ["Fiasco", "The Invincible"] as Set
- }
-}
-
-@MappedEntity("postgres_r2dbc_batch_insert_book")
-class PostgresR2dbcBatchInsertBook {
-
- @Id
- @GeneratedValue
- Long id
-
- String title
-}
-
-@R2dbcRepository(dialect = Dialect.POSTGRES)
-interface PostgresBatchInsertBookRepository extends CrudRepository {
-
- @Insert
- void customInsertAll(List entities)
-
- @Insert
- long customInsertAllCount(List entities)
-}
diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
deleted file mode 100644
index 7fd87742a1e..00000000000
--- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mariadb/MariaR2dbcBatchRecord.java
+++ /dev/null
@@ -1,30 +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.data.r2dbc.mariadb;
-
-import io.micronaut.data.annotation.GeneratedValue;
-import io.micronaut.data.annotation.Id;
-import io.micronaut.data.annotation.MappedEntity;
-
-/**
- * R2DBC batch insert test record.
- *
- * @param id The id
- * @param name The name
- */
-@MappedEntity("maria_r2dbc_batch_record")
-public record MariaR2dbcBatchRecord(@Id @GeneratedValue(GeneratedValue.Type.AUTO) Long id, String name) {
-}
diff --git a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java b/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
deleted file mode 100644
index 1d3ff3e4643..00000000000
--- a/data-r2dbc/src/test/java/io/micronaut/data/r2dbc/mysql/MySqlR2dbcBatchRecord.java
+++ /dev/null
@@ -1,30 +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.data.r2dbc.mysql;
-
-import io.micronaut.data.annotation.GeneratedValue;
-import io.micronaut.data.annotation.Id;
-import io.micronaut.data.annotation.MappedEntity;
-
-/**
- * R2DBC batch insert test record.
- *
- * @param id The id
- * @param name The name
- */
-@MappedEntity("mysql_r2dbc_batch_record")
-public record MySqlR2dbcBatchRecord(@Id @GeneratedValue(GeneratedValue.Type.AUTO) Long id, String name) {
-}
diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java
index 2598af0eb6f..54f9a92ae4d 100644
--- a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java
+++ b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java
@@ -80,25 +80,6 @@ public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity,
};
}
- /**
- * Resolves whether a runtime with database product metadata can use batch inserts.
- *
- * @param persistentEntity The persistent entity
- * @param dialect The SQL dialect
- * @param databaseProductName The concrete database product name if available
- * @param requiresGeneratedKeys Whether generated keys are needed back from the batch
- * @return {@code true} if the batch path can be used
- */
- public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity,
- Dialect dialect,
- @Nullable String databaseProductName,
- boolean requiresGeneratedKeys) {
- if (!requiresGeneratedKeys && dialect == Dialect.MYSQL && isMySqlFamily(databaseProductName)) {
- return true;
- }
- return isSupportsBatchInsert(persistentEntity, dialect);
- }
-
/**
* Resolves whether a JDBC connection can use batch inserts.
*
@@ -157,11 +138,6 @@ private static boolean hasNonGeneratedIdentity(PersistentEntity persistentEntity
return persistentEntity.hasIdentity() && !persistentEntity.getIdentity().isGenerated();
}
- private static boolean isMySqlFamily(@Nullable String databaseProductName) {
- return containsIgnoreCase(databaseProductName, MARIADB_PRODUCT_NAME)
- || containsIgnoreCase(databaseProductName, MYSQL_PRODUCT_NAME);
- }
-
private static boolean isMariaDb(@Nullable String databaseProductName, @Nullable String driverName) {
return containsIgnoreCase(databaseProductName, MARIADB_PRODUCT_NAME)
|| containsIgnoreCase(driverName, MARIADB_PRODUCT_NAME);
diff --git a/data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy b/data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy
index 95b4a34e23b..f96e5a6c06a 100644
--- a/data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy
+++ b/data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy
@@ -32,24 +32,6 @@ class SqlBatchSupportSpec extends Specification {
!SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL)
}
- @Unroll
- void "#databaseProductName can batch generated-id inserts when generated keys are not required"() {
- expect:
- SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, databaseProductName, false)
-
- where:
- databaseProductName << ["MariaDB", "MySQL"]
- }
-
- @Unroll
- void "#databaseProductName generated-id inserts stay conservative when generated keys are required without jdbc metadata"() {
- expect:
- !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, databaseProductName, true)
-
- where:
- databaseProductName << ["MariaDB", "MySQL"]
- }
-
@Unroll
void "jdbc mysql can batch generated-id inserts for #scenario"() {
expect:
@@ -107,6 +89,52 @@ class SqlBatchSupportSpec extends Specification {
"mysql metadata" | "MySQL" | "MySQL Connector/J"
}
+ @Unroll
+ void "jdbc mariadb #scenario"() {
+ given:
+ boolean requiresGeneratedKeys = SqlBatchSupport.requiresBatchGeneratedKeys(entityWithGeneratedId(), operation(resultArgument))
+
+ expect:
+ SqlBatchSupport.isSupportsJdbcBatchInsert(
+ entityWithGeneratedId(),
+ Dialect.MYSQL,
+ "MariaDB",
+ "MariaDB Connector/J",
+ true,
+ true,
+ requiresGeneratedKeys
+ ) == supported
+
+ where:
+ scenario | resultArgument || supported
+ "falls back for entity-returning saveAll" | Argument.listOf(String) || false
+ "can batch for void insertAll" | Argument.of(Void) || true
+ "can batch for count-returning insertAll" | Argument.of(Long) || true
+ }
+
+ @Unroll
+ void "jdbc mysql #scenario"() {
+ given:
+ boolean requiresGeneratedKeys = SqlBatchSupport.requiresBatchGeneratedKeys(entityWithGeneratedId(), operation(resultArgument))
+
+ expect:
+ SqlBatchSupport.isSupportsJdbcBatchInsert(
+ entityWithGeneratedId(),
+ Dialect.MYSQL,
+ "MySQL",
+ "MySQL Connector/J",
+ true,
+ true,
+ requiresGeneratedKeys
+ ) == supported
+
+ where:
+ scenario | resultArgument || supported
+ "can batch for entity-returning saveAll" | Argument.listOf(String) || true
+ "can batch for void insertAll" | Argument.of(Void) || true
+ "can batch for count-returning insertAll" | Argument.of(Long) || true
+ }
+
void "jdbc mysql family does not batch generated-id inserts when generated keys are unsupported"() {
expect:
!SqlBatchSupport.isSupportsJdbcBatchInsert(
From 7563027954481aafa32ba7e5f48a1e6b730c95af Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 13:45:50 +0200
Subject: [PATCH 12/15] Cache JDBC batch metadata lazily
---
.../DefaultJdbcRepositoryOperations.java | 52 ++++++++++++++-----
1 file changed, 38 insertions(+), 14 deletions(-)
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index c5dcb11f2a4..7b199b952a2 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -171,6 +171,8 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository
private final JdbcSchemaHandler schemaHandler;
private final ColumnIndexCallableResultReader columnIndexCallableResultReader;
private final Map> sqlExceptionMappers = new EnumMap<>(Dialect.class);
+ @Nullable
+ private volatile JdbcBatchCapabilities jdbcBatchCapabilities;
private final Integer defaultFetchSize;
@@ -1180,30 +1182,52 @@ private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
if (ctx.dialect != Dialect.MYSQL) {
return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, ctx.dialect);
}
+ JdbcBatchCapabilities capabilities = jdbcBatchCapabilities(ctx);
+ return SqlBatchSupport.isSupportsJdbcBatchInsert(
+ persistentEntity,
+ ctx.dialect,
+ capabilities.databaseProductName(),
+ capabilities.driverName(),
+ capabilities.supportsBatchUpdates(),
+ capabilities.supportsGetGeneratedKeys(),
+ requiresGeneratedKeys
+ );
+ }
+
+ private JdbcBatchCapabilities jdbcBatchCapabilities(JdbcOperationContext ctx) {
+ JdbcBatchCapabilities capabilities = jdbcBatchCapabilities;
+ if (capabilities == null) {
+ synchronized (this) {
+ capabilities = jdbcBatchCapabilities;
+ if (capabilities == null) {
+ capabilities = resolveJdbcBatchCapabilities(ctx);
+ jdbcBatchCapabilities = capabilities;
+ }
+ }
+ }
+ return capabilities;
+ }
+
+ private JdbcBatchCapabilities resolveJdbcBatchCapabilities(JdbcOperationContext ctx) {
try {
DatabaseMetaData metaData = ctx.connection.getMetaData();
- return SqlBatchSupport.isSupportsJdbcBatchInsert(
- persistentEntity,
- ctx.dialect,
+ return new JdbcBatchCapabilities(
metaData.getDatabaseProductName(),
metaData.getDriverName(),
metaData.supportsBatchUpdates(),
- metaData.supportsGetGeneratedKeys(),
- requiresGeneratedKeys
+ metaData.supportsGetGeneratedKeys()
);
} catch (SQLException ignored) {
- return SqlBatchSupport.isSupportsJdbcBatchInsert(
- persistentEntity,
- ctx.dialect,
- null,
- null,
- null,
- null,
- requiresGeneratedKeys
- );
+ return new JdbcBatchCapabilities(null, null, null, null);
}
}
+ private record JdbcBatchCapabilities(@Nullable String databaseProductName,
+ @Nullable String driverName,
+ @Nullable Boolean supportsBatchUpdates,
+ @Nullable Boolean supportsGetGeneratedKeys) {
+ }
+
@SuppressWarnings({"rawtypes", "unchecked"})
private RuntimePersistentEntity> resolveOracleReturningEntity(Class> type) {
return getEntity((Class) type);
From 61775323f767cb89c598bb9f93b3f585a21bbbc4 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 15:03:07 +0200
Subject: [PATCH 13/15] Fix checkstyle
---
.../operations/DefaultJdbcRepositoryOperations.java | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index 7b199b952a2..86e3dfc3f2c 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -1222,12 +1222,6 @@ private JdbcBatchCapabilities resolveJdbcBatchCapabilities(JdbcOperationContext
}
}
- private record JdbcBatchCapabilities(@Nullable String databaseProductName,
- @Nullable String driverName,
- @Nullable Boolean supportsBatchUpdates,
- @Nullable Boolean supportsGetGeneratedKeys) {
- }
-
@SuppressWarnings({"rawtypes", "unchecked"})
private RuntimePersistentEntity> resolveOracleReturningEntity(Class> type) {
return getEntity((Class) type);
@@ -1263,6 +1257,12 @@ private DataConversionService jdbcDataConversionService() {
return conversionService;
}
+ private record JdbcBatchCapabilities(@Nullable String databaseProductName,
+ @Nullable String driverName,
+ @Nullable Boolean supportsBatchUpdates,
+ @Nullable Boolean supportsGetGeneratedKeys) {
+ }
+
private final class JdbcParameterBinder implements BindableParametersStoredQuery.Binder {
private final SqlStoredQuery, ?> sqlStoredQuery;
From ff1ab26374a43ec0e5c84e68f5521f4b49e3fa6d Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 15:17:40 +0200
Subject: [PATCH 14/15] Address review comment.
---
.../DefaultJdbcRepositoryOperations.java | 11 ++++-
...DefaultJdbcRepositoryOperationsSpec.groovy | 46 +++++++++++++++++++
2 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index 86e3dfc3f2c..8081cfe4e68 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -1201,13 +1201,18 @@ private JdbcBatchCapabilities jdbcBatchCapabilities(JdbcOperationContext ctx) {
capabilities = jdbcBatchCapabilities;
if (capabilities == null) {
capabilities = resolveJdbcBatchCapabilities(ctx);
- jdbcBatchCapabilities = capabilities;
+ if (capabilities != null) {
+ jdbcBatchCapabilities = capabilities;
+ } else {
+ capabilities = JdbcBatchCapabilities.UNKNOWN;
+ }
}
}
}
return capabilities;
}
+ @Nullable
private JdbcBatchCapabilities resolveJdbcBatchCapabilities(JdbcOperationContext ctx) {
try {
DatabaseMetaData metaData = ctx.connection.getMetaData();
@@ -1218,7 +1223,7 @@ private JdbcBatchCapabilities resolveJdbcBatchCapabilities(JdbcOperationContext
metaData.supportsGetGeneratedKeys()
);
} catch (SQLException ignored) {
- return new JdbcBatchCapabilities(null, null, null, null);
+ return null;
}
}
@@ -1261,6 +1266,8 @@ private record JdbcBatchCapabilities(@Nullable String databaseProductName,
@Nullable String driverName,
@Nullable Boolean supportsBatchUpdates,
@Nullable Boolean supportsGetGeneratedKeys) {
+
+ private static final JdbcBatchCapabilities UNKNOWN = new JdbcBatchCapabilities(null, null, null, null);
}
private final class JdbcParameterBinder implements BindableParametersStoredQuery.Binder {
diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/DefaultJdbcRepositoryOperationsSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/DefaultJdbcRepositoryOperationsSpec.groovy
index add6c8e7288..12af37b60b4 100644
--- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/DefaultJdbcRepositoryOperationsSpec.groovy
+++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/DefaultJdbcRepositoryOperationsSpec.groovy
@@ -15,13 +15,17 @@
*/
package io.micronaut.data.jdbc
+import io.micronaut.core.annotation.AnnotationMetadata
import io.micronaut.context.ApplicationContext
import io.micronaut.data.connection.ConnectionOperations
import io.micronaut.data.jdbc.config.DataJdbcConfiguration
import io.micronaut.data.jdbc.operations.DefaultJdbcRepositoryOperations
import io.micronaut.data.jdbc.operations.JdbcSchemaHandler
+import io.micronaut.data.model.query.builder.sql.Dialect
import io.micronaut.data.model.runtime.AttributeConverterRegistry
import io.micronaut.data.model.runtime.RuntimeEntityRegistry
+import io.micronaut.data.model.runtime.RuntimePersistentEntity
+import io.micronaut.data.model.runtime.RuntimePersistentProperty
import io.micronaut.data.runtime.convert.DatabaseConversionContextFactory
import io.micronaut.data.runtime.convert.DataConversionService
import io.micronaut.data.runtime.date.DateTimeProvider
@@ -32,6 +36,8 @@ import spock.lang.Specification
import javax.sql.DataSource
import java.sql.Connection
+import java.sql.DatabaseMetaData
+import java.sql.SQLException
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -71,6 +77,46 @@ class DefaultJdbcRepositoryOperationsSpec extends Specification {
fallbackExecutor.isShutdown()
}
+ void "batch capability metadata failures are not cached"() {
+ given:
+ DefaultJdbcRepositoryOperations operations = newOperations(null)
+ DatabaseMetaData metaData = Mock {
+ getDatabaseProductName() >> "MySQL"
+ getDriverName() >> "MySQL Connector/J"
+ supportsBatchUpdates() >> true
+ supportsGetGeneratedKeys() >> true
+ }
+ Connection connection = Mock()
+ RuntimePersistentProperty> identity = Mock {
+ isGenerated() >> true
+ }
+ RuntimePersistentEntity> persistentEntity = Mock {
+ hasIdentity() >> true
+ getIdentity() >> identity
+ }
+ def operationContext = new DefaultJdbcRepositoryOperations.JdbcOperationContext(
+ AnnotationMetadata.EMPTY_METADATA,
+ null,
+ Object,
+ Dialect.MYSQL,
+ connection
+ )
+
+ when:
+ boolean firstAttempt = operations.isSupportsBatchInsert(operationContext, persistentEntity)
+
+ then:
+ 1 * connection.getMetaData() >> { throw new SQLException("temporary metadata failure") }
+ !firstAttempt
+
+ when:
+ boolean secondAttempt = operations.isSupportsBatchInsert(operationContext, persistentEntity)
+
+ then:
+ 1 * connection.getMetaData() >> metaData
+ secondAttempt
+ }
+
private DefaultJdbcRepositoryOperations newOperations(ExecutorService executorService) {
context = ApplicationContext.run()
return new DefaultJdbcRepositoryOperations(
From c295c4d8159ed4d60249a45b168192f81352c2d0 Mon Sep 17 00:00:00 2001
From: radovanradic
Date: Wed, 3 Jun 2026 17:18:59 +0200
Subject: [PATCH 15/15] Fix Sonar warning
---
.../DefaultJdbcRepositoryOperations.java | 22 ++++++++-----------
1 file changed, 9 insertions(+), 13 deletions(-)
diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
index 8081cfe4e68..b73aff61189 100644
--- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
+++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java
@@ -136,6 +136,7 @@
import java.util.Spliterators;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
@@ -171,8 +172,7 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository
private final JdbcSchemaHandler schemaHandler;
private final ColumnIndexCallableResultReader columnIndexCallableResultReader;
private final Map> sqlExceptionMappers = new EnumMap<>(Dialect.class);
- @Nullable
- private volatile JdbcBatchCapabilities jdbcBatchCapabilities;
+ private final AtomicReference jdbcBatchCapabilities = new AtomicReference<>();
private final Integer defaultFetchSize;
@@ -1195,18 +1195,14 @@ private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
}
private JdbcBatchCapabilities jdbcBatchCapabilities(JdbcOperationContext ctx) {
- JdbcBatchCapabilities capabilities = jdbcBatchCapabilities;
+ JdbcBatchCapabilities capabilities = jdbcBatchCapabilities.get();
if (capabilities == null) {
- synchronized (this) {
- capabilities = jdbcBatchCapabilities;
- if (capabilities == null) {
- capabilities = resolveJdbcBatchCapabilities(ctx);
- if (capabilities != null) {
- jdbcBatchCapabilities = capabilities;
- } else {
- capabilities = JdbcBatchCapabilities.UNKNOWN;
- }
- }
+ capabilities = resolveJdbcBatchCapabilities(ctx);
+ if (capabilities == null) {
+ return JdbcBatchCapabilities.UNKNOWN;
+ }
+ if (!jdbcBatchCapabilities.compareAndSet(null, capabilities)) {
+ capabilities = Objects.requireNonNull(jdbcBatchCapabilities.get());
}
}
return capabilities;