Skip to content

Commit 56086d8

Browse files
Add per Dialect configurable update row count semantics.
1 parent 1f6737d commit 56086d8

File tree

8 files changed

+162
-3
lines changed

8 files changed

+162
-3
lines changed

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutionContext.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,9 @@ private <T> RelationalPersistentEntity<T> getRequiredPersistentEntity(Class<T> t
354354
}
355355

356356
private <T> void updateWithoutVersion(DbAction.UpdateRoot<T> update) {
357-
accessStrategy.update(update.entity(), update.getEntityType());
357+
358+
boolean updated = accessStrategy.update(update.entity(), update.getEntityType());
359+
accessStrategy.getDialect().getUpdateRowCountVerification().rowsModified(updated);
358360
}
359361

360362
private <T> void updateWithVersion(DbAction.UpdateRoot<T> update) {

spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/dialect/DialectResolver.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.springframework.data.relational.core.dialect.LimitClause;
4040
import org.springframework.data.relational.core.dialect.LockClause;
4141
import org.springframework.data.relational.core.dialect.OrderByNullPrecedence;
42+
import org.springframework.data.relational.core.dialect.UpdateRowCountVerification;
4243
import org.springframework.data.relational.core.sql.IdentifierProcessing;
4344
import org.springframework.data.relational.core.sql.SimpleFunction;
4445
import org.springframework.data.relational.core.sql.render.SelectRenderContext;
@@ -268,6 +269,11 @@ public SimpleFunction getExistsFunction() {
268269
public boolean supportsSingleQueryLoading() {
269270
return delegate.supportsSingleQueryLoading();
270271
}
272+
273+
@Override
274+
public UpdateRowCountVerification getUpdateRowCountVerification() {
275+
return delegate.getUpdateRowCountVerification();
276+
}
271277
}
272278

273279
}

spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextImmutableUnitTests.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.springframework.data.mapping.PersistentPropertyPaths;
3636
import org.springframework.data.relational.core.conversion.DbAction;
3737
import org.springframework.data.relational.core.conversion.IdValueSource;
38+
import org.springframework.data.relational.core.dialect.AnsiDialect;
3839
import org.springframework.data.relational.core.mapping.AggregatePath;
3940
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
4041
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
@@ -120,12 +121,12 @@ public void idGenerationOfChildInList() {
120121
assertThat(newRoot.list.get(0).id).isEqualTo(24L);
121122
}
122123

123-
@Test
124-
// GH-537
124+
@Test // GH-537
125125
void populatesIdsIfNecessaryForAllRootsThatWereProcessed() {
126126

127127
DummyEntity root1 = new DummyEntity().withId(123L);
128128
when(accessStrategy.update(root1, DummyEntity.class)).thenReturn(true);
129+
when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE);
129130
DbAction.UpdateRoot<DummyEntity> rootUpdate1 = new DbAction.UpdateRoot<>(root1, null);
130131
executionContext.executeUpdateRoot(rootUpdate1);
131132
Content content1 = new Content();

spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateChangeExecutorContextUnitTests.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
import org.springframework.data.mapping.PersistentPropertyPaths;
3737
import org.springframework.data.relational.core.conversion.DbAction;
3838
import org.springframework.data.relational.core.conversion.IdValueSource;
39+
import org.springframework.data.relational.core.dialect.AnsiDialect;
3940
import org.springframework.data.relational.core.mapping.AggregatePath;
41+
import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException;
42+
import org.springframework.data.relational.core.dialect.Dialect;
43+
import org.springframework.data.relational.core.dialect.UpdateRowCountVerification;
4044
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
4145
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
4246
import org.springframework.data.relational.core.sql.SqlIdentifier;
@@ -196,6 +200,7 @@ void updates_whenReferencesWithImmutableIdAreInserted() {
196200

197201
root.id = 123L;
198202
when(accessStrategy.update(root, DummyEntity.class)).thenReturn(true);
203+
when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE);
199204
DbAction.UpdateRoot<DummyEntity> rootUpdate = new DbAction.UpdateRoot<>(root, null);
200205
executionContext.executeUpdateRoot(rootUpdate);
201206

