diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java index 1eb27495bb..c394d0dc1c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/AggregateChangeExecutor.java @@ -22,6 +22,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.function.BiConsumer; @@ -33,6 +34,7 @@ import org.springframework.data.relational.core.conversion.DbAction; import org.springframework.data.relational.core.conversion.Interpreter; import org.springframework.data.relational.core.conversion.RelationalConverter; +import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.util.Pair; @@ -45,6 +47,7 @@ * * @author Jens Schauder * @author Mark Paluch + * @author Tyler Van Gorder * @since 1.2 */ class AggregateChangeExecutor { @@ -60,7 +63,6 @@ class AggregateChangeExecutor { this.context = converter.getMappingContext(); } - @SuppressWarnings("unchecked") void execute(AggregateChange aggregateChange) { List> actions = new ArrayList<>(); @@ -70,17 +72,42 @@ void execute(AggregateChange aggregateChange) { actions.add(action); }); - T newRoot = (T) populateIdsIfNecessary(actions); - + T newRoot = populateIdsIfNecessary(actions); if (newRoot != null) { + newRoot = populateRootVersionIfNecessary(newRoot, actions); aggregateChange.setEntity(newRoot); } } + @SuppressWarnings("unchecked") + @Nullable + private T populateRootVersionIfNecessary(T newRoot, List> actions) { + + // Does the root entity have a version attribute? + RelationalPersistentEntity persistentEntity = (RelationalPersistentEntity) context + .getRequiredPersistentEntity(newRoot.getClass()); + if (!persistentEntity.hasVersionProperty()) { + return newRoot; + } + + // Find the root action + Optional> rootAction = actions.parallelStream().filter(action -> action instanceof DbAction.WithVersion) + .findFirst(); + + if (!rootAction.isPresent()) { + // This really should never happen. + return newRoot; + } + DbAction.WithVersion versionAction = (DbAction.WithVersion) rootAction.get(); + + return RelationalEntityVersionUtils.setVersionNumberOnEntity(newRoot, + versionAction.getNextVersion(), persistentEntity, converter); + } + @Nullable - private Object populateIdsIfNecessary(List> actions) { + private T populateIdsIfNecessary(List> actions) { - Object newRoot = null; + T newRoot = null; // have the actions so that the inserts on the leaves come first. List> reverseActions = new ArrayList<>(actions); @@ -102,15 +129,15 @@ private Object populateIdsIfNecessary(List> actions) { if (newEntity != ((DbAction.WithGeneratedId) action).getEntity()) { if (action instanceof DbAction.Insert) { - DbAction.Insert insert = (DbAction.Insert) action; + DbAction.Insert insert = (DbAction.Insert) action; - Pair qualifier = insert.getQualifier(); + Pair qualifier = insert.getQualifier(); cascadingValues.stage(insert.getDependingOn(), insert.getPropertyPath(), qualifier == null ? null : qualifier.getSecond(), newEntity); } else if (action instanceof DbAction.InsertRoot) { - newRoot = newEntity; + newRoot = (T) newEntity; } } } @@ -140,7 +167,7 @@ private Object setIdAndCascadingProperties(DbAction.WithGeneratedId actio } @SuppressWarnings("unchecked") - private PersistentPropertyPath getRelativePath(DbAction action, PersistentPropertyPath pathToValue) { + private PersistentPropertyPath getRelativePath(DbAction action, PersistentPropertyPath pathToValue) { if (action instanceof DbAction.Insert) { return pathToValue.getExtensionForBaseOf(((DbAction.Insert) action).getPropertyPath()); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreter.java index 49c12fec53..5d7ed69964 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreter.java @@ -17,7 +17,6 @@ import lombok.RequiredArgsConstructor; -import java.util.Collections; import java.util.Map; import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; @@ -34,6 +33,8 @@ import org.springframework.data.relational.core.conversion.DbAction.Update; import org.springframework.data.relational.core.conversion.DbAction.UpdateRoot; import org.springframework.data.relational.core.conversion.Interpreter; +import org.springframework.data.relational.core.conversion.RelationalConverter; +import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils; import org.springframework.data.relational.core.mapping.PersistentPropertyPathExtension; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; @@ -48,11 +49,13 @@ * @author Jens Schauder * @author Mark Paluch * @author Myeonghyeon Lee + * @author Tyler Van Gorder */ @RequiredArgsConstructor class DefaultJdbcInterpreter implements Interpreter { public static final String UPDATE_FAILED = "Failed to update entity [%s]. Id [%s] not found in database."; + private final RelationalConverter converter; private final RelationalMappingContext context; private final DataAccessStrategy accessStrategy; @@ -62,12 +65,15 @@ class DefaultJdbcInterpreter implements Interpreter { */ @Override public void interpret(Insert insert) { - Object id = accessStrategy.insert(insert.getEntity(), insert.getEntityType(), getParentKeys(insert)); - insert.setGeneratedId(id); } + @SuppressWarnings("unchecked") + private RelationalPersistentEntity getRequiredPersistentEntity(Class type) { + return (RelationalPersistentEntity) context.getRequiredPersistentEntity(type); + } + /* * (non-Javadoc) * @see org.springframework.data.relational.core.conversion.Interpreter#interpret(org.springframework.data.relational.core.conversion.DbAction.InsertRoot) @@ -75,8 +81,24 @@ public void interpret(Insert insert) { @Override public void interpret(InsertRoot insert) { - Object id = accessStrategy.insert(insert.getEntity(), insert.getEntityType(), Collections.emptyMap()); - insert.setGeneratedId(id); + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(insert.getEntityType()); + + if (persistentEntity.hasVersionProperty()) { + // The interpreter is responsible for setting the initial version on the entity prior to calling insert. + Number version = RelationalEntityVersionUtils.getVersionNumberFromEntity(insert.getEntity(), persistentEntity, + converter); + if (version != null && version.longValue() > 0) { + throw new IllegalArgumentException("The entity cannot be inserted because it already has a version."); + } + T rootEntity = RelationalEntityVersionUtils.setVersionNumberOnEntity(insert.getEntity(), 1, persistentEntity, + converter); + Object id = accessStrategy.insert(rootEntity, insert.getEntityType(), Identifier.empty()); + insert.setNextVersion(1); + insert.setGeneratedId(id); + } else { + Object id = accessStrategy.insert(insert.getEntity(), insert.getEntityType(), Identifier.empty()); + insert.setGeneratedId(id); + } } /* @@ -100,10 +122,31 @@ public void interpret(Update update) { @Override public void interpret(UpdateRoot update) { - if (!accessStrategy.update(update.getEntity(), update.getEntityType())) { - - throw new IncorrectUpdateSemanticsDataAccessException( - String.format(UPDATE_FAILED, update.getEntity(), getIdFrom(update))); + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(update.getEntityType()); + + if (persistentEntity.hasVersionProperty()) { + // If the root aggregate has a version property, increment it. + Number previousVersion = RelationalEntityVersionUtils.getVersionNumberFromEntity(update.getEntity(), + persistentEntity, converter); + Assert.notNull(previousVersion, "The root aggregate cannot be updated because the version property is null."); + + T rootEntity = RelationalEntityVersionUtils.setVersionNumberOnEntity(update.getEntity(), + previousVersion.longValue() + 1, persistentEntity, + converter); + + if (accessStrategy.updateWithVersion(rootEntity, update.getEntityType(), previousVersion)) { + // Successful update, set the in-memory version on the action. + update.setNextVersion(previousVersion); + } else { + throw new IncorrectUpdateSemanticsDataAccessException( + String.format(UPDATE_FAILED, update.getEntity(), getIdFrom(update))); + } + } else { + if (!accessStrategy.update(update.getEntity(), update.getEntityType())) { + + throw new IncorrectUpdateSemanticsDataAccessException( + String.format(UPDATE_FAILED, update.getEntity(), getIdFrom(update))); + } } } @@ -135,7 +178,16 @@ public void interpret(Delete delete) { */ @Override public void interpret(DeleteRoot delete) { - accessStrategy.delete(delete.getRootId(), delete.getEntityType()); + + if (delete.getEntity() != null) { + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(delete.getEntityType()); + if (persistentEntity.hasVersionProperty()) { + accessStrategy.deleteWithVersion(delete.getEntity(), delete.getEntityType()); + return; + } + } + + accessStrategy.delete(delete.getId(), delete.getEntityType()); } /* @@ -177,13 +229,13 @@ private Object getParentId(DbAction.WithDependingOn action) { PersistentPropertyPathExtension path = new PersistentPropertyPathExtension(context, action.getPropertyPath()); PersistentPropertyPathExtension idPath = path.getIdDefiningParentPath(); - DbAction.WithEntity idOwningAction = getIdOwningAction(action, idPath); + DbAction.WithEntity idOwningAction = getIdOwningAction(action, idPath); return getIdFrom(idOwningAction); } - @SuppressWarnings("unchecked") - private DbAction.WithEntity getIdOwningAction(DbAction.WithEntity action, PersistentPropertyPathExtension idPath) { + private DbAction.WithEntity getIdOwningAction(DbAction.WithEntity action, + PersistentPropertyPathExtension idPath) { if (!(action instanceof DbAction.WithDependingOn)) { @@ -193,7 +245,7 @@ private DbAction.WithEntity getIdOwningAction(DbAction.WithEntity action, Persis return action; } - DbAction.WithDependingOn withDependingOn = (DbAction.WithDependingOn) action; + DbAction.WithDependingOn withDependingOn = (DbAction.WithDependingOn) action; if (idPath.matches(withDependingOn.getPropertyPath())) { return action; @@ -202,7 +254,7 @@ private DbAction.WithEntity getIdOwningAction(DbAction.WithEntity action, Persis return getIdOwningAction(withDependingOn.getDependingOn(), idPath); } - private Object getIdFrom(DbAction.WithEntity idOwningAction) { + private Object getIdFrom(DbAction.WithEntity idOwningAction) { if (idOwningAction instanceof DbAction.WithGeneratedId) { @@ -221,4 +273,5 @@ private Object getIdFrom(DbAction.WithEntity idOwningAction) { return identifier; } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java index 096ffb8b5f..a27ea9272b 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java @@ -31,10 +31,20 @@ import org.springframework.data.relational.core.conversion.RelationalEntityDeleteWriter; import org.springframework.data.relational.core.conversion.RelationalEntityInsertWriter; import org.springframework.data.relational.core.conversion.RelationalEntityUpdateWriter; -import org.springframework.data.relational.core.conversion.RelationalEntityWriter; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; -import org.springframework.data.relational.core.mapping.event.*; +import org.springframework.data.relational.core.mapping.event.AfterDeleteCallback; +import org.springframework.data.relational.core.mapping.event.AfterDeleteEvent; +import org.springframework.data.relational.core.mapping.event.AfterLoadCallback; +import org.springframework.data.relational.core.mapping.event.AfterLoadEvent; +import org.springframework.data.relational.core.mapping.event.AfterSaveCallback; +import org.springframework.data.relational.core.mapping.event.AfterSaveEvent; +import org.springframework.data.relational.core.mapping.event.BeforeConvertCallback; +import org.springframework.data.relational.core.mapping.event.BeforeDeleteCallback; +import org.springframework.data.relational.core.mapping.event.BeforeDeleteEvent; +import org.springframework.data.relational.core.mapping.event.BeforeSaveCallback; +import org.springframework.data.relational.core.mapping.event.BeforeSaveEvent; +import org.springframework.data.relational.core.mapping.event.Identifier; import org.springframework.data.relational.core.mapping.event.Identifier.Specified; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -51,10 +61,8 @@ public class JdbcAggregateTemplate implements JdbcAggregateOperations { private final ApplicationEventPublisher publisher; private final RelationalMappingContext context; - private final RelationalConverter converter; private final Interpreter interpreter; - private final RelationalEntityWriter jdbcEntityWriter; private final RelationalEntityDeleteWriter jdbcEntityDeleteWriter; private final RelationalEntityInsertWriter jdbcEntityInsertWriter; private final RelationalEntityUpdateWriter jdbcEntityUpdateWriter; @@ -83,14 +91,12 @@ public JdbcAggregateTemplate(ApplicationContext publisher, RelationalMappingCont this.publisher = publisher; this.context = context; - this.converter = converter; this.accessStrategy = dataAccessStrategy; - this.jdbcEntityWriter = new RelationalEntityWriter(context); this.jdbcEntityInsertWriter = new RelationalEntityInsertWriter(context); this.jdbcEntityUpdateWriter = new RelationalEntityUpdateWriter(context); this.jdbcEntityDeleteWriter = new RelationalEntityDeleteWriter(context); - this.interpreter = new DefaultJdbcInterpreter(context, accessStrategy); + this.interpreter = new DefaultJdbcInterpreter(converter, context, accessStrategy); this.executor = new AggregateChangeExecutor(interpreter, converter); @@ -115,15 +121,12 @@ public JdbcAggregateTemplate(ApplicationEventPublisher publisher, RelationalMapp this.publisher = publisher; this.context = context; - this.converter = converter; this.accessStrategy = dataAccessStrategy; - this.jdbcEntityWriter = new RelationalEntityWriter(context); this.jdbcEntityInsertWriter = new RelationalEntityInsertWriter(context); this.jdbcEntityUpdateWriter = new RelationalEntityUpdateWriter(context); this.jdbcEntityDeleteWriter = new RelationalEntityDeleteWriter(context); - this.interpreter = new DefaultJdbcInterpreter(context, accessStrategy); - + this.interpreter = new DefaultJdbcInterpreter(converter, context, accessStrategy); this.executor = new AggregateChangeExecutor(interpreter, converter); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java index 69398a7c10..782face5d4 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CascadingDataAccessStrategy.java @@ -68,6 +68,15 @@ public boolean update(S instance, Class domainType) { return collect(das -> das.update(instance, domainType)); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#updateWithVersion(java.lang.Object, java.lang.Class, java.lang.Number) + */ + @Override + public boolean updateWithVersion(S instance, Class domainType, Number previousVersion) { + return collect(das -> das.updateWithVersion(instance, domainType, previousVersion)); + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, java.lang.Class) @@ -77,6 +86,15 @@ public void delete(Object id, Class domainType) { collectVoid(das -> das.delete(id, domainType)); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#deleteInstance(java.lang.Object, java.lang.Class) + */ + @Override + public void deleteWithVersion(T instance, Class domainType) { + collectVoid(das -> das.deleteWithVersion(instance, domainType)); + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, org.springframework.data.mapping.PersistentPropertyPath) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java index ec004609f5..e22baaf85d 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DataAccessStrategy.java @@ -29,6 +29,7 @@ * complete aggregates. * * @author Jens Schauder + * @author Tyler Van Gorder */ public interface DataAccessStrategy extends RelationResolver { @@ -74,8 +75,26 @@ default Object insert(T instance, Class domainType, Identifier identifier boolean update(T instance, Class domainType); /** - * deletes a single row identified by the id, from the table identified by the domainType. Does not handle cascading + * Updates the data of a single entity in the database and enforce optimistic record locking using the previousVersion + * property. Referenced entities don't get handled. + *

+ * The statement will be of the form : {@code UPDATE … SET … WHERE ID = :id and VERSION_COLUMN = :previousVersion } + * and throw an optimistic record locking exception if no rows have been updated. + * + * @param instance the instance to save. Must not be {@code null}. + * @param domainType the type of the instance to save. Must not be {@code null}. + * @param previousVersion The previous version assigned to the instance being saved. + * @param the type of the instance to save. + * @return whether the update actually updated a row. + */ + boolean updateWithVersion(T instance, Class domainType, Number previousVersion); + + /** + * Deletes a single row identified by the id, from the table identified by the domainType. Does not handle cascading * deletes. + *

+ * The statement will be of the form : {@code DELETE FROM … WHERE ID = :id and VERSION_COLUMN = :version } and throw + * an optimistic record locking exception if no rows have been updated. * * @param id the id of the row to be deleted. Must not be {@code null}. * @param domainType the type of entity to be deleted. Implicitly determines the table to operate on. Must not be @@ -83,6 +102,16 @@ default Object insert(T instance, Class domainType, Identifier identifier */ void delete(Object id, Class domainType); + /** + * Deletes a single entity from the database and enforce optimistic record locking using the version property. Does + * not handle cascading deletes. + * + * @param id the id of the row to be deleted. Must not be {@code null}. + * @param domainType the type of entity to be deleted. Implicitly determines the table to operate on. Must not be + * {@code null}. + */ + void deleteWithVersion(T instance, Class domainType); + /** * Deletes all entities reachable via {@literal propertyPath} from the instance identified by {@literal rootId}. * 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 ecef50c8f8..7237c179a6 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 @@ -15,6 +15,8 @@ */ package org.springframework.data.jdbc.core.convert; +import static org.springframework.data.jdbc.core.convert.SqlGenerator.VERSION_SQL_PARAMETER_NAME; + import java.sql.JDBCType; import java.util.ArrayList; import java.util.Arrays; @@ -28,11 +30,13 @@ import org.springframework.dao.DataRetrievalFailureException; import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.jdbc.support.JdbcUtil; import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils; import org.springframework.data.relational.core.mapping.PersistentPropertyPathExtension; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; @@ -55,6 +59,8 @@ * @author Thomas Lang * @author Bastian Wilhelm * @author Christoph Strobl + * @author Tom Hombergs + * @author Tyler Van Gorder * @since 1.1 */ public class DefaultDataAccessStrategy implements DataAccessStrategy { @@ -135,11 +141,34 @@ public Object insert(T instance, Class domainType, Identifier identifier) public boolean update(S instance, Class domainType) { RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); - return operations.update(sql(domainType).getUpdate(), getParameterSource(instance, persistentEntity, "", Predicates.includeAll())) != 0; } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#updateWithVersion(java.lang.Object, java.lang.Class, java.lang.Number) + */ + @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. + MapSqlParameterSource parameterSource = getParameterSource(instance, persistentEntity, "", + Predicates.includeAll()); + parameterSource.addValue(VERSION_SQL_PARAMETER_NAME, previousVersion); + + 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())); + } + + return true; + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, java.lang.Class) @@ -153,6 +182,33 @@ public void delete(Object id, Class domainType) { operations.update(deleteByIdSql, parameter); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#deleteInstance(java.lang.Object, java.lang.Class) + */ + @Override + public void deleteWithVersion(T instance, Class domainType) { + + RelationalPersistentEntity persistentEntity = getRequiredPersistentEntity(domainType); + Object id = getIdValueOrNull(instance, persistentEntity); + Assert.notNull(id, "Cannot delete an instance without it's ID being populated."); + + if (!persistentEntity.hasVersionProperty()) { + delete(id, domainType); + return; + } + + Number oldVersion = RelationalEntityVersionUtils.getVersionNumberFromEntity(instance, persistentEntity, converter); + MapSqlParameterSource parameterSource = createIdParameterSource(id, domainType); + parameterSource.addValue(VERSION_SQL_PARAMETER_NAME, oldVersion); + 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())); + } + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, org.springframework.data.mapping.PropertyPath) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java index e17dc7279f..5f89080a72 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DelegatingDataAccessStrategy.java @@ -60,6 +60,15 @@ public boolean update(S instance, Class domainType) { return delegate.update(instance, domainType); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#updateWithVersion(java.lang.Object, java.lang.Class, java.lang.Number) + */ + @Override + public boolean updateWithVersion(S instance, Class domainType, Number nextVersion) { + return delegate.updateWithVersion(instance, domainType, nextVersion); + + } /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, org.springframework.data.mapping.PersistentPropertyPath) @@ -78,6 +87,15 @@ public void delete(Object id, Class domainType) { delegate.delete(id, domainType); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#deleteWithVersion(java.lang.Object, java.lang.Class) + */ + @Override + public void deleteWithVersion(T instance, Class domainType) { + delegate.deleteWithVersion(instance, domainType); + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#deleteAll(java.lang.Class) diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java index 7057346971..cecd34329a 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlContext.java @@ -42,6 +42,13 @@ Column getIdColumn() { return table.column(entity.getIdColumn()); } + Column getVersionColumn() { + if (!entity.hasVersionProperty()) { + return null; + } + return table.column(entity.getVersionProperty().getColumnName()); + } + Table getTable() { return table; } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java index f7a93fca35..8d7bd741ee 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SqlGenerator.java @@ -37,7 +37,24 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; -import org.springframework.data.relational.core.sql.*; +import org.springframework.data.relational.core.sql.AssignValue; +import org.springframework.data.relational.core.sql.Assignments; +import org.springframework.data.relational.core.sql.BindMarker; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Delete; +import org.springframework.data.relational.core.sql.DeleteBuilder; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Expressions; +import org.springframework.data.relational.core.sql.Functions; +import org.springframework.data.relational.core.sql.Insert; +import org.springframework.data.relational.core.sql.InsertBuilder; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Update; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.domain.Identifier; import org.springframework.data.util.Lazy; @@ -52,11 +69,14 @@ * @author Bastian Wilhelm * @author Oleksandr Kucher * @author Mark Paluch + * @author Tom Hombergs + * @author Tyler Van Gorder */ class SqlGenerator { - private static final Pattern parameterPattern = Pattern.compile("\\W"); + static final String VERSION_SQL_PARAMETER_NAME = "___oldOptimisticLockingVersion"; + private static final Pattern parameterPattern = Pattern.compile("\\W"); private final RelationalPersistentEntity entity; private final MappingContext, RelationalPersistentProperty> mappingContext; @@ -71,8 +91,10 @@ class SqlGenerator { private final Lazy countSql = Lazy.of(this::createCountSql); private final Lazy updateSql = Lazy.of(this::createUpdateSql); + private final Lazy updateWithVersionSql = Lazy.of(this::createUpdateWithVersionSql); private final Lazy deleteByIdSql = Lazy.of(this::createDeleteSql); + private final Lazy deleteByIdAndVersionSql = Lazy.of(this::createDeleteByIdAndVersionSql); private final Lazy deleteByListSql = Lazy.of(this::createDeleteByListSql); /** @@ -242,6 +264,15 @@ String getUpdate() { return updateSql.get(); } + /** + * Create a {@code UPDATE … SET … WHERE ID = :id and VERSION_COLUMN = :___oldOptimisticLockingVersion } statement. + * + * @return + */ + String getUpdateWithVersion() { + return updateWithVersionSql.get(); + } + /** * Create a {@code SELECT COUNT(*) FROM …} statement. * @@ -260,6 +291,15 @@ String getDeleteById() { return deleteByIdSql.get(); } + /** + * Create a {@code DELETE FROM … WHERE :id = … and :___oldOptimisticLockingVersion = ...} statement. + * + * @return + */ + String getDeleteByIdAndVersion() { + return deleteByIdAndVersionSql.get(); + } + /** * Create a {@code DELETE FROM … WHERE :ids in (…)} statement. * @@ -461,7 +501,6 @@ private String createInsertSql(Set additionalColumns) { } private String createUpdateSql() { - Table table = getTable(); List assignments = columns.getUpdateableColumns() // @@ -472,9 +511,30 @@ private String createUpdateSql() { .collect(Collectors.toList()); Update update = Update.builder() // + .table(table) // + .set(assignments) // + .where(getIdColumn().isEqualTo(getBindMarker(entity.getIdColumn()))).build(); + + return render(update); + } + + private String createUpdateWithVersionSql() { + + Table table = getTable(); + + List assignments = columns.getUpdateableColumns() // + .stream() // + .map(columnName -> Assignments.value( // + table.column(columnName), // + getBindMarker(columnName))) // + .collect(Collectors.toList()); + + Update update = null; + update = Update.builder() // .table(table) // .set(assignments) // .where(getIdColumn().isEqualTo(getBindMarker(entity.getIdColumn()))) // + .and(getVersionColumn().isEqualTo(SQL.bindMarker(":" + VERSION_SQL_PARAMETER_NAME))) // .build(); return render(update); @@ -490,6 +550,18 @@ private String createDeleteSql() { return render(delete); } + private String createDeleteByIdAndVersionSql() { + Table table = getTable(); + + Delete delete = Delete.builder() // + .from(table) // + .where(getIdColumn().isEqualTo(SQL.bindMarker(":id"))) // + .and(getVersionColumn().isEqualTo(SQL.bindMarker(":" + VERSION_SQL_PARAMETER_NAME))) // + .build(); + + return render(delete); + } + private String createDeleteByPathAndCriteria(PersistentPropertyPathExtension path, Function rootCondition) { @@ -551,6 +623,10 @@ private Column getIdColumn() { return sqlContext.getIdColumn(); } + private Column getVersionColumn() { + return sqlContext.getVersionColumn(); + } + /** * Value object representing a {@code JOIN} association. */ diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java index 94cde0fd23..d10f00ad39 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategy.java @@ -15,9 +15,10 @@ */ package org.springframework.data.jdbc.mybatis; -import static java.util.Arrays.*; +import static java.util.Arrays.asList; import java.util.Collections; +import java.util.HashMap; import java.util.Map; import org.apache.ibatis.exceptions.PersistenceException; @@ -52,10 +53,12 @@ * @author Kazuki Shimizu * @author Oliver Gierke * @author Mark Paluch + * @author Tyler Van Gorder */ public class MyBatisDataAccessStrategy implements DataAccessStrategy { private static final Logger LOG = LoggerFactory.getLogger(MyBatisDataAccessStrategy.class); + private static final String VERSION_SQL_PARAMETER_NAME_OLD = "___oldOptimisticLockingVersion"; private final SqlSession sqlSession; private NamespaceStrategy namespaceStrategy = NamespaceStrategy.DEFAULT_INSTANCE; @@ -81,7 +84,7 @@ public static DataAccessStrategy createCombinedAccessStrategy(RelationalMappingC // cycle. In order to create it, we need something that allows to defer closing the cycle until all the elements are // created. That is the purpose of the DelegatingAccessStrategy. DelegatingDataAccessStrategy delegatingDataAccessStrategy = new DelegatingDataAccessStrategy(); - MyBatisDataAccessStrategy myBatisDataAccessStrategy = new MyBatisDataAccessStrategy(sqlSession); + MyBatisDataAccessStrategy myBatisDataAccessStrategy = new MyBatisDataAccessStrategy(sqlSession, context, converter); myBatisDataAccessStrategy.setNamespaceStrategy(namespaceStrategy); CascadingDataAccessStrategy cascadingDataAccessStrategy = new CascadingDataAccessStrategy( @@ -111,7 +114,7 @@ public static DataAccessStrategy createCombinedAccessStrategy(RelationalMappingC * * @param sqlSession Must be non {@literal null}. */ - public MyBatisDataAccessStrategy(SqlSession sqlSession) { + public MyBatisDataAccessStrategy(SqlSession sqlSession, RelationalMappingContext context, JdbcConverter converter) { this.sqlSession = sqlSession; } @@ -164,6 +167,20 @@ public boolean update(S instance, Class domainType) { new MyBatisContext(null, instance, domainType, Collections.emptyMap())) != 0; } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#updateWithVersion(java.lang.Object, java.lang.Class, java.lang.Number) + */ + @Override + public boolean updateWithVersion(S instance, Class domainType, Number previousVersion) { + + Map additionalValues = new HashMap<>(); + additionalValues.put(VERSION_SQL_PARAMETER_NAME_OLD, previousVersion); + + return sqlSession().update(namespace(domainType) + ".updateWithVersion", + new MyBatisContext(null, instance, domainType, additionalValues)) != 0; + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, java.lang.Class) @@ -175,6 +192,17 @@ public void delete(Object id, Class domainType) { new MyBatisContext(id, null, domainType, Collections.emptyMap())); } + /* + * (non-Javadoc) + * @see org.springframework.data.jdbc.core.DataAccessStrategy#deleteInstance(java.lang.Object, java.lang.Class) + */ + @Override + public void deleteWithVersion(T instance, Class domainType) { + + sqlSession().delete(namespace(domainType) + ".deleteWithVersion", + new MyBatisContext(null, instance, domainType, Collections.emptyMap())); + } + /* * (non-Javadoc) * @see org.springframework.data.jdbc.core.DataAccessStrategy#delete(java.lang.Object, org.springframework.data.mapping.PersistentPropertyPath) diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreterUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreterUnitTests.java index 7c45016109..7b423600cd 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreterUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/DefaultJdbcInterpreterUnitTests.java @@ -15,19 +15,24 @@ */ package org.springframework.data.jdbc.core; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.data.jdbc.core.PropertyPathTestingUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.data.jdbc.core.PropertyPathTestingUtils.toPath; import java.util.List; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; -import org.springframework.dao.TransientDataAccessResourceException; import org.springframework.data.annotation.Id; +import org.springframework.data.jdbc.core.convert.BasicJdbcConverter; import org.springframework.data.jdbc.core.convert.DataAccessStrategy; +import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.conversion.DbAction.Insert; @@ -50,9 +55,9 @@ public class DefaultJdbcInterpreterUnitTests { static final String BACK_REFERENCE = "container"; RelationalMappingContext context = new JdbcMappingContext(); - + JdbcConverter converter = new BasicJdbcConverter(context, (Identifier, path) -> null); DataAccessStrategy dataAccessStrategy = mock(DataAccessStrategy.class); - DefaultJdbcInterpreter interpreter = new DefaultJdbcInterpreter(context, dataAccessStrategy); + DefaultJdbcInterpreter interpreter = new DefaultJdbcInterpreter(converter, context, dataAccessStrategy); Container container = new Container(); Element element = new Element(); diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java index 62f93eb69d..f1168f2e43 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java @@ -15,11 +15,17 @@ */ package org.springframework.data.jdbc.core; -import static java.util.Collections.*; -import static org.assertj.core.api.Assertions.*; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.Value; +import lombok.experimental.Wither; import java.util.ArrayList; import java.util.Arrays; @@ -29,6 +35,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.IntStream; import org.assertj.core.api.SoftAssertions; @@ -42,8 +49,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.dao.IncorrectUpdateSemanticsDataAccessException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.ReadOnlyProperty; +import org.springframework.data.annotation.Version; import org.springframework.data.jdbc.core.convert.DataAccessStrategy; import org.springframework.data.jdbc.testing.DatabaseProfileValueSource; import org.springframework.data.jdbc.testing.HsqlDbOnly; @@ -69,6 +78,8 @@ * @author Thomas Lang * @author Mark Paluch * @author Myeonghyeon Lee + * @author Tom Hombergs + * @author Tyler Van Gorder */ @ContextConfiguration @Transactional @@ -629,6 +640,116 @@ public void readOnlyGetsLoadedButNotWritten() { .isEqualTo("from-db"); } + @Test // DATAJDBC-219 Test that immutable version attribute works as expected. + public void saveAndUpdateAggregateWithImmutableVersion() { + AggregateWithImmutableVersion aggregate = new AggregateWithImmutableVersion(null, null); + aggregate = template.save(aggregate); + + Long id = aggregate.getId(); + + AggregateWithImmutableVersion reloadedAggregate = template.findById(id, aggregate.getClass()); + assertThat(reloadedAggregate.getVersion()).isEqualTo(1L) + .withFailMessage("version field should initially have the value 1"); + reloadedAggregate = template.save(reloadedAggregate); + + AggregateWithImmutableVersion updatedAggregate = template.findById(id, aggregate.getClass()); + assertThat(updatedAggregate.getVersion()).isEqualTo(2L) + .withFailMessage("version field should increment by one with each save"); + + assertThatThrownBy(() -> template.save(new AggregateWithImmutableVersion(id, 1L))) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("saving an aggregate with an outdated version should raise an exception"); + + assertThatThrownBy(() -> template.save(new AggregateWithImmutableVersion(id, 3L))) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("saving an aggregate with a future version should raise an exception"); + } + + @Test // DATAJDBC-219 Test that a delete with a version attribute works as expected. + public void deleteAggregateWithVersion() { + + AggregateWithImmutableVersion aggregate = new AggregateWithImmutableVersion(null, null); + aggregate = template.save(aggregate); + + //Should have an ID and a version of 1. + final Long id = aggregate.getId(); + + assertThatThrownBy(() -> template.delete(new AggregateWithImmutableVersion(id, 0L), AggregateWithImmutableVersion.class)) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("deleting an aggregate with an outdated version should raise an exception"); + + assertThatThrownBy(() -> template.delete(new AggregateWithImmutableVersion(id, 3L), AggregateWithImmutableVersion.class)) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("deleting an aggregate with a future version should raise an exception"); + + + //This should succeed + template.delete(aggregate, AggregateWithImmutableVersion.class); + + + aggregate = new AggregateWithImmutableVersion(null, null); + aggregate = template.save(aggregate); + + //This should succeed, as version will not be used. + template.deleteById(aggregate.getId(), AggregateWithImmutableVersion.class); + + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithLongVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithLongVersion(), Number::longValue); + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithPrimitiveLongVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithPrimitiveLongVersion(), Number::longValue); + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithIntegerVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithIntegerVersion(), Number::intValue); + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithPrimitiveIntegerVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithPrimitiveIntegerVersion(), Number::intValue); + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithShortVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithShortVersion(), Number::shortValue); + } + + @Test // DATAJDBC-219 + public void saveAndUpdateAggregateWithPrimitiveShortVersion() { + saveAndUpdateAggregateWithVersion(new AggregateWithPrimitiveShortVersion(), Number::shortValue); + } + + private void saveAndUpdateAggregateWithVersion(VersionedAggregate aggregate, + Function toConcreteNumber) { + + template.save(aggregate); + + VersionedAggregate reloadedAggregate = template.findById(aggregate.getId(), aggregate.getClass()); + assertThat(reloadedAggregate.getVersion()).isEqualTo(toConcreteNumber.apply(1)) + .withFailMessage("version field should initially have the value 1"); + template.save(reloadedAggregate); + + VersionedAggregate updatedAggregate = template.findById(aggregate.getId(), aggregate.getClass()); + assertThat(updatedAggregate.getVersion()).isEqualTo(toConcreteNumber.apply(2)) + .withFailMessage("version field should increment by one with each save"); + + reloadedAggregate.setVersion(toConcreteNumber.apply(1)); + assertThatThrownBy(() -> template.save(reloadedAggregate)) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("saving an aggregate with an outdated version should raise an exception"); + + reloadedAggregate.setVersion(toConcreteNumber.apply(3)); + assertThatThrownBy(() -> template.save(reloadedAggregate)) + .hasRootCauseInstanceOf(OptimisticLockingFailureException.class) + .withFailMessage("saving an aggregate with a future version should raise an exception"); + } + private static NoIdMapChain4 createNoIdMapTree() { NoIdMapChain4 chain4 = new NoIdMapChain4(); @@ -892,6 +1013,109 @@ static class WithReadOnly { String name; @ReadOnlyProperty String readOnly; } + @Data + static abstract class VersionedAggregate { + + @Id private Long id; + + abstract Number getVersion(); + + abstract void setVersion(Number newVersion); + } + + @Value + @Wither + @Table("VERSIONED_AGGREGATE") + static class AggregateWithImmutableVersion { + + @Id private Long id; + @Version private final Long version; + + } + + @Data + @Table("VERSIONED_AGGREGATE") + static class AggregateWithLongVersion extends VersionedAggregate { + + @Version private Long version; + + @Override + void setVersion(Number newVersion) { + this.version = (Long) newVersion; + } + } + + @Table("VERSIONED_AGGREGATE") + static class AggregateWithPrimitiveLongVersion extends VersionedAggregate { + + @Version private long version; + + @Override + void setVersion(Number newVersion) { + this.version = (long) newVersion; + } + + @Override + Number getVersion() { + return this.version; + } + } + + @Data + @Table("VERSIONED_AGGREGATE") + static class AggregateWithIntegerVersion extends VersionedAggregate { + + @Version private Integer version; + + @Override + void setVersion(Number newVersion) { + this.version = (Integer) newVersion; + } + } + + @Table("VERSIONED_AGGREGATE") + static class AggregateWithPrimitiveIntegerVersion extends VersionedAggregate { + + @Version private int version; + + @Override + void setVersion(Number newVersion) { + this.version = (int) newVersion; + } + + @Override + Number getVersion() { + return this.version; + } + } + + @Data + @Table("VERSIONED_AGGREGATE") + static class AggregateWithShortVersion extends VersionedAggregate { + + @Version private Short version; + + @Override + void setVersion(Number newVersion) { + this.version = (Short) newVersion; + } + } + + @Table("VERSIONED_AGGREGATE") + static class AggregateWithPrimitiveShortVersion extends VersionedAggregate { + + @Version private short version; + + @Override + void setVersion(Number newVersion) { + this.version = (short) newVersion; + } + + @Override + Number getVersion() { + return this.version; + } + } @Configuration @Import(TestConfiguration.class) diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java index 4ca8aa891e..d85d7df10c 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/SqlGeneratorUnitTests.java @@ -15,8 +15,8 @@ */ package org.springframework.data.jdbc.core.convert; -import static java.util.Collections.*; -import static org.assertj.core.api.Assertions.*; +import static java.util.Collections.emptySet; +import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import java.util.Set; @@ -26,6 +26,7 @@ import org.junit.Test; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.ReadOnlyProperty; +import org.springframework.data.annotation.Version; import org.springframework.data.jdbc.core.PropertyPathTestingUtils; import org.springframework.data.jdbc.core.mapping.AggregateReference; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; @@ -49,6 +50,7 @@ * @author Oleksandr Kucher * @author Bastian Wilhelm * @author Mark Paluch + * @author Tom Hombergs */ public class SqlGeneratorUnitTests { @@ -172,7 +174,9 @@ public void findAllByProperty() { public void findAllByPropertyWithMultipartIdentifier() { // this would get called when ListParent is the element type of a Set - String sql = sqlGenerator.getFindAllByProperty(Identifier.of("backref", "some-value", String.class).withPart("backref_key", "key-value", Object.class), null, false); + String sql = sqlGenerator.getFindAllByProperty( + Identifier.of("backref", "some-value", String.class).withPart("backref_key", "key-value", Object.class), null, + false); assertThat(sql).contains("SELECT", // "dummy_entity.id1 AS id1", // @@ -185,9 +189,7 @@ public void findAllByPropertyWithMultipartIdentifier() { "FROM dummy_entity ", // "LEFT OUTER JOIN referenced_entity AS ref ON ref.dummy_entity = dummy_entity.id1", // "LEFT OUTER JOIN second_level_referenced_entity AS ref_further ON ref_further.referenced_entity = ref.x_l1id", // - "dummy_entity.backref = :backref", - "dummy_entity.backref_key = :backref_key" - ); + "dummy_entity.backref = :backref", "dummy_entity.backref_key = :backref_key"); } @Test // DATAJDBC-131, DATAJDBC-111 @@ -229,6 +231,21 @@ public void findAllByPropertyWithKeyOrdered() { + "WHERE dummy_entity.backref = :backref " + "ORDER BY key-column"); } + @Test // DATAJDBC-219 + public void updateWithVersion() { + + SqlGenerator sqlGenerator = createSqlGenerator(VersionedEntity.class); + + assertThat(sqlGenerator.getUpdateWithVersion()).containsSequence( // + "UPDATE", // + "versioned_entity", // + "SET", // + "WHERE", // + "id1 = :id", // + "AND", // + "version = :___oldOptimisticLockingVersion"); + } + @Test // DATAJDBC-264 public void getInsertForEmptyColumnList() { @@ -540,6 +557,10 @@ static class DummyEntity { AggregateReference other; } + static class VersionedEntity extends DummyEntity { + @Version Integer version; + } + @SuppressWarnings("unused") static class ReferencedEntity { diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisCustomizingNamespaceHsqlIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisCustomizingNamespaceHsqlIntegrationTests.java index b32ce69a4e..81dcd0406c 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisCustomizingNamespaceHsqlIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisCustomizingNamespaceHsqlIntegrationTests.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jdbc.mybatis; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import junit.framework.AssertionFailedError; @@ -34,8 +34,12 @@ import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Primary; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.jdbc.core.convert.BasicJdbcConverter; +import org.springframework.data.jdbc.core.convert.JdbcConverter; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories; import org.springframework.data.jdbc.testing.TestConfiguration; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.repository.CrudRepository; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; import org.springframework.test.context.ActiveProfiles; @@ -109,7 +113,10 @@ SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory factory) { @Primary MyBatisDataAccessStrategy dataAccessStrategy(SqlSession sqlSession) { - MyBatisDataAccessStrategy strategy = new MyBatisDataAccessStrategy(sqlSession); + RelationalMappingContext context = new JdbcMappingContext(); + JdbcConverter converter = new BasicJdbcConverter(context, (Identifier, path) -> null); + + MyBatisDataAccessStrategy strategy = new MyBatisDataAccessStrategy(sqlSession, context, converter); strategy.setNamespaceStrategy(new NamespaceStrategy() { @Override diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategyUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategyUnitTests.java index d64b524953..f7431d1289 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategyUnitTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/mybatis/MyBatisDataAccessStrategyUnitTests.java @@ -15,10 +15,18 @@ */ package org.springframework.data.jdbc.mybatis; -import static java.util.Arrays.*; -import static java.util.Collections.*; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.Collections; @@ -29,6 +37,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.data.jdbc.core.PropertyPathTestingUtils; +import org.springframework.data.jdbc.core.convert.BasicJdbcConverter; +import org.springframework.data.jdbc.core.convert.JdbcConverter; import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -44,11 +54,12 @@ public class MyBatisDataAccessStrategyUnitTests { RelationalMappingContext context = new JdbcMappingContext(); + JdbcConverter converter = new BasicJdbcConverter(context, (Identifier, path) -> null); SqlSession session = mock(SqlSession.class); ArgumentCaptor captor = ArgumentCaptor.forClass(MyBatisContext.class); - MyBatisDataAccessStrategy accessStrategy = new MyBatisDataAccessStrategy(session); + MyBatisDataAccessStrategy accessStrategy = new MyBatisDataAccessStrategy(session, context, converter); PersistentPropertyPath path = PropertyPathTestingUtils.toPath("one.two", DummyEntity.class, context); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql index 02071e25c8..aa63a84436 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-hsql.sql @@ -298,4 +298,10 @@ CREATE TABLE WITH_READ_ONLY ( ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 40) PRIMARY KEY, NAME VARCHAR(200), READ_ONLY VARCHAR(200) DEFAULT 'from-db' -) \ No newline at end of file +); + +CREATE TABLE VERSIONED_AGGREGATE +( + ID BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1) PRIMARY KEY, + VERSION BIGINT +); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mariadb.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mariadb.sql index 6d538bb1c7..63494fd12a 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mariadb.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mariadb.sql @@ -283,4 +283,10 @@ CREATE TABLE NO_ID_MAP_CHAIN0 NO_ID_MAP_CHAIN3_KEY, NO_ID_MAP_CHAIN2_KEY ) -); \ No newline at end of file +); + +CREATE TABLE VERSIONED_AGGREGATE +( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + VERSION BIGINT +); diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mssql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mssql.sql index 9048d9dfa4..1edfd32f00 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mssql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mssql.sql @@ -291,4 +291,10 @@ CREATE TABLE NO_ID_MAP_CHAIN0 NO_ID_MAP_CHAIN3_KEY, NO_ID_MAP_CHAIN2_KEY ) +); + +CREATE TABLE VERSIONED_AGGREGATE +( + ID BIGINT IDENTITY PRIMARY KEY, + VERSION BIGINT ); \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mysql.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mysql.sql index 5747ba0b6b..db71d7a10f 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mysql.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-mysql.sql @@ -282,4 +282,11 @@ CREATE TABLE NO_ID_MAP_CHAIN0 NO_ID_MAP_CHAIN3_KEY, NO_ID_MAP_CHAIN2_KEY ) -); \ No newline at end of file +); + +CREATE TABLE VERSIONED_AGGREGATE +( + ID BIGINT AUTO_INCREMENT PRIMARY KEY, + VERSION BIGINT +); + diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-postgres.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-postgres.sql index 20fbd20a43..9ac303a0ed 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-postgres.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core/JdbcAggregateTemplateIntegrationTests-postgres.sql @@ -303,4 +303,10 @@ CREATE TABLE NO_ID_MAP_CHAIN0 NO_ID_MAP_CHAIN3_KEY, NO_ID_MAP_CHAIN2_KEY ) +); + +CREATE TABLE VERSIONED_AGGREGATE +( + ID SERIAL PRIMARY KEY, + VERSION BIGINT ); \ No newline at end of file diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java index 24948e91a0..139a83cf49 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DbAction.java @@ -35,6 +35,7 @@ * @param the type of the entity that is affected by this action. * @author Jens Schauder * @author Mark Paluch + * @author Tyler Van Gorder */ public interface DbAction { @@ -92,16 +93,16 @@ public Class getEntityType() { } /** - * Represents an insert statement for the root of an aggregate. + * Represents an insert statement for the root of an aggregate. Upon a successful insert, the initial version and generated ids are populated. * * @param type of the entity for which this represents a database interaction. */ @Data @RequiredArgsConstructor - class InsertRoot implements WithEntity, WithGeneratedId { - - @NonNull private final T entity; + class InsertRoot implements WithVersion, WithGeneratedId { + @NonNull private T entity; + private Number nextVersion; private Object generatedId; @Override @@ -128,14 +129,15 @@ public void doExecuteWith(Interpreter interpreter) { } /** - * Represents an insert statement for the root of an aggregate. + * Represents an update statement for the aggregate root. * * @param type of the entity for which this represents a database interaction. */ - @Value - class UpdateRoot implements WithEntity { + @Data + class UpdateRoot implements WithVersion { - @NonNull private final T entity; + @NonNull private T entity; + @Nullable Number nextVersion; @Override public void doExecuteWith(Interpreter interpreter) { @@ -148,7 +150,7 @@ public void doExecuteWith(Interpreter interpreter) { * * @param type of the entity for which this represents a database interaction. */ - @Value + @Data class Merge implements WithDependingOn, WithPropertyPath { @NonNull T entity; @@ -181,7 +183,7 @@ public void doExecuteWith(Interpreter interpreter) { } /** - * Represents a delete statement for a aggregate root. + * Represents a delete statement for a aggregate root when only the ID is known. *

* Note that deletes for contained entities that reference the root are to be represented by separate * {@link DbAction}s. @@ -189,10 +191,11 @@ public void doExecuteWith(Interpreter interpreter) { * @param type of the entity for which this represents a database interaction. */ @Value - class DeleteRoot implements DbAction { + class DeleteRoot implements WithEntity { + @NonNull Object id; + @Nullable T entity; @NonNull Class entityType; - @NonNull Object rootId; @Override public void doExecuteWith(Interpreter interpreter) { @@ -347,4 +350,8 @@ default Class getEntityType() { return (Class) getPropertyPath().getRequiredLeafProperty().getActualType(); } } + interface WithVersion extends WithEntity { + Number getNextVersion(); + void setNextVersion(Number nextVersion); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/Interpreter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/Interpreter.java index ede9faf37c..b78f28d259 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/Interpreter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/Interpreter.java @@ -57,5 +57,5 @@ public interface Interpreter { void interpret(DeleteAll delete); - void interpret(DeleteAllRoot DeleteAllRoot); + void interpret(DeleteAllRoot deleteAllRoot); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java index 430a3e206a..090b3ef305 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityDeleteWriter.java @@ -27,7 +27,9 @@ /** * Converts an entity that is about to be deleted into {@link DbAction}s inside a {@link AggregateChange} that need to - * be executed against the database to recreate the appropriate state in the database. + * be executed against the database to recreate the appropriate state in the database. If the {@link AggregateChange} + * has a reference to the entity and the entity has a version attribute, the delete will include an optimistic record + * locking check. * * @author Jens Schauder * @author Mark Paluch @@ -58,7 +60,7 @@ public void write(@Nullable Object id, AggregateChange aggregateChange) { if (id == null) { deleteAll(aggregateChange.getEntityType()).forEach(aggregateChange::addAction); } else { - deleteById(id, aggregateChange).forEach(aggregateChange::addAction); + deleteRoot(id, aggregateChange).forEach(aggregateChange::addAction); } } @@ -67,8 +69,7 @@ private List> deleteAll(Class entityType) { List> actions = new ArrayList<>(); context.findPersistentPropertyPaths(entityType, PersistentProperty::isEntity) - .filter(p -> !p.getRequiredLeafProperty().isEmbedded()) - .forEach(p -> actions.add(new DbAction.DeleteAll<>(p))); + .filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> actions.add(new DbAction.DeleteAll<>(p))); Collections.reverse(actions); @@ -78,10 +79,10 @@ private List> deleteAll(Class entityType) { return actions; } - private List> deleteById(Object id, AggregateChange aggregateChange) { + private List> deleteRoot(Object id, AggregateChange aggregateChange) { List> actions = new ArrayList<>(deleteReferencedEntities(id, aggregateChange)); - actions.add(new DbAction.DeleteRoot<>(aggregateChange.getEntityType(), id)); + actions.add(new DbAction.DeleteRoot<>(id, aggregateChange.getEntity(), aggregateChange.getEntityType())); return actions; } @@ -97,8 +98,7 @@ private List> deleteReferencedEntities(Object id, AggregateChange List> actions = new ArrayList<>(); context.findPersistentPropertyPaths(aggregateChange.getEntityType(), PersistentProperty::isEntity) - .filter(p -> !p.getRequiredLeafProperty().isEmbedded()) - .forEach(p -> actions.add(new DbAction.Delete<>(id, p))); + .filter(p -> !p.getRequiredLeafProperty().isEmbedded()).forEach(p -> actions.add(new DbAction.Delete<>(id, p))); Collections.reverse(actions); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityVersionUtils.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityVersionUtils.java new file mode 100644 index 0000000000..cd507f0e97 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalEntityVersionUtils.java @@ -0,0 +1,63 @@ +package org.springframework.data.relational.core.conversion; + +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.lang.Nullable; + +/** + * Utilities commonly used to set/get properties for instances of RelationalPersistentEntities. + * + * @author Tyler Van Gorder + */ +public class RelationalEntityVersionUtils { + + private RelationalEntityVersionUtils() {} + + /** + * Get the current value of the version property for an instance of a relational persistent entity. + * + * @param instance must not be {@literal null}. + * @param persistentEntity must not be {@literal null}. + * @param converter must not be {@literal null}. + * @return Current value of the version property + * @throws IllegalArgumentException if the entity does not have a version property. + */ + @Nullable + public static Number getVersionNumberFromEntity(S instance, RelationalPersistentEntity persistentEntity, + RelationalConverter converter) { + if (!persistentEntity.hasVersionProperty()) { + throw new IllegalArgumentException("The entity does not have a version property."); + } + + ConvertingPropertyAccessor convertingPropertyAccessor = new ConvertingPropertyAccessor<>(persistentEntity.getPropertyAccessor(instance), + converter.getConversionService()); + return convertingPropertyAccessor.getProperty(persistentEntity.getRequiredVersionProperty(), Number.class); + } + + /** + * Set the version property on an instance of a relational persistent entity. This method returns an instance of the + * same type with the updated version property and will correctly handle the case where the version is immutable. + * + * @param instance must not be {@literal null}. + * @param version The value to be set on the version property. + * @param persistentEntity must not be {@literal null}. + * @param converter must not be {@literal null}. + * @return An instance of the entity with an updated version property. + * @throws IllegalArgumentException if the entity does not have a version property. + */ + public static S setVersionNumberOnEntity(S instance, @Nullable Number version, + RelationalPersistentEntity persistentEntity, RelationalConverter converter) { + + if (!persistentEntity.hasVersionProperty()) { + throw new IllegalArgumentException("The entity does not have a version property."); + } + + PersistentPropertyAccessor propertyAccessor = converter.getPropertyAccessor(persistentEntity, instance); + RelationalPersistentProperty versionProperty = persistentEntity.getRequiredVersionProperty(); + propertyAccessor.setProperty(versionProperty, version); + + return propertyAccessor.getBean(); + } +}