From 4a0abd0f668df76f9281d341cd9a335cfbc7829a Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 19 Jul 2017 15:52:34 +0200 Subject: [PATCH] DATACASS-213 - Create secondary indexes for annotated properties. We now create secondary indexes for annotated properties via @Indexed. Index creation is part of schema creation that is executed after Session initialization and table creation. We support plain secondary indexes and key/value/entry indexes for map columns. Index creation is useful for rapid development but should not be used in large setups or at least with care to not impact performance in a negative way. @Table public class Person { @Id private String key; @Indexed("name_index") private String name; private Map<@Indexed String, String> keys; } --- .../config/CassandraSessionFactoryBean.java | 1 + ...assandraPersistentEntitySchemaCreator.java | 32 ++++- .../generator/CreateIndexCqlGenerator.java | 24 +++- .../keyspace/CreateIndexSpecification.java | 71 ++++++++++ .../keyspace/CreateTableSpecification.java | 5 +- ...andraPersistentEntityMetadataVerifier.java | 11 +- .../BasicCassandraPersistentProperty.java | 42 ++++++ .../core/mapping/CassandraMappingContext.java | 104 +++++++++++++- .../mapping/CassandraPersistentProperty.java | 14 ++ .../data/cassandra/core/mapping/Indexed.java | 25 +++- ...PrimaryKeyClassEntityMetadataVerifier.java | 5 + .../CassandraSessionFactoryBeanUnitTests.java | 8 +- ...ersistentEntitySchemaCreatorUnitTests.java | 23 +++ ...eateIndexCqlGeneratorIntegrationTests.java | 71 ---------- .../CreateIndexCqlGeneratorUnitTests.java | 64 ++++----- ...LifecycleCqlGeneratorIntegrationTests.java | 61 -------- ...istentEntityMetadataVerifierUnitTests.java | 20 +++ .../CassandraMappingContextUnitTests.java | 133 +++++++++++++++++- ...yClassEntityMetadataVerifierUnitTests.java | 18 +++ src/main/asciidoc/new-features.adoc | 1 + src/main/asciidoc/reference/cassandra.adoc | 2 +- src/main/asciidoc/reference/mapping.adoc | 35 ++++- 22 files changed, 576 insertions(+), 194 deletions(-) delete mode 100755 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorIntegrationTests.java delete mode 100755 spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/IndexLifecycleCqlGeneratorIntegrationTests.java diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBean.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBean.java index 4f98940be..24f46e850 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBean.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBean.java @@ -165,6 +165,7 @@ public class CassandraSessionFactoryBean extends CassandraCqlSessionFactoryBean schemaCreator.createUserTypes(ifNotExists); schemaCreator.createTables(ifNotExists); + schemaCreator.createIndexes(ifNotExists); } /** diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreator.java index dd09eb0e9..94b453ce7 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreator.java @@ -26,8 +26,10 @@ import java.util.Set; import java.util.stream.Collectors; import org.springframework.data.cassandra.core.cql.CqlIdentifier; +import org.springframework.data.cassandra.core.cql.generator.CreateIndexCqlGenerator; 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.CreateIndexSpecification; 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.BasicCassandraPersistentEntity; @@ -94,6 +96,32 @@ public class CassandraPersistentEntitySchemaCreator { .collect(Collectors.toList()); } + /** + * Create indexes from types known to {@link CassandraMappingContext}. + * + * @param ifNotExists {@literal true} to create tables using {@code IF NOT EXISTS}. + */ + public void createIndexes(boolean ifNotExists) { + + createIndexSpecifications(ifNotExists).forEach(specification -> cassandraAdminOperations.getCqlOperations() + .execute(CreateIndexCqlGenerator.toCql(specification))); + } + + /** + * Create {@link List} of {@link CreateIndexSpecification}. + * + * @param ifNotExists {@literal true} to create indexes using {@code IF NOT EXISTS}. + * @return {@link List} of {@link CreateIndexSpecification}. + */ + protected List createIndexSpecifications(boolean ifNotExists) { + + return mappingContext.getTableEntities() // + .stream() // + .flatMap(entity -> mappingContext.getCreateIndexSpecificationsFor(entity).stream()) // + .peek(it -> it.ifNotExists(ifNotExists)) // + .collect(Collectors.toList()); + } + /** * Create user types from types known to {@link CassandraMappingContext}. * @@ -133,8 +161,8 @@ public class CassandraPersistentEntitySchemaCreator { List ordered = new ArrayList<>(seen); Collections.reverse(ordered); - specifications.addAll(ordered.stream() - .filter(created::add).map(identifier -> mappingContext + specifications.addAll(ordered + .stream().filter(created::add).map(identifier -> mappingContext .getCreateUserTypeSpecificationFor(byTableName.get(identifier)).ifNotExists(ifNotExists)) .collect(Collectors.toList())); }); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGenerator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGenerator.java index 4e398a87c..8464191ab 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGenerator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGenerator.java @@ -18,12 +18,14 @@ package org.springframework.data.cassandra.core.cql.generator; import static org.springframework.data.cassandra.core.cql.CqlStringUtils.*; import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification; +import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification.ColumnFunction; /** * CQL generator for generating a {@code CREATE INDEX} statement. * * @author Matthew T. Adams * @author David Webb + * @author Mark Paluch */ public class CreateIndexCqlGenerator extends IndexNameCqlGenerator { @@ -40,13 +42,25 @@ public class CreateIndexCqlGenerator extends IndexNameCqlGenerator implements IndexDescriptor { private boolean ifNotExists = false; + private boolean custom = false; + private CqlIdentifier tableName; + private CqlIdentifier columnName; + + private ColumnFunction columnFunction = ColumnFunction.NONE; + private String using; /** @@ -120,6 +127,61 @@ public class CreateIndexSpecification extends IndexNameSpecification { + private boolean ifNotExists = false; + /** * Entry point into the {@link CreateTableSpecification}'s fluent API to create a table. Convenient if imported * statically. @@ -48,8 +51,6 @@ public class CreateTableSpecification extends TableSpecification exceptions = new ArrayList<>(); - final List idProperties = new ArrayList<>(); - final List partitionKeyColumns = new ArrayList<>(); - final List primaryKeyColumns = new ArrayList<>(); + List idProperties = new ArrayList<>(); + List partitionKeyColumns = new ArrayList<>(); + List primaryKeyColumns = new ArrayList<>(); + + // @Indexed not allowed on type level + if (entity.isAnnotationPresent(Indexed.class)) { + exceptions.add(new MappingException("@Indexed cannot be used on entity classes")); + } // Ensure entity is not both a @Table(@Persistent) and a @PrimaryKeyClass if (entity.isCompositePrimaryKey()) { diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentProperty.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentProperty.java index 4d83e13be..0c62cdbdc 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentProperty.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentProperty.java @@ -17,6 +17,11 @@ package org.springframework.data.cassandra.core.mapping; import static org.springframework.data.cassandra.core.cql.CqlIdentifier.*; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.Optional; @@ -37,6 +42,7 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.model.AnnotationBasedPersistentProperty; import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.Optionals; import org.springframework.data.util.TypeInformation; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.util.Assert; @@ -422,4 +428,40 @@ public class BasicCassandraPersistentProperty extends AnnotationBasedPersistentP public boolean isMapLike() { return ClassUtils.isAssignable(Map.class, getType()); } + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty#findAnnotatedType(java.lang.Class) + */ + @Override + public AnnotatedType findAnnotatedType(Class annotationType) { + + return Optionals.toStream(Optional.ofNullable(getField()).map(Field::getAnnotatedType), // + Optional.ofNullable(getGetter()).map(Method::getAnnotatedReturnType), // + Optional.ofNullable(getSetter()).map(it -> it.getParameters()[0].getAnnotatedType())) // + .filter(it -> hasAnnotation(it, annotationType, getTypeInformation())) // + .findFirst() // + .orElse(null); + } + + private static boolean hasAnnotation(AnnotatedType type, Class annotationType, + TypeInformation typeInformation) { + + if (AnnotatedElementUtils.hasAnnotation(type, annotationType)) { + return true; + } + + AnnotatedParameterizedType parameterizedType = (AnnotatedParameterizedType) type; + AnnotatedType[] arguments = parameterizedType.getAnnotatedActualTypeArguments(); + + if (typeInformation.isCollectionLike() && arguments.length == 1) { + return AnnotatedElementUtils.hasAnnotation(arguments[0], annotationType); + } + + if (typeInformation.isMap() && arguments.length == 2) { + return AnnotatedElementUtils.hasAnnotation(arguments[0], annotationType) + || AnnotatedElementUtils.hasAnnotation(arguments[1], annotationType); + } + + return false; + } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContext.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContext.java index 2cfef5976..1b0f25e76 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContext.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContext.java @@ -19,6 +19,9 @@ import static org.springframework.data.cassandra.core.cql.CqlIdentifier.*; import static org.springframework.data.cassandra.core.cql.keyspace.CreateTableSpecification.*; import static org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder.*; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.AnnotatedType; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -36,6 +39,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.cassandra.core.cql.CqlIdentifier; +import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecification; 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.UserTypeUtil.FrozenLiteralDataType; @@ -371,7 +375,7 @@ public class CassandraMappingContext Assert.notNull(entity, "CassandraPersistentEntity must not be null"); - final CreateTableSpecification specification = createTable().name(entity.getTableName()); + CreateTableSpecification specification = createTable().name(entity.getTableName()); for (CassandraPersistentProperty property : entity) { @@ -416,6 +420,104 @@ public class CassandraMappingContext return specification; } + /** + * @param entity must not be {@literal null}. + * @return + * @since 2.0 + */ + public List getCreateIndexSpecificationsFor(CassandraPersistentEntity entity) { + + Assert.notNull(entity, "CassandraPersistentEntity must not be null"); + + return getCreateIndexSpecifications(entity.getTableName(), entity); + } + + private List getCreateIndexSpecifications(CqlIdentifier tableName, + CassandraPersistentEntity entity) { + + List indexes = new ArrayList<>(); + + for (CassandraPersistentProperty property : entity) { + + if (property.isCompositePrimaryKey()) { + indexes.addAll(getCreateIndexSpecifications(tableName, getRequiredPersistentEntity(property))); + } else { + indexes.addAll(createIndexSpecifications(tableName, property)); + } + } + return indexes; + } + + private List createIndexSpecifications(CqlIdentifier tableName, + CassandraPersistentProperty property) { + + List indexes = new ArrayList<>(); + + if (property.isAnnotationPresent(Indexed.class)) { + + Indexed annotation = property.findAnnotation(Indexed.class); + CreateIndexSpecification index = createIndexSpecification(annotation, tableName, property); + + if (property.isMapLike()) { + index.entries(); + } + + indexes.add(index); + } + + if (property.isMapLike()) { + + AnnotatedType type = property.findAnnotatedType(Indexed.class); + + if (type instanceof AnnotatedParameterizedType) { + + AnnotatedParameterizedType parameterizedType = (AnnotatedParameterizedType) type; + AnnotatedType[] typeArgs = parameterizedType.getAnnotatedActualTypeArguments(); + + Indexed keyIndex = typeArgs.length == 2 ? AnnotatedElementUtils.getMergedAnnotation(typeArgs[0], Indexed.class) + : null; + Indexed valueIndex = typeArgs.length == 2 + ? AnnotatedElementUtils.getMergedAnnotation(typeArgs[1], Indexed.class) + : null; + + if ((!indexes.isEmpty() && (keyIndex != null || valueIndex != null)) + || (keyIndex != null && valueIndex != null)) { + throw new MappingException("Multiple index declarations for " + property + + " found. A map index must be either declared for entries, keys or values."); + } + + if (keyIndex != null) { + + CreateIndexSpecification index = createIndexSpecification(keyIndex, tableName, property); + index.keys(); + indexes.add(index); + } + + if (valueIndex != null) { + CreateIndexSpecification index = createIndexSpecification(valueIndex, tableName, property); + index.values(); + indexes.add(index); + } + } + } + + return indexes; + } + + private CreateIndexSpecification createIndexSpecification(Indexed annotation, CqlIdentifier tableName, + CassandraPersistentProperty property) { + + CreateIndexSpecification index; + + if (StringUtils.hasText(annotation.value())) { + index = CreateIndexSpecification.createIndex(annotation.value()); + } else { + index = CreateIndexSpecification.createIndex(); + } + + return index.tableName(tableName).columnName(property.getColumnName()); + } + /** * Returns a {@link CreateUserTypeSpecification} for the given entity, including all mapping information. * 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 9620048de..880432a23 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 @@ -15,6 +15,9 @@ */ package org.springframework.data.cassandra.core.mapping; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedType; + import org.springframework.context.ApplicationContextAware; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.cassandra.core.cql.CqlIdentifier; @@ -101,4 +104,15 @@ public interface CassandraPersistentProperty * @return a boolean indicating whether this property type is a {@link java.util.Map}. */ boolean isMapLike(); + + /** + * 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 + * type arguments. + * + * @param annotationType must not be {@literal null}. + * @return the annotated type or {@literal null}. + * @since 2.0 + */ + AnnotatedType findAnnotatedType(Class annotationType); } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Indexed.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Indexed.java index 6ab305287..d60c3398b 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Indexed.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/Indexed.java @@ -21,13 +21,34 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Identifies a secondary index in the table on a single, non-key column. + * Identifies a secondary index in the table on a single column. + *

+ * The following columns of a {@link Table} type can be annotated with {@link Indexed}: + *

    + *
  • Scalar data types
  • + *
  • User-defined types
  • + *
  • Collection types
  • + *
  • Map type
  • + *
+ *

+ * Map types distinguish between the column function applied before indexing. Maps support entry, key or value-level + * indexing with the restriction that only a single secondary index is allowed. + * + *

+ * @Table
+ * class Person {
+ *
+ * 	Map<@Indexed String, String> indexedKey; // allows CONTAINS KEY queries
+ * 	Map indexedValue; // allows CONTAINS queries
+ * }
+ * 
* * @author Alex Shvid * @author Matthew T. Adams + * @author Mark Paluch */ @Retention(value = RetentionPolicy.RUNTIME) -@Target(value = { ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Target(value = { ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE, ElementType.TYPE_USE }) public @interface Indexed { /** diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifier.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifier.java index 01aeb6869..894dbca60 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifier.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifier.java @@ -51,6 +51,11 @@ public class PrimaryKeyClassEntityMetadataVerifier implements CassandraPersisten Class entityType = entity.getType(); + // @Indexed not allowed on type level + if (entity.isAnnotationPresent(Indexed.class)) { + exceptions.add(new MappingException("@Indexed cannot be used on primary key classes")); + } + // Ensure entity is not both a @Table(@Persistent) and a @PrimaryKey if (entity.isAnnotationPresent(Table.class)) { exceptions.add(new MappingException(String.format("Entity cannot be of type @%s and @%s", diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBeanUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBeanUnitTests.java index 944311f77..45392a4d6 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBeanUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/config/CassandraSessionFactoryBeanUnitTests.java @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.data.cassandra.config; import static org.assertj.core.api.Assertions.*; @@ -30,7 +29,6 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.data.cassandra.core.convert.CassandraConverter; -import org.springframework.data.cassandra.core.cql.CqlIdentifier; import com.datastax.driver.core.Cluster; import com.datastax.driver.core.Session; @@ -62,10 +60,6 @@ public class CassandraSessionFactoryBeanUnitTests { factoryBean.setCluster(mockCluster); } - protected CqlIdentifier newCqlIdentifier(String id) { - return new CqlIdentifier(id, false); - } - @Test // DATACASS-219 public void afterPropertiesSetPerformsSchemaAction() throws Exception { @@ -99,7 +93,7 @@ public class CassandraSessionFactoryBeanUnitTests { } private void performSchemaActionCallsCreateTableWithArgumentsMatchingTheSchemaAction(SchemaAction schemaAction, - final boolean dropTables, final boolean dropUnused, final boolean ifNotExists) { + boolean dropTables, boolean dropUnused, boolean ifNotExists) { doAnswer(invocationOnMock -> { assertThat(invocationOnMock. getArgument(0)).isEqualTo(dropTables); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreatorUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreatorUnitTests.java index f118bf940..c121428d5 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreatorUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/CassandraPersistentEntitySchemaCreatorUnitTests.java @@ -28,8 +28,11 @@ import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.cql.CqlOperations; import org.springframework.data.cassandra.core.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.core.mapping.Indexed; +import org.springframework.data.cassandra.core.mapping.Table; import org.springframework.data.cassandra.core.mapping.UserDefinedType; /** @@ -115,6 +118,19 @@ public class CassandraPersistentEntitySchemaCreatorUnitTests { verifyTypesGetCreatedInOrderFor("universetype", "moontype", "planettype"); } + @Test // DATACASS-213 + public void createsIndexes() { + + context.getPersistentEntity(IndexedEntity.class); + + CassandraPersistentEntitySchemaCreator schemaCreator = new CassandraPersistentEntitySchemaCreator(context, + adminOperations); + + schemaCreator.createIndexes(false); + + verify(operations).execute("CREATE INDEX ON indexedentity (firstname);"); + } + private void verifyTypesGetCreatedInOrderFor(String... typenames) { InOrder inOrder = Mockito.inOrder(operations); @@ -149,4 +165,11 @@ public class CassandraPersistentEntitySchemaCreatorUnitTests { static class SpaceAgencyType { List astronauts; } + + @Table + static class IndexedEntity { + + @Id String id; + @Indexed String firstName; + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorIntegrationTests.java deleted file mode 100755 index 0054dd190..000000000 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorIntegrationTests.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.cassandra.core.cql.generator; - -import org.junit.Test; -import org.springframework.data.cassandra.core.cql.generator.CreateIndexCqlGeneratorUnitTests.BasicTest; -import org.springframework.data.cassandra.core.cql.generator.CreateIndexCqlGeneratorUnitTests.CreateIndexTest; -import org.springframework.data.cassandra.support.CqlDataSet; -import org.springframework.data.cassandra.test.util.AbstractKeyspaceCreatingIntegrationTest; - -/** - * Integration tests that reuse unit tests. - * - * @author Matthew T. Adams - * @author Oliver Gierke - * @author Mark Paluch - */ -public class CreateIndexCqlGeneratorIntegrationTests { - - /** - * Integration test base class that knows how to do everything except instantiate the concrete unit test type T. - * - * @author Matthew T. Adams - * @param The concrete unit test class to which this integration test corresponds. - */ - public static abstract class Base extends AbstractKeyspaceCreatingIntegrationTest { - T unit; - - public abstract T unit(); - - @Test - public void test() { - unit = unit(); - unit.prepare(); - - session.execute(unit.cql); - - CqlIndexSpecificationAssertions.assertIndex(unit.specification, keyspace, session); - } - } - - public static class BasicIntegrationTest extends Base { - - public BasicIntegrationTest() { - - cassandraRule.before( - CqlDataSet.fromClassPath("integration/cql/generator/CreateIndexCqlGeneratorIntegrationTests-BasicTest.cql") - .executeIn(this.keyspace)); - } - - @Override - public BasicTest unit() { - return new BasicTest(); - } - - } - -} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorUnitTests.java index 8db40c865..f8423711f 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/CreateIndexCqlGeneratorUnitTests.java @@ -25,54 +25,44 @@ import org.springframework.data.cassandra.core.cql.keyspace.CreateIndexSpecifica * * @author Matthew T. Adams * @author David Webb + * @author Mark Paluch */ public class CreateIndexCqlGeneratorUnitTests { - /** - * Asserts that the preamble is first & correctly formatted in the given CQL string. - */ - public static void assertPreamble(String indexName, String tableName, String cql) { - assertThat(cql.startsWith("CREATE INDEX " + indexName + " ON " + tableName)).isTrue(); + @Test // DATACASS-213 + public void createIndex() { + + CreateIndexSpecification spec = CreateIndexSpecification.createIndex().name("myindex").tableName("mytable") + .columnName("column"); + + assertThat(CreateIndexCqlGenerator.toCql(spec)).isEqualTo("CREATE INDEX myindex ON mytable (column);"); } - /** - * Asserts that the given list of columns definitions are contained in the given CQL string properly. - * - * @param columnName IE, "(foo)" - */ - public static void assertColumn(String columnName, String cql) { - assertThat(cql.contains("(" + columnName + ")")).isTrue(); + @Test // DATACASS-213 + public void createCustomIndex() { + + CreateIndexSpecification spec = CreateIndexSpecification.createIndex().name("myindex").tableName("mytable") + .columnName("column").using("indexclass"); + + assertThat(CreateIndexCqlGenerator.toCql(spec)) + .isEqualTo("CREATE CUSTOM INDEX myindex ON mytable (column) USING 'indexclass';"); } - /** - * Convenient base class that other test classes can use so as not to repeat the generics declarations or - * {@link #generator()} method. - */ - public static abstract class CreateIndexTest - extends AbstractIndexOperationCqlGeneratorTest { + @Test // DATACASS-213 + public void createIndexOnKeys() { - public CreateIndexCqlGenerator generator() { - return new CreateIndexCqlGenerator(specification); - } + CreateIndexSpecification spec = CreateIndexSpecification.createIndex().tableName("mytable").keys() + .columnName("column"); + + assertThat(CreateIndexCqlGenerator.toCql(spec)).isEqualTo("CREATE INDEX ON mytable (KEYS(column));"); } - public static class BasicTest extends CreateIndexTest { + @Test // DATACASS-213 + public void createIndexIfNotExists() { - public String name = "myindex"; - public String tableName = "mytable"; - public String column1 = "column1"; + CreateIndexSpecification spec = CreateIndexSpecification.createIndex().tableName("mytable").columnName("column") + .ifNotExists(); - public CreateIndexSpecification specification() { - return CreateIndexSpecification.createIndex().name(name).tableName(tableName).columnName(column1); - } - - @Test - public void test() { - prepare(); - - assertPreamble(name, tableName, cql); - assertColumn(column1, cql); - } + assertThat(CreateIndexCqlGenerator.toCql(spec)).isEqualTo("CREATE INDEX IF NOT EXISTS ON mytable (column);"); } - } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/IndexLifecycleCqlGeneratorIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/IndexLifecycleCqlGeneratorIntegrationTests.java deleted file mode 100755 index 4a686929e..000000000 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/generator/IndexLifecycleCqlGeneratorIntegrationTests.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2017 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.cassandra.core.cql.generator; - -import org.junit.Before; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.cassandra.test.util.AbstractKeyspaceCreatingIntegrationTest; - -/** - * Integration tests that reuse unit tests. - * - * @author Matthew T. Adams - * @author Oliver Gierke - * @author Mark Paluch - */ -public class IndexLifecycleCqlGeneratorIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest { - - private static final Logger log = LoggerFactory.getLogger(IndexLifecycleCqlGeneratorIntegrationTests.class); - - @Before - public void setUp() throws Exception { - execute("integration/cql/generator/CreateIndexCqlGeneratorIntegrationTests-BasicTest.cql", this.keyspace); - } - - @Test - public void lifecycleTest() { - - CreateIndexCqlGeneratorUnitTests.BasicTest createTest = new CreateIndexCqlGeneratorUnitTests.BasicTest(); - DropIndexCqlGeneratorUnitTests.BasicTest dropTest = new DropIndexCqlGeneratorUnitTests.BasicTest(); - DropIndexCqlGeneratorUnitTests.IfExistsTest dropIfExists = new DropIndexCqlGeneratorUnitTests.IfExistsTest(); - - createTest.prepare(); - dropTest.prepare(); - dropIfExists.prepare(); - - log.info(createTest.cql); - session.execute(createTest.cql); - - CqlIndexSpecificationAssertions.assertIndex(createTest.specification, keyspace, session); - - log.info(dropTest.cql); - session.execute(dropTest.cql); - - CqlIndexSpecificationAssertions.assertNoIndex(createTest.specification, keyspace, session); - } -} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentEntityMetadataVerifierUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentEntityMetadataVerifierUnitTests.java index 128d5b347..b9c0772c0 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentEntityMetadataVerifierUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/BasicCassandraPersistentEntityMetadataVerifierUnitTests.java @@ -107,6 +107,17 @@ public class BasicCassandraPersistentEntityMetadataVerifierUnitTests { } } + @Test // DATACASS-213 + public void shouldFailOnIndexedEntity() { + + try { + verifier.verify(context.getRequiredPersistentEntity(InvalidIndexedPerson.class)); + fail("Missing MappingException"); + } catch (MappingException e) { + assertThat(e).hasMessageContaining("@Indexed cannot be used on entity classes"); + } + } + interface MyInterface {} static class NonPersistentClass { @@ -126,6 +137,15 @@ public class BasicCassandraPersistentEntityMetadataVerifierUnitTests { String lastName; } + @Table + @Indexed + static class InvalidIndexedPerson { + + @Id String id; + + String firstName; + } + @Table static class Animal { diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContextUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContextUnitTests.java index 1fb256c9f..1e7fcc865 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContextUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/CassandraMappingContextUnitTests.java @@ -23,17 +23,22 @@ import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import org.junit.Before; import org.junit.Test; import org.springframework.core.convert.converter.Converter; import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.annotation.AccessType; +import org.springframework.data.annotation.AccessType.Type; import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.core.convert.CassandraCustomConversions; import org.springframework.data.cassandra.core.cql.CqlIdentifier; import org.springframework.data.cassandra.core.cql.Ordering; import org.springframework.data.cassandra.core.cql.PrimaryKeyType; 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.convert.WritingConverter; import org.springframework.data.mapping.MappingException; @@ -298,6 +303,133 @@ public class CassandraMappingContextUnitTests { @PrimaryKey PrimaryKeyWithOrderedClusteredColumns key; } + @Test // DATACASS-213 + public void createIndexShouldConsiderAnnotatedProperties() { + + List specifications = mappingContext + .getCreateIndexSpecificationsFor(mappingContext.getRequiredPersistentEntity(IndexedType.class)); + + CreateIndexSpecification firstname = getSpecificationFor("first_name", specifications); + + assertThat(firstname.getColumnName()).isEqualTo(CqlIdentifier.cqlId("first_name")); + assertThat(firstname.getTableName()).isEqualTo(CqlIdentifier.cqlId("indexedtype")); + assertThat(firstname.getName()).isEqualTo(CqlIdentifier.cqlId("my_index")); + assertThat(firstname.getColumnFunction()).isEqualTo(ColumnFunction.NONE); + + CreateIndexSpecification entries = getSpecificationFor("entries", specifications); + + assertThat(entries.getColumnName()).isEqualTo(CqlIdentifier.cqlId("entries")); + assertThat(entries.getTableName()).isEqualTo(CqlIdentifier.cqlId("indexedtype")); + assertThat(entries.getName()).isNull(); + assertThat(entries.getColumnFunction()).isEqualTo(ColumnFunction.ENTRIES); + + CreateIndexSpecification phoneNumbers = getSpecificationFor("phoneNumbers", specifications); + + assertThat(phoneNumbers.getColumnName()).isEqualTo(CqlIdentifier.cqlId("phoneNumbers")); + assertThat(phoneNumbers.getTableName()).isEqualTo(CqlIdentifier.cqlId("indexedtype")); + assertThat(phoneNumbers.getName()).isNull(); + assertThat(phoneNumbers.getColumnFunction()).isEqualTo(ColumnFunction.NONE); + } + + @Test // DATACASS-213 + public void createMapKeyIndexShouldConsiderAnnotatedAccessors() { + + List specifications = mappingContext + .getCreateIndexSpecificationsFor(mappingContext.getRequiredPersistentEntity(IndexedMapKeyProperty.class)); + + CreateIndexSpecification entries = getSpecificationFor("entries", specifications); + + assertThat(entries.getColumnName()).isEqualTo(CqlIdentifier.cqlId("entries")); + assertThat(entries.getTableName()).isEqualTo(CqlIdentifier.cqlId("indexedmapkeyproperty")); + assertThat(entries.getName()).isNull(); + assertThat(entries.getColumnFunction()).isEqualTo(ColumnFunction.KEYS); + } + + @Test // DATACASS-213 + public void createMapValueIndexShouldConsiderAnnotatedAccessors() { + + List specifications = mappingContext + .getCreateIndexSpecificationsFor(mappingContext.getRequiredPersistentEntity(MapValueIndexProperty.class)); + + CreateIndexSpecification entries = getSpecificationFor("entries", specifications); + + assertThat(entries.getColumnName()).isEqualTo(CqlIdentifier.cqlId("entries")); + assertThat(entries.getTableName()).isEqualTo(CqlIdentifier.cqlId("mapvalueindexproperty")); + assertThat(entries.getName()).isNull(); + assertThat(entries.getColumnFunction()).isEqualTo(ColumnFunction.VALUES); + } + + @Test // DATACASS-213 + public void createIndexForClusteredPrimaryKeyShouldConsiderAnnotatedAccessors() { + + List specifications = mappingContext + .getCreateIndexSpecificationsFor(mappingContext.getRequiredPersistentEntity(CompositeKeyEntity.class)); + + CreateIndexSpecification entries = getSpecificationFor("last_name", specifications); + + assertThat(entries.getColumnName()).isEqualTo(CqlIdentifier.cqlId("last_name")); + assertThat(entries.getTableName()).isEqualTo(CqlIdentifier.cqlId("compositekeyentity")); + assertThat(entries.getName()).isEqualTo(CqlIdentifier.cqlId("my_index")); + assertThat(entries.getColumnFunction()).isEqualTo(ColumnFunction.NONE); + } + + private static CreateIndexSpecification getSpecificationFor(String column, + List specifications) { + + return specifications.stream().filter(it -> it.getColumnName().equals(CqlIdentifier.cqlId(column))).findFirst() + .orElseThrow(() -> new NoSuchElementException(column)); + } + + static class IndexedType { + + @PrimaryKeyColumn("first_name") @Indexed("my_index") String firstname; + + @Indexed List phoneNumbers; + + @Indexed Map entries; + + Map<@Indexed String, String> keys; + + Map values; + } + + @AccessType(Type.PROPERTY) + static class IndexedMapKeyProperty { + + public Map<@Indexed String, String> getEntries() { + return null; + } + + public void setEntries(Map entries) {} + } + + @AccessType(Type.PROPERTY) + static class MapValueIndexProperty { + + public Map getEntries() { + return null; + } + + public void setEntries(Map entries) {} + } + + @PrimaryKeyClass + static class CompositeKeyWithIndex { + + @PrimaryKeyColumn(value = "first_name", type = PrimaryKeyType.PARTITIONED) String firstname; + @PrimaryKeyColumn("last_name") @Indexed("my_index") String lastname; + } + + static class CompositeKeyEntity { + + @PrimaryKey CompositeKeyWithIndex key; + } + + static class InvalidMapIndex { + + @Indexed Map<@Indexed String, String> mixed; + } + @Test // DATACASS-296 public void shouldCreatePersistentEntityIfNoConversionRegistered() { @@ -542,5 +674,4 @@ public class CassandraMappingContextUnitTests { return "serialized"; } } - } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifierUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifierUnitTests.java index 6fc4893bc..a4191ffe6 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifierUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifierUnitTests.java @@ -142,6 +142,17 @@ public class PrimaryKeyClassEntityMetadataVerifierUnitTests { } } + @Test // DATACASS-213 + public void shouldFailForIndexedPrimaryKey() { + + try { + verifier.verify(getEntity(InvalidIndexedPrimaryKeyType.class)); + fail("Missing MappingException"); + } catch (MappingException e) { + assertThat(e).hasMessageContaining("@Indexed cannot be used on primary key classes"); + } + } + private CassandraPersistentEntity getEntity(Class entityClass) { return context.getRequiredPersistentEntity(entityClass); } @@ -236,6 +247,13 @@ public class PrimaryKeyClassEntityMetadataVerifierUnitTests { @PrimaryKeyColumn(type = PrimaryKeyType.PARTITIONED, ordinal = 0) String pk; } + @PrimaryKeyClass + @Indexed + static class InvalidIndexedPrimaryKeyType { + + @PrimaryKeyColumn(ordinal = 0, type = PrimaryKeyType.PARTITIONED) String species; + } + private static class NoOpVerifier implements CassandraPersistentEntityMetadataVerifier { @Override diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index c29458324..d91b6f77a 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -7,6 +7,7 @@ * CRUD repository interface renaming: `CassandraRepository` using `MapId` is now renamed to `MapIdCassandraRepository`. `TypedIdCassandraRepository` is renamed to `CassandraRepository`. * Lightweight transactions via `InsertOptions` and `UpdateOptions` using the Template API. * Merge of Spring CQL into Spring Data Cassandra. +* Index creation on application startup via `@Indexed`. [[new-features.1-5-0]] == What's new in Spring Data for Apache Cassandra 1.5 diff --git a/src/main/asciidoc/reference/cassandra.adoc b/src/main/asciidoc/reference/cassandra.adoc index 5641ff58f..eba81b975 100644 --- a/src/main/asciidoc/reference/cassandra.adoc +++ b/src/main/asciidoc/reference/cassandra.adoc @@ -672,7 +672,7 @@ These entity classes can be used to create Cassandra table specifications and us Schema creation is tied to `Session` initialization with `SchemaAction`. Following actions are supported: * `SchemaAction.NONE`: No tables/types will be created or dropped. This is the default setting. -* `SchemaAction.CREATE`: Create tables and user-defined types from entities annotated with `@Table` and types annotated with `@UserDefinedType`. Existing tables/types will cause an error if the type is attempted to be created. +* `SchemaAction.CREATE`: Create tables, indexesm and user-defined types from entities annotated with `@Table` and types annotated with `@UserDefinedType`. Existing tables/types will cause an error if the type is attempted to be created. * `SchemaAction.CREATE_IF_NOT_EXISTS`: Like `SchemaAction.CREATE` but with `IF NOT EXISTS` applied. Existing tables/types won't cause any errors but may remain stale. * `SchemaAction.RECREATE`: Drops and recreate existing tables and types that are known to be used. Tables and types that are not configured in the application are not dropped. * `SchemaAction.RECREATE_DROP_UNUSED`: Drop all tables and types and recreate only known tables and types. diff --git a/src/main/asciidoc/reference/mapping.adoc b/src/main/asciidoc/reference/mapping.adoc index f145ac20e..75a7cb6b6 100644 --- a/src/main/asciidoc/reference/mapping.adoc +++ b/src/main/asciidoc/reference/mapping.adoc @@ -245,6 +245,7 @@ be referenced with `@PrimaryKey`. where it is applied from being stored in the database. * `@Column` - applied at the field level. Describes the column name as it will be represented in the Cassandra table thus allowing the name to be different than the field name of the class. +* `@Indexed` - applied at the field level. Describes the index to be created at session initialization. * `@CassandraType` - applied at the field level to specify a Cassandra data type. Types are derived from the declaration by default. * `@UserDefinedType` - applied at the type level to specify a Cassandra user-defined data type (UDT). Types are derived @@ -286,6 +287,7 @@ public class Person { private String firstName; @Column(forceQuote = true) + @Indexed private String lastName; private Address address; @@ -299,7 +301,7 @@ public class Person { @CassandraType(type = Name.SET, typeArguments = Name.BIGINT) private Set timestamps; - private Map sessions; + private Map<@Indexed String, InetAddress> sessions; public Person(Integer ssn) { this.ssn = ssn; @@ -346,6 +348,37 @@ public class Address { NOTE: Working with User-Defined Types requires a `UserTypeResolver` configured with the mapping context. See the <> for how to configure a `UserTypeResolver`. +==== Index creation + +You can annotate particular entity properties with `@Indexed` if you whish to create secondary indexes on application +startup. Index creation will create simple secondary indexes for scalar types, user-defined, and collection types. + +Map types distinguish between `ENTRY`, `KEYS` and `VALUES` indexes. Index creation derives the index type from the +annotated element: + +.Variants of map indexing +==== +[source,java] +---- +@Table +public class Person { + + @Id + private String key; + + @Indexed("indexed_map") + private Map entries; + + private Map<@Indexed String, String> keys; + + private Map values; + + // … +} +---- +==== + +WARNING: Index creation on session initialization may have a severe performance impact on application startup. [[cassandra.mapping.explicit-converters]] === Overriding Mapping with explicit Converters