From 2c580ce02f40d912ef4f1a4d3faa70c0b078c8b4 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 25 Nov 2025 14:13:28 +0100 Subject: [PATCH] Polishing. No longer throw TransientDataAccessResourceException if R2DBC update does not yield any updated rows. Remove mentions of IncorrectUpdateSemanticsDataAccessException, add mention of OptimisticLockingFailureException to affected methods. Consistent OptimisticLockingFailureException exception message. See #2176 Original pull request: #2185 --- .../JdbcAggregateChangeExecutionContext.java | 22 +++-- .../jdbc/core/JdbcAggregateOperations.java | 17 ++-- .../convert/DefaultDataAccessStrategy.java | 13 ++- ...JdbcAggregateTemplateIntegrationTests.java | 2 +- ...RepositoryConcurrencyIntegrationTests.java | 11 --- .../r2dbc/core/R2dbcEntityOperations.java | 5 ++ .../data/r2dbc/core/R2dbcEntityTemplate.java | 58 +++++++------ ...SimpleR2dbcRepositoryIntegrationTests.java | 15 ++-- .../core/mapping/OptimisticLockingUtils.java | 87 +++++++++++++++++++ 9 files changed, 161 insertions(+), 69 deletions(-) create mode 100644 spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/OptimisticLockingUtils.java diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java index c4a6e022d..cfd8b653f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java @@ -15,14 +15,22 @@ */ package org.springframework.data.jdbc.core; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; -import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; -import org.springframework.dao.OptimisticLockingFailureException; + import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.core.convert.Identifier; import org.springframework.data.jdbc.core.convert.InsertSubject; @@ -35,6 +43,7 @@ import org.springframework.data.relational.core.conversion.DbAction; import org.springframework.data.relational.core.conversion.DbActionExecutionResult; import org.springframework.data.relational.core.conversion.IdValueSource; import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.data.relational.core.mapping.OptimisticLockingUtils; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -55,9 +64,6 @@ import org.springframework.util.Assert; @SuppressWarnings("rawtypes") class JdbcAggregateChangeExecutionContext { - private static final String UPDATE_FAILED = "Failed to update entity [%s]; Id [%s] not found in database"; - private static final String UPDATE_FAILED_OPTIMISTIC_LOCKING = "Failed to update entity [%s]; The entity was updated since it was read or it isn't in the database at all"; - private final RelationalMappingContext context; private final JdbcConverter converter; private final DataAccessStrategy accessStrategy; @@ -357,8 +363,8 @@ class JdbcAggregateChangeExecutionContext { Assert.notNull(previousVersion, "The root aggregate cannot be updated because the version property is null"); if (!accessStrategy.updateWithVersion(update.entity(), update.getEntityType(), previousVersion)) { - - throw new OptimisticLockingFailureException(String.format(UPDATE_FAILED_OPTIMISTIC_LOCKING, update.entity())); + throw OptimisticLockingUtils.updateFailed(update.entity(), previousVersion, + getRequiredPersistentEntity(update.getEntityType())); } } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java index be1a37f70..e524e3cc6 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateOperations.java @@ -20,7 +20,6 @@ import java.util.Optional; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; -import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; import org.springframework.data.domain.Example; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -50,8 +49,8 @@ public interface JdbcAggregateOperations { * @param instance the aggregate root of the aggregate to be saved. Must not be {@code null}. * @param the type of the aggregate root. * @return the saved instance. - * @throws IncorrectUpdateSemanticsDataAccessException when the instance is determined to be not new and the resulting - * update does not update any rows. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ T save(T instance); @@ -61,8 +60,8 @@ public interface JdbcAggregateOperations { * @param instances the aggregate roots to be saved. Must not be {@code null}. * @param the type of the aggregate root. * @return the saved instances. - * @throws IncorrectUpdateSemanticsDataAccessException when at least one instance is determined to be not new and the - * resulting update does not update any rows. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. * @since 3.0 */ List saveAll(Iterable instances); @@ -99,6 +98,8 @@ public interface JdbcAggregateOperations { * @param instance the aggregate root of the aggregate to be inserted. Must not be {@code null}. * @param the type of the aggregate root. * @return the saved instance. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ T update(T instance); @@ -108,6 +109,8 @@ public interface JdbcAggregateOperations { * @param instances the aggregate roots to be inserted. Must not be {@code null}. * @param the type of the aggregate root. * @return the saved instances. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. * @since 3.1 */ List updateAll(Iterable instances); @@ -319,6 +322,8 @@ public interface JdbcAggregateOperations { * * @param aggregateRoot to delete. Must not be {@code null}. * @param the type of the aggregate root. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ void delete(T aggregateRoot); @@ -334,6 +339,8 @@ public interface JdbcAggregateOperations { * * @param aggregateRoots to delete. Must not be {@code null}. * @param the type of the aggregate roots. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ void deleteAll(Iterable aggregateRoots); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java index e8cdee7da..557fdc43e 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java @@ -25,8 +25,8 @@ import java.util.Optional; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; + import org.springframework.dao.EmptyResultDataAccessException; -import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.mapping.PersistentPropertyPath; @@ -34,6 +34,7 @@ import org.springframework.data.relational.core.conversion.IdValueSource; import org.springframework.data.relational.core.dialect.Dialect; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.AggregatePath.TableInfo; +import org.springframework.data.relational.core.mapping.OptimisticLockingUtils; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -164,8 +165,6 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy { @Override public boolean updateWithVersion(S instance, Class domainType, Number previousVersion) { - RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); - // Adjust update statement to set the new version and use the old version in where clause. SqlIdentifierParameterSource parameterSource = sqlParametersFactory.forUpdate(instance, domainType); parameterSource.addValue(VERSION_SQL_PARAMETER, previousVersion); @@ -173,9 +172,8 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy { int affectedRows = operations.update(sql(domainType).getUpdateWithVersion(), parameterSource); if (affectedRows == 0) { - - throw new OptimisticLockingFailureException( - String.format("Optimistic lock exception on saving entity of type %s", persistentEntity.getName())); + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); + throw OptimisticLockingUtils.updateFailed(instance, previousVersion, persistentEntity); } return true; @@ -211,8 +209,7 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy { int affectedRows = operations.update(sql(domainType).getDeleteByIdAndVersion(), parameterSource); if (affectedRows == 0) { - throw new OptimisticLockingFailureException( - String.format("Optimistic lock exception deleting entity of type %s", persistentEntity.getName())); + throw OptimisticLockingUtils.deleteFailed(id, previousVersion, persistentEntity); } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java index a7234ba9f..616c14192 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java @@ -24,6 +24,7 @@ import static org.springframework.data.jdbc.testing.TestDatabaseFeatures.Feature import java.time.LocalDateTime; import java.util.*; +import java.util.ArrayList; import java.util.function.Function; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -36,7 +37,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.PersistenceCreator; diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryConcurrencyIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryConcurrencyIntegrationTests.java index a1ed2ab24..2692ac7ce 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryConcurrencyIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryConcurrencyIntegrationTests.java @@ -37,7 +37,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; import org.springframework.data.annotation.Id; import org.springframework.data.jdbc.repository.support.JdbcRepositoryFactory; import org.springframework.data.jdbc.testing.TestClass; @@ -155,11 +154,6 @@ public class JdbcRepositoryConcurrencyIntegrationTests { try { return repository.save(e); } catch (Exception ex) { - // When the delete execution is complete, the Update execution throws an - // IncorrectUpdateSemanticsDataAccessException. - if (ex instanceof IncorrectUpdateSemanticsDataAccessException) { - return null; - } throw ex; } }; @@ -189,11 +183,6 @@ public class JdbcRepositoryConcurrencyIntegrationTests { try { return repository.save(e); } catch (Exception ex) { - // When the delete execution is complete, the Update execution throws an - // IncorrectUpdateSemanticsDataAccessException. - if (ex instanceof IncorrectUpdateSemanticsDataAccessException) { - return null; - } throw ex; } }; diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java index bbce186a3..15fc7ab8b 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityOperations.java @@ -273,6 +273,8 @@ public interface R2dbcEntityOperations extends FluentR2dbcOperations { * @return the updated entity. * @throws DataAccessException if there is any problem issuing the execution. * @throws TransientDataAccessResourceException if the update did not affect any rows. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ Mono update(T entity) throws DataAccessException; @@ -282,6 +284,9 @@ public interface R2dbcEntityOperations extends FluentR2dbcOperations { * @param entity must not be {@literal null}. * @return the deleted entity. * @throws DataAccessException if there is any problem issuing the execution. + * @throws org.springframework.dao.OptimisticLockingFailureException in case of version mismatch in case a + * {@link org.springframework.data.annotation.Version} is defined. */ Mono delete(T entity) throws DataAccessException; + } diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java index 6f65043b0..efa40a66c 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java @@ -35,6 +35,7 @@ import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactoryAware; @@ -42,8 +43,6 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.convert.ConversionService; import org.springframework.dao.DataAccessException; -import org.springframework.dao.OptimisticLockingFailureException; -import org.springframework.dao.TransientDataAccessResourceException; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentPropertyAccessor; @@ -60,6 +59,7 @@ import org.springframework.data.r2dbc.mapping.event.AfterSaveCallback; import org.springframework.data.r2dbc.mapping.event.BeforeConvertCallback; import org.springframework.data.r2dbc.mapping.event.BeforeSaveCallback; import org.springframework.data.relational.core.conversion.AbstractRelationalConverter; +import org.springframework.data.relational.core.mapping.OptimisticLockingUtils; import org.springframework.data.relational.core.mapping.PersistentPropertyTranslator; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -607,15 +607,22 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw return maybeCallBeforeConvert(entity, tableName).flatMap(onBeforeConvert -> { T entityToUse; + Object version; Criteria matchingVersionCriteria; if (persistentEntity.hasVersionProperty()) { + PersistentPropertyAccessor propertyAccessor = persistentEntity.getPropertyAccessor(entity); + RelationalPersistentProperty versionProperty = persistentEntity.getRequiredVersionProperty(); + + version = propertyAccessor.getProperty(versionProperty); matchingVersionCriteria = createMatchingVersionCriteria(onBeforeConvert, persistentEntity); + entityToUse = incrementVersion(persistentEntity, onBeforeConvert); } else { entityToUse = onBeforeConvert; + version = null; matchingVersionCriteria = null; } @@ -653,17 +660,17 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw criteria = criteria.and(matchingVersionCriteria); } - return doUpdate(onBeforeSave, tableName, persistentEntity, criteria, outboundRow); + return doUpdate(onBeforeSave, version, tableName, persistentEntity, criteria, outboundRow); }); }); } @SuppressWarnings({ "unchecked", "rawtypes" }) - private Mono doUpdate(T entity, SqlIdentifier tableName, RelationalPersistentEntity persistentEntity, + private Mono doUpdate(T entity, @Nullable Object version, SqlIdentifier tableName, + RelationalPersistentEntity persistentEntity, Criteria criteria, OutboundRow outboundRow) { Update update = Update.from((Map) outboundRow); - StatementMapper mapper = dataAccessStrategy.getStatementMapper(); StatementMapper.UpdateSpec updateSpec = mapper.createUpdate(tableName, update).withCriteria(criteria); @@ -680,27 +687,11 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw } if (persistentEntity.hasVersionProperty()) { - sink.error(new OptimisticLockingFailureException( - formatOptimisticLockingExceptionMessage(entity, persistentEntity))); - } else { - sink.error(new TransientDataAccessResourceException( - formatTransientEntityExceptionMessage(entity, persistentEntity))); + sink.error(OptimisticLockingUtils.updateFailed(entity, version, persistentEntity)); } }).then(maybeCallAfterSave(entity, outboundRow, tableName)); } - private String formatOptimisticLockingExceptionMessage(T entity, RelationalPersistentEntity persistentEntity) { - - return String.format("Failed to update table [%s]; Version does not match for row with Id [%s]", - persistentEntity.getQualifiedTableName(), persistentEntity.getIdentifierAccessor(entity).getIdentifier()); - } - - private String formatTransientEntityExceptionMessage(T entity, RelationalPersistentEntity persistentEntity) { - - return String.format("Failed to update table [%s]; Row with Id [%s] does not exist", - persistentEntity.getQualifiedTableName(), persistentEntity.getIdentifierAccessor(entity).getIdentifier()); - } - @SuppressWarnings("unchecked") private T incrementVersion(RelationalPersistentEntity persistentEntity, T entity) { @@ -728,9 +719,7 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw private Criteria createMatchingVersionCriteria(T entity, RelationalPersistentEntity persistentEntity) { PersistentPropertyAccessor propertyAccessor = persistentEntity.getPropertyAccessor(entity); - RelationalPersistentProperty versionProperty = persistentEntity.getVersionProperty(); - - Assert.state(versionProperty != null, "Version property must not be null"); + RelationalPersistentProperty versionProperty = persistentEntity.getRequiredVersionProperty(); Object version = propertyAccessor.getProperty(versionProperty); Criteria.CriteriaStep versionColumn = Criteria.where(dataAccessStrategy.toSql(versionProperty.getColumnName())); @@ -748,7 +737,13 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw RelationalPersistentEntity persistentEntity = getRequiredEntity(entity); - return delete(getByIdQuery(entity, persistentEntity), persistentEntity.getType()).thenReturn(entity); + Mono delete = delete(getByIdQuery(entity, persistentEntity), persistentEntity.getType()); + if (persistentEntity.hasVersionProperty()) { + delete = delete.flatMap( + it -> it == 0 ? Mono.error(OptimisticLockingUtils.deleteFailed(entity, persistentEntity)) : Mono.just(it)); + } + + return delete.thenReturn(entity); } protected Mono maybeCallBeforeConvert(T object, SqlIdentifier table) { @@ -795,8 +790,17 @@ public class R2dbcEntityTemplate implements R2dbcEntityOperations, BeanFactoryAw IdentifierAccessor identifierAccessor = persistentEntity.getIdentifierAccessor(entity); Object id = identifierAccessor.getRequiredIdentifier(); + Criteria criteria = Criteria.where(persistentEntity.getRequiredIdProperty().getName()).is(id); + + if (persistentEntity.hasVersionProperty()) { + + RelationalPersistentProperty versionProperty = persistentEntity.getRequiredVersionProperty(); + Object version = persistentEntity.getPropertyAccessor(entity).getProperty(versionProperty); + Criteria.CriteriaStep versionColumn = Criteria.where(versionProperty.getName()); + criteria = version == null ? criteria.and(versionColumn.isNull()) : criteria.and(versionColumn.is(version)); + } - return Query.query(Criteria.where(persistentEntity.getRequiredIdProperty().getName()).is(id)); + return Query.query(criteria); } SqlIdentifier getTableName(Class entityClass) { diff --git a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/H2SimpleR2dbcRepositoryIntegrationTests.java b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/H2SimpleR2dbcRepositoryIntegrationTests.java index bafb3ddf1..da46f9e3b 100644 --- a/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/H2SimpleR2dbcRepositoryIntegrationTests.java +++ b/spring-data-r2dbc/src/test/java/org/springframework/data/r2dbc/repository/support/H2SimpleR2dbcRepositoryIntegrationTests.java @@ -27,10 +27,10 @@ import javax.sql.DataSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.dao.DataAccessException; -import org.springframework.dao.TransientDataAccessException; import org.springframework.data.annotation.Id; import org.springframework.data.domain.Persistable; import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration; @@ -92,7 +92,7 @@ public class H2SimpleR2dbcRepositoryIntegrationTests extends AbstractSimpleR2dbc return H2TestSupport.CREATE_TABLE_LEGOSET_WITH_ID_GENERATION; } - @Test // gh-90 + @Test // GH-90 void shouldInsertNewObjectWithGivenId() { try { @@ -122,18 +122,15 @@ public class H2SimpleR2dbcRepositoryIntegrationTests extends AbstractSimpleR2dbc assertThat(map).containsEntry("name", "SCHAUFELRADBAGGER").containsKey("id"); } - @Test // gh-232 - void updateShouldFailIfRowDoesNotExist() { + @Test // GH-232, GH-2176 + void updateShouldNotFailIfRowDoesNotExist() { LegoSet legoSet = new LegoSet(9999, "SCHAUFELRADBAGGER", 12); repository.save(legoSet) // .as(StepVerifier::create) // - .verifyErrorSatisfies(actual -> { - - assertThat(actual).isInstanceOf(TransientDataAccessException.class) - .hasMessage("Failed to update table [legoset]; Row with Id [9999] does not exist"); - }); + .expectNextCount(1) // + .verifyComplete(); } static class AlwaysNew implements Persistable { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/OptimisticLockingUtils.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/OptimisticLockingUtils.java new file mode 100644 index 000000000..6865c957d --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/OptimisticLockingUtils.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 the original author or 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 org.springframework.data.relational.core.mapping; + +import org.jspecify.annotations.Nullable; + +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.data.mapping.IdentifierAccessor; +import org.springframework.data.mapping.PersistentProperty; + +/** + * Utility methods to create {@link OptimisticLockingFailureException}s. + *

