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 ids = new ArrayList<>(); try (ResultSet generatedKeys = ps.getGeneratedKeys()) { 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 new file mode 100644 index 00000000000..2a284068981 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/MariaBatchInsertSpec.groovy @@ -0,0 +1,91 @@ +/* + * 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.mariadb + +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 MariaBatchInsertSpec extends Specification implements MariaTestPropertyProvider { + + @AutoCleanup + @Shared + ApplicationContext context = ApplicationContext.run(properties) + + @Shared + MariaBatchBookRepository repository = context.getBean(MariaBatchBookRepository) + + void setup() { + repository.deleteAll() + } + + void "custom void insertAll batches generated-id inserts without mutating input ids"() { + given: + def books = [ + new MariaBatchBook(title: "The Left Hand"), + new MariaBatchBook(title: "The Dispossessed") + ] + + when: + repository.customInsertAll(books) + + then: + repository.count() == 2 + books*.id == [null, null] + repository.findAll()*.id.every { it != null } + repository.findAll()*.title as Set == ["The Left Hand", "The Dispossessed"] as Set + } + + void "saveAll stays on the generated-key path for generated identities"() { + given: + def books = [ + new MariaBatchBook(title: "A Wizard of Earthsea"), + new MariaBatchBook(title: "The Tombs of Atuan") + ] + + when: + def saved = repository.saveAll(books) + + then: + saved*.id.every { it != null } + repository.count() == 2 + } +} + +@MappedEntity("maria_batch_book") +class MariaBatchBook { + + @Id + @GeneratedValue + Long id + + String title +} + +@JdbcRepository(dialect = Dialect.MYSQL) +interface MariaBatchBookRepository extends CrudRepository { + + @Insert + void customInsertAll(List entities) +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy new file mode 100644 index 00000000000..a5b822797a4 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy @@ -0,0 +1,115 @@ +/* + * 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.mariadb.legacy + +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 io.micronaut.data.runtime.config.SchemaGenerate +import org.testcontainers.containers.MariaDBContainer +import org.testcontainers.utility.DockerImageName +import spock.lang.Shared +import spock.lang.Specification + +class MariaLegacyBatchInsertSpec extends Specification { + + private static final String DATASOURCE_NAME = "legacy" + private static final String IMAGE = "mariadb:5.5.64" + + @Shared + MariaDBContainer mariaDb = new MariaDBContainer<>(DockerImageName.parse(IMAGE)) + .withDatabaseName("legacy") + .withUsername("test") + .withPassword("test") + .withCreateContainerCmdModifier { cmd -> + cmd.withPlatform("linux/amd64") + } + + @Shared + ApplicationContext context + + @Shared + MariaLegacyBatchBookRepository repository + + void setupSpec() { + mariaDb.start() + context = ApplicationContext.run(properties()) + repository = context.getBean(MariaLegacyBatchBookRepository) + } + + void cleanupSpec() { + context?.close() + mariaDb?.stop() + } + + void setup() { + repository.deleteAll() + } + + void "custom void insertAll batches generated-id inserts on MariaDB 5.5 without mutating input ids"() { + given: + def books = [ + new MariaLegacyBatchBook(title: "Solaris"), + new MariaLegacyBatchBook(title: "Fiasco") + ] + + when: + repository.customInsertAll(books) + + then: + repository.count() == 2 + books*.id == [null, null] + repository.findAll()*.id.every { it != null } + repository.findAll()*.title as Set == ["Solaris", "Fiasco"] as Set + } + + private Map properties() { + String prefix = "datasources." + DATASOURCE_NAME + [ + 'datasources.default.enabled' : false, + (prefix + '.enabled') : true, + (prefix + '.dialect') : Dialect.MYSQL.name(), + (prefix + '.schema-generate') : SchemaGenerate.CREATE.name(), + (prefix + '.packages') : [getClass().package.name], + (prefix + '.url') : mariaDb.jdbcUrl, + (prefix + '.username') : mariaDb.username, + (prefix + '.password') : mariaDb.password, + (prefix + '.driver-class-name') : mariaDb.driverClassName + ] + } +} + +@MappedEntity("maria_legacy_batch_book") +class MariaLegacyBatchBook { + + @Id + @GeneratedValue + Long id + + String title +} + +@JdbcRepository(value = "legacy", dialect = Dialect.MYSQL) +interface MariaLegacyBatchBookRepository extends CrudRepository { + + @Insert + void customInsertAll(List entities) +} diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/AbstractSqlRepositoryOperations.java b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/AbstractSqlRepositoryOperations.java index aeead4e26fa..cde0c6f39e1 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/AbstractSqlRepositoryOperations.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/AbstractSqlRepositoryOperations.java @@ -589,11 +589,7 @@ protected final SqlStoredQuery getSqlStoredQuery(@Nullable StoredQu * @return true if supported */ protected boolean isSupportsBatchInsert(PersistentEntity persistentEntity, SqlStoredQuery sqlStoredQuery) { - // Oracle and MySql doesn't support a batch with returning generated ID: "DML Returning cannot be batched" - if (sqlStoredQuery.getOperationType() == OperationType.INSERT_RETURNING) { - return false; - } - return isSupportsBatchInsert(persistentEntity, sqlStoredQuery.getDialect()); + return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, sqlStoredQuery); } /** @@ -604,18 +600,7 @@ protected boolean isSupportsBatchInsert(PersistentEntity persistentEntity, SqlSt * @return true if supported */ protected boolean isSupportsBatchInsert(PersistentEntity persistentEntity, Dialect dialect) { - // Oracle and MySql doesn't support a batch with returning generated ID: "DML Returning cannot be batched" - return switch (dialect) { - case SQL_SERVER -> false; - case MYSQL, ORACLE -> { - if (persistentEntity.hasIdentity()) { - // Oracle and MySql doesn't support a batch with returning generated ID: "DML Returning cannot be batched" - yield !persistentEntity.getIdentity().isGenerated(); - } - yield false; - } - default -> true; - }; + return SqlBatchSupport.isSupportsBatchInsert(persistentEntity, dialect); } /** 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 new file mode 100644 index 00000000000..7bf31f34edd --- /dev/null +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupport.java @@ -0,0 +1,162 @@ +/* + * 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.runtime.operations.internal.sql; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.type.Argument; +import io.micronaut.data.model.PersistentEntity; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.runtime.InsertBatchOperation; +import io.micronaut.data.model.runtime.RuntimePersistentEntity; +import io.micronaut.data.model.runtime.StoredQuery; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; + +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.CompletionStage; + +/** + * Shared SQL batch-operation capability checks. + * + *

Micronaut Data uses {@link Dialect#MYSQL} for both MySQL and MariaDB SQL generation. Runtime + * driver behavior can still diverge, so runtime capability checks stay internal and separate from + * the public dialect enum.

+ * + * @author Denis Stepanov + * @since 5.0.3 + */ +@Internal +public final class SqlBatchSupport { + + private static final String MARIADB_PRODUCT_NAME = "MARIADB"; + + private SqlBatchSupport() { + } + + /** + * Resolves whether insert batching is supported for a stored query. + * + * @param persistentEntity The persistent entity + * @param sqlStoredQuery The SQL stored query + * @return {@code true} if insert batching is supported + */ + public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity, + SqlStoredQuery sqlStoredQuery) { + if (sqlStoredQuery.getOperationType() == StoredQuery.OperationType.INSERT_RETURNING) { + return false; + } + return isSupportsBatchInsert(persistentEntity, sqlStoredQuery.getDialect()); + } + + /** + * Resolves whether insert batching is supported for a SQL dialect. + * + * @param persistentEntity The persistent entity + * @param dialect The SQL dialect + * @return {@code true} if insert batching is supported + */ + public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity, + Dialect dialect) { + return switch (dialect) { + case SQL_SERVER -> false; + case MYSQL, ORACLE -> supportsBatchWithGeneratedIds(persistentEntity); + default -> true; + }; + } + + /** + * Resolves whether MariaDB can use batch inserts without generated-key retrieval. + * + * @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 MariaDB can use the batch path + */ + public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity, + Dialect dialect, + @Nullable String databaseProductName, + boolean requiresGeneratedKeys) { + if (!requiresGeneratedKeys && dialect == Dialect.MYSQL && isMariaDb(databaseProductName)) { + return true; + } + return isSupportsBatchInsert(persistentEntity, dialect); + } + + /** + * Resolves whether a batch insert operation needs generated keys to be returned. + * + * @param persistentEntity The runtime persistent entity + * @param operation The insert batch operation + * @return {@code true} if generated keys must be requested + */ + public static boolean requiresBatchGeneratedKeys(RuntimePersistentEntity persistentEntity, + InsertBatchOperation operation) { + if (!persistentEntity.hasIdentity() || !persistentEntity.getIdentity().isGenerated()) { + return false; + } + if (persistentEntity.cascadesPersist() || persistentEntity.hasPostPersistEventListeners()) { + return true; + } + return returnsEntities(operation.getResultArgument()); + } + + private static boolean supportsBatchWithGeneratedIds(PersistentEntity persistentEntity) { + if (persistentEntity.hasIdentity()) { + return !persistentEntity.getIdentity().isGenerated(); + } + return false; + } + + private static boolean isMariaDb(@Nullable String databaseProductName) { + if (databaseProductName == null) { + return false; + } + return databaseProductName.toUpperCase(Locale.ENGLISH).contains(MARIADB_PRODUCT_NAME); + } + + private static boolean returnsEntities(Argument resultArgument) { + Argument unwrapped = unwrapResultArgument(resultArgument); + Class type = unwrapped.getType(); + if (unwrapped.isVoid() || type == Void.class || type == void.class) { + return false; + } + if (type.isPrimitive()) { + return false; + } + return !Number.class.isAssignableFrom(type); + } + + private static Argument unwrapResultArgument(Argument argument) { + Argument current = argument; + while (shouldUnwrap(current)) { + current = current.getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT); + } + return current; + } + + private static boolean shouldUnwrap(Argument argument) { + Class type = argument.getType(); + if (type.isArray()) { + return false; + } + return Iterable.class.isAssignableFrom(type) + || Publisher.class.isAssignableFrom(type) + || CompletionStage.class.isAssignableFrom(type) + || Optional.class.isAssignableFrom(type); + } +} 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 new file mode 100644 index 00000000000..314b27a37e9 --- /dev/null +++ b/data-runtime/src/test/groovy/io/micronaut/data/runtime/operations/internal/sql/SqlBatchSupportSpec.groovy @@ -0,0 +1,65 @@ +package io.micronaut.data.runtime.operations.internal.sql + +import io.micronaut.core.type.Argument +import io.micronaut.data.model.query.builder.sql.Dialect +import io.micronaut.data.model.runtime.InsertBatchOperation +import io.micronaut.data.model.runtime.RuntimePersistentEntity +import io.micronaut.data.model.runtime.RuntimePersistentProperty +import spock.lang.Specification +import spock.lang.Unroll + +import java.util.concurrent.CompletionStage + +class SqlBatchSupportSpec extends Specification { + + void "mysql dialect stays conservative for generated identities by default"() { + expect: + !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL) + } + + void "mariadb can batch generated-id inserts when generated keys are not required"() { + expect: + SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, "MariaDB", false) + } + + void "mariadb generated-id inserts stay conservative when generated keys are required"() { + expect: + !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, "MariaDB", true) + } + + @Unroll + void "generated keys are required for #scenario"() { + expect: + SqlBatchSupport.requiresBatchGeneratedKeys(entity(cascadesPersist, postPersist), operation(resultArgument)) == required + + where: + scenario | cascadesPersist | postPersist | resultArgument || required + "entity lists" | false | false | Argument.listOf(String) || true + "completion stage entity lists" | false | false | Argument.of(CompletionStage, Argument.listOf(String)) || true + "void returns" | false | false | Argument.of(Void) || false + "numeric returns" | false | false | Argument.of(Long) || false + "post persist listeners" | false | true | Argument.of(Long) || true + "cascade persist associations" | true | false | Argument.of(Void) || true + } + + private InsertBatchOperation operation(Argument resultArgument) { + Stub(InsertBatchOperation) { + getResultArgument() >> resultArgument + } + } + + private RuntimePersistentEntity entityWithGeneratedId() { + entity(false, false) + } + + private RuntimePersistentEntity entity(boolean cascadesPersistAssociations, boolean postPersist) { + Stub(RuntimePersistentEntity) { + hasIdentity() >> true + getIdentity() >> Stub(RuntimePersistentProperty) { + isGenerated() >> true + } + cascadesPersist() >> cascadesPersistAssociations + hasPostPersistEventListeners() >> postPersist + } + } +} From 2d6e8f05ecf9259fbe5c6553a597c0ed7564b76e Mon Sep 17 00:00:00 2001 From: radovanradic Date: Thu, 28 May 2026 16:25:59 +0200 Subject: [PATCH 02/15] Investigate MariaDB batch inserts --- .../MariaLegacyGeneratedValuesSpec.groovy | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy new file mode 100644 index 00000000000..419199e3cb5 --- /dev/null +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy @@ -0,0 +1,160 @@ +/* + * 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.legacy + +import io.micronaut.context.ApplicationContext +import io.micronaut.data.annotation.GeneratedValue +import io.micronaut.data.annotation.Id +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.reactive.ReactorCrudRepository +import io.micronaut.inject.qualifiers.Qualifiers +import io.micronaut.test.extensions.junit5.annotation.ScopeNamingStrategy +import io.micronaut.test.extensions.junit5.annotation.TestResourcesScope +import io.micronaut.test.support.TestPropertyProvider +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.Result +import io.r2dbc.spi.Statement +import reactor.core.publisher.Flux +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +@TestResourcesScope(namingStrategy = ScopeNamingStrategy.TestClassName) +class MariaLegacyGeneratedValuesSpec extends Specification implements TestPropertyProvider { + + private static final String DATASOURCE_NAME = "legacy" + private static final String TABLE_NAME = "legacy_tr_person" + private static final String INSERT_SQL = "INSERT INTO ${TABLE_NAME}(name, age, enabled) VALUES (?, ?, TRUE)" + + @AutoCleanup + @Shared + ApplicationContext context = ApplicationContext.run(properties) + + @Shared + ConnectionFactory connectionFactory = context.getBean(ConnectionFactory, Qualifiers.byName(DATASOURCE_NAME)) + + @Shared + MariaLegacyTrPersonRepository repository = context.getBean(MariaLegacyTrPersonRepository) + + @Override + Map getProperties() { + String prefix = "r2dbc.datasources.$DATASOURCE_NAME" + return [ + (prefix + ".db-type") : "mariadb", + (prefix + ".schema-generate") : "NONE", + (prefix + ".packages") : [getClass().package.name], + (prefix + ".test-resources.resource-name"): "mariadb-legacy-r2dbc", + "test-resources.containers.mariadb.image-name": "mariadb", + "test-resources.containers.mariadb.image-tag" : "10.4" + ] + } + + void setupSpec() { + createSchema() + } + + void cleanupSpec() { + dropSchema() + } + + void setup() { + repository.deleteAll().block() + } + + void "raw batch returnGeneratedValues on MariaDB 10.4 via test resources returns all ids"() { + when: + def ids = batchInsertAndCollectIds(["Jeff", "James"]) + + then: + ids == [1L, 2L] + repository.count().block() == 2 + } + + void "repository saveAll populates ids on MariaDB 10.4 via test resources"() { + given: + def people = [ + person("Leto"), + person("Ghanima") + ] + + when: + def saved = repository.saveAll(people).collectList().block() + + then: + saved*.id.every { it != null } + people*.id.every { it != null } + repository.count().block() == 2 + } + + private void createSchema() { + executeStatement("CREATE TABLE $TABLE_NAME (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, age INT NOT NULL, enabled BOOLEAN NOT NULL)") + } + + private void dropSchema() { + executeStatement("DROP TABLE IF EXISTS $TABLE_NAME") + } + + private void executeStatement(String sql) { + Flux.usingWhen(connectionFactory.create(), connection -> + Flux.from(connection.createStatement(sql).execute()).flatMap(Result::getRowsUpdated), + { connection -> connection.close() } + ).then().block() + } + + private List batchInsertAndCollectIds(List names) { + return Flux.usingWhen(connectionFactory.create(), connection -> { + Statement statement = connection.createStatement(INSERT_SQL).returnGeneratedValues("id") + boolean first = true + names.each { name -> + if (first) { + first = false + } else { + statement = statement.add() + } + statement.bind(0, name).bind(1, 0) + } + return Flux.from(statement.execute()) + .flatMap { Result result -> result.map((row, metadata) -> row.get(0, Long.class)) } + }, { connection -> connection.close() }).collectList().block() + } + + private static MariaLegacyTrPerson person(String name) { + def person = new MariaLegacyTrPerson() + person.name = name + person.age = 0 + return person + } +} + +@MappedEntity("legacy_tr_person") +class MariaLegacyTrPerson { + + @Id + @GeneratedValue + Long id + + String name + + int age + + boolean enabled = true +} + +@R2dbcRepository(value = "legacy", dialect = Dialect.MYSQL) +interface MariaLegacyTrPersonRepository extends ReactorCrudRepository { +} From 6a33acfd375f348b0db8e422e17b075770ae338a Mon Sep 17 00:00:00 2001 From: radovanradic Date: Thu, 28 May 2026 16:26:40 +0200 Subject: [PATCH 03/15] Trigger build --- .github/workflows/graalvm-latest.yml | 1 + .github/workflows/gradle.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/graalvm-latest.yml b/.github/workflows/graalvm-latest.yml index fe93fca26d7..3a023c909a6 100644 --- a/.github/workflows/graalvm-latest.yml +++ b/.github/workflows/graalvm-latest.yml @@ -9,6 +9,7 @@ on: branches: - master - '[0-9]+.[0-9]+.x' + - mariadb-batch pull_request: branches: - master diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 268cd6a9a70..7d823b609c1 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -9,6 +9,7 @@ on: branches: - master - '[0-9]+.[0-9]+.x' + - mariadb-batch pull_request: branches: - master From cc2a2e2d773b935dd086755408900df03efbdc8a Mon Sep 17 00:00:00 2001 From: radovanradic Date: Thu, 28 May 2026 16:56:58 +0200 Subject: [PATCH 04/15] More tests --- .../legacy/MariaLegacyBatchInsertSpec.groovy | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy index a5b822797a4..c277c8b709a 100644 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy @@ -81,6 +81,22 @@ class MariaLegacyBatchInsertSpec extends Specification { repository.findAll()*.title as Set == ["Solaris", "Fiasco"] as Set } + void "saveAll populates ids on MariaDB 5.5 via generated-key fallback"() { + given: + def books = [ + new MariaLegacyBatchBook(title: "The Cyberiad"), + new MariaLegacyBatchBook(title: "His Master's Voice") + ] + + when: + def saved = repository.saveAll(books) + + then: + saved*.id.every { it != null } + books*.id.every { it != null } + repository.count() == 2 + } + private Map properties() { String prefix = "datasources." + DATASOURCE_NAME [ From d1d22113cbac5cb4855a8f027edc69e80833e90c Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 2 Jun 2026 15:17:52 +0200 Subject: [PATCH 05/15] Fix MySQL/MariaDB JDBC batch inserts with generated IDs --- data-jdbc/build.gradle | 1 + .../DefaultJdbcRepositoryOperations.java | 37 ++++-- .../jdbc/mariadb/MariaBatchInsertSpec.groovy | 109 ++++++++++++++++ .../jdbc/mysql/MySqlBatchInsertSpec.groovy | 122 ++++++++++++++++++ .../data/jdbc/mariadb/MariaBatchRecord.java | 30 +++++ .../data/jdbc/mysql/MySqlBatchRecord.java | 30 +++++ .../internal/sql/SqlBatchSupport.java | 55 +++++++- .../internal/sql/SqlBatchSupportSpec.groovy | 112 +++++++++++++++- 8 files changed, 475 insertions(+), 21 deletions(-) create mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mariadb/MariaBatchRecord.java create mode 100644 data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlBatchRecord.java diff --git a/data-jdbc/build.gradle b/data-jdbc/build.gradle index 5b659c65001..68501de21ce 100644 --- a/data-jdbc/build.gradle +++ b/data-jdbc/build.gradle @@ -39,6 +39,7 @@ 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/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java index ab36f92ae5c..4dd65ba633c 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 @@ -115,6 +115,7 @@ import javax.sql.DataSource; import java.sql.CallableStatement; import java.sql.Connection; +import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -1157,7 +1158,8 @@ private DataAccessException sqlExceptionToDataAccessException(SQLException sqlEx @Override public boolean isSupportsBatchInsert(JdbcOperationContext jdbcOperationContext, RuntimePersistentEntity persistentEntity) { - return isSupportsBatchInsert(persistentEntity, jdbcOperationContext.dialect); + boolean requiresGeneratedKeys = persistentEntity.hasIdentity() && persistentEntity.getIdentity().isGenerated(); + return isSupportsBatchInsert(jdbcOperationContext, persistentEntity, requiresGeneratedKeys); } private boolean isSupportsBatchInsert(JdbcOperationContext ctx, @@ -1167,20 +1169,33 @@ private boolean isSupportsBatchInsert(JdbcOperationContext ctx, if (storedQuery.getOperationType() == StoredQuery.OperationType.INSERT_RETURNING) { return false; } - return SqlBatchSupport.isSupportsBatchInsert( - persistentEntity, - storedQuery.getDialect(), - databaseProductName(ctx.connection), - requiresGeneratedKeys - ); + return isSupportsBatchInsert(ctx, persistentEntity, requiresGeneratedKeys); } - @Nullable - private String databaseProductName(Connection connection) { + private boolean isSupportsBatchInsert(JdbcOperationContext ctx, + RuntimePersistentEntity persistentEntity, + boolean requiresGeneratedKeys) { try { - return connection.getMetaData().getDatabaseProductName(); + DatabaseMetaData metaData = ctx.connection.getMetaData(); + return SqlBatchSupport.isSupportsJdbcBatchInsert( + persistentEntity, + ctx.dialect, + metaData.getDatabaseProductName(), + metaData.getDriverName(), + metaData.supportsBatchUpdates(), + metaData.supportsGetGeneratedKeys(), + requiresGeneratedKeys + ); } catch (SQLException ignored) { - return null; + return SqlBatchSupport.isSupportsJdbcBatchInsert( + persistentEntity, + ctx.dialect, + null, + null, + null, + null, + requiresGeneratedKeys + ); } } 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 2a284068981..b7c8d58074a 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,6 +15,10 @@ */ 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 @@ -23,6 +27,7 @@ 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 @@ -36,8 +41,35 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro @Shared MariaBatchBookRepository repository = context.getBean(MariaBatchBookRepository) + @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"() { @@ -57,6 +89,24 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro repository.findAll()*.title as Set == ["The Left Hand", "The Dispossessed"] as Set } + void "custom count insertAll batches generated-id inserts without mutating input ids"() { + given: + def books = [ + new MariaBatchBook(title: "The Lathe of Heaven"), + new MariaBatchBook(title: "City of Illusions") + ] + + when: + long inserted = repository.customInsertAllCount(books) + + then: + inserted == 2 + repository.count() == 2 + books*.id == [null, null] + repository.findAll()*.id.every { it != null } + repository.findAll()*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set + } + void "saveAll stays on the generated-key path for generated identities"() { given: def books = [ @@ -71,6 +121,56 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro saved*.id.every { it != null } repository.count() == 2 } + + void "saveAll batches generated-id record inserts and populates ids"() { + given: + def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") } + + when: + List saved = recordRepository.saveAll(records) + + then: + saved.size() == 100 + saved.collect { it.id() }.every { it != null && it != 0L } + records.collect { it.id() }.every { it == 0L } + insertQueryExecutions("maria_batch_record") == 1 + } + + void "custom void insertAll batches generated-id record inserts without mutating input ids"() { + given: + def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") } + + when: + recordRepository.insertAll(records) + + then: + records.collect { it.id() }.every { it == 0L } + recordRepository.count() == 100 + recordRepository.findAll().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}`") + } + } } @MappedEntity("maria_batch_book") @@ -88,4 +188,13 @@ interface MariaBatchBookRepository extends CrudRepository @Insert void customInsertAll(List entities) + + @Insert + long customInsertAllCount(List entities) +} + +@JdbcRepository(dialect = Dialect.MYSQL) +interface MariaBatchRecordRepository extends CrudRepository { + + void insertAll(List entities) } 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 new file mode 100644 index 00000000000..300b8b21b93 --- /dev/null +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mysql/MySqlBatchInsertSpec.groovy @@ -0,0 +1,122 @@ +/* + * 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.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 + +class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyProvider { + + @AutoCleanup + @Shared + ApplicationContext context = ApplicationContext.run(properties) + + @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"() { + given: + def records = (0..<100).collect { new MySqlBatchRecord(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_batch_record") == 1 + } + + void "custom void insertAll batches generated-id record inserts without mutating input ids"() { + given: + def records = (0..<100).collect { new MySqlBatchRecord(0L, "name-$it") } + + when: + repository.insertAll(records) + + then: + records.collect { it.id() }.every { it == 0L } + repository.count() == 100 + repository.findAll().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}`") + } + } +} + +@JdbcRepository(dialect = Dialect.MYSQL) +interface MySqlBatchRecordRepository extends CrudRepository { + + void insertAll(List entities) +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mariadb/MariaBatchRecord.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mariadb/MariaBatchRecord.java new file mode 100644 index 00000000000..dbe534c7c66 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mariadb/MariaBatchRecord.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.jdbc.mariadb; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +/** + * Batch insert test record. + * + * @param id The id + * @param name The name + */ +@MappedEntity("maria_batch_record") +public record MariaBatchRecord(@Id @GeneratedValue(GeneratedValue.Type.AUTO) Long id, String name) { +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlBatchRecord.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlBatchRecord.java new file mode 100644 index 00000000000..e6c10a39e8e --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/mysql/MySqlBatchRecord.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.jdbc.mysql; + +import io.micronaut.data.annotation.GeneratedValue; +import io.micronaut.data.annotation.Id; +import io.micronaut.data.annotation.MappedEntity; + +/** + * Batch insert test record. + * + * @param id The id + * @param name The name + */ +@MappedEntity("mysql_batch_record") +public record MySqlBatchRecord(@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 7bf31f34edd..e3b57dd8f29 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 @@ -43,6 +43,7 @@ public final class SqlBatchSupport { private static final String MARIADB_PRODUCT_NAME = "MARIADB"; + private static final String MYSQL_PRODUCT_NAME = "MYSQL"; private SqlBatchSupport() { } @@ -79,19 +80,50 @@ public static boolean isSupportsBatchInsert(PersistentEntity persistentEntity, } /** - * Resolves whether MariaDB can use batch inserts without generated-key retrieval. + * 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 MariaDB can use the batch path + * @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 && isMariaDb(databaseProductName)) { + if (!requiresGeneratedKeys && dialect == Dialect.MYSQL && isMySqlFamily(databaseProductName)) { + return true; + } + return isSupportsBatchInsert(persistentEntity, dialect); + } + + /** + * Resolves whether a JDBC connection can use batch inserts. + * + * @param persistentEntity The persistent entity + * @param dialect The SQL dialect + * @param databaseProductName The concrete database product name if available + * @param driverName The JDBC driver name if available + * @param supportsBatchUpdates Whether the driver reports batch-update support + * @param supportsGetGeneratedKeys Whether the driver reports generated-key support + * @param requiresGeneratedKeys Whether generated keys are needed back from the batch + * @return {@code true} if JDBC can use the batch path + */ + public static boolean isSupportsJdbcBatchInsert(PersistentEntity persistentEntity, + Dialect dialect, + @Nullable String databaseProductName, + @Nullable String driverName, + @Nullable Boolean supportsBatchUpdates, + @Nullable Boolean supportsGetGeneratedKeys, + boolean requiresGeneratedKeys) { + if (dialect == Dialect.MYSQL && isMySqlFamily(databaseProductName, driverName)) { + if (Boolean.FALSE.equals(supportsBatchUpdates)) { + return false; + } + if (requiresGeneratedKeys && Boolean.FALSE.equals(supportsGetGeneratedKeys)) { + return false; + } return true; } return isSupportsBatchInsert(persistentEntity, dialect); @@ -122,11 +154,22 @@ private static boolean supportsBatchWithGeneratedIds(PersistentEntity persistent return false; } - private static boolean isMariaDb(@Nullable String databaseProductName) { - if (databaseProductName == null) { + private static boolean isMySqlFamily(@Nullable String databaseProductName) { + return containsIgnoreCase(databaseProductName, MARIADB_PRODUCT_NAME) + || containsIgnoreCase(databaseProductName, MYSQL_PRODUCT_NAME); + } + + private static boolean isMySqlFamily(@Nullable String databaseProductName, @Nullable String driverName) { + return isMySqlFamily(databaseProductName) + || containsIgnoreCase(driverName, MARIADB_PRODUCT_NAME) + || containsIgnoreCase(driverName, MYSQL_PRODUCT_NAME); + } + + private static boolean containsIgnoreCase(@Nullable String value, String expected) { + if (value == null) { return false; } - return databaseProductName.toUpperCase(Locale.ENGLISH).contains(MARIADB_PRODUCT_NAME); + return value.toUpperCase(Locale.ENGLISH).contains(expected); } private static boolean returnsEntities(Argument resultArgument) { 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 314b27a37e9..9eb96f5dcd7 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 @@ -1,3 +1,18 @@ +/* + * 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.runtime.operations.internal.sql import io.micronaut.core.type.Argument @@ -17,14 +32,102 @@ class SqlBatchSupportSpec extends Specification { !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL) } - void "mariadb can batch generated-id inserts when generated keys are not required"() { + @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, "MariaDB", false) + !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, databaseProductName, true) + + where: + databaseProductName << ["MariaDB", "MySQL"] } - void "mariadb generated-id inserts stay conservative when generated keys are required"() { + @Unroll + void "jdbc mysql family can batch generated-id inserts for #scenario"() { expect: - !SqlBatchSupport.isSupportsBatchInsert(entityWithGeneratedId(), Dialect.MYSQL, "MariaDB", true) + SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + databaseProductName, + driverName, + true, + true, + true + ) + + where: + scenario | databaseProductName | driverName + "mariadb product metadata" | "MariaDB" | "MariaDB Connector/J" + "mysql product metadata" | "MySQL" | "MySQL Connector/J" + "mariadb driver metadata" | null | "MariaDB Connector/J" + "mysql driver metadata" | null | "MySQL Connector/J" + } + + void "jdbc mysql family does not batch generated-id inserts when generated keys are unsupported"() { + expect: + !SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + "MariaDB", + "MariaDB Connector/J", + true, + false, + true + ) + } + + void "jdbc mysql family does not batch inserts when batch updates are unsupported"() { + expect: + !SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + "MySQL", + "MySQL Connector/J", + false, + true, + false + ) + } + + void "jdbc unknown mysql metadata stays conservative for generated identities"() { + expect: + !SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + null, + null, + null, + null, + true + ) + } + + @Unroll + void "jdbc mysql metadata does not change #dialect generated-id batch support"() { + expect: + SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + dialect, + "MySQL", + "MySQL Connector/J", + true, + true, + true + ) == supported + + where: + dialect || supported + Dialect.ORACLE || false + Dialect.SQL_SERVER || false + Dialect.POSTGRES || true } @Unroll @@ -38,6 +141,7 @@ class SqlBatchSupportSpec extends Specification { "completion stage entity lists" | false | false | Argument.of(CompletionStage, Argument.listOf(String)) || true "void returns" | false | false | Argument.of(Void) || false "numeric returns" | false | false | Argument.of(Long) || false + "primitive numeric returns" | false | false | Argument.of(Long.TYPE) || false "post persist listeners" | false | true | Argument.of(Long) || true "cascade persist associations" | true | false | Argument.of(Void) || true } From 753359d793526de0a2d5abd9d7f13b1a1d618dd5 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 2 Jun 2026 15:32:28 +0200 Subject: [PATCH 06/15] Fix MySQL/MariaDB JDBC batch inserts with generated IDs --- .../jdbc/mariadb/MariaBatchInsertSpec.groovy | 7 +- .../legacy/MariaLegacyBatchInsertSpec.groovy | 131 -------------- .../MariaLegacyGeneratedValuesSpec.groovy | 160 ------------------ .../internal/sql/SqlBatchSupport.java | 23 ++- .../internal/sql/SqlBatchSupportSpec.groovy | 65 ++++++- 5 files changed, 75 insertions(+), 311 deletions(-) delete mode 100644 data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy delete mode 100644 data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy 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 b7c8d58074a..d718c99c56d 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 @@ -107,7 +107,7 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro repository.findAll()*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set } - void "saveAll stays on the generated-key path for generated identities"() { + void "saveAll falls back to generated-key inserts for generated identities"() { given: def books = [ new MariaBatchBook(title: "A Wizard of Earthsea"), @@ -120,9 +120,10 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro then: saved*.id.every { it != null } repository.count() == 2 + insertQueryExecutions("maria_batch_book") == 2 } - void "saveAll batches generated-id record inserts and populates ids"() { + void "saveAll falls back to generated-key record inserts and populates ids"() { given: def records = (0..<100).collect { new MariaBatchRecord(0L, "name-$it") } @@ -133,7 +134,7 @@ 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") == 1 + insertQueryExecutions("maria_batch_record") == 100 } void "custom void insertAll batches generated-id record inserts without mutating input ids"() { diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy deleted file mode 100644 index c277c8b709a..00000000000 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/mariadb/legacy/MariaLegacyBatchInsertSpec.groovy +++ /dev/null @@ -1,131 +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.jdbc.mariadb.legacy - -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 io.micronaut.data.runtime.config.SchemaGenerate -import org.testcontainers.containers.MariaDBContainer -import org.testcontainers.utility.DockerImageName -import spock.lang.Shared -import spock.lang.Specification - -class MariaLegacyBatchInsertSpec extends Specification { - - private static final String DATASOURCE_NAME = "legacy" - private static final String IMAGE = "mariadb:5.5.64" - - @Shared - MariaDBContainer mariaDb = new MariaDBContainer<>(DockerImageName.parse(IMAGE)) - .withDatabaseName("legacy") - .withUsername("test") - .withPassword("test") - .withCreateContainerCmdModifier { cmd -> - cmd.withPlatform("linux/amd64") - } - - @Shared - ApplicationContext context - - @Shared - MariaLegacyBatchBookRepository repository - - void setupSpec() { - mariaDb.start() - context = ApplicationContext.run(properties()) - repository = context.getBean(MariaLegacyBatchBookRepository) - } - - void cleanupSpec() { - context?.close() - mariaDb?.stop() - } - - void setup() { - repository.deleteAll() - } - - void "custom void insertAll batches generated-id inserts on MariaDB 5.5 without mutating input ids"() { - given: - def books = [ - new MariaLegacyBatchBook(title: "Solaris"), - new MariaLegacyBatchBook(title: "Fiasco") - ] - - when: - repository.customInsertAll(books) - - then: - repository.count() == 2 - books*.id == [null, null] - repository.findAll()*.id.every { it != null } - repository.findAll()*.title as Set == ["Solaris", "Fiasco"] as Set - } - - void "saveAll populates ids on MariaDB 5.5 via generated-key fallback"() { - given: - def books = [ - new MariaLegacyBatchBook(title: "The Cyberiad"), - new MariaLegacyBatchBook(title: "His Master's Voice") - ] - - when: - def saved = repository.saveAll(books) - - then: - saved*.id.every { it != null } - books*.id.every { it != null } - repository.count() == 2 - } - - private Map properties() { - String prefix = "datasources." + DATASOURCE_NAME - [ - 'datasources.default.enabled' : false, - (prefix + '.enabled') : true, - (prefix + '.dialect') : Dialect.MYSQL.name(), - (prefix + '.schema-generate') : SchemaGenerate.CREATE.name(), - (prefix + '.packages') : [getClass().package.name], - (prefix + '.url') : mariaDb.jdbcUrl, - (prefix + '.username') : mariaDb.username, - (prefix + '.password') : mariaDb.password, - (prefix + '.driver-class-name') : mariaDb.driverClassName - ] - } -} - -@MappedEntity("maria_legacy_batch_book") -class MariaLegacyBatchBook { - - @Id - @GeneratedValue - Long id - - String title -} - -@JdbcRepository(value = "legacy", dialect = Dialect.MYSQL) -interface MariaLegacyBatchBookRepository extends CrudRepository { - - @Insert - void customInsertAll(List entities) -} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy deleted file mode 100644 index 419199e3cb5..00000000000 --- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/mariadb/legacy/MariaLegacyGeneratedValuesSpec.groovy +++ /dev/null @@ -1,160 +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.legacy - -import io.micronaut.context.ApplicationContext -import io.micronaut.data.annotation.GeneratedValue -import io.micronaut.data.annotation.Id -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.reactive.ReactorCrudRepository -import io.micronaut.inject.qualifiers.Qualifiers -import io.micronaut.test.extensions.junit5.annotation.ScopeNamingStrategy -import io.micronaut.test.extensions.junit5.annotation.TestResourcesScope -import io.micronaut.test.support.TestPropertyProvider -import io.r2dbc.spi.ConnectionFactory -import io.r2dbc.spi.Result -import io.r2dbc.spi.Statement -import reactor.core.publisher.Flux -import spock.lang.AutoCleanup -import spock.lang.Shared -import spock.lang.Specification - -@TestResourcesScope(namingStrategy = ScopeNamingStrategy.TestClassName) -class MariaLegacyGeneratedValuesSpec extends Specification implements TestPropertyProvider { - - private static final String DATASOURCE_NAME = "legacy" - private static final String TABLE_NAME = "legacy_tr_person" - private static final String INSERT_SQL = "INSERT INTO ${TABLE_NAME}(name, age, enabled) VALUES (?, ?, TRUE)" - - @AutoCleanup - @Shared - ApplicationContext context = ApplicationContext.run(properties) - - @Shared - ConnectionFactory connectionFactory = context.getBean(ConnectionFactory, Qualifiers.byName(DATASOURCE_NAME)) - - @Shared - MariaLegacyTrPersonRepository repository = context.getBean(MariaLegacyTrPersonRepository) - - @Override - Map getProperties() { - String prefix = "r2dbc.datasources.$DATASOURCE_NAME" - return [ - (prefix + ".db-type") : "mariadb", - (prefix + ".schema-generate") : "NONE", - (prefix + ".packages") : [getClass().package.name], - (prefix + ".test-resources.resource-name"): "mariadb-legacy-r2dbc", - "test-resources.containers.mariadb.image-name": "mariadb", - "test-resources.containers.mariadb.image-tag" : "10.4" - ] - } - - void setupSpec() { - createSchema() - } - - void cleanupSpec() { - dropSchema() - } - - void setup() { - repository.deleteAll().block() - } - - void "raw batch returnGeneratedValues on MariaDB 10.4 via test resources returns all ids"() { - when: - def ids = batchInsertAndCollectIds(["Jeff", "James"]) - - then: - ids == [1L, 2L] - repository.count().block() == 2 - } - - void "repository saveAll populates ids on MariaDB 10.4 via test resources"() { - given: - def people = [ - person("Leto"), - person("Ghanima") - ] - - when: - def saved = repository.saveAll(people).collectList().block() - - then: - saved*.id.every { it != null } - people*.id.every { it != null } - repository.count().block() == 2 - } - - private void createSchema() { - executeStatement("CREATE TABLE $TABLE_NAME (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255) NOT NULL, age INT NOT NULL, enabled BOOLEAN NOT NULL)") - } - - private void dropSchema() { - executeStatement("DROP TABLE IF EXISTS $TABLE_NAME") - } - - private void executeStatement(String sql) { - Flux.usingWhen(connectionFactory.create(), connection -> - Flux.from(connection.createStatement(sql).execute()).flatMap(Result::getRowsUpdated), - { connection -> connection.close() } - ).then().block() - } - - private List batchInsertAndCollectIds(List names) { - return Flux.usingWhen(connectionFactory.create(), connection -> { - Statement statement = connection.createStatement(INSERT_SQL).returnGeneratedValues("id") - boolean first = true - names.each { name -> - if (first) { - first = false - } else { - statement = statement.add() - } - statement.bind(0, name).bind(1, 0) - } - return Flux.from(statement.execute()) - .flatMap { Result result -> result.map((row, metadata) -> row.get(0, Long.class)) } - }, { connection -> connection.close() }).collectList().block() - } - - private static MariaLegacyTrPerson person(String name) { - def person = new MariaLegacyTrPerson() - person.name = name - person.age = 0 - return person - } -} - -@MappedEntity("legacy_tr_person") -class MariaLegacyTrPerson { - - @Id - @GeneratedValue - Long id - - String name - - int age - - boolean enabled = true -} - -@R2dbcRepository(value = "legacy", dialect = Dialect.MYSQL) -interface MariaLegacyTrPersonRepository extends ReactorCrudRepository { -} 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 e3b57dd8f29..38b747fb85e 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 @@ -117,14 +117,15 @@ public static boolean isSupportsJdbcBatchInsert(PersistentEntity persistentEntit @Nullable Boolean supportsBatchUpdates, @Nullable Boolean supportsGetGeneratedKeys, boolean requiresGeneratedKeys) { - if (dialect == Dialect.MYSQL && isMySqlFamily(databaseProductName, driverName)) { - if (Boolean.FALSE.equals(supportsBatchUpdates)) { - return false; + if (dialect == Dialect.MYSQL) { + if (isMariaDb(databaseProductName, driverName)) { + // MariaDB generated keys for batched inserts are driver-option dependent. + return !requiresGeneratedKeys && Boolean.TRUE.equals(supportsBatchUpdates); } - if (requiresGeneratedKeys && Boolean.FALSE.equals(supportsGetGeneratedKeys)) { - return false; + if (isMySql(databaseProductName, driverName)) { + return Boolean.TRUE.equals(supportsBatchUpdates) + && (!requiresGeneratedKeys || Boolean.TRUE.equals(supportsGetGeneratedKeys)); } - return true; } return isSupportsBatchInsert(persistentEntity, dialect); } @@ -159,9 +160,13 @@ private static boolean isMySqlFamily(@Nullable String databaseProductName) { || containsIgnoreCase(databaseProductName, MYSQL_PRODUCT_NAME); } - private static boolean isMySqlFamily(@Nullable String databaseProductName, @Nullable String driverName) { - return isMySqlFamily(databaseProductName) - || containsIgnoreCase(driverName, MARIADB_PRODUCT_NAME) + private static boolean isMariaDb(@Nullable String databaseProductName, @Nullable String driverName) { + return containsIgnoreCase(databaseProductName, MARIADB_PRODUCT_NAME) + || containsIgnoreCase(driverName, MARIADB_PRODUCT_NAME); + } + + private static boolean isMySql(@Nullable String databaseProductName, @Nullable String driverName) { + return containsIgnoreCase(databaseProductName, MYSQL_PRODUCT_NAME) || containsIgnoreCase(driverName, MYSQL_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 9eb96f5dcd7..95b4a34e23b 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 @@ -51,7 +51,7 @@ class SqlBatchSupportSpec extends Specification { } @Unroll - void "jdbc mysql family can batch generated-id inserts for #scenario"() { + void "jdbc mysql can batch generated-id inserts for #scenario"() { expect: SqlBatchSupport.isSupportsJdbcBatchInsert( entityWithGeneratedId(), @@ -64,11 +64,47 @@ class SqlBatchSupportSpec extends Specification { ) where: - scenario | databaseProductName | driverName - "mariadb product metadata" | "MariaDB" | "MariaDB Connector/J" - "mysql product metadata" | "MySQL" | "MySQL Connector/J" - "mariadb driver metadata" | null | "MariaDB Connector/J" - "mysql driver metadata" | null | "MySQL Connector/J" + scenario | databaseProductName | driverName + "product metadata" | "MySQL" | "MySQL Connector/J" + "driver metadata" | null | "MySQL Connector/J" + } + + @Unroll + void "jdbc mariadb stays conservative for generated-id inserts for #scenario"() { + expect: + !SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + databaseProductName, + driverName, + true, + true, + true + ) + + where: + scenario | databaseProductName | driverName + "product metadata" | "MariaDB" | "MariaDB Connector/J" + "driver metadata" | null | "MariaDB Connector/J" + } + + @Unroll + void "jdbc mysql family can batch generated-id inserts without generated keys for #scenario"() { + expect: + SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + databaseProductName, + driverName, + true, + false, + false + ) + + where: + scenario | databaseProductName | driverName + "mariadb metadata" | "MariaDB" | "MariaDB Connector/J" + "mysql metadata" | "MySQL" | "MySQL Connector/J" } void "jdbc mysql family does not batch generated-id inserts when generated keys are unsupported"() { @@ -76,8 +112,8 @@ class SqlBatchSupportSpec extends Specification { !SqlBatchSupport.isSupportsJdbcBatchInsert( entityWithGeneratedId(), Dialect.MYSQL, - "MariaDB", - "MariaDB Connector/J", + "MySQL", + "MySQL Connector/J", true, false, true @@ -97,6 +133,19 @@ class SqlBatchSupportSpec extends Specification { ) } + void "jdbc mysql family requires explicit batch update support"() { + expect: + !SqlBatchSupport.isSupportsJdbcBatchInsert( + entityWithGeneratedId(), + Dialect.MYSQL, + "MySQL", + "MySQL Connector/J", + null, + true, + true + ) + } + void "jdbc unknown mysql metadata stays conservative for generated identities"() { expect: !SqlBatchSupport.isSupportsJdbcBatchInsert( From da03c4161a1dbb63434d7c5a878ba8f5f41f5331 Mon Sep 17 00:00:00 2001 From: radovanradic Date: Tue, 2 Jun 2026 16:04:54 +0200 Subject: [PATCH 07/15] Fix MySQL/MariaDB JDBC batch inserts with generated IDs --- .../jdbc/mariadb/MariaBatchInsertSpec.groovy | 13 +++++++----- .../jdbc/mysql/MySqlBatchInsertSpec.groovy | 3 ++- .../internal/sql/SqlBatchSupport.java | 20 ++++++++++--------- 3 files changed, 21 insertions(+), 15 deletions(-) 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 d718c99c56d..44c3dc2648c 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 @@ -81,12 +81,13 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro when: repository.customInsertAll(books) + def savedBooks = repository.findAll() then: repository.count() == 2 books*.id == [null, null] - repository.findAll()*.id.every { it != null } - repository.findAll()*.title as Set == ["The Left Hand", "The Dispossessed"] as Set + savedBooks*.id.every { it != null } + savedBooks*.title as Set == ["The Left Hand", "The Dispossessed"] as Set } void "custom count insertAll batches generated-id inserts without mutating input ids"() { @@ -98,13 +99,14 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro when: long inserted = repository.customInsertAllCount(books) + def savedBooks = repository.findAll() then: inserted == 2 repository.count() == 2 books*.id == [null, null] - repository.findAll()*.id.every { it != null } - repository.findAll()*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set + savedBooks*.id.every { it != null } + savedBooks*.title as Set == ["The Lathe of Heaven", "City of Illusions"] as Set } void "saveAll falls back to generated-key inserts for generated identities"() { @@ -143,11 +145,12 @@ class MariaBatchInsertSpec extends Specification implements MariaTestPropertyPro when: recordRepository.insertAll(records) + def savedRecords = recordRepository.findAll() then: records.collect { it.id() }.every { it == 0L } recordRepository.count() == 100 - recordRepository.findAll().every { it.id() != null && it.id() != 0L } + 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 300b8b21b93..66c3aca77b7 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 @@ -84,11 +84,12 @@ class MySqlBatchInsertSpec extends Specification implements MySQLTestPropertyPro when: repository.insertAll(records) + def savedRecords = repository.findAll() then: records.collect { it.id() }.every { it == 0L } repository.count() == 100 - repository.findAll().every { it.id() != null && it.id() != 0L } + savedRecords.every { it.id() != null && it.id() != 0L } insertQueryExecutions("mysql_batch_record") == 1 } 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 38b747fb85e..2598af0eb6f 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 @@ -36,8 +36,7 @@ * driver behavior can still diverge, so runtime capability checks stay internal and separate from * the public dialect enum.

* - * @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;