DATACASS-560 - Allow INSERT with null values through InsertOptions.insertNulls.

We now allow the configuration whether null values of entity objects should be inserted as tombstones using INSERT statements. By default, this option is disable. Enabling tombstones allows upsert usage through INSERT statements as null values get inserted in Cassandra. This option is useful if an object should be persisted in its whole state. Inserting nulls causes tombstones to be created and can have a negative impact on performance.

This change allows SimpleRepository and SimpleCassandraRepository to use CassandraTemplate directly allowing emission of lifecycle events.
This commit is contained in:
Mark Paluch
2018-06-11 15:14:09 +02:00
parent f20a11073e
commit df92c8b184
11 changed files with 195 additions and 57 deletions

View File

@@ -502,8 +502,9 @@ public class AsyncCassandraTemplate implements AsyncCassandraOperations, Applica
Assert.notNull(entity, "Entity must not be null");
Assert.notNull(options, "InsertOptions must not be null");
CqlIdentifier tableName = getTableName(entity);
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter());
CassandraPersistentEntity<?> persistentEntity = getRequiredPersistentEntity(entity.getClass());
CqlIdentifier tableName = persistentEntity.getTableName();
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter(), persistentEntity);
maybeEmitEvent(new BeforeSaveEvent<>(entity, tableName, insert));

View File

@@ -20,6 +20,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.data.cassandra.core.cql.QueryOptions;
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.util.Assert;
import com.datastax.driver.core.querybuilder.Batch;
@@ -109,10 +111,16 @@ class CassandraBatchTemplate implements CassandraBatchOperations {
Assert.notNull(entities, "Entities must not be null");
Assert.notNull(options, "WriteOptions must not be null");
CassandraMappingContext mappingContext = operations.getConverter().getMappingContext();
for (Object entity : entities) {
Assert.notNull(entity, "Entity must not be null");
batch.add(QueryUtils.createInsertQuery(getTableName(entity), entity, options, operations.getConverter()));
BasicCassandraPersistentEntity<?> persistentEntity = mappingContext
.getRequiredPersistentEntity(entity.getClass());
batch.add(QueryUtils.createInsertQuery(persistentEntity.getTableName().toCql(), entity, options,
operations.getConverter(), persistentEntity));
}
return this;

View File

@@ -549,7 +549,9 @@ public class CassandraTemplate implements CassandraOperations, ApplicationEventP
WriteResult doInsert(Object entity, WriteOptions options, CqlIdentifier tableName) {
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter());
CassandraPersistentEntity<?> persistentEntity = getRequiredPersistentEntity(entity.getClass());
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter(), persistentEntity);
maybeEmitEvent(new BeforeSaveEvent<>(entity, tableName, insert));

View File

@@ -41,13 +41,16 @@ public class InsertOptions extends WriteOptions {
private boolean ifNotExists;
private boolean insertNulls;
private InsertOptions(@Nullable ConsistencyLevel consistencyLevel, @Nullable RetryPolicy retryPolicy,
@Nullable Boolean tracing, @Nullable Integer fetchSize, Duration readTimeout, Duration ttl,
@Nullable Long timestamp, boolean ifNotExists) {
@Nullable Long timestamp, boolean ifNotExists, boolean insertNulls) {
super(consistencyLevel, retryPolicy, tracing, fetchSize, readTimeout, ttl, timestamp);
this.ifNotExists = ifNotExists;
this.insertNulls = insertNulls;
}
/**
@@ -86,6 +89,14 @@ public class InsertOptions extends WriteOptions {
return this.ifNotExists;
}
/**
* @return {@literal true} to insert {@literal null} values from an entity.
* @since 2.1
*/
public boolean isInsertNulls() {
return this.insertNulls;
}
/**
* Builder for {@link InsertOptions}.
*
@@ -97,6 +108,8 @@ public class InsertOptions extends WriteOptions {
private boolean ifNotExists;
private boolean insertNulls;
private InsertOptionsBuilder() {}
private InsertOptionsBuilder(InsertOptions insertOptions) {
@@ -104,6 +117,7 @@ public class InsertOptions extends WriteOptions {
super(insertOptions);
this.ifNotExists = insertOptions.ifNotExists;
this.insertNulls = insertOptions.insertNulls;
}
/* (non-Javadoc)
@@ -247,6 +261,34 @@ public class InsertOptions extends WriteOptions {
return this;
}
/**
* Insert {@literal null} values from an entity. This allows the usage of {@code INSERT} statements as upsert by
* ensuring * that the whole entity state is persisted. Inserting {@literal null}s in Cassandra creates tombstones
* so this * option should be used with caution.
*
* @return {@code this} {@link InsertOptionsBuilder}
* @since 2.1
*/
public InsertOptionsBuilder withInsertNulls() {
return withInsertNulls(true);
}
/**
* Insert {@literal null} values from an entity. This allows the usage of {@code INSERT} statements as upsert by
* ensuring that the whole entity state is persisted. Inserting {@literal null}s in Cassandra creates tombstones so
* this option should be used with caution.
*
* @param insertNulls {@literal true} to enable insertion of {@literal null} values.
* @return {@code this} {@link InsertOptionsBuilder}
* @since 2.1
*/
public InsertOptionsBuilder withInsertNulls(boolean insertNulls) {
this.insertNulls = insertNulls;
return this;
}
/**
* Builds a new {@link InsertOptions} with the configured values.
*
@@ -254,7 +296,7 @@ public class InsertOptions extends WriteOptions {
*/
public InsertOptions build() {
return new InsertOptions(this.consistencyLevel, this.retryPolicy, this.tracing, this.fetchSize, this.readTimeout,
this.ttl, this.timestamp, this.ifNotExists);
this.ttl, this.timestamp, this.ifNotExists, this.insertNulls);
}
}
}