@@ -219,6 +224,7 @@ void populatesIdsIfNecessaryForAllRootsThatWereProcessed() {
219224
DummyEntity root1 = new DummyEntity();
220225
root1.id = 123L;
221226
when(accessStrategy.update(root1, DummyEntity.class)).thenReturn(true);
227+
when(accessStrategy.getDialect()).thenReturn(AnsiDialect.INSTANCE);
222228
DbAction.UpdateRoot<DummyEntity> rootUpdate1 = new DbAction.UpdateRoot<>(root1, null);
223229
executionContext.executeUpdateRoot(rootUpdate1);
224230
Content content1 = new Content();
@@ -242,6 +248,38 @@ void populatesIdsIfNecessaryForAllRootsThatWereProcessed() {
242248
assertThat(content2.id).isEqualTo(12L);
243249
}
244250

251+
@Test // GH-2209
252+
void updateWithoutVersionThrowsWhenZeroRowsUpdatedAndDialectIsStrict() {
253+
254+
root.id = 123L;
255+
when(accessStrategy.update(root, DummyEntity.class)).thenReturn(false);
256+
Dialect dialect = mock(Dialect.class);
257+
when(dialect.getUpdateRowCountVerification()).thenReturn(UpdateRowCountVerification.STRICT);
258+
when(accessStrategy.getDialect()).thenReturn(dialect);
259+
260+
DbAction.UpdateRoot<DummyEntity> rootUpdate = new DbAction.UpdateRoot<>(root, null);
261+
262+
assertThatThrownBy(() -> executionContext.executeUpdateRoot(rootUpdate)) //
263+
.isInstanceOf(IncorrectUpdateSemanticsDataAccessException.class) //
264+
.hasMessageContaining("No rows were updated");
265+
}
266+
267+
@Test // GH-2209
268+
void updateWithoutVersionSucceedsWhenZeroRowsUpdatedAndDialectIsLenient() {
269+
270+
root.id = 123L;
271+
when(accessStrategy.update(root, DummyEntity.class)).thenReturn(false);
272+
Dialect dialect = mock(Dialect.class);
273+
when(dialect.getUpdateRowCountVerification()).thenReturn(UpdateRowCountVerification.LENIENT);
274+
when(accessStrategy.getDialect()).thenReturn(dialect);
275+
276+
DbAction.UpdateRoot<DummyEntity> rootUpdate = new DbAction.UpdateRoot<>(root, null);
277+
executionContext.executeUpdateRoot(rootUpdate);
278+
279+
List<DummyEntity> newRoots = executionContext.populateIdsIfNecessary();
280+
assertThat(newRoots).containsExactly(root);
281+
}
282+
245283
DbAction.Insert<?> createInsert(DbAction.WithEntity<?> parent, String propertyName, Object value,
246284
@Nullable Object key, IdValueSource idValueSource) {
247285

spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/core/R2dbcEntityTemplate.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,11 @@ private <T> Mono<T> doUpdate(T entity, @Nullable Object version, SqlIdentifier t
689689
if (persistentEntity.hasVersionProperty()) {
690690
sink.error(OptimisticLockingUtils.updateFailed(entity, version, persistentEntity));
691691
}
692+
try {
693+
dataAccessStrategy.getDialect().getUpdateRowCountVerification().rowsModified(rowsUpdated);
694+
} catch (DataAccessException ex) {
695+
sink.error(ex);
696+
}
692697
}).then(maybeCallAfterSave(entity, outboundRow, tableName));
693698
}
694699

spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,16 @@ default SimpleFunction getExistsFunction() {
147147
default boolean supportsSingleQueryLoading() {
148148
return true;
149149
}
150+
151+
/**
152+
* How to verify the result of an UPDATE (e.g. whether zero rows updated is considered an error). Database and
153+
* driver behavior differs (affected vs matched rows). Override in dialect implementations to reflect
154+
* database-specific semantics.
155+
*
156+
* @return the update row count verification for this dialect. Default is {@link UpdateRowCountVerification#LENIENT}.
157+
* @since 4.1
158+
*/
159+
default UpdateRowCountVerification getUpdateRowCountVerification() {
160+
return UpdateRowCountVerification.LENIENT;
161+
}
150162
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2026-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.relational.core.dialect;
17+
18+
import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException;
19+
20+
/**
21+
* Defines whether the result of a save/update is considered an error.
22+
* <p>
23+
* Database and driver behavior differs: some report <em>affected</em> rows (e.g. MySQL/InnoDB can report 0 for a no-op
24+
* update), others report <em>matched</em> rows. Use {@link #LENIENT} when the database may legitimately report 0 rows
25+
* for a successful no-op update; use {@link #STRICT} when you want to detect missing rows or failed updates.
26+
*
27+
* @since 4.1
28+
*/
29+
@FunctionalInterface
30+
public interface UpdateRowCountVerification {
31+
32+
/**
33+
* 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.
34+
* MySQL/InnoDB with affected-rows semantics, Vitess).
35+
*/
36+
UpdateRowCountVerification LENIENT = (rowsModified) -> {};
37+
38+
/**
39+
* Throw {@link org.springframework.dao.IncorrectUpdateSemanticsDataAccessException} when an UPDATE affects 0 rows.
40+
* Use to detect missing rows, RLS-blocked updates, or stale identifiers.
41+
*/
42+
UpdateRowCountVerification STRICT = (rowsModified) -> {
43+
throw new IncorrectUpdateSemanticsDataAccessException("No rows were updated");
44+
};
45+
46+
/**
47+
* @param rowsModified flag to indicate whether the update affected any rows.
48+
* @throws IncorrectUpdateSemanticsDataAccessException in case the update did not affect any rows and this is
49+
* considered a failed operation.
50+
*/
51+
void rowsModified(boolean rowsModified);
52+
53+
/**
54+
* @param nrRows number of rows affected by the update.
55+
* @throws IncorrectUpdateSemanticsDataAccessException in case the update did not affect any rows and this is
56+
* considered a failed operation.
57+
*/
58+
default void rowsModified(long nrRows) {
59+
rowsModified(nrRows > 0);
60+
}
61+
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2026-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.relational.core.dialect;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import org.junit.jupiter.api.Test;
21+
22+
/**
23+
* Unit tests for {@link UpdateRowCountVerification} and {@link Dialect#getUpdateRowCountVerification()}.
24+
*
25+
* @since 4.1
26+
*/
27+
class UpdateRowCountVerificationUnitTests {
28+
29+
@Test
30+
void dialectDefaultIsLenient() {
31+
assertThat(AnsiDialect.INSTANCE.getUpdateRowCountVerification()).isEqualTo(UpdateRowCountVerification.LENIENT);
32+
}
33+
}

0 commit comments

Comments
 (0)