diff --git a/pom.xml b/pom.xml index 3699853ee9..c6c6ed91ee 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index 824e70541b..8607276654 100644 --- a/spring-data-jdbc-distribution/pom.xml +++ b/spring-data-jdbc-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index 87567d6220..2be141837e 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT 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 6cd8fa2de1..5b1df2ace8 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 @@ -354,7 +354,9 @@ private RelationalPersistentEntity getRequiredPersistentEntity(Class t } private void updateWithoutVersion(DbAction.UpdateRoot update) { - accessStrategy.update(update.entity(), update.getEntityType()); + + boolean updated = accessStrategy.update(update.entity(), update.getEntityType()); + accessStrategy.getDialect().getUpdateRowCountVerification().rowsModified(updated); } private void updateWithVersion(DbAction.UpdateRoot update) { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java index dd1e016c31..b93966eb0f 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java @@ -39,6 +39,7 @@ import org.springframework.data.relational.core.dialect.LimitClause; import org.springframework.data.relational.core.dialect.LockClause; import org.springframework.data.relational.core.dialect.OrderByNullPrecedence; +import org.springframework.data.relational.core.dialect.UpdateRowCountVerification; import org.springframework.data.relational.core.sql.IdentifierProcessing; import org.springframework.data.relational.core.sql.SimpleFunction; import org.springframework.data.relational.core.sql.render.SelectRenderContext; @@ -268,6 +269,11 @@ public SimpleFunction getExistsFunction() { public boolean supportsSingleQueryLoading() { return delegate.supportsSingleQueryLoading(); } + + @Override + public UpdateRowCountVerification getUpdateRowCountVerification() { + return delegate.getUpdateRowCountVerification(); + } } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java index 4b3bbad21a..5c39734227 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java @@ -35,6 +35,7 @@ import org.springframework.data.mapping.PersistentPropertyPaths; import org.springframework.data.relational.core.conversion.DbAction; import org.springframework.data.relational.core.conversion.IdValueSource; +import org.springframework.data.relational.core.dialect.AnsiDialect; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; @@ -120,12 +121,12 @@ public void idGenerationOfChildInList() { assertThat(newRoot.list.get(0).id).isEqualTo(24L); } - @Test - // GH-537 + @Test // GH-537 void populatesIdsIfNecessaryForAllRootsThatWereProcessed() { DummyEntity root1 = new DummyEntity().withId(123L); when(accessStrategy.update(root1, DummyEntity.class)).thenReturn(true); + when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE); DbAction.UpdateRoot rootUpdate1 = new DbAction.UpdateRoot<>(root1, null); executionContext.executeUpdateRoot(rootUpdate1); Content content1 = new Content(); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java index a7fac10cb3..713f59b3dd 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java @@ -36,7 +36,11 @@ import org.springframework.data.mapping.PersistentPropertyPaths; import org.springframework.data.relational.core.conversion.DbAction; import org.springframework.data.relational.core.conversion.IdValueSource; +import org.springframework.data.relational.core.dialect.AnsiDialect; import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; +import org.springframework.data.relational.core.dialect.Dialect; +import org.springframework.data.relational.core.dialect.UpdateRowCountVerification; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.sql.SqlIdentifier; @@ -196,6 +200,7 @@ void updates_whenReferencesWithImmutableIdAreInserted() { root.id = 123L; when(accessStrategy.update(root, DummyEntity.class)).thenReturn(true); + when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE); DbAction.UpdateRoot rootUpdate = new DbAction.UpdateRoot<>(root, null); executionContext.executeUpdateRoot(rootUpdate); @@ -219,6 +224,7 @@ void populatesIdsIfNecessaryForAllRootsThatWereProcessed() { DummyEntity root1 = new DummyEntity(); root1.id = 123L; when(accessStrategy.update(root1, DummyEntity.class)).thenReturn(true); + when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE); DbAction.UpdateRoot rootUpdate1 = new DbAction.UpdateRoot<>(root1, null); executionContext.executeUpdateRoot(rootUpdate1); Content content1 = new Content(); @@ -242,6 +248,38 @@ void populatesIdsIfNecessaryForAllRootsThatWereProcessed() { assertThat(content2.id).isEqualTo(12L); } + @Test // GH-2209 + void updateWithoutVersionThrowsWhenZeroRowsUpdatedAndDialectIsStrict() { + + root.id = 123L; + when(accessStrategy.update(root, DummyEntity.class)).thenReturn(false); + Dialect dialect = mock(Dialect.class); + when(dialect.getUpdateRowCountVerification()).thenReturn(UpdateRowCountVerification.STRICT); + when(accessStrategy.getDialect()).thenReturn(dialect); + + DbAction.UpdateRoot rootUpdate = new DbAction.UpdateRoot<>(root, null); + + assertThatThrownBy(() -> executionContext.executeUpdateRoot(rootUpdate)) // + .isInstanceOf(IncorrectUpdateSemanticsDataAccessException.class) // + .hasMessageContaining("No rows were updated"); + } + + @Test // GH-2209 + void updateWithoutVersionSucceedsWhenZeroRowsUpdatedAndDialectIsLenient() { + + root.id = 123L; + when(accessStrategy.update(root, DummyEntity.class)).thenReturn(false); + Dialect dialect = mock(Dialect.class); + when(dialect.getUpdateRowCountVerification()).thenReturn(UpdateRowCountVerification.LENIENT); + when(accessStrategy.getDialect()).thenReturn(dialect); + + DbAction.UpdateRoot rootUpdate = new DbAction.UpdateRoot<>(root, null); + executionContext.executeUpdateRoot(rootUpdate); + + List newRoots = executionContext.populateIdsIfNecessary(); + assertThat(newRoots).containsExactly(root); + } + DbAction.Insert createInsert(DbAction.WithEntity parent, String propertyName, Object value, @Nullable Object key, IdValueSource idValueSource) { diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index b8ac584eee..d1c10a7dba 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT 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 b447bb3ff4..62c414bd1f 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 @@ -689,6 +689,11 @@ private Mono doUpdate(T entity, @Nullable Object version, SqlIdentifier t if (persistentEntity.hasVersionProperty()) { sink.error(OptimisticLockingUtils.updateFailed(entity, version, persistentEntity)); } + try { + dataAccessStrategy.getDialect().getUpdateRowCountVerification().rowsModified(rowsUpdated); + } catch (DataAccessException ex) { + sink.error(ex); + } }).then(maybeCallAfterSave(entity, outboundRow, tableName)); } diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 652ad2853d..b91c6a76bd 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 4.1.0-SNAPSHOT + 4.1.x-GH-2209-SNAPSHOT diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index ca3b5264cf..3b9a4e55af 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -147,4 +147,16 @@ default SimpleFunction getExistsFunction() { default boolean supportsSingleQueryLoading() { return true; } + + /** + * How to verify the result of an UPDATE (e.g. whether zero rows updated is considered an error). Database and + * driver behavior differs (affected vs matched rows). Override in dialect implementations to reflect + * database-specific semantics. + * + * @return the update row count verification for this dialect. Default is {@link UpdateRowCountVerification#LENIENT}. + * @since 4.1 + */ + default UpdateRowCountVerification getUpdateRowCountVerification() { + return UpdateRowCountVerification.LENIENT; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerification.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerification.java new file mode 100644 index 0000000000..4cf7ea7dc1 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerification.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026-present 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.dialect; + +import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; + +/** + * Defines whether the result of a save/update is considered an error. + *

+ * Database and driver behavior differs: some report affected rows (e.g. MySQL/InnoDB can report 0 for a no-op + * update), others report matched rows. Use {@link #LENIENT} when the database may legitimately report 0 rows + * for a successful no-op update; use {@link #STRICT} when you want to detect missing rows or failed updates. + * + * @since 4.1 + */ +@FunctionalInterface +public interface UpdateRowCountVerification { + + /** + * Do not throw when an UPDATE affects 0 rows. Use when the database or driver may report 0 for a no-op update (e.g. + * MySQL/InnoDB with affected-rows semantics, Vitess). + */ + UpdateRowCountVerification LENIENT = (rowsModified) -> {}; + + /** + * Throw {@link org.springframework.dao.IncorrectUpdateSemanticsDataAccessException} when an UPDATE affects 0 rows. + * Use to detect missing rows, RLS-blocked updates, or stale identifiers. + */ + UpdateRowCountVerification STRICT = (rowsModified) -> { + throw new IncorrectUpdateSemanticsDataAccessException("No rows were updated"); + }; + + /** + * @param rowsModified flag to indicate whether the update affected any rows. + * @throws IncorrectUpdateSemanticsDataAccessException in case the update did not affect any rows and this is + * considered a failed operation. + */ + void rowsModified(boolean rowsModified); + + /** + * @param nrRows number of rows affected by the update. + * @throws IncorrectUpdateSemanticsDataAccessException in case the update did not affect any rows and this is + * considered a failed operation. + */ + default void rowsModified(long nrRows) { + rowsModified(nrRows > 0); + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerificationUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerificationUnitTests.java new file mode 100644 index 0000000000..b456e946a5 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/dialect/UpdateRowCountVerificationUnitTests.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026-present 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.dialect; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link UpdateRowCountVerification} and {@link Dialect#getUpdateRowCountVerification()}. + * + * @since 4.1 + */ +class UpdateRowCountVerificationUnitTests { + + @Test + void dialectDefaultIsLenient() { + assertThat(AnsiDialect.INSTANCE.getUpdateRowCountVerification()).isEqualTo(UpdateRowCountVerification.LENIENT); + } +}