diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/IndexSpecificationFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/IndexSpecificationFactory.java index 6df14377d..b521c92d3 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/IndexSpecificationFactory.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/IndexSpecificationFactory.java @@ -42,6 +42,7 @@ import org.springframework.util.StringUtils; * index-annotated {@link CassandraPersistentProperty properties}. * * @author Mark Paluch + * @author Christoph Strobl * @since 2.0 * @see Indexed * @see SASI @@ -124,7 +125,7 @@ class IndexSpecificationFactory { return indexes; } - private static CreateIndexSpecification createIndexSpecification(Indexed annotation, + static CreateIndexSpecification createIndexSpecification(Indexed annotation, CassandraPersistentProperty property) { CreateIndexSpecification index; diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverter.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverter.java index 6f7499c65..905883165 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverter.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverter.java @@ -27,7 +27,6 @@ import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.context.ApplicationContext; @@ -41,6 +40,9 @@ import org.springframework.data.cassandra.core.mapping.BasicMapId; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty; +import org.springframework.data.cassandra.core.mapping.Embedded; +import org.springframework.data.cassandra.core.mapping.Embedded.OnEmpty; +import org.springframework.data.cassandra.core.mapping.EmbeddedEntityOperations; import org.springframework.data.cassandra.core.mapping.MapId; import org.springframework.data.cassandra.core.mapping.MapIdentifiable; import org.springframework.data.cassandra.core.mapping.UserTypeResolver; @@ -83,6 +85,7 @@ import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; * @author Mark Paluch * @author Antoine Toulme * @author John Blum + * @author Christoph Strobl */ public class MappingCassandraConverter extends AbstractCassandraConverter implements ApplicationContextAware, BeanClassLoaderAware { @@ -100,6 +103,7 @@ public class MappingCassandraConverter extends AbstractCassandraConverter private SpELContext spELContext; private final DefaultColumnTypeResolver cassandraTypeResolver; + private final EmbeddedEntityOperations embeddedEntityOperations; /** * Create a new {@link MappingCassandraConverter} with a {@link CassandraMappingContext}. @@ -117,6 +121,7 @@ public class MappingCassandraConverter extends AbstractCassandraConverter userTypeName -> getUserTypeResolver().resolveType(userTypeName), this::getCodecRegistry, this::getCustomConversions); this.setCustomConversions(conversions); + this.embeddedEntityOperations = new EmbeddedEntityOperations(mappingContext); } /** @@ -137,6 +142,7 @@ public class MappingCassandraConverter extends AbstractCassandraConverter userTypeName -> getUserTypeResolver().resolveType(userTypeName), this::getCodecRegistry, this::getCustomConversions); this.setCustomConversions(mappingContext.getCustomConversions()); + this.embeddedEntityOperations = new EmbeddedEntityOperations(mappingContext); } private static ConversionService newConversionService() { @@ -404,7 +410,7 @@ public class MappingCassandraConverter extends AbstractCassandraConverter return; } - if (property.isCompositePrimaryKey() || valueProvider.hasProperty(property)) { + if (property.isCompositePrimaryKey() || valueProvider.hasProperty(property) || property.isEmbedded()) { propertyAccessor.setProperty(property, getReadValue(valueProvider, property)); } } @@ -493,11 +499,21 @@ public class MappingCassandraConverter extends AbstractCassandraConverter continue; } - if (log.isDebugEnabled()) { - log.debug("Adding map.entry [{}] - [{}]", property.getRequiredColumnName(), value); - } + if (value != null && property.isEmbedded() && property.isEntity()) { - sink.put(property.getRequiredColumnName(), value); + if (log.isDebugEnabled()) { + log.debug("Mapping embedded property [{}] - [{}]", property.getRequiredColumnName(), value); + } + + write(value, sink, embeddedEntityOperations.getEntity(property)); + } else { + + if (log.isDebugEnabled()) { + log.debug("Adding map.entry [{}] - [{}]", property.getRequiredColumnName(), value); + } + + sink.put(property.getRequiredColumnName(), value); + } } } @@ -625,6 +641,7 @@ public class MappingCassandraConverter extends AbstractCassandraConverter continue; } + // value resolution Object value = getWriteValue(property, propertyAccessor); if (log.isDebugEnabled()) { @@ -636,6 +653,23 @@ public class MappingCassandraConverter extends AbstractCassandraConverter log.debug("Adding udt.value [{}] - [{}]", property.getRequiredColumnName(), value); } + if (property.isEmbedded() && property.isEntity()) { + + if (log.isDebugEnabled()) { + log.debug("Mapping embedded property [{}] - [{}]", property.getRequiredColumnName(), value); + } + + if (value == null) { + continue; + } + + CassandraPersistentEntity targetEntity = embeddedEntityOperations.getEntity(property); + writeUDTValue(new ConvertingPropertyAccessor<>(targetEntity.getPropertyAccessor(value), getConversionService()), + udtValue, targetEntity); + + continue; + } + TypeCodec typeCodec = getCodec(property); udtValue.set(property.getRequiredColumnName().toString(), value, typeCodec); @@ -900,6 +934,12 @@ public class MappingCassandraConverter extends AbstractCassandraConverter return doReadEntity(keyEntity, valueProvider); } + if (property.isEntity() && property.isEmbedded()) { + + CassandraPersistentEntity targetEntity = embeddedEntityOperations.getEntity(property); + return isNullEmbedded(targetEntity, property, valueProvider) ? null : doReadEntity(targetEntity, valueProvider); + } + if (!valueProvider.hasProperty(property)) { return null; } @@ -908,6 +948,31 @@ public class MappingCassandraConverter extends AbstractCassandraConverter return value == null ? null : convertReadValue(value, property.getTypeInformation()); } + /** + * @param entity the property domain type + * @param property the current property annotated with {@link Embedded}. + * @param valueProvider + * @return {@literal true} if the property represents a {@link Embedded.Nullable nullable embedded} entity where all + * values obtainable from the given {@link CassandraValueProvider} are {@literal null}. + * @since 3.0 + */ + private boolean isNullEmbedded(CassandraPersistentEntity entity, CassandraPersistentProperty property, + CassandraValueProvider valueProvider) { + + if (OnEmpty.USE_EMPTY.equals(property.findAnnotation(Embedded.class).onEmpty())) { + return false; + } + + for (CassandraPersistentProperty embeddedProperty : entity) { + + if (valueProvider.hasProperty(embeddedProperty) && valueProvider.getPropertyValue(embeddedProperty) != null) { + return false; + } + } + + return true; + } + @Nullable @SuppressWarnings("unchecked") private Object convertReadValue(Object value, TypeInformation typeInformation) { @@ -1123,5 +1188,4 @@ public class MappingCassandraConverter extends AbstractCassandraConverter return parent.getSource(); } } - } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java index a83c20bf9..0b3a1790d 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/QueryMapper.java @@ -27,6 +27,7 @@ import java.util.stream.Collectors; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty; +import org.springframework.data.cassandra.core.mapping.EmbeddedEntityOperations; import org.springframework.data.cassandra.core.query.ColumnName; import org.springframework.data.cassandra.core.query.Columns; import org.springframework.data.cassandra.core.query.Columns.ColumnSelector; @@ -53,6 +54,7 @@ import com.datastax.oss.driver.api.core.CqlIdentifier; * Map {@link org.springframework.data.cassandra.core.query.Query} to CQL-specific data types. * * @author Mark Paluch + * @author Christoph Strobl * @see ColumnName * @see Columns * @see Criteria @@ -517,10 +519,29 @@ public class QueryMapper { @Override public ColumnName getMappedKey() { - return path.map(PersistentPropertyPath::getLeafProperty) // - .map(CassandraPersistentProperty::getColumnName) // - .map(ColumnName::from) // - .orElse(name); + if (!path.isPresent()) { + return name; + } + + boolean embedded = false; + CassandraPersistentEntity parentEntity = null; + CassandraPersistentProperty leafProperty = null; + for (CassandraPersistentProperty p : path.get()) { + + leafProperty = p; + if (embedded) { + + embedded = false; + leafProperty = parentEntity.getPersistentProperty(p.getName()); + parentEntity = null; + } + if (p.isEmbedded() && p.isEntity()) { + embedded = true; + parentEntity = new EmbeddedEntityOperations(mappingContext).getEntity(p); + } + } + + return ColumnName.from(leafProperty.getColumnName()); } } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/SchemaFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/SchemaFactory.java index 1f89d5945..90088c27a 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/SchemaFactory.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/SchemaFactory.java @@ -26,6 +26,8 @@ import org.springframework.data.cassandra.core.cql.keyspace.CreateTableSpecifica import org.springframework.data.cassandra.core.cql.keyspace.CreateUserTypeSpecification; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty; +import org.springframework.data.cassandra.core.mapping.EmbeddedEntityOperations; +import org.springframework.data.cassandra.core.mapping.Indexed; import org.springframework.data.cassandra.core.mapping.UserTypeResolver; import org.springframework.data.convert.CustomConversions; import org.springframework.data.mapping.MappingException; @@ -43,6 +45,7 @@ import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; * Factory for Cassandra Schema objects such as user-defined types, tables and indexes. * * @author Mark Paluch + * @author Christoph Strobl * @since 3.0 * @see CreateUserTypeSpecification * @see CreateTableSpecification @@ -55,6 +58,8 @@ public class SchemaFactory { private final ColumnTypeResolver typeResolver; + private final EmbeddedEntityOperations embeddedEntityOperations; + /** * Creates a new {@link SchemaFactory} given {@link CassandraConverter}. * @@ -67,6 +72,7 @@ public class SchemaFactory { this.mappingContext = converter.getMappingContext(); this.typeResolver = new DefaultColumnTypeResolver(mappingContext, ShallowUserTypeResolver.INSTANCE, converter::getCodecRegistry, converter::getCustomConversions); + this.embeddedEntityOperations = new EmbeddedEntityOperations(this.mappingContext); } /** @@ -88,6 +94,7 @@ public class SchemaFactory { this.mappingContext = mappingContext; this.typeResolver = new DefaultColumnTypeResolver(mappingContext, ShallowUserTypeResolver.INSTANCE, () -> codecRegistry, () -> customConversions); + this.embeddedEntityOperations = new EmbeddedEntityOperations(this.mappingContext); } /** @@ -151,6 +158,16 @@ public class SchemaFactory { primaryKeyProperty.getPrimaryKeyOrdering()); } } + } else if (property.isEmbedded() && property.isEntity()) { + + CassandraPersistentEntity embeddedEntity = embeddedEntityOperations.getEntity(property); + + for (CassandraPersistentProperty embeddedProperty : embeddedEntity) { + + DataType dataType = getDataType(embeddedProperty); + specification.column(embeddedProperty.getRequiredColumnName(), dataType); + } + } else { DataType type = UserTypeUtil.potentiallyFreeze(getDataType(property)); @@ -230,6 +247,17 @@ public class SchemaFactory { for (CassandraPersistentProperty property : entity) { if (property.isCompositePrimaryKey()) { indexes.addAll(getCreateIndexSpecificationsFor(mappingContext.getRequiredPersistentEntity(property))); + } + if (property.isEmbedded() && property.isEntity()) { + + if (property.isAnnotationPresent(Indexed.class)) { + Indexed indexed = property.findAnnotation(Indexed.class); + for (CassandraPersistentProperty embeddedProperty : embeddedEntityOperations.getEntity(property)) { + indexes.add(IndexSpecificationFactory.createIndexSpecification(indexed, embeddedProperty)); + } + } else { + indexes.addAll(getCreateIndexSpecificationsFor(embeddedEntityOperations.getEntity(property))); + } } else { indexes.addAll(IndexSpecificationFactory.createIndexSpecifications(property)); } @@ -252,8 +280,19 @@ public class SchemaFactory { CreateUserTypeSpecification specification = CreateUserTypeSpecification.createType(entity.getTableName()); for (CassandraPersistentProperty property : entity) { - // Use frozen literal to not resolve types from Cassandra; At this stage, they might be not created yet. - specification.field(property.getRequiredColumnName(), UserTypeUtil.potentiallyFreeze(getDataType(property))); + + if (property.isEmbedded() && property.isEntity()) { + + CassandraPersistentEntity embeddedEntity = embeddedEntityOperations.getEntity(property); + for (CassandraPersistentProperty embeddedProperty : embeddedEntity) { + + DataType dataType = getDataType(embeddedProperty); + specification.field(embeddedProperty.getRequiredColumnName(), dataType); + } + } else { + // Use frozen literal to not resolve types from Cassandra; At this stage, they might be not created yet. + specification.field(property.getRequiredColumnName(), UserTypeUtil.potentiallyFreeze(getDataType(property))); + } } if (specification.getFields().isEmpty()) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraPersistentProperty.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraPersistentProperty.java index 7d9d44d47..c1dd120f0 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraPersistentProperty.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraPersistentProperty.java @@ -34,6 +34,7 @@ import com.datastax.oss.driver.api.core.CqlIdentifier; * @author David T. Webb * @author Mark Paluch * @author John Blum + * @author Christoph Strobl */ public interface CassandraPersistentProperty extends PersistentProperty, ApplicationContextAware { @@ -157,6 +158,14 @@ public interface CassandraPersistentProperty */ boolean isPrimaryKeyColumn(); + /** + * @return {@literal true} if the property should be embedded. + * @since 3.0 + */ + default boolean isEmbedded() { + return findAnnotation(Embedded.class) != null; + } + /** * Find an {@link AnnotatedType} by {@code annotationType} derived from the property type. Annotated type is looked up * by introspecting property field/accessors. Collection/Map-like types are introspected for type annotations within diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Embedded.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Embedded.java new file mode 100644 index 000000000..39d26139e --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Embedded.java @@ -0,0 +1,139 @@ +/* + * Copyright 2020 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 + * + * https://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.mapping; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import javax.annotation.meta.When; + +import org.springframework.core.annotation.AliasFor; + +/** + * @author Christoph Strobl + * @since 3.0 + */ +@Documented +@Retention(value = RetentionPolicy.RUNTIME) +@Target(value = { ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +public @interface Embedded { + /** + * Set the load strategy for the embedded object if all contained fields yield {@literal null} values. + *

+ * {@link Nullable @Embedded.Nullable} and {@link Empty @Embedded.Empty} offer shortcuts for this. + * + * @return never {@link} null. + */ + OnEmpty onEmpty(); + + /** + * @return prefix for columns in the embedded value object. An empty {@link String} by default. + */ + String prefix() default ""; + + /** + * Load strategy to be used {@link Embedded#onEmpty()}. + * + * @author Christoph Strobl + * @since 1.1 + */ + enum OnEmpty { + USE_NULL, USE_EMPTY + } + + /** + * Shortcut for a nullable embedded property. + * + *

+	 * 
+	 * @Embedded.Nullable
+	 * private Address address;
+	 * 
+	 * 
+ * + * as alternative to the more verbose + * + *
+	 * 
+	 *
+	 * @Embedded(onEmpty = USE_NULL)
+	 * @javax.annotation.Nonnull(when = When.MAYBE)
+	 * private Address address;
+	 *
+	 * 
+	 * 
+ * + * @author Christoph Strobl + * @since 3.0 + * @see Embedded#onEmpty() + */ + @Embedded(onEmpty = OnEmpty.USE_NULL) + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.FIELD, ElementType.METHOD }) + @javax.annotation.Nonnull(when = When.MAYBE) + @interface Nullable { + + /** + * @return prefix for columns in the embedded value object. An empty {@link String} by default. + */ + @AliasFor(annotation = Embedded.class, attribute = "prefix") + String prefix() default ""; + } + + /** + * Shortcut for an empty embedded property. + * + *
+	 * 
+	 * @Embedded.Empty
+	 * private Address address;
+	 * 
+	 * 
+ * + * as alternative to the more verbose + * + *
+	 * 
+	 *
+	 * @Embedded(onEmpty = USE_EMPTY)
+	 * @javax.annotation.Nonnull(when = When.NEVER)
+	 * private Address address;
+	 *
+	 * 
+	 * 
+ * + * @author Christoph Strobl + * @since 3.0 + * @see Embedded#onEmpty() + */ + @Embedded(onEmpty = OnEmpty.USE_EMPTY) + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.FIELD, ElementType.METHOD }) + @javax.annotation.Nonnull(when = When.NEVER) + @interface Empty { + + /** + * @return prefix for columns in the embedded value object. An empty {@link String} by default. + */ + @AliasFor(annotation = Embedded.class, attribute = "prefix") + String prefix() default ""; + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/EmbeddedEntityOperations.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/EmbeddedEntityOperations.java new file mode 100644 index 000000000..98d931386 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/EmbeddedEntityOperations.java @@ -0,0 +1,610 @@ +/* + * Copyright 2020 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 + * + * https://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.mapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.function.Consumer; + +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.data.cassandra.core.cql.Ordering; +import org.springframework.data.cassandra.core.mapping.Embedded.Nullable; +import org.springframework.data.mapping.*; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.StringUtils; + +import com.datastax.oss.driver.api.core.CqlIdentifier; + +/** + * @author Christoph Strobl + * @since 3.0 + */ +public class EmbeddedEntityOperations { + + private final MappingContext, CassandraPersistentProperty> mappingContext; + + public EmbeddedEntityOperations(MappingContext, CassandraPersistentProperty> mappingContext) { + this.mappingContext = mappingContext; + } + + public CassandraPersistentEntity getEntity(CassandraPersistentProperty property) { + return withPrefix(getPrefix(property), mappingContext.getPersistentEntity(property)); + } + + static CassandraPersistentEntity withPrefix(@org.springframework.lang.Nullable String prefix, + CassandraPersistentEntity source) { + + if (!StringUtils.hasText(prefix)) { + return source; + } + + return new PrefixedCassandraPersistentEntity<>(prefix, source); + } + + @Nullable + static String getPrefix(CassandraPersistentProperty property) { + + Embedded embedded = property.findAnnotation(Embedded.class); + return embedded != null ? embedded.prefix() : null; + } + + static class PrefixedCassandraPersistentEntity implements CassandraPersistentEntity { + + private final String prefix; + private CassandraPersistentEntity delegate; + + public PrefixedCassandraPersistentEntity(String prefix, CassandraPersistentEntity delegate) { + + this.prefix = prefix; + this.delegate = delegate; + } + + @Override + public boolean isCompositePrimaryKey() { + return delegate.isCompositePrimaryKey(); + } + + @Override + @Deprecated + public void setForceQuote(boolean forceQuote) { + delegate.setForceQuote(forceQuote); + } + + @Override + public CqlIdentifier getTableName() { + return delegate.getTableName(); + } + + @Override + @Deprecated + public void setTableName(org.springframework.data.cassandra.core.cql.CqlIdentifier tableName) { + delegate.setTableName(tableName); + } + + @Override + public void setTableName(CqlIdentifier tableName) { + delegate.setTableName(tableName); + } + + @Override + public boolean isTupleType() { + return delegate.isTupleType(); + } + + @Override + public boolean isUserDefinedType() { + return delegate.isUserDefinedType(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + @org.springframework.lang.Nullable + public PreferredConstructor getPersistenceConstructor() { + return delegate.getPersistenceConstructor(); + } + + @Override + public boolean isConstructorArgument(PersistentProperty property) { + return delegate.isConstructorArgument(property); + } + + @Override + public boolean isIdProperty(PersistentProperty property) { + return delegate.isIdProperty(property); + } + + @Override + public boolean isVersionProperty(PersistentProperty property) { + return delegate.isVersionProperty(property); + } + + @Override + @org.springframework.lang.Nullable + public CassandraPersistentProperty getIdProperty() { + return delegate.getIdProperty(); + } + + @Override + public CassandraPersistentProperty getRequiredIdProperty() { + return delegate.getRequiredIdProperty(); + } + + @Override + @org.springframework.lang.Nullable + public CassandraPersistentProperty getVersionProperty() { + return delegate.getVersionProperty(); + } + + @Override + public CassandraPersistentProperty getRequiredVersionProperty() { + return delegate.getRequiredVersionProperty(); + } + + @Override + @org.springframework.lang.Nullable + public CassandraPersistentProperty getPersistentProperty(String name) { + return new PrefixedCassandraPersistentProperty(prefix, delegate.getPersistentProperty(name)); + } + + @Override + public CassandraPersistentProperty getRequiredPersistentProperty(String name) { + return new PrefixedCassandraPersistentProperty(prefix, delegate.getRequiredPersistentProperty(name)); + } + + @Override + @org.springframework.lang.Nullable + public CassandraPersistentProperty getPersistentProperty(Class annotationType) { + return new PrefixedCassandraPersistentProperty(prefix, delegate.getPersistentProperty(annotationType)); + } + + @Override + public Iterable getPersistentProperties(Class annotationType) { + return delegate.getPersistentProperties(annotationType); + } + + @Override + public boolean hasIdProperty() { + return delegate.hasIdProperty(); + } + + @Override + public boolean hasVersionProperty() { + return delegate.hasVersionProperty(); + } + + @Override + public Class getType() { + return delegate.getType(); + } + + @Override + public Alias getTypeAlias() { + return delegate.getTypeAlias(); + } + + @Override + public TypeInformation getTypeInformation() { + return delegate.getTypeInformation(); + } + + @Override + public void doWithProperties(PropertyHandler handler) { + + delegate.doWithProperties((PropertyHandler) property -> { + handler.doWithPersistentProperty(wrap(property)); + }); + } + + @Override + public void doWithProperties(SimplePropertyHandler handler) { + + delegate.doWithProperties((SimplePropertyHandler) property -> { + + if (property instanceof CassandraPersistentProperty) { + handler.doWithPersistentProperty(wrap((CassandraPersistentProperty) property)); + } else { + handler.doWithPersistentProperty(property); + } + }); + delegate.doWithProperties(handler); + } + + @Override + public void doWithAssociations(AssociationHandler handler) { + delegate.doWithAssociations(handler); + } + + @Override + public void doWithAssociations(SimpleAssociationHandler handler) { + delegate.doWithAssociations(handler); + } + + @Override + @org.springframework.lang.Nullable + public A findAnnotation(Class annotationType) { + return delegate.findAnnotation(annotationType); + } + + @Override + public A getRequiredAnnotation(Class annotationType) throws IllegalStateException { + return delegate.getRequiredAnnotation(annotationType); + } + + @Override + public boolean isAnnotationPresent(Class annotationType) { + return delegate.isAnnotationPresent(annotationType); + } + + @Override + public PersistentPropertyAccessor getPropertyAccessor(B bean) { + return delegate.getPropertyAccessor(bean); + } + + @Override + public PersistentPropertyPathAccessor getPropertyPathAccessor(B bean) { + return delegate.getPropertyPathAccessor(bean); + } + + @Override + public IdentifierAccessor getIdentifierAccessor(Object bean) { + return delegate.getIdentifierAccessor(bean); + } + + @Override + public boolean isNew(Object bean) { + return delegate.isNew(bean); + } + + @Override + public boolean isImmutable() { + return delegate.isImmutable(); + } + + @Override + public boolean requiresPropertyPopulation() { + return delegate.requiresPropertyPopulation(); + } + + @NotNull + @Override + public Iterator iterator() { + + List target = new ArrayList<>(); + delegate.iterator().forEachRemaining(it -> target.add(wrap(it))); + return target.iterator(); + } + + @Override + public void forEach(Consumer action) { + delegate.forEach(it -> action.accept(wrap(it))); + } + + @Override + public Spliterator spliterator() { + return delegate.spliterator(); + } + + private PrefixedCassandraPersistentProperty wrap(CassandraPersistentProperty source) { + return new PrefixedCassandraPersistentProperty(prefix, source); + } + } + + static class PrefixedCassandraPersistentProperty implements CassandraPersistentProperty { + + private final String prefix; + private final CassandraPersistentProperty delegate; + + public PrefixedCassandraPersistentProperty(String prefix, CassandraPersistentProperty delegate) { + this.prefix = prefix; + this.delegate = delegate; + } + + @Override + @Deprecated + public void setColumnName(org.springframework.data.cassandra.core.cql.CqlIdentifier columnName) { + delegate.setColumnName(columnName); + } + + @Override + public void setColumnName(CqlIdentifier columnName) { + delegate.setColumnName(columnName); + } + + @Override + @org.springframework.lang.Nullable + public CqlIdentifier getColumnName() { + return CqlIdentifier.fromInternal(prefix + delegate.getColumnName().asInternal()); + } + + @Override + @Deprecated + public void setForceQuote(boolean forceQuote) { + delegate.setForceQuote(forceQuote); + } + + @Override + @org.springframework.lang.Nullable + public Integer getOrdinal() { + return delegate.getOrdinal(); + } + + @Override + public int getRequiredOrdinal() { + return delegate.getRequiredOrdinal(); + } + + @Override + @org.springframework.lang.Nullable + public Ordering getPrimaryKeyOrdering() { + return delegate.getPrimaryKeyOrdering(); + } + + @Override + public boolean isClusterKeyColumn() { + return delegate.isClusterKeyColumn(); + } + + @Override + public boolean isCompositePrimaryKey() { + return delegate.isCompositePrimaryKey(); + } + + @Override + public boolean isMapLike() { + return delegate.isMapLike(); + } + + @Override + public boolean isPartitionKeyColumn() { + return delegate.isPartitionKeyColumn(); + } + + @Override + public boolean isPrimaryKeyColumn() { + return delegate.isPrimaryKeyColumn(); + } + + @Override + public boolean isEmbedded() { + return delegate.isEmbedded(); + } + + @Override + @org.springframework.lang.Nullable + public AnnotatedType findAnnotatedType(Class annotationType) { + return delegate.findAnnotatedType(annotationType); + } + + @Override + public PersistentEntity getOwner() { + return delegate.getOwner(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public Class getType() { + return delegate.getType(); + } + + @Override + public TypeInformation getTypeInformation() { + return delegate.getTypeInformation(); + } + + @Override + public Iterable> getPersistentEntityTypes() { + return delegate.getPersistentEntityTypes(); + } + + @Override + @org.springframework.lang.Nullable + public Method getGetter() { + return delegate.getGetter(); + } + + @Override + public Method getRequiredGetter() { + return delegate.getRequiredGetter(); + } + + @Override + @org.springframework.lang.Nullable + public Method getSetter() { + return delegate.getSetter(); + } + + @Override + public Method getRequiredSetter() { + return delegate.getRequiredSetter(); + } + + @Override + @org.springframework.lang.Nullable + public Method getWither() { + return delegate.getWither(); + } + + @Override + public Method getRequiredWither() { + return delegate.getRequiredWither(); + } + + @Override + @org.springframework.lang.Nullable + public Field getField() { + return delegate.getField(); + } + + @Override + public Field getRequiredField() { + return delegate.getRequiredField(); + } + + @Override + @org.springframework.lang.Nullable + public String getSpelExpression() { + return delegate.getSpelExpression(); + } + + @Override + @org.springframework.lang.Nullable + public Association getAssociation() { + return delegate.getAssociation(); + } + + @Override + public Association getRequiredAssociation() { + return delegate.getRequiredAssociation(); + } + + @Override + public boolean isEntity() { + return delegate.isEntity(); + } + + @Override + public boolean isIdProperty() { + return delegate.isIdProperty(); + } + + @Override + public boolean isVersionProperty() { + return delegate.isVersionProperty(); + } + + @Override + public boolean isCollectionLike() { + return delegate.isCollectionLike(); + } + + @Override + public boolean isMap() { + return delegate.isMap(); + } + + @Override + public boolean isArray() { + return delegate.isArray(); + } + + @Override + public boolean isTransient() { + return delegate.isTransient(); + } + + @Override + public boolean isWritable() { + return delegate.isWritable(); + } + + @Override + public boolean isImmutable() { + return delegate.isImmutable(); + } + + @Override + public boolean isAssociation() { + return delegate.isAssociation(); + } + + @Override + @org.springframework.lang.Nullable + public Class getComponentType() { + return delegate.getComponentType(); + } + + @Override + public Class getRawType() { + return delegate.getRawType(); + } + + @Override + @org.springframework.lang.Nullable + public Class getMapValueType() { + return delegate.getMapValueType(); + } + + @Override + public Class getActualType() { + return delegate.getActualType(); + } + + @Override + @org.springframework.lang.Nullable + public A findAnnotation(Class annotationType) { + return delegate.findAnnotation(annotationType); + } + + @Override + public A getRequiredAnnotation(Class annotationType) throws IllegalStateException { + return delegate.getRequiredAnnotation(annotationType); + } + + @Override + @org.springframework.lang.Nullable + public A findPropertyOrOwnerAnnotation(Class annotationType) { + return delegate.findPropertyOrOwnerAnnotation(annotationType); + } + + @Override + public boolean isAnnotationPresent(Class annotationType) { + return delegate.isAnnotationPresent(annotationType); + } + + @Override + public boolean usePropertyAccess() { + return delegate.usePropertyAccess(); + } + + @Override + public boolean hasActualTypeAnnotation(Class annotationType) { + return delegate.hasActualTypeAnnotation(annotationType); + } + + @Override + @org.springframework.lang.Nullable + public Class getAssociationTargetType() { + return delegate.getAssociationTargetType(); + } + + @Override + public PersistentPropertyAccessor getAccessorForOwner(T owner) { + return delegate.getAccessorForOwner(owner); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + delegate.setApplicationContext(applicationContext); + } + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateIntegrationTests.java index 575ddbdbf..1b67c611a 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraTemplateIntegrationTests.java @@ -37,15 +37,17 @@ import java.util.stream.Stream; import org.junit.Before; import org.junit.Test; - import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.convert.MappingCassandraConverter; import org.springframework.data.cassandra.core.cql.CqlTemplate; import org.springframework.data.cassandra.core.cql.PrimaryKeyType; import org.springframework.data.cassandra.core.mapping.BasicMapId; +import org.springframework.data.cassandra.core.mapping.Embedded; import org.springframework.data.cassandra.core.mapping.PrimaryKey; import org.springframework.data.cassandra.core.mapping.PrimaryKeyClass; import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; +import org.springframework.data.cassandra.core.mapping.SimpleUserTypeResolver; +import org.springframework.data.cassandra.core.mapping.UserDefinedType; import org.springframework.data.cassandra.core.query.CassandraPageRequest; import org.springframework.data.cassandra.core.query.Columns; import org.springframework.data.cassandra.core.query.Query; @@ -60,12 +62,14 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.util.Version; +import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.uuid.Uuids; /** * Integration tests for {@link CassandraTemplate}. * * @author Mark Paluch + * @author Christoph Strobl */ public class CassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest { @@ -79,6 +83,7 @@ public class CassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingI public void setUp() { MappingCassandraConverter converter = new MappingCassandraConverter(); + converter.setUserTypeResolver(new SimpleUserTypeResolver(session, CqlIdentifier.fromCql(keyspace))); converter.afterPropertiesSet(); cassandraVersion = CassandraVersion.get(session); @@ -90,10 +95,20 @@ public class CassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingI SchemaTestUtils.potentiallyCreateTableFor(BookReference.class, template); SchemaTestUtils.potentiallyCreateTableFor(TimeClass.class, template); SchemaTestUtils.potentiallyCreateTableFor(TypeWithCompositeKey.class, template); + SchemaTestUtils.potentiallyCreateTableFor(WithNullableEmbeddedType.class, template); + SchemaTestUtils.potentiallyCreateTableFor(WithEmptyEmbeddedType.class, template); + SchemaTestUtils.potentiallyCreateTableFor(WithPrefixedNullableEmbeddedType.class, template); + SchemaTestUtils.createTableAndTypes(OuterWithNullableEmbeddedType.class, template); + SchemaTestUtils.createTableAndTypes(OuterWithPrefixedNullableEmbeddedType.class, template); SchemaTestUtils.truncate(User.class, template); SchemaTestUtils.truncate(UserToken.class, template); SchemaTestUtils.truncate(BookReference.class, template); SchemaTestUtils.truncate(TypeWithCompositeKey.class, template); + SchemaTestUtils.truncate(WithNullableEmbeddedType.class, template); + SchemaTestUtils.truncate(WithEmptyEmbeddedType.class, template); + SchemaTestUtils.truncate(WithPrefixedNullableEmbeddedType.class, template); + SchemaTestUtils.truncate(OuterWithNullableEmbeddedType.class, template); + SchemaTestUtils.truncate(OuterWithPrefixedNullableEmbeddedType.class, template); } @Test // DATACASS-343 @@ -556,6 +571,119 @@ public class CassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingI assertThat(iterations).isEqualTo(10); } + @Test // DATACASS-167 + public void shouldSaveAndReadPrefixedEmbeddedCorrectly() { + + WithPrefixedNullableEmbeddedType entity = new WithPrefixedNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = new EmbeddedWithSimpleTypes(); + entity.nested.firstname = "fn"; + entity.nested.age = 30; + + template.insert(WithPrefixedNullableEmbeddedType.class).one(entity); + + WithPrefixedNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + WithPrefixedNullableEmbeddedType.class); + assertThat(target).isEqualTo(entity); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadEmbeddedCorrectly() { + + WithNullableEmbeddedType entity = new WithNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = new EmbeddedWithSimpleTypes(); + entity.nested.firstname = "fn"; + entity.nested.age = 30; + + template.insert(WithNullableEmbeddedType.class).one(entity); + + WithNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + WithNullableEmbeddedType.class); + assertThat(target).isEqualTo(entity); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadNullableEmbeddedCorrectly() { + + WithNullableEmbeddedType entity = new WithNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = null; + + template.insert(WithNullableEmbeddedType.class).one(entity); + + WithNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + WithNullableEmbeddedType.class); + assertThat(target.id).isEqualTo("id-1"); + assertThat(target.nested).isNull(); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadEmptyEmbeddedCorrectly() { + + WithEmptyEmbeddedType entity = new WithEmptyEmbeddedType(); + entity.id = "id-1"; + entity.nested = null; + + template.insert(WithEmptyEmbeddedType.class).one(entity); + + WithEmptyEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), WithEmptyEmbeddedType.class); + assertThat(target.id).isEqualTo("id-1"); + assertThat(target.nested).isNotNull(); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadEmbeddedUDTCorrectly() { + + OuterWithNullableEmbeddedType entity = new OuterWithNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new UDTWithNullableEmbeddedType(); + entity.udtValue.value = "value-1"; + entity.udtValue.nested = new EmbeddedWithSimpleTypes(); + entity.udtValue.nested.firstname = "fn"; + entity.udtValue.nested.age = 30; + + template.insert(OuterWithNullableEmbeddedType.class).one(entity); + + OuterWithNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + OuterWithNullableEmbeddedType.class); + assertThat(target).isEqualTo(entity); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadPrefixedUdtEmbeddedCorrectly() { + + OuterWithPrefixedNullableEmbeddedType entity = new OuterWithPrefixedNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new UDTWithPrefixedNullableEmbeddedType(); + entity.udtValue.value = "value-1"; + entity.udtValue.nested = new EmbeddedWithSimpleTypes(); + entity.udtValue.nested.firstname = "fn"; + entity.udtValue.nested.age = 30; + + template.insert(OuterWithPrefixedNullableEmbeddedType.class).one(entity); + + OuterWithPrefixedNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + OuterWithPrefixedNullableEmbeddedType.class); + assertThat(target).isEqualTo(entity); + } + + @Test // DATACASS-167 + public void shouldSaveAndReadNullEmbeddedUDTCorrectly() { + + OuterWithNullableEmbeddedType entity = new OuterWithNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new UDTWithNullableEmbeddedType(); + entity.udtValue.value = "value-1"; + entity.udtValue.nested = null; + + template.insert(OuterWithNullableEmbeddedType.class).one(entity); + + OuterWithNullableEmbeddedType target = template.selectOne(Query.query(where("id").is("id-1")), + OuterWithNullableEmbeddedType.class); + assertThat(target).isEqualTo(entity); + } + @Data static class TimeClass { @@ -579,4 +707,69 @@ public class CassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingI @PrimaryKeyColumn(type = PrimaryKeyType.PARTITIONED) String lastname; } + @Data + static class WithNullableEmbeddedType { + + @Id String id; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @Data + static class WithPrefixedNullableEmbeddedType { + + @Id String id; + + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + + @Data + static class WithEmptyEmbeddedType { + + @Id String id; + + @Embedded.Empty EmbeddedWithSimpleTypes nested; + } + + @Data + static class EmbeddedWithSimpleTypes { + + String firstname; + Integer age; + } + + @Data + static class OuterWithNullableEmbeddedType { + + @Id String id; + + UDTWithNullableEmbeddedType udtValue; + } + + @Data + static class OuterWithPrefixedNullableEmbeddedType { + + @Id String id; + + UDTWithPrefixedNullableEmbeddedType udtValue; + } + + @UserDefinedType + @Data + static class UDTWithNullableEmbeddedType { + + String value; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @UserDefinedType + @Data + static class UDTWithPrefixedNullableEmbeddedType { + + String value; + + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUDTUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUDTUnitTests.java index 82c020761..1e7656789 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUDTUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUDTUnitTests.java @@ -34,9 +34,10 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - +import org.springframework.data.annotation.Id; import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.mapping.Embedded; import org.springframework.data.cassandra.core.mapping.UserDefinedType; import org.springframework.data.cassandra.core.mapping.UserTypeResolver; import org.springframework.data.cassandra.support.UserDefinedTypeBuilder; @@ -51,6 +52,7 @@ import com.datastax.oss.driver.api.core.type.DataTypes; * Unit tests for UDT through {@link MappingCassandraConverter}. * * @author Mark Paluch + * @author Christoph Strobl */ @RunWith(MockitoJUnitRunner.Silent.class) // there are some unused stubbings in RowMockUtil but they're used in other public class MappingCassandraConverterUDTUnitTests { @@ -61,6 +63,12 @@ public class MappingCassandraConverterUDTUnitTests { .withField("name", DataTypes.TEXT).withField("displayname", DataTypes.TEXT).build(); com.datastax.oss.driver.api.core.type.UserDefinedType currency = UserDefinedTypeBuilder.forName("mycurrency") .withField("currency", DataTypes.TEXT).build(); + com.datastax.oss.driver.api.core.type.UserDefinedType withnullableembeddedtype = UserDefinedTypeBuilder + .forName("withnullableembeddedtype").withField("value", DataTypes.TEXT).withField("firstname", DataTypes.TEXT) + .withField("age", DataTypes.INT).build(); + com.datastax.oss.driver.api.core.type.UserDefinedType withprefixednullableembeddedtype = UserDefinedTypeBuilder + .forName("withnullableembeddedtype").withField("value", DataTypes.TEXT) + .withField("prefixfirstname", DataTypes.TEXT).withField("prefixage", DataTypes.INT).build(); Row rowMock; @@ -78,6 +86,10 @@ public class MappingCassandraConverterUDTUnitTests { when(userTypeResolver.resolveType(CqlIdentifier.fromCql("manufacturer"))).thenReturn(manufacturer); when(userTypeResolver.resolveType(CqlIdentifier.fromCql("currency"))).thenReturn(currency); + when(userTypeResolver.resolveType(CqlIdentifier.fromCql("withnullableembeddedtype"))) + .thenReturn(withnullableembeddedtype); + when(userTypeResolver.resolveType(CqlIdentifier.fromCql("withprefixednullableembeddedtype"))) + .thenReturn(withprefixednullableembeddedtype); } @Test // DATACASS-487, DATACASS-623 @@ -127,6 +139,99 @@ public class MappingCassandraConverterUDTUnitTests { .contains("{currency:'EUR'}", "{currency:'USD'}"); } + @Test // DATACASS-167 + public void writeFlattensEmbeddedType() { + + OuterWithNullableEmbeddedType entity = new OuterWithNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new WithNullableEmbeddedType(); + entity.udtValue.value = "value-string"; + entity.udtValue.nested = new EmbeddedWithSimpleTypes(); + entity.udtValue.nested.firstname = "fn"; + entity.udtValue.nested.age = 30; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink).containsEntry(CqlIdentifier.fromInternal("id"), "id-1"); + assertThat((UdtValue) sink.get(CqlIdentifier.fromInternal("udtvalue"))).extracting(UdtValue::getFormattedContents) + .isEqualTo("{value:'value-string',firstname:'fn',age:30}"); + } + + @Test // DATACASS-167 + public void writeNullEmbeddedType() { + + OuterWithNullableEmbeddedType entity = new OuterWithNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new WithNullableEmbeddedType(); + entity.udtValue.value = "value-string"; + entity.udtValue.nested = null; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink).containsEntry(CqlIdentifier.fromInternal("id"), "id-1"); + assertThat((UdtValue) sink.get(CqlIdentifier.fromInternal("udtvalue"))).extracting(UdtValue::getFormattedContents) + .isEqualTo("{value:'value-string',firstname:NULL,age:NULL}"); + } + + @Test // DATACASS-167 + public void writePrefixesEmbeddedType() { + + OuterWithPrefixedNullableEmbeddedType entity = new OuterWithPrefixedNullableEmbeddedType(); + entity.id = "id-1"; + entity.udtValue = new WithPrefixedNullableEmbeddedType(); + entity.udtValue.value = "value-string"; + entity.udtValue.nested = new EmbeddedWithSimpleTypes(); + entity.udtValue.nested.firstname = "fn"; + entity.udtValue.nested.age = 30; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink).containsEntry(CqlIdentifier.fromInternal("id"), "id-1"); + assertThat((UdtValue) sink.get(CqlIdentifier.fromInternal("udtvalue"))).extracting(UdtValue::getFormattedContents) + .isEqualTo("{value:'value-string',prefixfirstname:'fn',prefixage:30}"); + } + + @Test // DATACASS-167 + public void readEmbeddedType() { + + UdtValue udtValue = withnullableembeddedtype.newValue().setString("value", "value-string") + .setString("firstname", "fn").setInt("age", 30); + + rowMock = RowMockUtil.newRowMock(column("id", "id-1", DataTypes.TEXT), + column("udtvalue", udtValue, withnullableembeddedtype)); + + OuterWithNullableEmbeddedType target = mappingCassandraConverter.read(OuterWithNullableEmbeddedType.class, rowMock); + assertThat(target.getId()).isEqualTo("id-1"); + assertThat(target.udtValue).isNotNull(); + assertThat(target.udtValue.value).isEqualTo("value-string"); + assertThat(target.udtValue.nested.firstname).isEqualTo("fn"); + assertThat(target.udtValue.nested.age).isEqualTo(30); + } + + @Test // DATACASS-167 + public void readPrefixedEmbeddedType() { + + UdtValue udtValue = withprefixednullableembeddedtype.newValue().setString("value", "value-string") + .setString("prefixfirstname", "fn").setInt("prefixage", 30); + + rowMock = RowMockUtil.newRowMock(column("id", "id-1", DataTypes.TEXT), + column("udtvalue", udtValue, withprefixednullableembeddedtype)); + + OuterWithPrefixedNullableEmbeddedType target = mappingCassandraConverter + .read(OuterWithPrefixedNullableEmbeddedType.class, rowMock); + assertThat(target.getId()).isEqualTo("id-1"); + assertThat(target.udtValue).isNotNull(); + assertThat(target.udtValue.value).isEqualTo("value-string"); + assertThat(target.udtValue.nested.firstname).isEqualTo("fn"); + assertThat(target.udtValue.nested.age).isEqualTo(30); + } + @UserDefinedType @Data @AllArgsConstructor @@ -148,4 +253,63 @@ public class MappingCassandraConverterUDTUnitTests { private static class Supplier { Map> acceptedCurrencies; } + + @Data + static class OuterWithNullableEmbeddedType { + + @Id String id; + + WithNullableEmbeddedType udtValue; + } + + @Data + static class OuterWithPrefixedNullableEmbeddedType { + + @Id String id; + + WithPrefixedNullableEmbeddedType udtValue; + } + + @UserDefinedType + @Data + static class WithNullableEmbeddedType { + + String value; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @UserDefinedType + @Data + static class WithPrefixedNullableEmbeddedType { + + String value; + + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + + @UserDefinedType + @Data + static class WithEmptyEmbeddedType { + + String value; + + @Embedded.Empty EmbeddedWithSimpleTypes nested; + } + + @Data + static class EmbeddedWithSimpleTypes { + + String firstname; + Integer age; + + public String getFirstname() { + return firstname; + } + + public Integer getAge() { + return age; + } + } + } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUnitTests.java index 323b2d407..e1bdbdf1b 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/MappingCassandraConverterUnitTests.java @@ -20,7 +20,10 @@ import static org.springframework.data.cassandra.core.mapping.BasicMapId.*; import static org.springframework.data.cassandra.test.util.RowMockUtil.*; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; +import lombok.ToString; import java.io.Serializable; import java.math.BigDecimal; @@ -37,7 +40,6 @@ import java.util.*; import org.junit.Before; import org.junit.Test; - import org.springframework.beans.factory.annotation.Value; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.ReadOnlyProperty; @@ -46,6 +48,7 @@ import org.springframework.data.cassandra.core.cql.PrimaryKeyType; import org.springframework.data.cassandra.core.mapping.BasicMapId; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraType; +import org.springframework.data.cassandra.core.mapping.Embedded; import org.springframework.data.cassandra.core.mapping.MapId; import org.springframework.data.cassandra.core.mapping.PrimaryKey; import org.springframework.data.cassandra.core.mapping.PrimaryKeyClass; @@ -68,6 +71,7 @@ import com.datastax.oss.driver.api.core.type.DataTypes; * Unit tests for {@link MappingCassandraConverter}. * * @author Mark Paluch + * @author Christoph Strobl * @soundtrack Outlandich - Dont Leave Me Feat Cyt (Sun Kidz Electrocore Mix) */ public class MappingCassandraConverterUnitTests { @@ -292,8 +296,7 @@ public class MappingCassandraConverterUnitTests { @Test // DATACASS-280 public void shouldReadInetAddressCorrectly() throws UnknownHostException { - InetAddress localHost = InetAddress.getLocalHost(); - + InetAddress localHost = InetAddress.getLoopbackAddress(); rowMock = RowMockUtil.newRowMock(column("foo", localHost, DataTypes.UUID)); InetAddress result = mappingCassandraConverter.readRow(InetAddress.class, rowMock); @@ -1255,4 +1258,128 @@ public class MappingCassandraConverterUnitTests { this.firstname = firstname; } } + + @ToString + static class WithNullableEmbeddedType { + + String id; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @ToString + static class WithPrefixedNullableEmbeddedType { + + String id; + + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + + @ToString + static class WithEmptyEmbeddedType { + + String id; + + @Embedded.Empty EmbeddedWithSimpleTypes nested; + } + + @ToString + @EqualsAndHashCode + @NoArgsConstructor + @AllArgsConstructor + static class EmbeddedWithSimpleTypes { + + String firstname; + Integer age; + @Transient String displayName; + } + + @Test // DATACASS-167 + public void writeFlattensEmbeddedType() { + + WithNullableEmbeddedType entity = new WithNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = new EmbeddedWithSimpleTypes(); + entity.nested.firstname = "fn"; + entity.nested.age = 30; + entity.nested.displayName = "dp-name"; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink) // + .containsEntry(CqlIdentifier.fromCql("id"), "id-1") // + .containsEntry(CqlIdentifier.fromCql("age"), 30) // + .containsEntry(CqlIdentifier.fromCql("firstname"), "fn") // + .doesNotContainKey(CqlIdentifier.fromCql("displayName")); + } + + @Test // DATACASS-167 + public void writePrefixesEmbeddedType() { + + WithPrefixedNullableEmbeddedType entity = new WithPrefixedNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = new EmbeddedWithSimpleTypes(); + entity.nested.firstname = "fn"; + entity.nested.age = 30; + entity.nested.displayName = "dp-name"; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink) // + .containsEntry(CqlIdentifier.fromCql("id"), "id-1") // + .containsEntry(CqlIdentifier.fromCql("prefixage"), 30) // + .containsEntry(CqlIdentifier.fromCql("prefixfirstname"), "fn") // + .doesNotContainKey(CqlIdentifier.fromCql("displayName")); + } + + @Test // DATACASS-167 + public void writeNullEmbeddedType() { + + WithNullableEmbeddedType entity = new WithNullableEmbeddedType(); + entity.id = "id-1"; + entity.nested = null; + + Map sink = new LinkedHashMap<>(); + + mappingCassandraConverter.write(entity, sink); + + assertThat(sink) // + .containsEntry(CqlIdentifier.fromCql("id"), "id-1") // + .doesNotContainKey(CqlIdentifier.fromCql("age")) // + .doesNotContainKey(CqlIdentifier.fromCql("firstname")) // + .doesNotContainKey(CqlIdentifier.fromCql("displayName")); + } + + @Test // DATACASS-167 + public void readEmbeddedType() { + + Row source = RowMockUtil.newRowMock(column("id", "id-1", DataTypes.TEXT), column("age", 30, DataTypes.INT), + column("firstname", "fn", DataTypes.TEXT)); + + WithNullableEmbeddedType target = mappingCassandraConverter.read(WithNullableEmbeddedType.class, source); + assertThat(target.nested).isEqualTo(new EmbeddedWithSimpleTypes("fn", 30, null)); + } + + @Test // DATACASS-167 + public void readPrefixedEmbeddedType() { + + Row source = RowMockUtil.newRowMock(column("id", "id-1", DataTypes.TEXT), column("prefixage", 30, DataTypes.INT), + column("prefixfirstname", "fn", DataTypes.TEXT)); + + WithPrefixedNullableEmbeddedType target = mappingCassandraConverter.read(WithPrefixedNullableEmbeddedType.class, source); + assertThat(target.nested).isEqualTo(new EmbeddedWithSimpleTypes("fn", 30, null)); + } + + @Test // DATACASS-167 + public void readEmbeddedTypeWhenSourceDoesNotContainValues() { + + Row source = RowMockUtil.newRowMock(column("id", "id-1", DataTypes.TEXT)); + + WithNullableEmbeddedType target = mappingCassandraConverter.read(WithNullableEmbeddedType.class, source); + assertThat(target.nested).isNull(); + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/QueryMapperUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/QueryMapperUnitTests.java index 70b0963fe..88a9ecb13 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/QueryMapperUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/QueryMapperUnitTests.java @@ -20,6 +20,7 @@ import static org.mockito.Matchers.any; import static org.mockito.Mockito.*; import lombok.AllArgsConstructor; +import lombok.Data; import java.util.Collection; import java.util.Collections; @@ -36,12 +37,12 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.Column; import org.springframework.data.cassandra.core.mapping.Element; +import org.springframework.data.cassandra.core.mapping.Embedded; import org.springframework.data.cassandra.core.mapping.Tuple; import org.springframework.data.cassandra.core.mapping.UserDefinedType; import org.springframework.data.cassandra.core.mapping.UserTypeResolver; @@ -378,6 +379,28 @@ public class QueryMapperUnitTests { this.mappingContext.getRequiredPersistentEntity(Person.class))); } + @Test // DATACASS-167 + public void shouldMapEmbeddedType() { + + Filter filter = Filter.from(Criteria.where("nested.firstname").is("spring")); + + Filter mappedObject = this.queryMapper.getMappedObject(filter, + this.mappingContext.getRequiredPersistentEntity(WithNullableEmbeddedType.class)); + + assertThat(mappedObject.iterator().next().getColumnName()).isEqualTo(ColumnName.from("firstname")); + } + + @Test // DATACASS-167 + public void shouldMapPrefixedEmbeddedType() { + + Filter filter = Filter.from(Criteria.where("nested.firstname").is("spring")); + + Filter mappedObject = this.queryMapper.getMappedObject(filter, + this.mappingContext.getRequiredPersistentEntity(WithPrefixedNullableEmbeddedType.class)); + + assertThat(mappedObject.iterator().next().getColumnName()).isEqualTo(ColumnName.from("prefixfirstname")); + } + static class Person { @Id String id; @@ -413,4 +436,27 @@ public class QueryMapperUnitTests { enum State { Active, Inactive; } + + @Data + static class WithNullableEmbeddedType { + + @Id String id; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @Data + static class WithPrefixedNullableEmbeddedType { + + @Id String id; + + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + + @Data + static class EmbeddedWithSimpleTypes { + + String firstname; + Integer age; + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/SchemaFactoryUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/SchemaFactoryUnitTests.java index ad4b29674..ba3f50008 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/SchemaFactoryUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/SchemaFactoryUnitTests.java @@ -35,7 +35,6 @@ import java.util.Set; import org.junit.Before; import org.junit.Test; - import org.springframework.core.convert.converter.Converter; import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.cql.Ordering; @@ -44,16 +43,7 @@ import org.springframework.data.cassandra.core.cql.keyspace.ColumnSpecification; import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification; import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification.ColumnFunction; import org.springframework.data.cassandra.core.cql.keyspace.CreateTableSpecification; -import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; -import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; -import org.springframework.data.cassandra.core.mapping.CassandraType; -import org.springframework.data.cassandra.core.mapping.Element; -import org.springframework.data.cassandra.core.mapping.Indexed; -import org.springframework.data.cassandra.core.mapping.PrimaryKey; -import org.springframework.data.cassandra.core.mapping.PrimaryKeyClass; -import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn; -import org.springframework.data.cassandra.core.mapping.Table; -import org.springframework.data.cassandra.core.mapping.Tuple; +import org.springframework.data.cassandra.core.mapping.*; import org.springframework.data.cassandra.domain.AllPossibleTypes; import org.springframework.data.cassandra.support.UserDefinedTypeBuilder; import org.springframework.data.mapping.MappingException; @@ -76,6 +66,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; * * @author Matthew T. Adams * @author Mark Paluch + * @author Christoph Strobl */ public class SchemaFactoryUnitTests { @@ -827,4 +818,61 @@ public class SchemaFactoryUnitTests { @Id String id; Map untyped; } + + @Table + @Data + static class TypeWithEmbedded { + + @Id String id; + @Embedded.Nullable EmbeddedTpe name; + @Embedded.Nullable(prefix = "a") EmbeddedTpe alias; + @Indexed @Embedded.Nullable(prefix = "aego") EmbeddedTpe alterEgo; + } + + @Data + static class EmbeddedTpe { + + @Indexed String firstname; + String lastname; + } + + @Test // DATACASS-167 + public void createTableSpecificationShouldConsiderEmbeddedType() { + + CreateTableSpecification specification = getCreateTableSpecificationFor(TypeWithEmbedded.class); + + assertThat(specification).isNotNull(); + assertThat(getColumn("firstname", specification)).isNotNull(); + assertThat(getColumn("lastname", specification)).isNotNull(); + assertThat(getColumn("afirstname", specification)).isNotNull(); + assertThat(getColumn("alastname", specification)).isNotNull(); + assertThat(getColumn("aegofirstname", specification)).isNotNull(); + assertThat(getColumn("aegolastname", specification)).isNotNull(); + } + + @Test // DATACASS-167 + public void createIndexSpecificationShouldConsiderEmbeddedType() { + + List specifications = schemaFactory + .getCreateIndexSpecificationsFor(ctx.getRequiredPersistentEntity(TypeWithEmbedded.class)); + + CreateIndexSpecification firstname = getSpecificationFor("firstname", specifications); + + assertThat(firstname.getColumnName()).isEqualTo(CqlIdentifier.fromCql("firstname")); + assertThat(firstname.getName()).isNull(); + assertThat(firstname.getColumnFunction()).isEqualTo(ColumnFunction.NONE); + + CreateIndexSpecification afirstname = getSpecificationFor("afirstname", specifications); + + assertThat(afirstname.getColumnName()).isEqualTo(CqlIdentifier.fromCql("afirstname")); + assertThat(afirstname.getName()).isNull(); + assertThat(afirstname.getColumnFunction()).isEqualTo(ColumnFunction.NONE); + + CreateIndexSpecification aegofirstname = getSpecificationFor("aegofirstname", specifications); + assertThat(aegofirstname.getColumnName()).isEqualTo(CqlIdentifier.fromCql("aegofirstname")); + + CreateIndexSpecification aegolastname = getSpecificationFor("aegolastname", specifications); + assertThat(aegolastname.getColumnName()).isEqualTo(CqlIdentifier.fromCql("aegolastname")); + } + } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/UpdateMapperUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/UpdateMapperUnitTests.java index b3b0854eb..0a7638b66 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/UpdateMapperUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/convert/UpdateMapperUnitTests.java @@ -15,9 +15,11 @@ */ package org.springframework.data.cassandra.core.convert; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import lombok.AllArgsConstructor; +import lombok.Data; import java.time.LocalTime; import java.util.Collections; @@ -31,12 +33,13 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; - import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; import org.springframework.data.cassandra.core.mapping.Column; import org.springframework.data.cassandra.core.mapping.Element; +import org.springframework.data.cassandra.core.mapping.Embedded; +import org.springframework.data.cassandra.core.mapping.Indexed; import org.springframework.data.cassandra.core.mapping.Tuple; import org.springframework.data.cassandra.core.mapping.UserDefinedType; import org.springframework.data.cassandra.core.mapping.UserTypeResolver; @@ -46,13 +49,11 @@ import org.springframework.data.cassandra.support.UserDefinedTypeBuilder; import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.type.DataTypes; -import lombok.AllArgsConstructor; -import lombok.Data; - /** * Unit tests for {@link UpdateMapper}. * * @author Mark Paluch + * @author Christoph Strobl */ @RunWith(MockitoJUnitRunner.class) public class UpdateMapperUnitTests { @@ -259,6 +260,26 @@ public class UpdateMapperUnitTests { () -> this.updateMapper.getMappedObject(Update.empty().set("tuple.zip", "bar"), this.persistentEntity)); } + @Test // DATACASS-167 + public void shouldMapEmbeddedEntity() { + + Update update = this.updateMapper.getMappedObject(Update.empty().set("nested.firstname", "spring"), + mappingContext.getRequiredPersistentEntity(WithNullableEmbeddedType.class)); + + assertThat(update.getUpdateOperations()).hasSize(1); + assertThat(update.toString()).startsWith("firstname = 'spring'"); + } + + @Test // DATACASS-167 + public void shouldMapPrefixedEmbeddedEntity() { + + Update update = this.updateMapper.getMappedObject(Update.empty().set("nested.firstname", "spring"), + mappingContext.getRequiredPersistentEntity(WithPrefixedNullableEmbeddedType.class)); + + assertThat(update.getUpdateOperations()).hasSize(1); + assertThat(update.toString()).startsWith("prefixfirstname = 'spring'"); + } + @SuppressWarnings("unused") static class Person { @@ -295,4 +316,30 @@ public class UpdateMapperUnitTests { static class Manufacturer { String name; } + + @Data + static class WithNullableEmbeddedType { + + @Id String id; + + @Embedded.Nullable EmbeddedWithSimpleTypes nested; + } + + @Data + static class WithPrefixedNullableEmbeddedType { + + @Id String id; + + // @Indexed -> index for all properties of nested + @Embedded.Nullable(prefix = "prefix") EmbeddedWithSimpleTypes nested; + } + + @Data + static class EmbeddedWithSimpleTypes { + + @Indexed // single property index (IndexSpecificationFactory) | sassi index etc. :'( + String firstname; + Integer age; + } + } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java index deac55290..52b3c51a6 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/QueryDerivationIntegrationTests.java @@ -18,11 +18,13 @@ package org.springframework.data.cassandra.repository; import static org.assertj.core.api.Assertions.*; import static org.junit.Assume.*; +import lombok.Data; + import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -30,16 +32,19 @@ import org.assertj.core.api.Assertions; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.FilterType; import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.config.SchemaAction; import org.springframework.data.cassandra.core.CassandraOperations; import org.springframework.data.cassandra.core.cql.generator.CreateIndexCqlGenerator; import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification; +import org.springframework.data.cassandra.core.mapping.Embedded; +import org.springframework.data.cassandra.core.mapping.Indexed; +import org.springframework.data.cassandra.core.mapping.Table; import org.springframework.data.cassandra.core.query.CassandraPageRequest; import org.springframework.data.cassandra.domain.AddressType; import org.springframework.data.cassandra.domain.Person; @@ -64,6 +69,7 @@ import com.datastax.oss.driver.api.core.CqlSession; * Integration tests for query derivation through {@link PersonRepository}. * * @author Mark Paluch + * @author Christoph Strobl */ @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration @@ -77,18 +83,19 @@ public class QueryDerivationIntegrationTests extends AbstractSpringDataEmbeddedC @Override protected Set> getInitialEntitySet() { - return Collections.singleton(Person.class); + return new HashSet<>(Arrays.asList(Person.class, PersonWithEmbedded.class)); } @Override public SchemaAction getSchemaAction() { - return SchemaAction.RECREATE_DROP_UNUSED; + return SchemaAction.CREATE; } } @Autowired CassandraOperations template; @Autowired CqlSession session; @Autowired PersonRepository personRepository; + @Autowired EmbeddedPersonRepository personWithEmbeddedRepository; private Person walter; private Person skyler; @@ -368,6 +375,20 @@ public class QueryDerivationIntegrationTests extends AbstractSpringDataEmbeddedC assertThat(personRepository.existsByLastname("Schrader")).isFalse(); } + @Test // DATACASS-167 + public void derivedQueryOnPropertyOfEmbeddedEntity() { + + PersonWithEmbedded source = new PersonWithEmbedded(); + source.id = "id-1"; + source.name = new Name(); + source.name.firstname = "spring"; + source.name.lastname = "data"; + + personWithEmbeddedRepository.save(source); + + assertThat(personWithEmbeddedRepository.findByName_Firstname("spring")).isEqualTo(source); + } + /** * @author Mark Paluch */ @@ -439,4 +460,28 @@ public class QueryDerivationIntegrationTests extends AbstractSpringDataEmbeddedC } } } + + /** + * @author Christoph Strobl + */ + static interface EmbeddedPersonRepository extends CassandraRepository { + + PersonWithEmbedded findByName_Firstname(String firstname); + + } + + @Table + @Data + static class PersonWithEmbedded { + + @Id String id; + @Embedded.Nullable Name name; + } + + @Data + static class Name { + + @Indexed String firstname; + String lastname; + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SchemaTestUtils.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SchemaTestUtils.java index 1bbb44690..5de781c70 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SchemaTestUtils.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SchemaTestUtils.java @@ -21,9 +21,13 @@ import org.springframework.data.cassandra.core.CassandraOperations; import org.springframework.data.cassandra.core.convert.SchemaFactory; import org.springframework.data.cassandra.core.cql.SessionCallback; import org.springframework.data.cassandra.core.cql.generator.CreateTableCqlGenerator; +import org.springframework.data.cassandra.core.cql.generator.CreateUserTypeCqlGenerator; import org.springframework.data.cassandra.core.cql.keyspace.CreateTableSpecification; +import org.springframework.data.cassandra.core.cql.keyspace.CreateUserTypeSpecification; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity; +import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty; +import org.springframework.data.cassandra.core.mapping.EmbeddedEntityOperations; import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; @@ -32,6 +36,7 @@ import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata; * scenarios. * * @author Mark Paluch + * @author Christoph Strobl */ public class SchemaTestUtils { @@ -43,10 +48,36 @@ public class SchemaTestUtils { */ public static void potentiallyCreateTableFor(Class entityClass, CassandraOperations operations) { + CassandraMappingContext mappingContext = operations.getConverter().getMappingContext(); + CassandraPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entityClass); + + potentiallyCreateTableFor(persistentEntity, operations, new SchemaFactory(operations.getConverter())); + } + + /** + * Create a table and UDTs for {@code entityClass} if it not exists. + * + * @param entityClass must not be {@literal null}. + * @param operations must not be {@literal null}. + */ + public static void createTableAndTypes(Class entityClass, CassandraOperations operations) { + CassandraMappingContext mappingContext = operations.getConverter().getMappingContext(); CassandraPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entityClass); SchemaFactory schemaFactory = new SchemaFactory(operations.getConverter()); + potentiallyCreateUdtFor(persistentEntity, operations, schemaFactory); + potentiallyCreateTableFor(persistentEntity, operations, schemaFactory); + } + + public static void potentiallyCreateUdtFor(Class entityType, CassandraOperations operations) { + potentiallyCreateUdtFor(operations.getConverter().getMappingContext().getRequiredPersistentEntity(entityType), + operations, new SchemaFactory(operations.getConverter())); + } + + private static void potentiallyCreateTableFor(CassandraPersistentEntity persistentEntity, + CassandraOperations operations, SchemaFactory schemaFactory) { + operations.getCqlOperations().execute((SessionCallback) session -> { Optional table = session.getKeyspace().flatMap(it -> session.getMetadata().getKeyspace(it)) @@ -60,6 +91,32 @@ public class SchemaTestUtils { }); } + private static void potentiallyCreateUdtFor(CassandraPersistentEntity persistentEntity, + CassandraOperations operations, SchemaFactory schemaFactory) { + + if (persistentEntity.isUserDefinedType()) { + + CreateUserTypeSpecification udtspec = schemaFactory.getCreateUserTypeSpecificationFor(persistentEntity) + .ifNotExists(); + operations.getCqlOperations().execute(CreateUserTypeCqlGenerator.toCql(udtspec)); + + } else { + + for (CassandraPersistentProperty property : persistentEntity) { + if (property.isEntity()) { + if (property.isEmbedded()) { + potentiallyCreateUdtFor( + new EmbeddedEntityOperations(operations.getConverter().getMappingContext()).getEntity(property), + operations, schemaFactory); + } else { + potentiallyCreateUdtFor(operations.getConverter().getMappingContext().getRequiredPersistentEntity(property), + operations, schemaFactory); + } + } + } + } + } + /** * Truncate table for {@code entityClass}. * diff --git a/src/main/asciidoc/reference/mapping.adoc b/src/main/asciidoc/reference/mapping.adoc index 20fde7cf6..350f83976 100644 --- a/src/main/asciidoc/reference/mapping.adoc +++ b/src/main/asciidoc/reference/mapping.adoc @@ -344,6 +344,63 @@ public class LoginEvent { ---- ==== +[[mapping.embedded-entities]] +=== Embedded Entity Support + +Embedded entities are used to have value objects in your java data model who's properties are flattened out into the table. +In the following example you see, that `User.name` is annotated with `@Embedded`. +The consequence of this is the properties of `UserName` are folded into the `user` table which consists of 3 columns (`user_id`, `firstname`, `lastname`). + +[WARNING] +==== +Embedded entities may only contain simple property types. It is not possible to nest an embedded entity into another embedded one. +==== + +However, if the `firstname` and `lastname` column values are actually `null` within the result set, the entire property `name` will be set to `null` according to the `onEmpty` of `@Embedded`, which ``null``s objects when all nested properties are `null`. + +Opposite to this behavior `USE_EMPTY` tries to create a new instance using either a default constructor or one that accepts nullable parameter values from the result set. + +.Sample Code of embedding objects +==== +[source, java] +---- +public class User { + + @PrimaryKey("user_id") + private String userId; + + @Embedded(onEmpty = USE_NULL) <1> + UserName name; +} + +public class UserName { + private String firstname; + private String lastname; +} +---- +<1> ``Null``s `embeddedEntity` if `name` in `null`. Use `USE_EMPTY` to instantiate `embeddedEntity` with a potential `null` value for the `name` property. +==== + +If you need a value object multiple times in an entity, this can be achieved with the optional `prefix` element of the `@Embedded` annotation. +This element represents a prefix and is prepend for each column name in the embedded object. + +[TIP] +==== +Make use of the shortcuts `@Embedded.Nullable` & `@Embedded.Empty` for `@Embedded(onEmpty = USE_NULL)` and `@Embedded(onEmpty = USE_EMPTY)` to reduce verbosity and simultaneously set JSR-305 `@javax.annotation.Nonnull` accordingly. + +[source, java] +---- +public class MyEntity { + + @Id + Integer id; + + @Embedded.Nullable <1> + EmbeddedEntity embeddedEntity; +} +---- +<1> Shortcut for `@Embedded(onEmpty = USE_NULL)`. +==== + [[mapping.usage-annotations]] === Mapping Annotation Overview @@ -443,6 +500,11 @@ include::../{example-root}/mapping/PersonWithIndexes.java[tags=class] ---- ==== +[NOTE] +==== +The `@Indexed` annotation can be applied to single properties of embedded entities or along side with the `@Embedded` annotation, in which case all properties of the embedded are indexed. +==== + CAUTION: Index creation on session initialization may have a severe performance impact on application startup. include::./converters.adoc[]