Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -114,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;
Expand Down Expand Up @@ -169,6 +171,8 @@
private final JdbcSchemaHandler schemaHandler;
private final ColumnIndexCallableResultReader columnIndexCallableResultReader;
private final Map<Dialect, List<SqlExceptionMapper>> sqlExceptionMappers = new EnumMap<>(Dialect.class);
@Nullable
private volatile JdbcBatchCapabilities jdbcBatchCapabilities;

Check warning on line 175 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a thread-safe type; adding "volatile" is not enough to make this field thread-safe.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZ6N7KcPyfiVHIXFYaVq&open=AZ6N7KcPyfiVHIXFYaVq&pullRequest=3896

private final Integer defaultFetchSize;

Expand Down Expand Up @@ -850,7 +854,8 @@
final SqlStoredQuery<T, ?> storedQuery = getSqlStoredQuery(operation.getStoredQuery());
final RuntimePersistentEntity<T> 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<T> op = new JdbcEntityOperations<>(ctx, storedQuery, persistentEntity, persistOp.getEntity(), true);
Expand All @@ -859,7 +864,7 @@
})
.toList();
} else {
JdbcEntitiesOperations<T> op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery, true);
JdbcEntitiesOperations<T> op = new JdbcEntitiesOperations<>(ctx, persistentEntity, operation, storedQuery, true, requiresGeneratedKeys);
op.persist();
return op.getEntities();
}
Expand Down Expand Up @@ -1155,7 +1160,71 @@

@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,
RuntimePersistentEntity<?> persistentEntity,
SqlStoredQuery<?, ?> storedQuery,
boolean requiresGeneratedKeys) {
if (storedQuery.getOperationType() == StoredQuery.OperationType.INSERT_RETURNING) {
return false;
}
return isSupportsBatchInsert(ctx, persistentEntity, requiresGeneratedKeys);
}

private boolean isSupportsBatchInsert(JdbcOperationContext ctx,
Comment thread
radovanradic marked this conversation as resolved.
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);
}
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);
if (capabilities != null) {
jdbcBatchCapabilities = capabilities;
} else {
capabilities = JdbcBatchCapabilities.UNKNOWN;
}
}
}
}
return capabilities;
}

@Nullable
private JdbcBatchCapabilities resolveJdbcBatchCapabilities(JdbcOperationContext ctx) {
try {
DatabaseMetaData metaData = ctx.connection.getMetaData();
return new JdbcBatchCapabilities(
metaData.getDatabaseProductName(),
metaData.getDriverName(),
metaData.supportsBatchUpdates(),
metaData.supportsGetGeneratedKeys()
);
} catch (SQLException ignored) {

Check warning on line 1225 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace "ignored" with an unnamed pattern.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZ6M4CVVUDyJwRZkyKU0&open=AZ6M4CVVUDyJwRZkyKU0&pullRequest=3896
return null;
}
Comment thread
radovanradic marked this conversation as resolved.
}

@SuppressWarnings({"rawtypes", "unchecked"})
Expand Down Expand Up @@ -1193,6 +1262,14 @@
return conversionService;
}

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 {

private final SqlStoredQuery<?, ?> sqlStoredQuery;
Expand Down Expand Up @@ -1420,18 +1497,29 @@
private final class JdbcEntitiesOperations<T> extends AbstractSyncEntitiesOperations<JdbcOperationContext, T, SQLException> {

private final SqlStoredQuery<T, ?> storedQuery;
private final boolean requiresGeneratedKeys;
private int rowsUpdated;

private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity<T> persistentEntity, Iterable<T> entities, SqlStoredQuery<T, ?> storedQuery) {
this(ctx, persistentEntity, entities, storedQuery, false);
}

private JdbcEntitiesOperations(JdbcOperationContext ctx, RuntimePersistentEntity<T> persistentEntity, Iterable<T> entities, SqlStoredQuery<T, ?> storedQuery, boolean insert) {
this(ctx, persistentEntity, entities, storedQuery, insert, insert && persistentEntity.hasIdentity() && persistentEntity.getIdentity().isGenerated());
}

private JdbcEntitiesOperations(JdbcOperationContext ctx,
RuntimePersistentEntity<T> persistentEntity,
Iterable<T> entities,
SqlStoredQuery<T, ?> storedQuery,
boolean insert,
boolean requiresGeneratedKeys) {
super(ctx,
DefaultJdbcRepositoryOperations.this.cascadeOperations,
DefaultJdbcRepositoryOperations.this.conversionService,
entityEventRegistry, persistentEntity, entities, insert);
this.storedQuery = storedQuery;
this.requiresGeneratedKeys = requiresGeneratedKeys;
}

@Override
Expand All @@ -1447,7 +1535,7 @@
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"})
Expand All @@ -1458,7 +1546,7 @@
}
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());
Expand Down Expand Up @@ -1487,7 +1575,7 @@
try (PreparedStatement ps = prepare(ctx.connection)) {
setParameters(ps, storedQuery);
rowsUpdated = Arrays.stream(ps.executeBatch()).sum();
if (hasGeneratedId) {
if (requiresGeneratedKeys) {
RuntimePersistentProperty<T> identity = persistentEntity.getIdentity();
List<Object> ids = new ArrayList<>();
try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<H2BatchInsertBook, Long> {

@Insert
void customInsertAll(List<H2BatchInsertBook> entities)

@Insert
long customInsertAllCount(List<H2BatchInsertBook> entities)
}
Loading
Loading