View File

@@ -16,16 +16,21 @@
package org.springframework.data.cassandra.core;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.DirectFieldAccessor;
import org.springframework.data.cassandra.core.convert.CassandraConverter;
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.RowMapper;
import org.springframework.data.cassandra.core.cql.WriteOptions;
import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity;
import org.springframework.data.cassandra.core.query.CassandraPageRequest;
import org.springframework.data.convert.EntityWriter;
import org.springframework.data.domain.PageRequest;
@@ -64,17 +69,20 @@ class QueryUtils {
* @param objectToUpdate the object to save, must not be {@literal null}.
* @param options optional {@link WriteOptions} to apply to the {@link Insert} statement, may be {@literal null}.
* @param entityWriter the {@link EntityWriter} to write insert values.
* @param entity must not be {@literal null}.
* @return The Query object to run with session.execute();
*/
static Insert createInsertQuery(String tableName, Object objectToUpdate, WriteOptions options,
EntityWriter<Object, Object> entityWriter) {
CassandraConverter entityWriter, CassandraPersistentEntity<?> entity) {
Assert.hasText(tableName, "TableName must not be empty");
Assert.notNull(objectToUpdate, "Object to insert must not be null");
Assert.notNull(entityWriter, "EntityWriter must not be null");
Assert.notNull(entityWriter, "CassandraConverter must not be null");
Assert.notNull(entity, "CassandraPersistentEntity must not be null");
Insert insert = QueryOptionsUtil.addWriteOptions(QueryBuilder.insertInto(tableName), options);
boolean insertNulls = false;
if (options instanceof InsertOptions) {
InsertOptions insertOptions = (InsertOptions) options;
@@ -82,9 +90,22 @@ class QueryUtils {
if (insertOptions.isIfNotExists()) {
insert = insert.ifNotExists();
}
insertNulls = insertOptions.isInsertNulls();
}
entityWriter.write(objectToUpdate, insert);
if (insertNulls) {
Map<String, Object> toInsert = new LinkedHashMap<>();
entityWriter.write(objectToUpdate, toInsert, entity);
for (Entry<String, Object> entry : toInsert.entrySet()) {
insert.value(entry.getKey(), entry.getValue());
}
} else {
entityWriter.write(objectToUpdate, insert);
}
return insert;
}

View File

@@ -15,13 +15,12 @@
*/
package org.springframework.data.cassandra.core;
import java.util.function.Function;
import lombok.Value;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.function.Function;
import org.reactivestreams.Publisher;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
@@ -433,7 +432,9 @@ public class ReactiveCassandraTemplate implements ReactiveCassandraOperations, A
Mono<WriteResult> doInsert(Object entity, WriteOptions options, CqlIdentifier tableName) {
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter());
CassandraPersistentEntity<?> persistentEntity = getRequiredPersistentEntity(entity.getClass());
Insert insert = QueryUtils.createInsertQuery(tableName.toCql(), entity, options, getConverter(), persistentEntity);
// noinspection ConstantConditions
Mono<WriteResult> result = getReactiveCqlOperations().execute(new StatementCallback(insert))

View File

@@ -15,7 +15,7 @@
*/
package org.springframework.data.cassandra.repository.support;
import static org.springframework.data.cassandra.core.query.Criteria.where;
import static org.springframework.data.cassandra.core.query.Criteria.*;
import java.util.ArrayList;
import java.util.List;
@@ -23,6 +23,7 @@ import java.util.Optional;
import org.springframework.data.cassandra.core.CassandraOperations;
import org.springframework.data.cassandra.core.CassandraTemplate;
import org.springframework.data.cassandra.core.InsertOptions;
import org.springframework.data.cassandra.core.query.Query;
import org.springframework.data.cassandra.repository.CassandraRepository;
import org.springframework.data.cassandra.repository.query.CassandraEntityInformation;
@@ -46,13 +47,15 @@ import com.datastax.driver.core.querybuilder.Select;
*/
public class SimpleCassandraRepository<T, ID> implements CassandraRepository<T, ID> {
private static final InsertOptions INSERT_NULLS = InsertOptions.builder().withInsertNulls().build();
private final CassandraEntityInformation<T, ID> entityInformation;
private final CassandraOperations operations;
/**
* Create a new {@link SimpleCassandraRepository} for the given {@link CassandraEntityInformation}
* and {@link CassandraTemplate}.
* Create a new {@link SimpleCassandraRepository} for the given {@link CassandraEntityInformation} and
* {@link CassandraTemplate}.
*
* @param metadata must not be {@literal null}.
* @param operations must not be {@literal null}.
@@ -74,9 +77,7 @@ public class SimpleCassandraRepository<T, ID> implements CassandraRepository<T,
Assert.notNull(entity, "Entity must not be null");
Insert insert = createInsert(entity);
operations.getCqlOperations().execute(insert);
operations.insert(entity, INSERT_NULLS);
return entity;
}
@@ -94,7 +95,7 @@ public class SimpleCassandraRepository<T, ID> implements CassandraRepository<T,
for (S entity : entities) {
result.add(entity);
operations.getCqlOperations().execute(createInsert(entity));
operations.insert(entity, INSERT_NULLS);
}
return result;
@@ -105,6 +106,8 @@ public class SimpleCassandraRepository<T, ID> implements CassandraRepository<T,
*
* @param entity the entity, must not be {@literal null}.
* @return the constructed {@link Insert} statement.
* @deprecated since 2.1, use {@link InsertOptions#isInsertNulls()} with
* {@link CassandraOperations#insert(Object, InsertOptions)}.
*/
protected <S extends T> Insert createInsert(S entity) {
return InsertUtil.createInsert(operations.getConverter(), entity);

View File

@@ -18,13 +18,13 @@ package org.springframework.data.cassandra.repository.support;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.reactivestreams.Publisher;
import org.springframework.data.cassandra.core.InsertOptions;
import org.springframework.data.cassandra.core.ReactiveCassandraOperations;
import org.springframework.data.cassandra.repository.ReactiveCassandraRepository;
import org.springframework.data.cassandra.repository.query.CassandraEntityInformation;
import org.springframework.util.Assert;
import org.reactivestreams.Publisher;
import com.datastax.driver.core.querybuilder.Insert;
import com.datastax.driver.core.querybuilder.QueryBuilder;
import com.datastax.driver.core.querybuilder.Select;
@@ -38,6 +38,8 @@ import com.datastax.driver.core.querybuilder.Select;
*/
public class SimpleReactiveCassandraRepository<T, ID> implements ReactiveCassandraRepository<T, ID> {
private static final InsertOptions INSERT_NULLS = InsertOptions.builder().withInsertNulls().build();
private final CassandraEntityInformation<T, ID> entityInformation;
private final ReactiveCassandraOperations operations;
@@ -67,7 +69,7 @@ public class SimpleReactiveCassandraRepository<T, ID> implements ReactiveCassand
Assert.notNull(entity, "Entity must not be null");
return operations.getReactiveCqlOperations().execute(createInsert(entity)).map(it -> entity);
return operations.insert(entity, INSERT_NULLS).thenReturn(entity);
}
/**
@@ -75,6 +77,8 @@ public class SimpleReactiveCassandraRepository<T, ID> implements ReactiveCassand
*
* @param entity the entity, must not be {@literal null}.
* @return the constructed {@link Insert} statement.
* @deprecated since 2.1, use {@link InsertOptions#isInsertNulls()} with
* {@link ReactiveCassandraOperations#insert(Object, InsertOptions)}.
*/
private <S extends T> Insert createInsert(S entity) {
return InsertUtil.createInsert(operations.getConverter(), entity);
@@ -100,7 +104,7 @@ public class SimpleReactiveCassandraRepository<T, ID> implements ReactiveCassand
Assert.notNull(entityStream, "The given Publisher of entities must not be null");
return Flux.from(entityStream)
.flatMap(entity -> operations.getReactiveCqlOperations().execute(createInsert(entity)).map(it -> entity));
.flatMap(entity -> operations.insert(entity, INSERT_NULLS).thenReturn(entity));
}
/* (non-Javadoc)

View File

@@ -15,14 +15,11 @@
*/
package org.springframework.data.cassandra.core;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
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.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
import java.util.Collections;
import java.util.List;
@@ -36,7 +33,6 @@ import org.mockito.Captor;
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.Query;
import org.springframework.data.cassandra.domain.User;
@@ -250,8 +246,24 @@ public class CassandraTemplateUnitTests {
.isEqualTo("INSERT INTO users (firstname,id,lastname) VALUES ('Walter','heisenberg','White') IF NOT EXISTS;");
}
@Test // DATACASS-560
public void insertShouldInsertWithNulls() {
InsertOptions insertOptions = InsertOptions.builder().withInsertNulls().build();
when(resultSet.wasApplied()).thenReturn(true);
User user = new User("heisenberg", null, null);
template.insert(user, insertOptions);
verify(session).execute(statementCaptor.capture());
assertThat(statementCaptor.getValue().toString())
.isEqualTo("INSERT INTO users (firstname,id,lastname) VALUES (null,'heisenberg',null);");
}
@Test // DATACASS-292
public void insertShouldTranslateException() throws Exception {
public void insertShouldTranslateException() {
reset(session);
when(session.execute(any(Statement.class))).thenThrow(new NoHostAvailableException(Collections.emptyMap()));
@@ -351,7 +363,7 @@ public class CassandraTemplateUnitTests {
}
@Test // DATACASS-292
public void deleteShouldTranslateException() throws Exception {
public void deleteShouldTranslateException() {
reset(session);
when(session.execute(any(Statement.class))).thenThrow(new NoHostAvailableException(Collections.emptyMap()));

View File

@@ -15,23 +15,29 @@
*/
package org.springframework.data.cassandra.repository.support;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.cassandra.core.CassandraOperations;
import org.springframework.data.cassandra.core.mapping.event.AbstractCassandraEventListener;
import org.springframework.data.cassandra.core.mapping.event.AfterSaveEvent;
import org.springframework.data.cassandra.core.mapping.event.BeforeSaveEvent;
import org.springframework.data.cassandra.core.mapping.event.CassandraMappingEvent;
import org.springframework.data.cassandra.core.query.CassandraPageRequest;
import org.springframework.data.cassandra.domain.User;
import org.springframework.data.cassandra.repository.CassandraRepository;
@@ -59,9 +65,15 @@ public class SimpleCassandraRepositoryIntegrationTests extends AbstractKeyspaceC
public String[] getEntityBasePackages() {
return new String[] { User.class.getPackage().getName() };
}
@Bean
CaptureEventListener eventListener() {
return new CaptureEventListener();
}
}
@Autowired private CassandraOperations operations;
@Autowired private CaptureEventListener eventListener;
private BeanFactory beanFactory;
private CassandraRepositoryFactory factory;
@@ -99,6 +111,8 @@ public class SimpleCassandraRepositoryIntegrationTests extends AbstractKeyspaceC
boyd = new User("45", "Boyd", "Tinsley");
repository.saveAll(Arrays.asList(oliver, dave, carter, boyd));
eventListener.clear();
}
@Test // DATACASS-396
@@ -217,6 +231,18 @@ public class SimpleCassandraRepositoryIntegrationTests extends AbstractKeyspaceC
});
}
@Test // DATACASS-560
public void saveShouldEmitEvents() {
dave.setFirstname("Hello, Dave");
dave.setLastname("Bowman");
repository.save(dave);
assertThat(eventListener.getBeforeSave()).hasSize(1);
assertThat(eventListener.getAfterSave()).hasSize(1);
}
@Test // DATACASS-396
public void saveEntityShouldInsertNewEntity() {
@@ -304,4 +330,35 @@ public class SimpleCassandraRepositoryIntegrationTests extends AbstractKeyspaceC
interface UserRepostitory extends CassandraRepository<User, String> { }
static class CaptureEventListener extends AbstractCassandraEventListener<User> {
private final List<CassandraMappingEvent<?>> events = new CopyOnWriteArrayList<>();
@Override
public void onBeforeSave(BeforeSaveEvent<User> event) {
events.add(event);
}
@Override
public void onAfterSave(AfterSaveEvent<User> event) {
events.add(event);
}
private void clear() {
events.clear();
}
List<BeforeSaveEvent<User>> getBeforeSave() {
return filter(BeforeSaveEvent.class);
}
List<AfterSaveEvent<User>> getAfterSave() {
return filter(AfterSaveEvent.class);
}
@SuppressWarnings("unchecked")
private <T> List<T> filter(Class<? super T> targetType) {
return (List) events.stream().filter(targetType::isInstance).map(targetType::cast).collect(Collectors.toList());
}
}
}

View File

@@ -15,15 +15,12 @@
*/
package org.springframework.data.cassandra.repository.support;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.Serializable;
import static org.mockito.Mockito.*;
import lombok.Data;
import java.io.Serializable;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -31,11 +28,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.springframework.data.annotation.Id;
import org.springframework.data.cassandra.core.CassandraOperations;
import org.springframework.data.cassandra.core.InsertOptions;
import org.springframework.data.cassandra.core.convert.MappingCassandraConverter;
import org.springframework.data.cassandra.core.cql.CqlIdentifier;
import org.springframework.data.cassandra.core.cql.CqlOperations;
import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraMappingContext;
@@ -72,15 +68,10 @@ public class SimpleCassandraRepositoryUnitTests {
@Before
public void before() {
mappingContext.setUserTypeResolver(userTypeResolver);
when(cassandraOperations.getConverter()).thenReturn(converter);
when(cassandraOperations.getCqlOperations()).thenReturn(cqlOperations);
when(userTypeResolver.resolveType(CqlIdentifier.of("address"))).thenReturn(userType);
}
@Test // DATACASS-428
@Test // DATACASS-428, DATACASS-560
public void saveShouldInsertNewPrimaryKeyOnlyEntity() {
CassandraPersistentEntity<?> entity = converter.getMappingContext().getRequiredPersistentEntity(SimplePerson.class);
@@ -92,11 +83,10 @@ public class SimpleCassandraRepositoryUnitTests {
repository.save(person);
verify(cqlOperations).execute(insertCaptor.capture());
assertThat(insertCaptor.getValue().toString()).isEqualTo("INSERT INTO simpleperson (id) VALUES (null);");
verify(cassandraOperations).insert(person, InsertOptions.builder().withInsertNulls().build());
}
@Test // DATACASS-428
@Test // DATACASS-428, DATACASS-560
public void saveShouldUpdateNewEntity() {
CassandraPersistentEntity<?> entity = converter.getMappingContext().getRequiredPersistentEntity(Person.class);
@@ -108,10 +98,10 @@ public class SimpleCassandraRepositoryUnitTests {
repository.save(person);
verify(cqlOperations).execute(any(Insert.class));
verify(cassandraOperations).insert(person, InsertOptions.builder().withInsertNulls().build());
}
@Test // DATACASS-428
@Test // DATACASS-428, DATACASS-560
public void saveShouldUpdateExistingEntity() {
CassandraPersistentEntity<?> entity = converter.getMappingContext().getRequiredPersistentEntity(Person.class);
@@ -125,10 +115,7 @@ public class SimpleCassandraRepositoryUnitTests {
repository.save(person);
verify(cqlOperations).execute(insertCaptor.capture());
assertThat(insertCaptor.getValue().toString())
.contains("INSERT INTO person (lastname,firstname,alternativeaddresses,");
assertThat(insertCaptor.getValue().toString()).contains("VALUES ('bar','foo',null");
verify(cassandraOperations).insert(person, InsertOptions.builder().withInsertNulls().build());
}
@Test // DATACASS-428