From ee8cf96556d5345d1f216bbd9240247110b4d0ee Mon Sep 17 00:00:00 2001 From: Radovan Radic Date: Thu, 25 Apr 2024 17:01:44 +0200 Subject: [PATCH] Minor Improvement for Oracle JsonView optimistic locking (#2915) * Minor Improvement for Oracle JsonView optimistic locking Extract SqlExceptionMapper for extensibility. Added support for optimistic lock checking in batch update for JsonView. * Separate handlers for Jdbc and R2dbc * Address review comments. * Removed unused var and imports * More changes per CR comments * Sonar suggested changes --- .../DefaultJdbcRepositoryOperations.java | 107 +++++++++++++----- .../operations/OracleSqlExceptionMapper.java | 55 +++++++++ .../jdbc/operations/SqlExceptionMapper.java | 52 +++++++++ .../jsonview/OracleJdbcJsonViewSpec.groovy | 5 + .../DefaultR2dbcRepositoryOperations.java | 69 +++++++++-- .../OracleR2dbcExceptionMapper.java | 52 +++++++++ .../operations/R2dbcExceptionMapper.java | 51 +++++++++ .../jsonview/OracleR2DbcJsonViewSpec.groovy | 5 + .../sql/AbstractSqlRepositoryOperations.java | 44 +------ .../tck/tests/AbstractRepositorySpec.groovy | 6 + 10 files changed, 363 insertions(+), 83 deletions(-) create mode 100644 data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleSqlExceptionMapper.java create mode 100644 data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlExceptionMapper.java create mode 100644 data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcExceptionMapper.java create mode 100644 data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcExceptionMapper.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 98215e7240..8995775f59 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 @@ -28,6 +28,7 @@ import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.type.Argument; import io.micronaut.core.util.ArgumentUtils; +import io.micronaut.core.util.CollectionUtils; import io.micronaut.data.connection.ConnectionOperations; import io.micronaut.data.connection.annotation.Connectable; import io.micronaut.data.connection.jdbc.advice.DelegatingDataSource; @@ -108,6 +109,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.EnumMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -143,6 +145,7 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository ReactiveCapableRepository, AutoCloseable, SyncCascadeOperations.SyncCascadeOperationsHelper { + private final ConnectionOperations connectionOperations; private final TransactionOperations transactionOperations; private final DataSource dataSource; @@ -157,6 +160,7 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository private final ColumnNameCallableResultReader columnNameCallableResultReader; private final ColumnIndexCallableResultReader columnIndexCallableResultReader; + private final Map> sqlExceptionMappers = new EnumMap<>(Dialect.class); /** * Default constructor. @@ -164,6 +168,7 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository * @param dataSourceName The data source name * @param jdbcConfiguration The jdbcConfiguration * @param dataSource The datasource + * @param connectionOperations The connection operations * @param transactionOperations The JDBC operations for the data source * @param executorService The executor service * @param beanContext The bean context @@ -175,26 +180,26 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository * @param schemaHandler The schema handler * @param jsonMapper The JSON mapper * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider - * @param connectionOperations + * @param sqlExceptionMapperList The SQL exception mapper list */ @Internal @SuppressWarnings("ParameterNumber") DefaultJdbcRepositoryOperations(@Parameter String dataSourceName, - @Parameter DataJdbcConfiguration jdbcConfiguration, - DataSource dataSource, - @Parameter ConnectionOperations connectionOperations, - @Parameter TransactionOperations transactionOperations, - @Named("io") @Nullable ExecutorService executorService, - BeanContext beanContext, - @NonNull DateTimeProvider dateTimeProvider, - RuntimeEntityRegistry entityRegistry, - DataConversionService conversionService, - AttributeConverterRegistry attributeConverterRegistry, - @Nullable - SchemaTenantResolver schemaTenantResolver, - JdbcSchemaHandler schemaHandler, - @Nullable JsonMapper jsonMapper, - SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider) { + @Parameter DataJdbcConfiguration jdbcConfiguration, + DataSource dataSource, + @Parameter ConnectionOperations connectionOperations, + @Parameter TransactionOperations transactionOperations, + @Named("io") @Nullable ExecutorService executorService, + BeanContext beanContext, + @NonNull DateTimeProvider dateTimeProvider, + RuntimeEntityRegistry entityRegistry, + DataConversionService conversionService, + AttributeConverterRegistry attributeConverterRegistry, + @Nullable SchemaTenantResolver schemaTenantResolver, + JdbcSchemaHandler schemaHandler, + @Nullable JsonMapper jsonMapper, + SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + List sqlExceptionMapperList) { super( dataSourceName, new ColumnNameResultSetReader(conversionService), @@ -220,6 +225,17 @@ public final class DefaultJdbcRepositoryOperations extends AbstractSqlRepository this.jdbcConfiguration = jdbcConfiguration; this.columnNameCallableResultReader = new ColumnNameCallableResultReader(conversionService); this.columnIndexCallableResultReader = new ColumnIndexCallableResultReader(conversionService); + if (CollectionUtils.isNotEmpty(sqlExceptionMapperList)) { + for (SqlExceptionMapper sqlExceptionMapper : sqlExceptionMapperList) { + Dialect dialect = sqlExceptionMapper.getDialect(); + List dialectSqlExceptionMapperList = sqlExceptionMappers.get(dialect); + if (dialectSqlExceptionMapperList == null) { + dialectSqlExceptionMapperList = new ArrayList<>(); + } + dialectSqlExceptionMapperList.add(sqlExceptionMapper); + sqlExceptionMappers.put(dialect, dialectSqlExceptionMapperList); + } + } } @NonNull @@ -531,11 +547,7 @@ public Optional executeUpdate(@NonNull PreparedQuery pq) { } return Optional.of(result); } catch (SQLException e) { - Throwable throwable = handleSqlException(e, preparedQuery.getDialect()); - if (throwable instanceof DataAccessException dataAccessException) { - throw dataAccessException; - } - throw new DataAccessException("Error executing SQL UPDATE: " + e.getMessage(), e); + throw sqlExceptionToDataAccessException(e, preparedQuery.getDialect(), sqlException -> new DataAccessException("Error executing SQL UPDATE: " + sqlException.getMessage(), sqlException)); } }); } @@ -551,11 +563,7 @@ public List execute(PreparedQuery pq) { return findAll(connection, preparedQuery, false); } } catch (SQLException e) { - Throwable throwable = handleSqlException(e, preparedQuery.getDialect()); - if (throwable instanceof DataAccessException dataAccessException) { - throw dataAccessException; - } - throw new DataAccessException("Error executing SQL UPDATE: " + e.getMessage(), e); + throw sqlExceptionToDataAccessException(e, preparedQuery.getDialect(), sqlException -> new DataAccessException("Error executing SQL UPDATE: " + sqlException.getMessage(), sqlException)); } }); } @@ -936,6 +944,28 @@ public T next() { return StreamSupport.stream(iterable.spliterator(), false); } + /** + * Maps SQL exception, used in context of update but could be used elsewhere. + * It will return custom {@DataAccessException} based on the {@link SQLException} or null + * if it cannot be mapped to the custom {@link DataAccessException}. + * + * @param sqlException the SQL exception + * @param dialect the SQL dialect + * @return custom {@link DataAccessException} exception based on {@link SQLException} that was thrown or null + * if exception is not mappable to {@link DataAccessException} in given dialect {@link SqlExceptionMapper} + */ + @Nullable + private DataAccessException mapSqlException(SQLException sqlException, Dialect dialect) { + List dialectSqlExceptionMapperList = sqlExceptionMappers.getOrDefault(dialect, List.of()); + for (SqlExceptionMapper dialectSqlExceptionMapper : dialectSqlExceptionMapperList) { + DataAccessException dataAccessException = dialectSqlExceptionMapper.mapSqlException(sqlException); + if (dataAccessException != null) { + return dataAccessException; + } + } + return null; + } + @NonNull private ResultConsumer.Context newMappingContext(ResultSet rs) { return new ResultConsumer.Context<>() { @@ -996,6 +1026,23 @@ private Object getGeneratedIdentity(@NonNull ResultSet generatedKeysResultSet, R return columnIndexResultSetReader.readDynamic(generatedKeysResultSet, 1, identity.getDataType()); } + /** + * Handles {@link SQLException} first trying to map it to {@link DataAccessException} using {@link SqlExceptionMapper}. + * If mapped exception is not {@link DataAccessException} then returns {@link DataAccessException} using provided fallbackMapper. + * + * @param sqlException The SQL exception that was thrown + * @param dialect The dialect + * @param fallbackMapper The fallback mapper that returns {@link DataAccessException} if {@link SQLException} was not mapped to {@link DataAccessException} + * @return DataAccessException + */ + private DataAccessException sqlExceptionToDataAccessException(SQLException sqlException, Dialect dialect, Function fallbackMapper) { + DataAccessException dataAccessException = mapSqlException(sqlException, dialect); + if (dataAccessException != null) { + return dataAccessException; + } + return fallbackMapper.apply(sqlException); + } + @Override public boolean isSupportsBatchInsert(JdbcOperationContext jdbcOperationContext, RuntimePersistentEntity persistentEntity) { return isSupportsBatchInsert(persistentEntity, jdbcOperationContext.dialect); @@ -1138,8 +1185,8 @@ protected void execute() throws SQLException { checkOptimisticLocking(1, rowsUpdated); } } catch (SQLException e) { - Throwable throwable = handleSqlException(e, ctx.dialect); - if (throwable instanceof DataAccessException dataAccessException) { + DataAccessException dataAccessException = mapSqlException(e, ctx.dialect); + if (dataAccessException != null) { throw dataAccessException; } throw e; @@ -1245,7 +1292,7 @@ private void setParameters(PreparedStatement stmt, SqlStoredQuery storedQu } @Override - protected void execute() throws SQLException { + protected void execute() { if (QUERY_LOG.isDebugEnabled()) { QUERY_LOG.debug("Executing SQL query: {}", storedQuery.getQuery()); } @@ -1282,6 +1329,8 @@ protected void execute() throws SQLException { int expected = (int) entities.stream().filter(d -> !d.vetoed).count(); checkOptimisticLocking(expected, rowsUpdated); } + } catch (SQLException e) { + throw sqlExceptionToDataAccessException(e, ctx.dialect, sqlException -> new DataAccessException("Error executing batch SQL UPDATE: " + sqlException.getMessage(), sqlException)); } } diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleSqlExceptionMapper.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleSqlExceptionMapper.java new file mode 100644 index 0000000000..4188f54c3c --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/OracleSqlExceptionMapper.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017-2024 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.operations; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.exceptions.OptimisticLockException; +import io.micronaut.data.model.query.builder.sql.Dialect; +import jakarta.inject.Singleton; + +import java.sql.SQLException; + +/** + * The {@link SqlExceptionMapper} for {@link Dialect#ORACLE}. + * Handles {@link SQLException} for Oracle update commands. Can add more logic if needed, but this + * now handles only optimistic locking exception for given error code. + * + * @since 4.8.0 + */ +@Singleton +@Internal +final class OracleSqlExceptionMapper implements SqlExceptionMapper { + + private static final int JSON_VIEW_ETAG_NOT_MATCHING_ERROR = 42699; + + @Override + public Dialect getDialect() { + return Dialect.ORACLE; + } + + @Override + @Nullable + public DataAccessException mapSqlException(@NonNull SQLException sqlException) { + if (sqlException.getErrorCode() == JSON_VIEW_ETAG_NOT_MATCHING_ERROR) { + return new OptimisticLockException("ETAG did not match when updating record: " + sqlException.getMessage(), sqlException); + } + return null; + } +} + diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlExceptionMapper.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlExceptionMapper.java new file mode 100644 index 0000000000..c805a2acdf --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/SqlExceptionMapper.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 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.operations; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.order.Ordered; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.model.query.builder.sql.Dialect; + +import java.sql.SQLException; + +/** + * The {@link SQLException} mapper interface. Can be used to map given SQL exceptions to some custom exceptions + * (for example {@link DataAccessException} and its descendents like {@link io.micronaut.data.exceptions.OptimisticLockException}). + * + * @since 4.8.0 + */ +@Experimental +public interface SqlExceptionMapper extends Ordered { + + /** + * @return the {@link Dialect} that this mapper supports + */ + @NonNull + Dialect getDialect(); + + /** + * Maps {@link SQLException} to custom {@link DataAccessException}. + * In case when mapper is not able to map {@link SQLException} to custom {@link DataAccessException} then result will be null + * indicating that mapper cannot map the exception. + * + * @param sqlException The SQL exception + * @return mapped {@link DataAccessException} from {@link SQLException} or if mapper cannot map {@link SQLException} then returns null + */ + @Nullable + DataAccessException mapSqlException(@NonNull SQLException sqlException); +} diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/jsonview/OracleJdbcJsonViewSpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/jsonview/OracleJdbcJsonViewSpec.groovy index bedd28f54f..2e06b4c299 100644 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/jsonview/OracleJdbcJsonViewSpec.groovy +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/jsonview/OracleJdbcJsonViewSpec.groovy @@ -195,6 +195,11 @@ class OracleJdbcJsonViewSpec extends Specification { studentViewRepository.update(newJoshStudentView) then: thrown(OptimisticLockException) + + when:"Optimistic lock exception with invalid ETAG in batch update" + studentViewRepository.updateAll(List.of(newJoshStudentView)) + then: + thrown(OptimisticLockException) } def "insert new"() { 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 cf59b542f6..66cfde33d2 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 @@ -30,6 +30,7 @@ import io.micronaut.core.convert.ConversionService; import io.micronaut.core.propagation.PropagatedContext; import io.micronaut.core.type.Argument; +import io.micronaut.core.util.CollectionUtils; import io.micronaut.data.connection.ConnectionDefinition; import io.micronaut.data.connection.reactive.ReactorConnectionOperations; import io.micronaut.data.exceptions.DataAccessException; @@ -88,6 +89,7 @@ import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.Parameters; +import io.r2dbc.spi.R2dbcException; import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Readable; import io.r2dbc.spi.Result; @@ -103,11 +105,13 @@ import reactor.util.function.Tuple2; import reactor.util.function.Tuples; -import java.sql.SQLException; +import java.util.ArrayList; import java.util.Collection; +import java.util.EnumMap; import java.util.Iterator; import java.util.List; import java.util.ListIterator; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ExecutorService; @@ -130,7 +134,9 @@ final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperations implements BlockingExecutorReactorRepositoryOperations, R2dbcRepositoryOperations, R2dbcOperations, ReactiveCascadeOperations.ReactiveCascadeOperationsHelper { + private static final Logger LOG = LoggerFactory.getLogger(DefaultR2dbcRepositoryOperations.class); + private final ConnectionFactory connectionFactory; private final ReactorReactiveRepositoryOperations reactiveOperations; private final String dataSourceName; @@ -143,6 +149,7 @@ final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperat private final SchemaTenantResolver schemaTenantResolver; private final R2dbcSchemaHandler schemaHandler; private final DataR2dbcConfiguration configuration; + private final Map> r2dbcExceptionMappers = new EnumMap<>(Dialect.class); /** * Default constructor. @@ -160,6 +167,7 @@ final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperat * @param configuration The configuration * @param jsonMapper The JSON mapper * @param sqlJsonColumnMapperProvider The SQL JSON column mapper provider + * @param r2dbcExceptionMapperList The R2dbc exception mapper list * @param transactionOperations The transaction operations * @param connectionOperations The connection operations */ @@ -179,6 +187,7 @@ final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperat @Parameter DataR2dbcConfiguration configuration, @Nullable JsonMapper jsonMapper, SqlJsonColumnMapperProvider sqlJsonColumnMapperProvider, + List r2dbcExceptionMapperList, @Parameter R2dbcReactorTransactionOperations transactionOperations, @Parameter ReactorConnectionOperations connectionOperations) { super( @@ -207,6 +216,17 @@ final class DefaultR2dbcRepositoryOperations extends AbstractSqlRepositoryOperat if (name == null) { name = "default"; } + if (CollectionUtils.isNotEmpty(r2dbcExceptionMapperList)) { + for (R2dbcExceptionMapper r2dbcExceptionMapper : r2dbcExceptionMapperList) { + Dialect dialect = r2dbcExceptionMapper.getDialect(); + List dialectR2dbcExceptionMapperList = r2dbcExceptionMappers.get(dialect); + if (dialectR2dbcExceptionMapperList == null) { + dialectR2dbcExceptionMapperList = new ArrayList<>(); + } + dialectR2dbcExceptionMapperList.add(r2dbcExceptionMapper); + r2dbcExceptionMappers.put(dialect, dialectR2dbcExceptionMapperList); + } + } } @Override @@ -389,6 +409,30 @@ public boolean isSupportsBatchInsert(R2dbcOperationContext context, RuntimePersi return isSupportsBatchInsert(persistentEntity, context.dialect); } + /** + * Maps R2dbc exception, used in context of update but could be used elsewhere. + * It will return custom {@DataAccessException} based on the {@link R2dbcException} or null + * if it cannot be mapped to the custom {@link DataAccessException}. + * + * @param r2dbcException the R2dbc exception + * @param dialect the SQL dialect + * @return custom {@link DataAccessException} exception based on {@link R2dbcException} that was thrown or null + * if exception is not mappable to {@link DataAccessException} in given dialect {@link R2dbcExceptionMapper} + */ + @Nullable + private DataAccessException mapR2dbcException(R2dbcException r2dbcException, Dialect dialect) { + List dialectR2dbcExceptionMapperList = r2dbcExceptionMappers.getOrDefault(dialect, List.of()); + if (CollectionUtils.isNotEmpty(dialectR2dbcExceptionMapperList)) { + for (R2dbcExceptionMapper dialectR2dbcExceptionMapper : dialectR2dbcExceptionMapperList) { + DataAccessException dataAccessException = dialectR2dbcExceptionMapper.mapR2dbcException(r2dbcException); + if (dataAccessException != null) { + return dataAccessException; + } + } + } + return null; + } + private static Flux executeAndMapEachRow(Statement statement, Function mapper) { return Flux.from(statement.execute()) .flatMap(result -> Flux.from(result.map((row, rowMetadata) -> mapper.apply(row)))); @@ -404,15 +448,11 @@ private static Flux executeAndMapEachRowNullable(Statement statement, Fun .flatMap(result -> Flux.from(result.map((row, metadata) -> Mono.justOrEmpty(mapper.apply(row)))).flatMap(t -> t)); } - private static Mono executeAndMapEachRowSingle(Statement statement, Dialect dialect, Function mapper) { - return executeAndMapEachRow(statement, mapper).onErrorResume(errorHandler(dialect)).as(DefaultR2dbcRepositoryOperations::toSingleResult); - } - - private static Flux executeAndMapEachReadable(Statement statement, Dialect dialect, Function mapper) { + private Flux executeAndMapEachReadable(Statement statement, Dialect dialect, Function mapper) { return executeAndMapEachReadable(statement, mapper).onErrorResume(errorHandler(dialect)); } - private static Mono executeAndGetRowsUpdatedSingle(Statement statement, Dialect dialect) { + private Mono executeAndGetRowsUpdatedSingle(Statement statement, Dialect dialect) { return executeAndGetRowsUpdated(statement) .onErrorResume(errorHandler(dialect)) .as(DefaultR2dbcRepositoryOperations::toSingleResult); @@ -424,11 +464,13 @@ private static Flux executeAndGetRowsUpdated(Statement statement) { .map((Number n) -> n.longValue()); } - private static Function> errorHandler(Dialect dialect) { + private Function> errorHandler(Dialect dialect) { return throwable -> { - if (throwable.getCause() instanceof SQLException sqlException) { - Throwable newThrowable = handleSqlException(sqlException, dialect); - return Mono.error(newThrowable); + if (throwable instanceof R2dbcException r2dbcException) { + DataAccessException dataAccessException = mapR2dbcException(r2dbcException, dialect); + if (dataAccessException != null) { + return Mono.error(dataAccessException); + } } return Mono.error(throwable); }; @@ -903,6 +945,10 @@ private void setParameters(Statement stmt, SqlStoredQuery storedQuery) { }); } + private Mono executeAndMapEachRowSingle(Statement statement, Dialect dialect, Function mapper) { + return executeAndMapEachRow(statement, mapper).onErrorResume(errorHandler(dialect)).as(DefaultR2dbcRepositoryOperations::toSingleResult); + } + @Override protected void execute() throws RuntimeException { if (QUERY_LOG.isDebugEnabled()) { @@ -1054,6 +1100,7 @@ protected void execute() throws RuntimeException { return Mono.just(Tuples.of(list, 0L)); } return executeAndGetRowsUpdated(statement) + .onErrorResume(errorHandler(ctx.dialect)) .map(Number::longValue) .reduce(0L, Long::sum) .map(rowsUpdated -> { diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcExceptionMapper.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcExceptionMapper.java new file mode 100644 index 0000000000..ca16e8fb7e --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/OracleR2dbcExceptionMapper.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 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.operations; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.exceptions.OptimisticLockException; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.r2dbc.spi.R2dbcException; +import jakarta.inject.Singleton; + +/** + * The {@link R2dbcExceptionMapper} for {@link Dialect#ORACLE}. + * Handles {@link io.r2dbc.spi.R2dbcException} for Oracle update commands. Can add more logic if needed, but this + * now handles only optimistic locking exception for given error code. + */ +@Singleton +@Internal +final class OracleR2dbcExceptionMapper implements R2dbcExceptionMapper { + + private static final int JSON_VIEW_ETAG_NOT_MATCHING_ERROR = 42699; + + @Override + public Dialect getDialect() { + return Dialect.ORACLE; + } + + @Override + @Nullable + public DataAccessException mapR2dbcException(@NonNull R2dbcException r2dbcException) { + if (r2dbcException.getErrorCode() == JSON_VIEW_ETAG_NOT_MATCHING_ERROR) { + return new OptimisticLockException("ETAG did not match when updating record: " + r2dbcException.getMessage(), r2dbcException); + } + return null; + } +} + diff --git a/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcExceptionMapper.java b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcExceptionMapper.java new file mode 100644 index 0000000000..e63c6498f0 --- /dev/null +++ b/data-r2dbc/src/main/java/io/micronaut/data/r2dbc/operations/R2dbcExceptionMapper.java @@ -0,0 +1,51 @@ +/* + * Copyright 2017-2024 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.operations; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.order.Ordered; +import io.micronaut.data.exceptions.DataAccessException; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.r2dbc.spi.R2dbcException; + +/** + * The {@link io.r2dbc.spi.R2dbcException} mapper interface. Can be used to map given R2dbc exceptions to some custom exceptions + * (for example {@link DataAccessException} and its descendents like {@link io.micronaut.data.exceptions.OptimisticLockException}). + * + * @since 4.8.0 + */ +@Experimental +public interface R2dbcExceptionMapper extends Ordered { + + /** + * @return the {@link Dialect} that this mapper supports + */ + @NonNull + Dialect getDialect(); + + /** + * Maps {@link io.r2dbc.spi.R2dbcException} to custom {@link DataAccessException}. + * In case when mapper is not able to map {@link io.r2dbc.spi.R2dbcException} to custom {@link DataAccessException} then result will be null + * indicating that mapper cannot map the exception. + * + * @param r2dbcException The R2dbc exception + * @return mapped {@link DataAccessException} from {@link R2dbcException} or if mapper cannot map {@link R2dbcException} then returns null + */ + @Nullable + DataAccessException mapR2dbcException(@NonNull R2dbcException r2dbcException); +} diff --git a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/jsonview/OracleR2DbcJsonViewSpec.groovy b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/jsonview/OracleR2DbcJsonViewSpec.groovy index 2e4eaf1a75..fb54bfe4e6 100644 --- a/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/jsonview/OracleR2DbcJsonViewSpec.groovy +++ b/data-r2dbc/src/test/groovy/io/micronaut/data/r2dbc/oraclexe/jsonview/OracleR2DbcJsonViewSpec.groovy @@ -81,6 +81,11 @@ class OracleR2DbcJsonViewSpec extends Specification { def e = thrown(OptimisticLockException) e.message.startsWith("ETAG did not match when updating record") + when:"Optimistic lock exception with invalid ETAG in batch update" + contactViewRepository.updateAll(List.of(contactView)) + then: + thrown(OptimisticLockException) + when:"Save multiple at once" ContactView contactView1 = new ContactView() contactView1.name = "ContactNew1" 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 c25f5f568b..38cd84088a 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 @@ -28,7 +28,6 @@ import io.micronaut.data.annotation.Repository; import io.micronaut.data.annotation.TypeRole; import io.micronaut.data.exceptions.DataAccessException; -import io.micronaut.data.exceptions.OptimisticLockException; import io.micronaut.data.model.Association; import io.micronaut.data.model.DataType; import io.micronaut.data.model.JsonDataType; @@ -74,7 +73,6 @@ import org.slf4j.Logger; import java.io.IOException; -import java.sql.SQLException; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collection; @@ -108,6 +106,7 @@ public abstract class AbstractSqlRepositoryOperations columnNameResultSetReader; @@ -565,22 +564,6 @@ protected final SqlTypeMapper createQueryResultMapper(SqlStoredQue return createJsonQueryResultMapper(sqlStoredQuery, columnName, jsonDataType, resultSetType, persistentEntity, loadListener); } - /** - * Handles SQL exception, used in context of update but could be used elsewhere. - * It can throw custom exception based on the {@link SQLException}. - * - * @param sqlException the SQL exception - * @param dialect the SQL dialect - * @return custom exception based on {@link SQLException} that was thrown or that same - * exception if nothing specific was about it - */ - protected static Throwable handleSqlException(SQLException sqlException, Dialect dialect) { - if (dialect == Dialect.ORACLE) { - return OracleSqlExceptionHandler.handleSqlException(sqlException); - } - return sqlException; - } - /** * Return an indicator telling whether prepared query result produces JSON result. * @@ -759,31 +742,6 @@ public Object read(RS object, String name) { }; } - /** - * Handles {@link SQLException} for Oracle update commands. Can add more logic if needed, but this - * now handles only optimistic locking exception for given error code. - */ - private static final class OracleSqlExceptionHandler { - private static final int JSON_VIEW_ETAG_NOT_MATCHING_ERROR = 42699; - - /** - * Handles SQL exception for Oracle dialect, used in context of update but could be used elsewhere. - * It can throw custom exception based on the {@link SQLException}. - * Basically throws {@link OptimisticLockException} if error thrown is matching expected error code - * that is used to represent ETAG not matching when updating Json View. - * - * @param sqlException the SQL exception - * @return custom exception based on {@link SQLException} that was thrown or that same - * exception if nothing specific was about it - */ - static Throwable handleSqlException(SQLException sqlException) { - if (sqlException.getErrorCode() == JSON_VIEW_ETAG_NOT_MATCHING_ERROR) { - return new OptimisticLockException("ETAG did not match when updating record: " + sqlException.getMessage(), sqlException); - } - return sqlException; - } - } - /** * Used to cache queries for entities. */ diff --git a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy index c25027aaa3..bfbb14e1a1 100644 --- a/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy +++ b/data-tck/src/main/groovy/io/micronaut/data/tck/tests/AbstractRepositorySpec.groovy @@ -2735,6 +2735,9 @@ abstract class AbstractRepositorySpec extends Specification { then: !result.isPresent() + + cleanup: + entityWithIdClassRepository.deleteAll() } void "entity with id class 2"() { @@ -2794,6 +2797,9 @@ abstract class AbstractRepositorySpec extends Specification { then: !result.isPresent() + + cleanup: + entityWithIdClass2Repository.deleteAll() } private GregorianCalendar getYearMonthDay(Date dateCreated) {