diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/AsyncCassandraTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/AsyncCassandraTemplate.java index af001a962..ad4acc5b4 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/AsyncCassandraTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/AsyncCassandraTemplate.java @@ -312,8 +312,7 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(query, "Query must not be null"); Assert.notNull(entityClass, "Entity type must not be null"); - return select(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), - entityClass); + return select(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), entityClass); } /* (non-Javadoc) @@ -325,8 +324,7 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(query, "Query must not be null"); Assert.notNull(entityClass, "Entity type must not be null"); - return slice(this.statementFactory.select(query, getRequiredPersistentEntity(entityClass)), - entityClass); + return slice(this.statementFactory.select(query, getRequiredPersistentEntity(entityClass)), entityClass); } /* (non-Javadoc) @@ -340,8 +338,8 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(entityConsumer, "Entity Consumer must not be empty"); Assert.notNull(entityClass, "Entity type must not be null"); - return select(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), - entityConsumer, entityClass); + return select(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), entityConsumer, + entityClass); } /* (non-Javadoc) @@ -353,8 +351,7 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(query, "Query must not be null"); Assert.notNull(entityClass, "Entity type must not be null"); - return selectOne(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), - entityClass); + return selectOne(getStatementFactory().select(query, getRequiredPersistentEntity(entityClass)), entityClass); } /* (non-Javadoc) @@ -368,8 +365,8 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(update, "Update must not be null"); Assert.notNull(entityClass, "Entity type must not be null"); - return getAsyncCqlOperations().execute( - getStatementFactory().update(query, update, getRequiredPersistentEntity(entityClass))); + return getAsyncCqlOperations() + .execute(getStatementFactory().update(query, update, getRequiredPersistentEntity(entityClass))); } /* (non-Javadoc) @@ -482,7 +479,7 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica return new MappingListenableFutureAdapter<>( getAsyncCqlOperations().query(select, (row, rowNum) -> mapper.apply(row)), - it -> it.isEmpty() ? null : (T) it.get(0)); + it -> it.isEmpty() ? null : (T) it.get(0)); } /* (non-Javadoc) @@ -533,8 +530,9 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "UpdateOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - Update update = EntityQueryUtils.createUpdateQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Update update = getStatementFactory().update(entity, options, getConverter(), persistentEntity, tableName); maybeEmitEvent(new BeforeSaveEvent<>(entity, tableName, update)); @@ -562,8 +560,9 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "QueryOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - Delete delete = EntityQueryUtils.createDeleteQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Delete delete = getStatementFactory().delete(entity, options, getConverter(), persistentEntity, tableName); maybeEmitEvent(new BeforeDeleteEvent<>(delete, entity.getClass(), tableName)); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraBatchTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraBatchTemplate.java index 3c1c85266..f6ac0f810 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraBatchTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraBatchTemplate.java @@ -18,10 +18,14 @@ package org.springframework.data.cassandra.core; import java.util.Arrays; import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.data.cassandra.core.convert.CassandraConverter; +import org.springframework.data.cassandra.core.convert.UpdateMapper; import org.springframework.data.cassandra.core.cql.WriteOptions; import org.springframework.data.cassandra.core.mapping.BasicCassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import com.datastax.driver.core.querybuilder.Batch; import com.datastax.driver.core.querybuilder.QueryBuilder; @@ -42,6 +46,12 @@ class CassandraBatchTemplate implements CassandraBatchOperations { private final Batch batch = QueryBuilder.batch(); + private final CassandraConverter converter; + + private final CassandraMappingContext mappingContext; + + private final StatementFactory statementFactory; + /** * Create a new {@link CassandraBatchTemplate} given {@link CassandraOperations}. * @@ -52,6 +62,9 @@ class CassandraBatchTemplate implements CassandraBatchOperations { Assert.notNull(operations, "CassandraOperations must not be null"); this.operations = operations; + this.converter = operations.getConverter(); + this.mappingContext = converter.getMappingContext(); + this.statementFactory = new StatementFactory(new UpdateMapper(converter)); } /* (non-Javadoc) @@ -156,7 +169,10 @@ class CassandraBatchTemplate implements CassandraBatchOperations { for (Object entity : entities) { Assert.notNull(entity, "Entity must not be null"); - batch.add(EntityQueryUtils.createUpdateQuery(getTableName(entity), entity, options, operations.getConverter())); + + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + batch.add( + statementFactory.update(entity, options, this.converter, persistentEntity, persistentEntity.getTableName())); } return this; @@ -192,8 +208,12 @@ class CassandraBatchTemplate implements CassandraBatchOperations { Assert.notNull(options, "WriteOptions must not be null"); for (Object entity : entities) { + Assert.notNull(entity, "Entity must not be null"); - batch.add(EntityQueryUtils.createDeleteQuery(getTableName(entity), entity, options, operations.getConverter())); + + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + batch.add( + statementFactory.delete(entity, options, this.converter, persistentEntity, persistentEntity.getTableName())); } return this; @@ -203,10 +223,7 @@ class CassandraBatchTemplate implements CassandraBatchOperations { Assert.state(!executed.get(), "This Cassandra Batch was already executed"); } - private String getTableName(Object entity) { - - Assert.notNull(entity, "Entity must not be null"); - - return operations.getTableName(entity.getClass()).toCql(); + private CassandraPersistentEntity getRequiredPersistentEntity(Class entityType) { + return this.mappingContext.getRequiredPersistentEntity(ClassUtils.getUserClass(entityType)); } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java index 6418b2fbd..996fe0889 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraTemplate.java @@ -589,8 +589,9 @@ public class CassandraTemplate implements CassandraOperations, ApplicationEventP Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "UpdateOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - Update update = EntityQueryUtils.createUpdateQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Update update = getStatementFactory().update(entity, options, getConverter(), persistentEntity, tableName); maybeEmitEvent(new BeforeSaveEvent<>(entity, tableName, update)); @@ -619,8 +620,9 @@ public class CassandraTemplate implements CassandraOperations, ApplicationEventP Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "QueryOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - Delete delete = EntityQueryUtils.createDeleteQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Delete delete = getStatementFactory().delete(entity, options, getConverter(), persistentEntity, tableName); maybeEmitEvent(new BeforeDeleteEvent<>(delete, entity.getClass(), tableName)); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/DeleteOptions.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/DeleteOptions.java index b6a7d61f1..2fb7921f6 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/DeleteOptions.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/DeleteOptions.java @@ -22,7 +22,10 @@ import java.time.Instant; import java.util.concurrent.TimeUnit; import org.springframework.data.cassandra.core.cql.WriteOptions; +import org.springframework.data.cassandra.core.query.CriteriaDefinition; +import org.springframework.data.cassandra.core.query.Filter; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import com.datastax.driver.core.ConsistencyLevel; import com.datastax.driver.core.policies.RetryPolicy; @@ -38,15 +41,18 @@ public class DeleteOptions extends WriteOptions { private static final DeleteOptions EMPTY = new DeleteOptionsBuilder().build(); - private boolean ifExists; + private final boolean ifExists; + + private final @Nullable Filter ifCondition; private DeleteOptions(@Nullable ConsistencyLevel consistencyLevel, @Nullable RetryPolicy retryPolicy, @Nullable Boolean tracing, @Nullable Integer fetchSize, Duration readTimeout, Duration ttl, - @Nullable Long timestamp, boolean ifExists) { + @Nullable Long timestamp, boolean ifExists, @Nullable Filter ifCondition) { super(consistencyLevel, retryPolicy, tracing, fetchSize, readTimeout, ttl, timestamp); this.ifExists = ifExists; + this.ifCondition = ifCondition; } /** @@ -78,12 +84,20 @@ public class DeleteOptions extends WriteOptions { } /** - * @return {@literal true} to apply {@code IF EXISTS} to {@code UPDATE} operations. + * @return {@literal true} to apply {@code IF EXISTS} to {@code DELETE} operations. */ public boolean isIfExists() { return this.ifExists; } + /** + * @return the {@link Filter IF condition} for conditional deletes. + */ + @Nullable + public Filter getIfCondition() { + return ifCondition; + } + /** * Builder for {@link DeleteOptions}. * @@ -93,6 +107,8 @@ public class DeleteOptions extends WriteOptions { private boolean ifExists; + private @Nullable Filter ifCondition; + private DeleteOptionsBuilder() {} private DeleteOptionsBuilder(DeleteOptions deleteOptions) { @@ -223,7 +239,7 @@ public class DeleteOptions extends WriteOptions { } /** - * Use light-weight transactions by applying {@code IF EXISTS}. + * Use light-weight transactions by applying {@code IF EXISTS}. Replaces a previous {@link #ifCondition(Filter)}. * * @return {@code this} {@link DeleteOptionsBuilder} */ @@ -232,7 +248,7 @@ public class DeleteOptions extends WriteOptions { } /** - * Use light-weight transactions by applying {@code IF EXISTS}. + * Use light-weight transactions by applying {@code IF EXISTS}. Replaces a previous {@link #ifCondition(Filter)}. * * @param ifNotExists {@literal true} to enable {@code IF EXISTS}. * @return {@code this} {@link DeleteOptionsBuilder} @@ -240,6 +256,38 @@ public class DeleteOptions extends WriteOptions { public DeleteOptionsBuilder ifExists(boolean ifNotExists) { this.ifExists = ifNotExists; + this.ifCondition = null; + + return this; + } + + /** + * Use light-weight transactions by applying {@code IF} {@link CriteriaDefinition condition}. Replaces a previous + * {@link #ifCondition(Filter)} and {@link #ifExists(boolean)}. + * + * @param criteria the {@link Filter criteria} to apply for conditional updates, must not be {@literal null}. + * @return {@code this} {@link DeleteOptionsBuilder} + */ + public DeleteOptionsBuilder ifCondition(CriteriaDefinition criteria) { + + Assert.notNull(criteria, "CriteriaDefinition must not be null"); + + return ifCondition(Filter.from(criteria)); + } + + /** + * Use light-weight transactions by applying {@code IF} {@link Filter condition}. Replaces a previous + * {@link #ifCondition(Filter)} and {@link #ifExists(boolean)}. + * + * @param condition the {@link Filter condition} to apply for conditional deletes, must not be {@literal null}. + * @return {@code this} {@link DeleteOptionsBuilder} + */ + public DeleteOptionsBuilder ifCondition(Filter condition) { + + Assert.notNull(condition, "Filter condition must not be null"); + + this.ifCondition = condition; + this.ifExists = false; return this; } @@ -251,7 +299,7 @@ public class DeleteOptions extends WriteOptions { */ public DeleteOptions build() { return new DeleteOptions(this.consistencyLevel, this.retryPolicy, this.tracing, this.fetchSize, this.readTimeout, - this.ttl, this.timestamp, this.ifExists); + this.ttl, this.timestamp, this.ifExists, this.ifCondition); } } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraBatchTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraBatchTemplate.java index 73427e6b2..45e07744f 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraBatchTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraBatchTemplate.java @@ -27,9 +27,11 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import org.springframework.data.cassandra.core.convert.CassandraConverter; +import org.springframework.data.cassandra.core.convert.UpdateMapper; import org.springframework.data.cassandra.core.cql.WriteOptions; import org.springframework.data.cassandra.core.mapping.BasicCassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -55,6 +57,12 @@ class ReactiveCassandraBatchTemplate implements ReactiveCassandraBatchOperations private final Batch batch = QueryBuilder.batch(); + private final CassandraConverter converter; + + private final CassandraMappingContext mappingContext; + + private final StatementFactory statementFactory; + private final List>> batchMonos = new CopyOnWriteArrayList<>(); /** @@ -67,6 +75,9 @@ class ReactiveCassandraBatchTemplate implements ReactiveCassandraBatchOperations Assert.notNull(operations, "CassandraOperations must not be null"); this.operations = operations; + this.converter = operations.getConverter(); + this.mappingContext = converter.getMappingContext(); + this.statementFactory = new StatementFactory(new UpdateMapper(converter)); } /* (non-Javadoc) @@ -250,7 +261,9 @@ class ReactiveCassandraBatchTemplate implements ReactiveCassandraBatchOperations Assert.notNull(entity, "Entity must not be null"); - updateQueries.add(EntityQueryUtils.createUpdateQuery(getTable(entity), entity, options, converter)); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + updateQueries + .add(statementFactory.update(entity, options, converter, persistentEntity, persistentEntity.getTableName())); } return updateQueries; @@ -322,7 +335,9 @@ class ReactiveCassandraBatchTemplate implements ReactiveCassandraBatchOperations Assert.notNull(entity, "Entity must not be null"); - deleteQueries.add(EntityQueryUtils.createDeleteQuery(getTable(entity), entity, options, converter)); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + deleteQueries + .add(statementFactory.delete(entity, options, converter, persistentEntity, persistentEntity.getTableName())); } return deleteQueries; @@ -332,11 +347,7 @@ class ReactiveCassandraBatchTemplate implements ReactiveCassandraBatchOperations Assert.state(!executed.get(), "This Cassandra Batch was already executed"); } - private String getTable(Object entity) { - - Assert.notNull(entity, "Entity must not be null"); - - return operations.getConverter().getMappingContext() - .getRequiredPersistentEntity(ClassUtils.getUserClass(entity.getClass())).getTableName().toCql(); + private CassandraPersistentEntity getRequiredPersistentEntity(Class entityType) { + return this.mappingContext.getRequiredPersistentEntity(ClassUtils.getUserClass(entityType)); } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java index 6a7566ee7..c6264ae8f 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java @@ -526,8 +526,9 @@ public class ReactiveCassandraTemplate implements ReactiveCassandraOperations, A Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "UpdateOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - Update update = EntityQueryUtils.createUpdateQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Update update = getStatementFactory().update(entity, options, getConverter(), persistentEntity, tableName); Mono> result = getReactiveCqlOperations() // .execute(new StatementCallback(update)) // @@ -555,9 +556,9 @@ public class ReactiveCassandraTemplate implements ReactiveCassandraOperations, A Assert.notNull(entity, "Entity must not be null"); Assert.notNull(options, "QueryOptions must not be null"); - CqlIdentifier tableName = getTableName(entity); - - Delete delete = EntityQueryUtils.createDeleteQuery(tableName.toCql(), entity, options, getConverter()); + CassandraPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); + CqlIdentifier tableName = persistentEntity.getTableName(); + Delete delete = getStatementFactory().delete(entity, options, getConverter(), persistentEntity, tableName); Mono result = getReactiveCqlOperations() // .execute(new StatementCallback(delete)) // diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java index 1e9055704..874222842 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/StatementFactory.java @@ -21,10 +21,13 @@ import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; import org.springframework.data.cassandra.core.convert.QueryMapper; import org.springframework.data.cassandra.core.convert.UpdateMapper; import org.springframework.data.cassandra.core.cql.CqlIdentifier; +import org.springframework.data.cassandra.core.cql.QueryOptions; import org.springframework.data.cassandra.core.cql.QueryOptionsUtil; import org.springframework.data.cassandra.core.cql.WriteOptions; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; @@ -46,6 +49,7 @@ import org.springframework.data.cassandra.core.query.Update.RemoveOp; import org.springframework.data.cassandra.core.query.Update.SetAtIndexOp; import org.springframework.data.cassandra.core.query.Update.SetAtKeyOp; import org.springframework.data.cassandra.core.query.Update.SetOp; +import org.springframework.data.convert.EntityWriter; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.mapping.PersistentEntity; @@ -53,6 +57,7 @@ import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.ProjectionInformation; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -322,6 +327,11 @@ public class StatementFactory { com.datastax.driver.core.querybuilder.Update update = update(tableName, mappedUpdate, filter); query.getQueryOptions().ifPresent(queryOptions -> { + + potentiallyApplyIfCondition(queryOptions, UpdateOptions.class, UpdateOptions::getIfCondition, condition -> { + addIfCondition(condition, update, entity); + }); + if (queryOptions instanceof WriteOptions) { EntityQueryUtils.addWriteOptions(update, (WriteOptions) queryOptions); } else { @@ -334,6 +344,164 @@ public class StatementFactory { return update; } + /** + * Create an {@literal UPDATE} statement by mapping {@code entity} to {@link Update} considering + * {@link UpdateOptions}. + * + * @param entity must not be {@literal null}. + * @param options must not be {@literal null}. + * @param entityWriter must not be {@literal null}. + * @param persistentEntity must not be {@literal null}. + * @param tableName must not be {@literal null}. + * @return the update object. + */ + com.datastax.driver.core.querybuilder.Update update(Object entity, WriteOptions options, + EntityWriter entityWriter, CassandraPersistentEntity persistentEntity, + CqlIdentifier tableName) { + + com.datastax.driver.core.querybuilder.Update update = EntityQueryUtils.createUpdateQuery(tableName.toCql(), entity, + options, entityWriter); + + potentiallyApplyIfCondition(options, UpdateOptions.class, UpdateOptions::getIfCondition, condition -> { + addIfCondition(condition, update, persistentEntity); + }); + + return update; + } + + /** + * Create a {@literal DELETE} statement by mapping {@link Query} to {@link Delete}. + * + * @param query must not be {@literal null}. + * @param entity must not be {@literal null}. + * @return the rendered {@link RegularStatement}. + */ + public RegularStatement delete(Query query, CassandraPersistentEntity entity) { + + Assert.notNull(query, "Query must not be null"); + Assert.notNull(entity, "Entity must not be null"); + + return delete(query, entity, entity.getTableName()); + } + + /** + * Create a {@literal DELETE} statement by mapping {@link Query} to {@link Delete}. + * + * @param query must not be {@literal null}. + * @param entity must not be {@literal null}. + * @param tableName must not be {@literal null}. + * @return the rendered {@link RegularStatement}. + * @see 2.1 + */ + public RegularStatement delete(Query query, CassandraPersistentEntity entity, CqlIdentifier tableName) { + + Assert.notNull(query, "Query must not be null"); + Assert.notNull(entity, "Entity must not be null"); + Assert.notNull(tableName, "Table name must not be null"); + + Filter filter = getQueryMapper().getMappedObject(query, entity); + + List columnNames = getQueryMapper().getMappedColumnNames(query.getColumns(), entity); + + Delete delete = delete(columnNames, tableName, filter); + + query.getQueryOptions().ifPresent(queryOptions -> { + + potentiallyApplyIfCondition(queryOptions, DeleteOptions.class, DeleteOptions::getIfCondition, condition -> { + addIfCondition(condition, delete, entity); + }); + + if (queryOptions instanceof WriteOptions) { + EntityQueryUtils.addWriteOptions(delete, (WriteOptions) queryOptions); + } else { + QueryOptionsUtil.addQueryOptions(delete, queryOptions); + } + }); + + query.getPagingState().ifPresent(delete::setPagingState); + + return delete; + } + + /** + * Create an {@literal DELETE} statement by mapping {@code entity} to {@link Delete} considering + * {@link DeleteOptions}. + * + * @param entity must not be {@literal null}. + * @param options must not be {@literal null}. + * @param entityWriter must not be {@literal null}. + * @param persistentEntity must not be {@literal null}. + * @param tableName must not be {@literal null}. + * @return the update object. + */ + Delete delete(Object entity, QueryOptions options, EntityWriter entityWriter, + CassandraPersistentEntity persistentEntity, CqlIdentifier tableName) { + + Delete delete = EntityQueryUtils.createDeleteQuery(tableName.toCql(), entity, options, entityWriter); + + potentiallyApplyIfCondition(options, DeleteOptions.class, DeleteOptions::getIfCondition, condition -> { + addIfCondition(condition, delete, persistentEntity); + }); + + return delete; + } + + /** + * Compute the {@link Columns} to include type if the {@code returnType} is a {@literal DTO projection} or a + * {@literal closed interface projection}. + * + * @param columns must not be {@literal null}. + * @param persistentEntity must not be {@literal null}. + * @param returnType must not be {@literal null}. + * @return {@link Columns} with columns to be included. + * @since 2.2 + */ + Columns computeColumnsForProjection(Columns columns, PersistentEntity persistentEntity, Class returnType) { + + if (!columns.isEmpty() || ClassUtils.isAssignable(persistentEntity.getType(), returnType)) { + return columns; + } + + Columns projectedColumns = Columns.empty(); + + if (returnType.isInterface()) { + + ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(returnType); + + if (projectionInformation.isClosed()) { + + for (PropertyDescriptor inputProperty : projectionInformation.getInputProperties()) { + projectedColumns = projectedColumns.include(inputProperty.getName()); + } + } + } else { + for (PersistentProperty property : persistentEntity) { + projectedColumns = projectedColumns.include(property.getName()); + } + } + + return projectedColumns; + } + + private void addIfCondition(Filter filter, com.datastax.driver.core.querybuilder.Update update, + CassandraPersistentEntity persistentEntity) { + + Filter ifCondition = getQueryMapper().getMappedObject(filter, persistentEntity); + + for (CriteriaDefinition criteria : ifCondition) { + update.onlyIf(toClause(criteria)); + } + } + + private void addIfCondition(Filter filter, Delete delete, CassandraPersistentEntity persistentEntity) { + + Filter ifCondition = getQueryMapper().getMappedObject(filter, persistentEntity); + + for (CriteriaDefinition criteria : ifCondition) { + delete.onlyIf(toClause(criteria)); + } + } + private static com.datastax.driver.core.querybuilder.Update update(CqlIdentifier table, Update mappedUpdate, Filter filter) { @@ -426,92 +594,6 @@ public class StatementFactory { return QueryBuilder.putAll(updateOp.getColumnName().toCql(), updateOp.getValue()); } - /** - * Create a {@literal DELETE} statement by mapping {@link Query} to {@link Delete}. - * - * @param query must not be {@literal null}. - * @param entity must not be {@literal null}. - * @return the rendered {@link RegularStatement}. - */ - public RegularStatement delete(Query query, CassandraPersistentEntity entity) { - - Assert.notNull(query, "Query must not be null"); - Assert.notNull(entity, "Entity must not be null"); - - return delete(query, entity, entity.getTableName()); - } - - /** - * Create a {@literal DELETE} statement by mapping {@link Query} to {@link Delete}. - * - * @param query must not be {@literal null}. - * @param entity must not be {@literal null}. - * @param tableName must not be {@literal null}. - * @return the rendered {@link RegularStatement}. - * @see 2.1 - */ - public RegularStatement delete(Query query, CassandraPersistentEntity entity, CqlIdentifier tableName) { - - Assert.notNull(query, "Query must not be null"); - Assert.notNull(entity, "Entity must not be null"); - Assert.notNull(tableName, "Table name must not be null"); - - Filter filter = getQueryMapper().getMappedObject(query, entity); - - List columnNames = getQueryMapper().getMappedColumnNames(query.getColumns(), entity); - - Delete delete = delete(columnNames, tableName, filter); - - query.getQueryOptions().ifPresent(queryOptions -> { - if (queryOptions instanceof WriteOptions) { - EntityQueryUtils.addWriteOptions(delete, (WriteOptions) queryOptions); - } else { - QueryOptionsUtil.addQueryOptions(delete, queryOptions); - } - }); - - query.getPagingState().ifPresent(delete::setPagingState); - - return delete; - } - - /** - * Compute the {@link Columns} to include type if the {@code returnType} is a {@literal DTO projection} or a - * {@literal closed interface projection}. - * - * @param columns must not be {@literal null}. - * @param persistentEntity must not be {@literal null}. - * @param returnType must not be {@literal null}. - * @return {@link Columns} with columns to be included. - * @since 2.2 - */ - Columns computeColumnsForProjection(Columns columns, PersistentEntity persistentEntity, Class returnType) { - - if (!columns.isEmpty() || ClassUtils.isAssignable(persistentEntity.getType(), returnType)) { - return columns; - } - - Columns projectedColumns = Columns.empty(); - - if (returnType.isInterface()) { - - ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(returnType); - - if (projectionInformation.isClosed()) { - - for (PropertyDescriptor inputProperty : projectionInformation.getInputProperties()) { - projectedColumns = projectedColumns.include(inputProperty.getName()); - } - } - } else { - for (PersistentProperty property : persistentEntity) { - projectedColumns = projectedColumns.include(property.getName()); - } - } - - return projectedColumns; - } - private static Delete delete(List columnNames, CqlIdentifier from, Filter filter) { Delete select; @@ -531,6 +613,25 @@ public class StatementFactory { return select; } + /** + * Extract a {@link Filter} from an options {@code object} and callback {@link Consumer}. This method checks + * defensively if the {@code object} is an instance of {@link Class optionsClass} and tries to extract the + * {@link Filter}. If a filter is present, the {@link Consumer} gets called. + */ + private static void potentiallyApplyIfCondition(@Nullable Object object, Class optionsClass, + Function filterExtractor, Consumer consumeIfPresent) { + + if (optionsClass.isInstance(object)) { + + T options = optionsClass.cast(object); + Filter filter = filterExtractor.apply(options); + if (filter != null) { + consumeIfPresent.accept(filter); + } + } + + } + private static Clause toClause(CriteriaDefinition criteriaDefinition) { Predicate predicate = criteriaDefinition.getPredicate(); @@ -588,4 +689,5 @@ public class StatementFactory { throw new IllegalArgumentException( String.format("Criteria %s %s %s not supported", columnName, predicate.getOperator(), predicate.getValue())); } + } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/UpdateOptions.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/UpdateOptions.java index 09790205e..6972ba55a 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/UpdateOptions.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/UpdateOptions.java @@ -22,7 +22,10 @@ import java.time.Instant; import java.util.concurrent.TimeUnit; import org.springframework.data.cassandra.core.cql.WriteOptions; +import org.springframework.data.cassandra.core.query.CriteriaDefinition; +import org.springframework.data.cassandra.core.query.Filter; import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import com.datastax.driver.core.ConsistencyLevel; import com.datastax.driver.core.policies.RetryPolicy; @@ -39,15 +42,18 @@ public class UpdateOptions extends WriteOptions { private static final UpdateOptions EMPTY = new UpdateOptionsBuilder().build(); - private boolean ifExists; + private final boolean ifExists; + + private final @Nullable Filter ifCondition; private UpdateOptions(@Nullable ConsistencyLevel consistencyLevel, @Nullable RetryPolicy retryPolicy, @Nullable Boolean tracing, @Nullable Integer fetchSize, Duration readTimeout, Duration ttl, - @Nullable Long timestamp, boolean ifExists) { + @Nullable Long timestamp, boolean ifExists, @Nullable Filter ifCondition) { super(consistencyLevel, retryPolicy, tracing, fetchSize, readTimeout, ttl, timestamp); this.ifExists = ifExists; + this.ifCondition = ifCondition; } /** @@ -86,6 +92,15 @@ public class UpdateOptions extends WriteOptions { return this.ifExists; } + /** + * @return the {@link Filter IF condition} for conditional updates. + * @since 2.2 + */ + @Nullable + public Filter getIfCondition() { + return ifCondition; + } + /** * Builder for {@link UpdateOptions}. * @@ -97,6 +112,8 @@ public class UpdateOptions extends WriteOptions { private boolean ifExists; + private @Nullable Filter ifCondition; + private UpdateOptionsBuilder() {} private UpdateOptionsBuilder(UpdateOptions updateOptions) { @@ -104,6 +121,7 @@ public class UpdateOptions extends WriteOptions { super(updateOptions); this.ifExists = updateOptions.ifExists; + this.ifCondition = updateOptions.ifCondition; } /* (non-Javadoc) @@ -227,7 +245,7 @@ public class UpdateOptions extends WriteOptions { } /** - * Use light-weight transactions by applying {@code IF EXISTS}. + * Use light-weight transactions by applying {@code IF EXISTS}. Replaces a previous {@link #ifCondition(Filter)}. * * @return {@code this} {@link UpdateOptionsBuilder} */ @@ -236,7 +254,7 @@ public class UpdateOptions extends WriteOptions { } /** - * Use light-weight transactions by applying {@code IF EXISTS}. + * Use light-weight transactions by applying {@code IF EXISTS}. Replaces a previous {@link #ifCondition(Filter)}. * * @param ifNotExists {@literal true} to enable {@code IF EXISTS}. * @return {@code this} {@link UpdateOptionsBuilder} @@ -244,6 +262,40 @@ public class UpdateOptions extends WriteOptions { public UpdateOptionsBuilder ifExists(boolean ifNotExists) { this.ifExists = ifNotExists; + this.ifCondition = null; + + return this; + } + + /** + * Use light-weight transactions by applying {@code IF} {@link CriteriaDefinition condition}. Replaces a previous + * {@link #ifCondition(Filter)} and {@link #ifExists(boolean)}. + * + * @param criteria the {@link Filter criteria} to apply for conditional updates, must not be {@literal null}. + * @return {@code this} {@link UpdateOptionsBuilder} + * @since 2.2 + */ + public UpdateOptionsBuilder ifCondition(CriteriaDefinition criteria) { + + Assert.notNull(criteria, "CriteriaDefinition must not be null"); + + return ifCondition(Filter.from(criteria)); + } + + /** + * Use light-weight transactions by applying {@code IF} {@link Filter condition}. Replaces a previous + * {@link #ifCondition(Filter)} and {@link #ifExists(boolean)}. + * + * @param condition the {@link Filter condition} to apply for conditional updates, must not be {@literal null}. + * @return {@code this} {@link UpdateOptionsBuilder} + * @since 2.2 + */ + public UpdateOptionsBuilder ifCondition(Filter condition) { + + Assert.notNull(condition, "Filter condition must not be null"); + + this.ifCondition = condition; + this.ifExists = false; return this; } @@ -255,7 +307,7 @@ public class UpdateOptions extends WriteOptions { */ public UpdateOptions build() { return new UpdateOptions(this.consistencyLevel, this.retryPolicy, this.tracing, this.fetchSize, this.readTimeout, - this.ttl, this.timestamp, this.ifExists); + this.ttl, this.timestamp, this.ifExists, this.ifCondition); } } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/AsyncCassandraTemplateUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/AsyncCassandraTemplateUnitTests.java index 2f0b54a98..7cedca280 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/AsyncCassandraTemplateUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/AsyncCassandraTemplateUnitTests.java @@ -16,10 +16,9 @@ package org.springframework.data.cassandra.core; import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.springframework.data.cassandra.core.query.Criteria.*; import java.util.ArrayList; import java.util.Collections; @@ -37,7 +36,9 @@ import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.data.cassandra.CassandraConnectionFailureException; +import org.springframework.data.cassandra.core.query.Filter; import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.cassandra.core.query.Update; import org.springframework.data.cassandra.domain.User; import org.springframework.util.concurrent.ListenableFuture; @@ -298,6 +299,56 @@ public class AsyncCassandraTemplateUnitTests { .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg';"); } + @Test // DATACASS-575 + public void updateShouldUpdateEntityWithOptions() { + + UpdateOptions updateOptions = UpdateOptions.builder().withIfExists().build(); + User user = new User("heisenberg", "Walter", "White"); + + template.update(user, updateOptions); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF EXISTS;"); + } + + @Test // DATACASS-575 + public void updateShouldUpdateEntityWithLwt() { + + UpdateOptions options = UpdateOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + User user = new User("heisenberg", "Walter", "White"); + + template.update(user, options); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQuery() { + + template.update(Query.query(where("id").is("heisenberg")), Update.update("firstname", "Walter"), User.class); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter' WHERE id='heisenberg';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQueryWitLwt() { + + Filter ifCondition = Filter.from(where("firstname").is("Walter"), where("lastname").is("White")); + Query query = Query.query(where("id").is("heisenberg")) + .queryOptions(UpdateOptions.builder().ifCondition(ifCondition).build()); + + template.update(query, Update.update("firstname", "Walter"), User.class); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo( + "UPDATE users SET firstname='Walter' WHERE id='heisenberg' IF firstname='Walter' AND lastname='White';"); + } + @Test // DATACASS-292 public void updateShouldTranslateException() throws Exception { @@ -345,6 +396,32 @@ public class AsyncCassandraTemplateUnitTests { assertThat(statementCaptor.getValue().toString()).isEqualTo("DELETE FROM users WHERE id='heisenberg';"); } + @Test // DATACASS-575 + public void deleteShouldRemoveEntityWithLwt() { + + User user = new User("heisenberg", "Walter", "White"); + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + + template.delete(user, options); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void deleteShouldRemoveByQueryWithLwt() { + + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + Query query = Query.query(where("id").is("heisenberg")).queryOptions(options); + + template.delete(query, User.class); + + verify(session).executeAsync(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + @Test // DATACASS-292 public void deleteShouldTranslateException() throws Exception { diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateUnitTests.java index 08be3ec98..c7739d023 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateUnitTests.java @@ -18,6 +18,7 @@ package org.springframework.data.cassandra.core; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.springframework.data.cassandra.core.query.Criteria.*; import java.util.Collections; import java.util.List; @@ -32,7 +33,9 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.data.cassandra.CassandraConnectionFailureException; +import org.springframework.data.cassandra.core.query.Filter; import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.cassandra.core.query.Update; import org.springframework.data.cassandra.domain.User; import com.datastax.driver.core.ColumnDefinitions; @@ -336,6 +339,43 @@ public class CassandraTemplateUnitTests { .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF EXISTS;"); } + @Test // DATACASS-575 + public void updateShouldUpdateEntityWithLwt() { + + UpdateOptions options = UpdateOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + User user = new User("heisenberg", "Walter", "White"); + + template.update(user, options); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQuery() { + + template.update(Query.query(where("id").is("heisenberg")), Update.update("firstname", "Walter"), User.class); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter' WHERE id='heisenberg';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQueryWitLwt() { + + Filter ifCondition = Filter.from(where("firstname").is("Walter"), where("lastname").is("White")); + Query query = Query.query(where("id").is("heisenberg")) + .queryOptions(UpdateOptions.builder().ifCondition(ifCondition).build()); + + template.update(query, Update.update("firstname", "Walter"), User.class); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo( + "UPDATE users SET firstname='Walter' WHERE id='heisenberg' IF firstname='Walter' AND lastname='White';"); + } + @Test // DATACASS-292 public void updateShouldTranslateException() { @@ -368,8 +408,6 @@ public class CassandraTemplateUnitTests { @Test // DATACASS-292 public void deleteShouldRemoveEntity() { - when(resultSet.wasApplied()).thenReturn(true); - User user = new User("heisenberg", "Walter", "White"); template.delete(user); @@ -378,6 +416,32 @@ public class CassandraTemplateUnitTests { assertThat(statementCaptor.getValue().toString()).isEqualTo("DELETE FROM users WHERE id='heisenberg';"); } + @Test // DATACASS-575 + public void deleteShouldRemoveEntityWithLwt() { + + User user = new User("heisenberg", "Walter", "White"); + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + + template.delete(user, options); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void deleteShouldRemoveByQueryWithLwt() { + + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + Query query = Query.query(where("id").is("heisenberg")).queryOptions(options); + + template.delete(query, User.class); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + @Test // DATACASS-292 public void deleteShouldTranslateException() { diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/DeleteOptionsUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/DeleteOptionsUnitTests.java new file mode 100644 index 000000000..c0b1ca5d4 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/DeleteOptionsUnitTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.cassandra.core; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.Test; +import org.springframework.data.cassandra.core.query.Query; + +/** + * Unit tests for {@link DeleteOptions}. + * + * @author Mark Paluch + */ +public class DeleteOptionsUnitTests { + + @Test // DATACASS-575 + public void shouldConfigureDeleteOptions() { + + Instant now = Instant.ofEpochSecond(1234); + + DeleteOptions deleteOptions = DeleteOptions.builder() // + .ttl(10) // + .timestamp(now) // + .withIfExists() // + .build(); + + assertThat(deleteOptions.getTtl()).isEqualTo(Duration.ofSeconds(10)); + assertThat(deleteOptions.getTimestamp()).isEqualTo(now.toEpochMilli() * 1000); + assertThat(deleteOptions.isIfExists()).isTrue(); + } + + @Test // DATACASS-575 + public void buildDeleteOptionsMutate() { + + DeleteOptions deleteOptions = DeleteOptions.builder() // + .ttl(10) // + .timestamp(1519222753) // + .withIfExists() // + .build(); + + DeleteOptions mutated = deleteOptions.mutate().ttl(20).timestamp(1519000753).build(); + + assertThat(mutated).isNotNull(); + assertThat(mutated).isNotSameAs(deleteOptions); + assertThat(mutated.getTtl()).isEqualTo(Duration.ofSeconds(20)); + assertThat(mutated.getTimestamp()).isEqualTo(1519000753); + assertThat(mutated.isIfExists()).isTrue(); + } + + @Test // DATACASS-575 + public void shouldApplyFilterCondition() { + + DeleteOptions deleteOptions = DeleteOptions.builder() // + .withIfExists() // + .ifCondition(Query.empty()) // + .build(); + + assertThat(deleteOptions.isIfExists()).isFalse(); + assertThat(deleteOptions.getIfCondition()).isEqualTo(Query.empty()); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java index eaa774c8f..d9d0629a1 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java @@ -18,6 +18,7 @@ package org.springframework.data.cassandra.core; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.springframework.data.cassandra.core.query.Criteria.*; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -34,7 +35,9 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.data.cassandra.ReactiveResultSet; import org.springframework.data.cassandra.ReactiveSession; +import org.springframework.data.cassandra.core.query.Filter; import org.springframework.data.cassandra.core.query.Query; +import org.springframework.data.cassandra.core.query.Update; import org.springframework.data.cassandra.domain.User; import com.datastax.driver.core.ColumnDefinitions; @@ -261,6 +264,76 @@ public class ReactiveCassandraTemplateUnitTests { .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg';"); } + @Test // DATACASS-575 + public void updateShouldUpdateEntityWithOptions() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + UpdateOptions updateOptions = UpdateOptions.builder().withIfExists().build(); + User user = new User("heisenberg", "Walter", "White"); + + template.update(user, updateOptions) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF EXISTS;"); + } + + @Test // DATACASS-575 + public void updateShouldUpdateEntityWithLwt() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + UpdateOptions options = UpdateOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + User user = new User("heisenberg", "Walter", "White"); + + template.update(user, options) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter',lastname='White' WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQuery() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + template.update(Query.query(where("id").is("heisenberg")), Update.update("firstname", "Walter"), User.class) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE users SET firstname='Walter' WHERE id='heisenberg';"); + } + + @Test // DATACASS-575 + public void updateShouldApplyUpdateQueryWitLwt() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Filter ifCondition = Filter.from(where("firstname").is("Walter"), where("lastname").is("White")); + Query query = Query.query(where("id").is("heisenberg")) + .queryOptions(UpdateOptions.builder().ifCondition(ifCondition).build()); + + template.update(query, Update.update("firstname", "Walter"), User.class) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo( + "UPDATE users SET firstname='Walter' WHERE id='heisenberg' IF firstname='Walter' AND lastname='White';"); + } + @Test // DATACASS-335 public void deleteShouldRemoveEntity() { @@ -275,6 +348,42 @@ public class ReactiveCassandraTemplateUnitTests { assertThat(statementCaptor.getValue().toString()).isEqualTo("DELETE FROM users WHERE id='heisenberg';"); } + @Test // DATACASS-575 + public void deleteShouldRemoveEntityWithLwt() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + User user = new User("heisenberg", "Walter", "White"); + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + + template.delete(user, options) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + + @Test // DATACASS-575 + public void deleteShouldRemoveByQueryWithLwt() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + DeleteOptions options = DeleteOptions.builder().ifCondition(where("firstname").is("Walter")).build(); + Query query = Query.query(where("id").is("heisenberg")).queryOptions(options); + + template.delete(query, User.class) // + .as(StepVerifier::create) // + .expectNextCount(1) // + .verifyComplete(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("DELETE FROM users WHERE id='heisenberg' IF firstname='Walter';"); + } + @Test // DATACASS-335 public void truncateShouldRemoveEntities() { diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/UpdateOptionsUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/UpdateOptionsUnitTests.java index 80c27bc59..e3635ff04 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/UpdateOptionsUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/UpdateOptionsUnitTests.java @@ -21,6 +21,7 @@ import java.time.Duration; import java.time.Instant; import org.junit.Test; +import org.springframework.data.cassandra.core.query.Query; /** * Unit tests for {@link UpdateOptions}. @@ -35,10 +36,10 @@ public class UpdateOptionsUnitTests { Instant now = Instant.ofEpochSecond(1234); - UpdateOptions updateOptions = UpdateOptions.builder() - .ttl(10) - .timestamp(now) - .withIfExists() + UpdateOptions updateOptions = UpdateOptions.builder() // + .ttl(10) // + .timestamp(now) // + .withIfExists() // .build(); assertThat(updateOptions.getTtl()).isEqualTo(Duration.ofSeconds(10)); @@ -49,13 +50,16 @@ public class UpdateOptionsUnitTests { @Test // DATACASS-56, DATACASS-155 public void buildUpdateOptionsMutate() { - UpdateOptions updateOptions = UpdateOptions.builder() - .ttl(10) - .timestamp(1519222753) - .withIfExists() + UpdateOptions updateOptions = UpdateOptions.builder() // + .ttl(10) // + .timestamp(1519222753) // + .withIfExists() // .build(); - UpdateOptions mutated = updateOptions.mutate().ttl(20).timestamp(1519000753).build(); + UpdateOptions mutated = updateOptions.mutate() // + .ttl(20) // + .timestamp(1519000753) // + .build(); assertThat(mutated).isNotNull(); assertThat(mutated).isNotSameAs(updateOptions); @@ -63,4 +67,16 @@ public class UpdateOptionsUnitTests { assertThat(mutated.getTimestamp()).isEqualTo(1519000753); assertThat(mutated.isIfExists()).isTrue(); } + + @Test // DATACASS-575 + public void shouldApplyFilterCondition() { + + UpdateOptions updateOptions = UpdateOptions.builder() // + .withIfExists() // + .ifCondition(Query.empty()) // + .build(); + + assertThat(updateOptions.isIfExists()).isFalse(); + assertThat(updateOptions.getIfCondition()).isEqualTo(Query.empty()); + } } diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index ee85997f3..2f4572888 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -8,6 +8,8 @@ This chapter summarizes changes and new features for each release. * Read-only properties annotated with `@ReadOnlyProperty` to exclude non-writable properties from entity-bound `INSERT` and `UPDATE` operations. * Support for derived `Between` queries. * Kotlin Coroutine extensions for `ReactiveFluentCassandraOperations`. +* Lightweight transaction support via `DeleteOptions` using the Template API. +* Filter conditions for lightweight transaction update and delete (`UPDATE … IF `, `DELETE … IF `). [[new-features.2-1-0]] == What's new in Spring Data for Apache Cassandra 2.1