+ * Strictly for internal use within the framework. + * + * @author Mark Paluch + * @since 4.0.1 + */ +public class OptimisticLockingUtils { + + /** + * Create an {@link OptimisticLockingFailureException} for an update failure. + * + * @param entity the object. + * @param version the object version. + * @param persistentEntity the {@link RelationalPersistentEntity} metadata. + * @return the exception. + */ + public static OptimisticLockingFailureException updateFailed(Object entity, @Nullable Object version, + RelationalPersistentEntity persistentEntity) { + + IdentifierAccessor identifierAccessor = persistentEntity.getIdentifierAccessor(entity); + Object id = identifierAccessor.getRequiredIdentifier(); + + return new OptimisticLockingFailureException(String.format( + "Failed to update versioned entity with id '%s' (version '%s') in table [%s]; Was the entity updated or deleted concurrently?", + id, version, persistentEntity.getTableName())); + } + + /** + * Create an {@link OptimisticLockingFailureException} for a delete failure. + * + * @param entity actual entity to be deleted. + * @param persistentEntity the {@link RelationalPersistentEntity} metadata. + * @return the exception. + */ + public static OptimisticLockingFailureException deleteFailed(Object entity, + RelationalPersistentEntity persistentEntity) { + + IdentifierAccessor identifierAccessor = persistentEntity.getIdentifierAccessor(entity); + Object id = identifierAccessor.getRequiredIdentifier(); + PersistentProperty versionProperty = persistentEntity.getRequiredVersionProperty(); + Object version = persistentEntity.getPropertyAccessor(entity).getProperty(versionProperty); + + return deleteFailed(id, version, persistentEntity); + } + + /** + * Create an {@link OptimisticLockingFailureException} for a delete failure. + * + * @param id the object identifier. + * @param version the object version. + * @param persistentEntity the {@link RelationalPersistentEntity} metadata. + * @return the exception. + */ + public static OptimisticLockingFailureException deleteFailed(@Nullable Object id, @Nullable Object version, + RelationalPersistentEntity persistentEntity) { + + return new OptimisticLockingFailureException(String.format( + "Failed to delete versioned entity with id '%s' (version '%s') in table [%s]; Was the entity updated or deleted concurrently?", + id, version, persistentEntity.getTableName())); + } + +}