From 9591e1f4fcc2f2257d154ba099d51b3536f46efd Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 28 Nov 2016 17:05:59 +0100 Subject: [PATCH] DATACASS-359 - Support DTO projections. We now support DTO projections for query methods. DTO projection selects records from Cassandra and applies projected results on the DTO. DTOs are plain Java objects that fit to the underlying entity. @Table class Person { @PrimaryKeyColumn(type = PrimaryKeyType.PARTITIONED, ordinal = 0) private String lastname; @PrimaryKeyColumn(type = PrimaryKeyType.CLUSTERED, ordinal = 1) private String firstname; private String nickname; private Date birthDate; // more columns } interface PersonRepository extends CrudRepository { Collection findPersonDtoBy(); T findDtoByFirstnameStartsWith(String prefix, Class projectionType); } class PersonDto { public String firstname, lastname; public PersonDto(String firstname, String lastname) { this.firstname = firstname; this.lastname = lastname; } } --- .../query/AbstractCassandraQuery.java | 17 +-- .../query/CassandraQueryCreator.java | 16 +-- .../query/CassandraQueryExecution.java | 19 ++- .../query/CassandraQueryMethod.java | 1 + .../query/DtoInstantiatingConverter.java | 108 ++++++++++++++++++ .../query/CassandraQueryCreatorUnitTests.java | 14 +-- .../derived/PersonRepository.java | 17 +++ .../QueryDerivationIntegrationTests.java | 35 +++++- 8 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/DtoInstantiatingConverter.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractCassandraQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractCassandraQuery.java index b41668579..3a3127be0 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractCassandraQuery.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractCassandraQuery.java @@ -15,6 +15,8 @@ */ package org.springframework.data.cassandra.repository.query; +import lombok.RequiredArgsConstructor; + import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -37,6 +39,7 @@ import org.springframework.data.cassandra.repository.query.CassandraQueryExecuti import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.ResultSetQuery; import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.SingleEntityExecution; import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.StreamExecution; +import org.springframework.data.convert.EntityInstantiators; import org.springframework.data.repository.query.ParameterAccessor; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; @@ -59,6 +62,7 @@ public abstract class AbstractCassandraQuery implements RepositoryQuery { private final CassandraQueryMethod queryMethod; private final CassandraOperations template; + private final EntityInstantiators instantiators; /** * Creates a new {@link AbstractCassandraQuery} from the given {@link CassandraQueryMethod} and @@ -74,6 +78,7 @@ public abstract class AbstractCassandraQuery implements RepositoryQuery { this.queryMethod = queryMethod; this.template = operations; + this.instantiators = new EntityInstantiators(); } /* (non-Javadoc) @@ -93,12 +98,12 @@ public abstract class AbstractCassandraQuery implements RepositoryQuery { CassandraParameterAccessor parameterAccessor = new ConvertingParameterAccessor(template.getConverter(), new CassandraParametersParameterAccessor(queryMethod, parameters)); - String query = createQuery(parameterAccessor); - ResultProcessor resultProcessor = queryMethod.getResultProcessor().withDynamicProjection(parameterAccessor); + String query = createQuery(parameterAccessor); + CassandraQueryExecution queryExecution = getExecution(query, parameterAccessor, - new ResultProcessingConverter(resultProcessor)); + new ResultProcessingConverter(resultProcessor, template.getConverter().getMappingContext(), instantiators)); CassandraReturnedType returnedType = new CassandraReturnedType(resultProcessor.getReturnedType(), template.getConverter().getCustomConversions()); @@ -237,16 +242,12 @@ public abstract class AbstractCassandraQuery implements RepositoryQuery { */ protected abstract String createQuery(CassandraParameterAccessor accessor); + @RequiredArgsConstructor private class CassandraReturnedType { private final ReturnedType returnedType; private final CustomConversions customConversions; - CassandraReturnedType(ReturnedType returnedType, CustomConversions customConversions) { - this.returnedType = returnedType; - this.customConversions = customConversions; - } - boolean isProjecting() { if (!returnedType.isProjecting()) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java index b3dc0f3cd..e4df2b2ea 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreator.java @@ -23,6 +23,7 @@ import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.cassandra.core.cql.CqlIdentifier; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.cassandra.mapping.CassandraMappingContext; import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; @@ -53,14 +54,12 @@ import com.datastax.driver.core.querybuilder.Select; class CassandraQueryCreator extends AbstractQueryCreator { private static final Pattern PUNCTUATION_PATTERN = Pattern.compile("\\p{Punct}"); - private static final Logger LOG = LoggerFactory.getLogger(CassandraQueryCreator.class); private final CassandraMappingContext mappingContext; - private final CassandraPersistentEntity entity; - private final WhereBuilder whereBuilder = new WhereBuilder(); + private final CqlIdentifier tableName; /** * Creates a new {@link CassandraQueryCreator} from the given {@link PartTree}, {@link ConvertingParameterAccessor} @@ -72,15 +71,16 @@ class CassandraQueryCreator extends AbstractQueryCreator { * @param entityMetadata must not be {@literal null}. */ public CassandraQueryCreator(PartTree tree, CassandraParameterAccessor accessor, - CassandraMappingContext mappingContext, EntityMetadata entityMetadata) { + CassandraMappingContext mappingContext, CassandraEntityMetadata entityMetadata) { super(tree, accessor); Assert.notNull(mappingContext, "CassandraMappingContext must not be null"); - Assert.notNull(entityMetadata, "EntityMetaData must not be null"); + Assert.notNull(entityMetadata, "CassandraEntityMetadata must not be null"); this.mappingContext = mappingContext; this.entity = mappingContext.getPersistentEntity(entityMetadata.getJavaType()); + this.tableName = entityMetadata.getTableName(); } /* (non-Javadoc) @@ -131,7 +131,7 @@ class CassandraQueryCreator extends AbstractQueryCreator { whereBuilder.and(criteria); } - Select select = StatementBuilder.select(entity, whereBuilder, sort); + Select select = StatementBuilder.select(entity, tableName, whereBuilder, sort); if (LOG.isDebugEnabled()) { LOG.debug("Created query {}", select); @@ -253,9 +253,9 @@ class CassandraQueryCreator extends AbstractQueryCreator { * Build a {@link Select} statement from the given {@link WhereBuilder} and {@link Sort}. Resolves property names * for {@link Sort} using the {@link CassandraPersistentEntity}. */ - static Select select(CassandraPersistentEntity entity, WhereBuilder whereBuilder, Sort sort) { + static Select select(CassandraPersistentEntity entity, CqlIdentifier tableName, WhereBuilder whereBuilder, Sort sort) { - Select select = QueryBuilder.select().from(entity.getTableName().toCql()); + Select select = QueryBuilder.select().from(tableName.toCql()); whereBuilder.build(select.where()); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryExecution.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryExecution.java index fe6ec3c43..536450ceb 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryExecution.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryExecution.java @@ -16,18 +16,20 @@ package org.springframework.data.cassandra.repository.query; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + import java.util.function.Function; import org.springframework.core.convert.converter.Converter; import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.convert.EntityInstantiators; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.util.StreamUtils; import org.springframework.util.ClassUtils; -import lombok.NonNull; -import lombok.RequiredArgsConstructor; - /** * Query executions for Cassandra. * @@ -151,6 +153,8 @@ interface CassandraQueryExecution { final class ResultProcessingConverter implements Converter { private final @NonNull ResultProcessor processor; + private final @NonNull CassandraMappingContext mappingContext; + private final @NonNull EntityInstantiators instantiators; /* (non-Javadoc) * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) @@ -164,7 +168,14 @@ interface CassandraQueryExecution { return source; } - return processor.processResult(source); + if (source != null && returnedType.isInstance(source)) { + return source; + } + + Converter converter = new DtoInstantiatingConverter(returnedType.getReturnedType(), + mappingContext, instantiators); + + return processor.processResult(source, converter); } } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java index 36dc4f4a5..3d1c863fc 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java @@ -96,6 +96,7 @@ public class CassandraQueryMethod extends QueryMethod { mappingContext.getPersistentEntity(domainClass)); } else { + CassandraPersistentEntity returnedEntity = mappingContext.getPersistentEntity(returnedObjectType); CassandraPersistentEntity managedEntity = mappingContext.getPersistentEntity(domainClass); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/DtoInstantiatingConverter.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/DtoInstantiatingConverter.java new file mode 100644 index 000000000..8ad8d4cc4 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/DtoInstantiatingConverter.java @@ -0,0 +1,108 @@ +/* + * Copyright 2016 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.repository.query; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; +import org.springframework.data.cassandra.mapping.CassandraPersistentProperty; +import org.springframework.data.convert.EntityInstantiator; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PreferredConstructor; +import org.springframework.data.mapping.PreferredConstructor.Parameter; +import org.springframework.data.mapping.SimplePropertyHandler; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.util.Assert; + +/** + * {@link Converter} to instantiate DTOs from fully equipped domain objects. + * + * @author Mark Paluch + */ +class DtoInstantiatingConverter implements Converter { + + private final Class targetType; + private final MappingContext, ? extends PersistentProperty> context; + private final EntityInstantiator instantiator; + + /** + * Creates a new {@link Converter} to instantiate DTOs. + * + * @param dtoType must not be {@literal null}. + * @param context must not be {@literal null}. + * @param instantiators must not be {@literal null}. + */ + DtoInstantiatingConverter(Class dtoType, + MappingContext, CassandraPersistentProperty> context, + EntityInstantiators instantiator) { + + Assert.notNull(dtoType, "DTO type must not be null!"); + Assert.notNull(context, "MappingContext must not be null!"); + Assert.notNull(instantiator, "EntityInstantiators must not be null!"); + + this.targetType = dtoType; + this.context = context; + this.instantiator = instantiator.getInstantiatorFor(context.getPersistentEntity(dtoType)); + } + + /* + * (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + @Override + public Object convert(Object source) { + + if (targetType.isInterface()) { + return source; + } + + final PersistentEntity sourceEntity = context.getPersistentEntity(source.getClass()); + final PersistentPropertyAccessor sourceAccessor = sourceEntity.getPropertyAccessor(source); + final PersistentEntity targetEntity = context.getPersistentEntity(targetType); + final PreferredConstructor> constructor = targetEntity + .getPersistenceConstructor(); + + @SuppressWarnings({ "rawtypes", "unchecked" }) + Object dto = instantiator.createInstance(targetEntity, new ParameterValueProvider() { + + @Override + public Object getParameterValue(Parameter parameter) { + return sourceAccessor.getProperty(sourceEntity.getPersistentProperty(parameter.getName())); + } + }); + + final PersistentPropertyAccessor dtoAccessor = targetEntity.getPropertyAccessor(dto); + + targetEntity.doWithProperties(new SimplePropertyHandler() { + + @Override + public void doWithPersistentProperty(PersistentProperty property) { + + if (constructor.isConstructorParameter(property)) { + return; + } + + dtoAccessor.setProperty(property, + sourceAccessor.getProperty(sourceEntity.getPersistentProperty(property.getName()))); + } + }); + + return dto; + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreatorUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreatorUnitTests.java index 41ab02dde..9f070206d 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreatorUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/CassandraQueryCreatorUnitTests.java @@ -36,12 +36,13 @@ import org.springframework.data.cassandra.convert.MappingCassandraConverter; import org.springframework.data.cassandra.domain.Person; import org.springframework.data.cassandra.mapping.BasicCassandraMappingContext; import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.mapping.Column; import org.springframework.data.cassandra.mapping.PrimaryKey; import org.springframework.data.cassandra.mapping.PrimaryKeyClass; import org.springframework.data.cassandra.mapping.PrimaryKeyColumn; import org.springframework.data.cassandra.mapping.Table; -import org.springframework.data.repository.core.EntityMetadata; +import org.springframework.data.cassandra.repository.support.MappingCassandraEntityInformation; import org.springframework.data.repository.query.parser.PartTree; /** @@ -350,13 +351,10 @@ public class CassandraQueryCreatorUnitTests { return creator.createQuery().toString(); } - private EntityMetadata getEntityInformation(final Class entityClass) { - return new EntityMetadata() { - @Override - public Class getJavaType() { - return entityClass; - } - }; + @SuppressWarnings("unchecked") + private CassandraEntityInformation getEntityInformation(final Class entityClass) { + return new MappingCassandraEntityInformation( + (CassandraPersistentEntity) context.getPersistentEntity(entityClass), converter); } @Table diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/PersonRepository.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/PersonRepository.java index d15748ab8..f5a286aec 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/PersonRepository.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/PersonRepository.java @@ -19,6 +19,8 @@ import java.time.LocalDate; import java.util.Collection; import java.util.List; +import lombok.Data; +import lombok.Getter; import org.springframework.data.cassandra.repository.CassandraRepository; import org.springframework.data.cassandra.repository.Query; import org.springframework.data.cassandra.test.integration.repository.querymethods.declared.Address; @@ -53,6 +55,10 @@ interface PersonRepository extends CassandraRepository { Collection findPersonProjectedBy(); + Collection findPersonDtoBy(); + + T findDtoByNicknameStartsWith(String prefix, Class projectionType); + @Query("select * from person where firstname = ?0 and lastname = 'White'") List findByFirstname(String firstname); @@ -66,4 +72,15 @@ interface PersonRepository extends CassandraRepository { String getLastname(); } + + static class PersonDto { + + public String firstname, lastname; + + public PersonDto(String firstname, String lastname) { + + this.firstname = firstname; + this.lastname = lastname; + } + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java index 36b0d1ed0..3f988dc5c 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java @@ -36,6 +36,7 @@ import org.springframework.data.cassandra.repository.config.EnableCassandraRepos import org.springframework.data.cassandra.test.integration.repository.querymethods.declared.Address; import org.springframework.data.cassandra.test.integration.repository.querymethods.declared.Person; import org.springframework.data.cassandra.test.integration.repository.querymethods.derived.PersonRepository.NumberOfChildren; +import org.springframework.data.cassandra.test.integration.repository.querymethods.derived.PersonRepository.PersonDto; import org.springframework.data.cassandra.test.integration.repository.querymethods.derived.PersonRepository.PersonProjection; import org.springframework.data.cassandra.test.integration.support.AbstractSpringDataEmbeddedCassandraIntegrationTest; import org.springframework.data.cassandra.test.integration.support.CassandraVersion; @@ -175,13 +176,45 @@ public class QueryDerivationIntegrationTests extends AbstractSpringDataEmbeddedC * @see DATACASS-7 */ @Test - public void executesCollectionQueryWithProjectionCorrectly() { + public void executesCollectionQueryWithProjection() { Collection collection = personRepository.findPersonProjectedBy(); assertThat(collection).hasSize(3).extracting("lastname").contains("White", "White", "White"); } + /** + * @see DATACASS-359 + */ + @Test + public void executesCollectionQueryWithDtoProjection() { + + Collection collection = personRepository.findPersonDtoBy(); + + assertThat(collection).hasSize(3).extracting("lastname").contains("White", "White", "White"); + } + + /** + * @see DATACASS-359 + */ + @Test + public void executesCollectionQueryWithDtoDynamicallyProjected() throws Exception { + + template.execute( + "CREATE CUSTOM INDEX IF NOT EXISTS fn_starts_with ON person (nickname) USING 'org.apache.cassandra.index.sasi.SASIIndex';"); + + // Give Cassandra some time to build the index + Thread.sleep(500); + + walter.setNickname("Heisenberg"); + personRepository.save(walter); + + PersonDto heisenberg = personRepository.findDtoByNicknameStartsWith("Heisen", PersonDto.class); + + assertThat(heisenberg.firstname).isEqualTo("Walter"); + assertThat(heisenberg.lastname).isEqualTo("White"); + } + /** * @see DATACASS-7 */