Skip to content

Commit 080d8ae

Browse files
thombergsschauder
authored andcommitted
DATAJDBC-219 - optimistic locking
1 parent a085844 commit 080d8ae

11 files changed

+434
-32
lines changed

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.dao.DataRetrievalFailureException;
3030
import org.springframework.dao.EmptyResultDataAccessException;
3131
import org.springframework.dao.InvalidDataAccessApiUsageException;
32+
import org.springframework.dao.OptimisticLockingFailureException;
3233
import org.springframework.data.jdbc.support.JdbcUtil;
3334
import org.springframework.data.mapping.PersistentPropertyAccessor;
3435
import org.springframework.data.mapping.PersistentPropertyPath;
@@ -54,6 +55,7 @@
5455
* @author Mark Paluch
5556
* @author Thomas Lang
5657
* @author Bastian Wilhelm
58+
* @author Tom Hombergs
5759
*/
5860
@RequiredArgsConstructor
5961
public class DefaultDataAccessStrategy implements DataAccessStrategy {
@@ -96,7 +98,6 @@ public <T> Object insert(T instance, Class<T> domainType, Identifier identifier)
9698

9799
KeyHolder holder = new GeneratedKeyHolder();
98100
RelationalPersistentEntity<T> persistentEntity = getRequiredPersistentEntity(domainType);
99-
100101
Map<String, Object> parameters = new LinkedHashMap<>(identifier.size());
101102
identifier.forEach((name, value, type) -> {
102103
parameters.put(name, converter.writeValue(value, ClassTypeInformation.from(type)));
@@ -115,6 +116,14 @@ public <T> Object insert(T instance, Class<T> domainType, Identifier identifier)
115116
converter.writeValue(idValue, ClassTypeInformation.from(idProperty.getColumnType())));
116117
}
117118

119+
if (persistentEntity.hasVersionProperty()) {
120+
VersionAccessor versionAccessor = new VersionAccessor<>(instance, persistentEntity);
121+
Number newVersion = versionAccessor.nextVersion();
122+
versionAccessor.setVersion(newVersion);
123+
parameters.put(persistentEntity.getVersionProperty().getColumnName(), converter.writeValue(newVersion,
124+
ClassTypeInformation.from(persistentEntity.getVersionProperty().getColumnType())));
125+
}
126+
118127
parameters.forEach(parameterSource::addValue);
119128

120129
operations.update( //
@@ -132,12 +141,43 @@ public <T> Object insert(T instance, Class<T> domainType, Identifier identifier)
132141
*/
133142
@Override
134143
public <S> boolean update(S instance, Class<S> domainType) {
135-
136144
RelationalPersistentEntity<S> persistentEntity = getRequiredPersistentEntity(domainType);
137145

146+
if (persistentEntity.hasVersionProperty()) {
147+
return updateWithVersion(instance, domainType);
148+
} else {
149+
return updateWithoutVersion(instance, domainType);
150+
}
151+
}
152+
153+
private <S> boolean updateWithoutVersion(S instance, Class<S> domainType) {
154+
155+
RelationalPersistentEntity<S> persistentEntity = getRequiredPersistentEntity(domainType);
138156
return operations.update(sql(domainType).getUpdate(), getPropertyMap(instance, persistentEntity, "")) != 0;
139157
}
140158

159+
private <S> boolean updateWithVersion(S instance, Class<S> domainType) {
160+
161+
RelationalPersistentEntity<S> persistentEntity = getRequiredPersistentEntity(domainType);
162+
VersionAccessor<S> versionAccessor = new VersionAccessor<>(instance, persistentEntity);
163+
164+
Number oldVersion = versionAccessor.currentVersion();
165+
Number newVersion = versionAccessor.nextVersion();
166+
versionAccessor.setVersion(newVersion);
167+
168+
int affectedRows = operations.update(sql(domainType).getUpdateWithVersion(oldVersion),
169+
getPropertyMap(instance, persistentEntity, ""));
170+
171+
if (affectedRows == 0) {
172+
// reverting version update on entity
173+
versionAccessor.setVersion(oldVersion);
174+
throw new OptimisticLockingFailureException(
175+
String.format("Optimistic lock exception on saving entity of type %s.", persistentEntity.getName()));
176+
}
177+
178+
return true;
179+
}
180+
141181
/*
142182
* (non-Javadoc)
143183
* @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, java.lang.Class)
@@ -343,6 +383,7 @@ private Object convertForWrite(RelationalPersistentProperty property, @Nullable
343383

344384
return operations.getJdbcOperations()
345385
.execute((Connection c) -> c.createArrayOf(typeName, (Object[]) convertedValue));
386+
346387
}
347388

348389
@SuppressWarnings("unchecked")
@@ -410,4 +451,5 @@ private <S> RelationalPersistentEntity<S> getRequiredPersistentEntity(Class<S> d
410451
private SqlGenerator sql(Class<?> domainType) {
411452
return sqlGeneratorSource.getSqlGenerator(domainType);
412453
}
454+
413455
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* @author Yoichi Imai
4646
* @author Bastian Wilhelm
4747
* @author Oleksandr Kucher
48+
* @author Tom Hombergs
4849
*/
4950
class SqlGenerator {
5051

@@ -176,6 +177,10 @@ String getUpdate() {
176177
return updateSql.get();
177178
}
178179

180+
String getUpdateWithVersion(Number version) {
181+
return String.format("%s AND %s = %s", updateSql.get(), entity.getVersionProperty().getColumnName(), version);
182+
}
183+
179184
String getCount() {
180185
return countSql.get();
181186
}
@@ -343,8 +348,8 @@ private String createInsertSql(Set<String> additionalColumns) {
343348
String tableColumns = String.join(", ", columnNamesForInsert);
344349

345350
String parameterNames = columnNamesForInsert.stream()//
346-
.map(this::columnNameToParameterName)
347-
.map(n -> String.format(":%s", n))//
351+
.map(this::columnNameToParameterName) //
352+
.map(n -> String.format(":%s", n)) //
348353
.collect(Collectors.joining(", "));
349354

350355
return String.format(insertTemplate, entity.getTableName(), tableColumns, parameterNames);
@@ -458,7 +463,7 @@ private String cascadeConditions(String innerCondition, PersistentPropertyPath<R
458463
);
459464
}
460465

461-
private String columnNameToParameterName(String columnName){
466+
private String columnNameToParameterName(String columnName) {
462467
return parameterPattern.matcher(columnName).replaceAll("");
463468
}
464469
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2017-2019 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+
* http://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.jdbc.core;
17+
18+
import java.util.HashMap;
19+
import java.util.Map;
20+
import java.util.function.Function;
21+
22+
import org.springframework.data.mapping.PersistentPropertyAccessor;
23+
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
24+
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
25+
import org.springframework.util.Assert;
26+
27+
/**
28+
* Helper class for convenient access to the value of an entity's version field, which can have one of several numeric
29+
* types.
30+
*
31+
* @param <T> type of the persistent entity
32+
* @author Tom Hombergs
33+
*/
34+
class VersionAccessor<T> {
35+
36+
private T instance;
37+
private final RelationalPersistentProperty versionProperty;
38+
39+
VersionAccessor(T instance, RelationalPersistentEntity<T> entity) {
40+
this.instance = instance;
41+
this.versionProperty = entity.getVersionProperty();
42+
Assert.notNull(versionProperty, "Entity must have a @Version property.");
43+
}
44+
45+
Number nextVersion() {
46+
PersistentPropertyAccessor<T> accessor = versionProperty.getOwner().getPropertyAccessor(instance);
47+
Object currentValue = accessor.getProperty(versionProperty);
48+
return VersionMapper.forType(versionProperty.getType()).next(currentValue);
49+
}
50+
51+
Number currentVersion() {
52+
PersistentPropertyAccessor<T> accessor = versionProperty.getOwner().getPropertyAccessor(instance);
53+
Object currentValue = accessor.getProperty(versionProperty);
54+
return VersionMapper.forType(versionProperty.getType()).get(currentValue);
55+
}
56+
57+
void setVersion(Number newVersion) {
58+
PersistentPropertyAccessor<T> accessor = versionProperty.getOwner().getPropertyAccessor(instance);
59+
accessor.setProperty(versionProperty, newVersion);
60+
}
61+
62+
/**
63+
* Maps an object to a numeric value.
64+
*/
65+
enum VersionMapper {
66+
67+
PRIMITIVE_SHORT((o) -> (short) o, (o) -> (short) (((short) o) + 1)), PRIMITIVE_INT((o) -> (int) o,
68+
(o) -> ((int) o) + 1), PRIMITIVE_LONG((o) -> (long) o, (o) -> ((long) o) + 1), SHORT(
69+
(o) -> o == null ? (short) 0 : (Short) o,
70+
(o) -> o == null ? (short) 1 : (short) (((Short) o) + 1)), INTEGER((o) -> o == null ? 0 : (Integer) o,
71+
(o) -> o == null ? 1 : ((Integer) o) + 1), LONG((o) -> o == null ? 0L : (Long) o,
72+
(o) -> o == null ? 1L : ((Long) o) + 1);
73+
74+
private final Function<Object, Number> getFunction;
75+
private final Function<Object, Number> nextFunction;
76+
77+
VersionMapper(Function<Object, Number> getFunction, Function<Object, Number> nextFunction) {
78+
79+
this.getFunction = getFunction;
80+
this.nextFunction = nextFunction;
81+
}
82+
83+
Number get(Object o) {
84+
return getFunction.apply(o);
85+
}
86+
87+
Number next(Object o) {
88+
return nextFunction.apply(o);
89+
}
90+
91+
static final Map<Class<?>, VersionMapper> BY_TYPE = new HashMap<>();
92+
93+
static VersionMapper forType(Class<?> type) {
94+
if (BY_TYPE.isEmpty()) {
95+
initByTypeMap();
96+
}
97+
VersionMapper mapper = BY_TYPE.get(type);
98+
if (mapper == null) {
99+
throw new IllegalStateException(String.format("Invalid type for @Version field: %s.", type.getName()));
100+
}
101+
return mapper;
102+
}
103+
104+
private static void initByTypeMap() {
105+
BY_TYPE.put(int.class, PRIMITIVE_INT);
106+
BY_TYPE.put(Integer.class, INTEGER);
107+
BY_TYPE.put(short.class, PRIMITIVE_SHORT);
108+
BY_TYPE.put(Short.class, SHORT);
109+
BY_TYPE.put(long.class, PRIMITIVE_LONG);
110+
BY_TYPE.put(Long.class, LONG);
111+
}
112+
113+
}
114+
115+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
import org.springframework.context.annotation.Bean;
3737
import org.springframework.context.annotation.Configuration;
3838
import org.springframework.context.annotation.Import;
39+
import org.springframework.dao.OptimisticLockingFailureException;
3940
import org.springframework.data.annotation.Id;
41+
import org.springframework.data.annotation.Version;
4042
import org.springframework.data.jdbc.testing.DatabaseProfileValueSource;
4143
import org.springframework.data.jdbc.testing.TestConfiguration;
4244
import org.springframework.data.relational.core.conversion.RelationalConverter;
@@ -57,6 +59,7 @@
5759
* @author Jens Schauder
5860
* @author Thomas Lang
5961
* @author Mark Paluch
62+
* @author Tom Hombergs
6063
*/
6164
@ContextConfiguration
6265
@Transactional
@@ -419,6 +422,33 @@ public void saveAndLoadAnEntityWithSet() {
419422
assertThat(reloaded.digits).isEqualTo(new HashSet<>(Arrays.asList("one", "two", "three")));
420423
}
421424

425+
@Test // DATAJDBC-219
426+
public void saveAndUpdateVersionedAggregate() {
427+
SoftAssertions softly = new SoftAssertions();
428+
429+
VersionedAggregate aggregate = new VersionedAggregate();
430+
template.save(aggregate);
431+
432+
VersionedAggregate reloadedAggregate = template.findById(aggregate.getId(), VersionedAggregate.class);
433+
assertThat(reloadedAggregate.version).isEqualTo(1)
434+
.withFailMessage("version field should increment by one with each save");
435+
template.save(reloadedAggregate);
436+
437+
VersionedAggregate updatedAggregate = template.findById(aggregate.getId(), VersionedAggregate.class);
438+
assertThat(updatedAggregate.version).isEqualTo(2)
439+
.withFailMessage("version field should increment by one with each save");
440+
441+
reloadedAggregate.setVersion(1);
442+
assertThatThrownBy(() -> template.save(reloadedAggregate))
443+
.hasRootCauseInstanceOf(OptimisticLockingFailureException.class)
444+
.withFailMessage("saving an aggregate with an outdated version should raise an exception");
445+
446+
reloadedAggregate.setVersion(3);
447+
assertThatThrownBy(() -> template.save(reloadedAggregate))
448+
.hasRootCauseInstanceOf(OptimisticLockingFailureException.class)
449+
.withFailMessage("saving an aggregate with an non-existent version should raise an exception");
450+
}
451+
422452
private static void assumeNot(String dbProfileName) {
423453

424454
Assume.assumeTrue("true"
@@ -501,6 +531,14 @@ static class ElementNoId {
501531
private String content;
502532
}
503533

534+
@Data
535+
static class VersionedAggregate {
536+
537+
@Id private Long id;
538+
@Version private Integer version;
539+
540+
}
541+
504542
@Configuration
505543
@Import(TestConfiguration.class)
506544
static class Config {

0 commit comments

Comments
 (0)