From 0e624c9cc4b7514dbfcc21f5bc32b604dac5c926 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 29 Jan 2019 13:57:48 +0100 Subject: [PATCH] DATACASS-575 - Support IF conditions in lightweight transactions with UPDATE and DELETE. We now support conditions in lightweight transactions for UPDATE and DELETE statements. Conditions are Filter objects similar to the WHERE clause. Conditions are supported for entity and query-based update/delete methods. UpdateOptions options = UpdateOptions.builder().ifCondition(where("firstname").is("Walter")).build(); User user = new User("heisenberg", "Walter", "White"); template.update(user, options); DeleteOptions options = DeleteOptions.builder().ifCondition(where("counter").is(42)).build(); Query query = Query.query(where("id").is("heisenberg")).queryOptions(options); template.delete(query, User.class); --- .../core/AsyncCassandraTemplate.java | 29 +- .../core/CassandraBatchTemplate.java | 31 +- .../cassandra/core/CassandraTemplate.java | 10 +- .../data/cassandra/core/DeleteOptions.java | 60 +++- .../core/ReactiveCassandraBatchTemplate.java | 27 +- .../core/ReactiveCassandraTemplate.java | 11 +- .../data/cassandra/core/StatementFactory.java | 274 ++++++++++++------ .../data/cassandra/core/UpdateOptions.java | 62 +++- .../core/AsyncCassandraTemplateUnitTests.java | 83 +++++- .../core/CassandraTemplateUnitTests.java | 68 ++++- .../core/DeleteOptionsUnitTests.java | 78 +++++ .../ReactiveCassandraTemplateUnitTests.java | 109 +++++++ .../core/UpdateOptionsUnitTests.java | 34 ++- src/main/asciidoc/new-features.adoc | 2 + 14 files changed, 728 insertions(+), 150 deletions(-) create mode 100644 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/DeleteOptionsUnitTests.java 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