message) {
+ if (!CollectionUtils.isEmpty(message)) {
+ this.validationErrors.addAll(message.stream().map(ValidationError::new).collect(Collectors.toSet()));
+ }
+ }
+
+ public record ValidationError(String errorMessage) { }
+
+ public boolean validationFailed() {
+ return !validationErrors.isEmpty();
+ }
+
+ public String renderExceptionMessage() {
+
+ Assert.state(validationFailed(), "Schema validation was successful but error message rendering requested");
+
+ StringBuilder constructedMessage = new StringBuilder("The following errors were encountered during cassandra schema validation:\n");
+ validationErrors.forEach(validationError -> constructedMessage.append("\t- %s\n".formatted(validationError.errorMessage())));
+ return constructedMessage.toString();
+ }
+}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/CassandraSchemaValidator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/CassandraSchemaValidator.java
new file mode 100644
index 0000000000..95c2359ed7
--- /dev/null
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/CassandraSchemaValidator.java
@@ -0,0 +1,193 @@
+package org.springframework.data.cassandra.core.convert;
+
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.SmartInitializingSingleton;
+import org.springframework.data.cassandra.CassandraKeyspaceDoesNotExistsException;
+import org.springframework.data.cassandra.CassandraNoActiveKeyspaceSetForCqlSessionException;
+import org.springframework.data.cassandra.CassandraSchemaValidationException;
+import org.springframework.data.cassandra.config.CassandraSchemaValidationProfile;
+import org.springframework.data.cassandra.core.mapping.BasicCassandraPersistentEntity;
+import org.springframework.data.cassandra.core.mapping.CassandraMappingContext;
+import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity;
+import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty;
+import org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder;
+import org.springframework.data.mapping.PropertyHandler;
+import org.springframework.util.Assert;
+
+import com.datastax.oss.driver.api.core.CqlIdentifier;
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata;
+import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata;
+import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata;
+import com.datastax.oss.driver.api.core.type.DataType;
+
+/**
+ * Class that is responsible to validate cassandra schema inside {@link CqlSession} keyspace.
+ *
+ * @author Mikhail Polivakha
+ */
+public class CassandraSchemaValidator implements SmartInitializingSingleton {
+
+ private static final Log logger = LogFactory.getLog(CassandraSchemaValidator.class);
+
+ private final CqlSession cqlSession;
+
+ private final CassandraMappingContext cassandraMappingContext;
+
+ private final ColumnTypeResolver columnTypeResolver;
+
+ private final boolean strictValidation;
+
+ public CassandraSchemaValidator(
+ CqlSession cqlSession,
+ CassandraConverter cassandraConverter,
+ boolean strictValidation
+ ) {
+ this.strictValidation = strictValidation;
+ this.cqlSession = cqlSession;
+ this.cassandraMappingContext = cassandraConverter.getMappingContext();
+ this.columnTypeResolver = new DefaultColumnTypeResolver(
+ cassandraMappingContext,
+ SchemaFactory.ShallowUserTypeResolver.INSTANCE,
+ cassandraConverter::getCodecRegistry,
+ cassandraConverter::getCustomConversions
+ );
+ }
+
+ /**
+ * Here, we only consider {@link CqlSession#getKeyspace() current session keyspace},
+ * because for now there is no way to customize keyspace for {@link CassandraPersistentEntity}.
+ *
+ * See related issue
+ */
+ @Override
+ public void afterSingletonsInstantiated() {
+ CqlIdentifier activeKeyspace = cqlSession
+ .getKeyspace()
+ .orElseThrow(CassandraNoActiveKeyspaceSetForCqlSessionException::new);
+
+ KeyspaceMetadata keyspaceMetadata = cqlSession
+ .getMetadata()
+ .getKeyspace(activeKeyspace)
+ .orElseThrow(() -> new CassandraKeyspaceDoesNotExistsException(activeKeyspace.asInternal()));
+
+ Collection> persistentEntities = cassandraMappingContext.getPersistentEntities();
+
+ CassandraSchemaValidationProfile validationProfile = CassandraSchemaValidationProfile.empty();
+
+ for (BasicCassandraPersistentEntity> persistentEntity : persistentEntities) {
+ validationProfile.addValidationErrors(validatePersistentEntity(keyspaceMetadata, persistentEntity));
+ }
+
+ evaluateValidationResult(validationProfile);
+ }
+
+ private void evaluateValidationResult(CassandraSchemaValidationProfile validationProfile) {
+ if (validationProfile.validationFailed()) {
+ if (strictValidation) {
+ throw new CassandraSchemaValidationException(validationProfile.renderExceptionMessage());
+ } else {
+ if (logger.isErrorEnabled()) {
+ logger.error(validationProfile.renderExceptionMessage());
+ }
+ }
+ } else {
+ if (logger.isDebugEnabled()) {
+ logger.debug("Cassandra schema validation completed successfully");
+ }
+ }
+ }
+
+ private List validatePersistentEntity(
+ KeyspaceMetadata keyspaceMetadata,
+ BasicCassandraPersistentEntity> entity
+ ) {
+
+ if (entity.isTupleType() || entity.isUserDefinedType()) {
+ return List.of();
+ }
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Validating persistent entity '%s'".formatted(keyspaceMetadata.getName()));
+ }
+
+ Optional table = keyspaceMetadata.getTable(entity.getTableName());
+
+ if (table.isPresent()) {
+ return this.validateProperties(table.get(), entity);
+ } else {
+ return List.of(
+ "Unable to locate target table for persistent entity '%s'. Expected table name is '%s', but no such table in keyspace '%s'".formatted(
+ entity.getName(),
+ entity.getTableName(),
+ keyspaceMetadata.getName()
+ )
+ );
+ }
+ }
+
+ private List validateProperties(TableMetadata tableMetadata, BasicCassandraPersistentEntity> entity) {
+
+ List validationErrors = new LinkedList<>();
+
+ entity.doWithProperties((PropertyHandler) persistentProperty -> {
+
+ if (persistentProperty.isTransient()) {
+ return;
+ }
+
+ CqlIdentifier expectedColumnName = persistentProperty.getColumnName();
+
+ Assert.notNull(expectedColumnName, "Column cannot not be null at this point");
+
+ Optional column = tableMetadata.getColumn(expectedColumnName);
+
+ if (column.isPresent()) {
+ ColumnMetadata columnMetadata = column.get();
+ DataType dataTypeExpected = columnTypeResolver.resolve(persistentProperty).getDataType();
+
+ if (dataTypeExpected == null) {
+ validationErrors.add(
+ "Unable to deduce cassandra data type for property '%s' inside the persistent entity '%s'".formatted(
+ persistentProperty.getName(),
+ entity.getName()
+ )
+ );
+ } else {
+ if (!Objects.equals(dataTypeExpected.getProtocolCode(), columnMetadata.getType().getProtocolCode())) {
+ validationErrors.add(
+ "Expected '%s' data type for '%s' property in the '%s' persistent entity, but actual data type is '%s'".formatted(
+ dataTypeExpected,
+ persistentProperty.getName(),
+ entity.getName(),
+ columnMetadata.getType()
+ )
+ );
+ }
+ }
+ } else {
+ validationErrors.add(
+ "Unable to locate target column for persistent property '%s' in persistent entity '%s'. Expected to see column with name '%s', but there is no such column in table '%s'".formatted(
+ persistentProperty.getName(),
+ entity.getName(),
+ expectedColumnName,
+ entity.getTableName()
+ )
+ );
+ }
+ });
+
+ return validationErrors;
+ }
+
+ public boolean isStrictValidation() {
+ return strictValidation;
+ }
+}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CassandraAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CassandraAccessor.java
index 2ec24ea2ad..ffb70f7169 100644
--- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CassandraAccessor.java
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CassandraAccessor.java
@@ -45,6 +45,7 @@
* @author Mark Paluch
* @author John Blum
* @author Tomasz Lelek
+ * @author Mikhail Polivakha
* @see org.springframework.beans.factory.InitializingBean
* @see com.datastax.oss.driver.api.core.CqlSession
*/
@@ -85,7 +86,7 @@ public class CassandraAccessor implements InitializingBean {
*/
private @Nullable ConsistencyLevel serialConsistencyLevel;
- private @Nullable SessionFactory sessionFactory;
+ private SessionFactory sessionFactory;
/**
* Ensures the Cassandra {@link CqlSession} and exception translator has been propertly set.
@@ -314,7 +315,6 @@ public void setSessionFactory(SessionFactory sessionFactory) {
* @since 2.0
* @see SessionFactory
*/
- @Nullable
public SessionFactory getSessionFactory() {
return this.sessionFactory;
}
@@ -361,11 +361,10 @@ protected Statement> applyStatementSettings(Statement> statement) {
}
if (keyspace != null) {
- if (statementToUse instanceof BatchStatement) {
- statementToUse = ((BatchStatement) statementToUse).setKeyspace(keyspace);
- }
- if (statementToUse instanceof SimpleStatement) {
- statementToUse = ((SimpleStatement) statementToUse).setKeyspace(keyspace);
+ if (statementToUse instanceof BatchStatement bs) {
+ statementToUse = bs.setKeyspace(keyspace);
+ } else if (statementToUse instanceof SimpleStatement ss) {
+ statementToUse = ss.setKeyspace(keyspace);
}
}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CqlTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CqlTemplate.java
index 7bd5bad1d3..364c8149b5 100644
--- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CqlTemplate.java
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CqlTemplate.java
@@ -620,7 +620,7 @@ private CqlSession getCurrentSession() {
SessionFactory sessionFactory = getSessionFactory();
- Assert.state(sessionFactory != null, "SessionFactory is null");
+ Assert.notNull(sessionFactory, "SessionFactory is null");
return sessionFactory.getSession();
}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/AbstractCollectionTerm.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/AbstractCollectionTerm.java
new file mode 100644
index 0000000000..4df5f009ce
--- /dev/null
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/AbstractCollectionTerm.java
@@ -0,0 +1,62 @@
+package org.springframework.data.cassandra.core.cql.util;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Optional;
+
+import org.jetbrains.annotations.NotNull;
+import org.springframework.lang.NonNull;
+
+import com.datastax.oss.driver.api.querybuilder.term.Term;
+import com.datastax.oss.driver.internal.querybuilder.CqlHelper;
+
+/**
+ * Represents an abstract collection like {@link Term} such as Set, List, Tuple in CQL
+ *
+ * @author Mikhail Polivakha
+ */
+public abstract class AbstractCollectionTerm implements Term {
+
+ @NonNull
+ private final Collection extends Term> components;
+
+ /**
+ * @return EnclosingLiterals that are used to render the collection of terms
+ */
+ public abstract EnclosingLiterals enclosingLiterals();
+
+ public AbstractCollectionTerm(Collection extends Term> components) {
+ this.components = Optional.ofNullable(components).orElse(Collections.emptySet());
+ }
+
+ @Override
+ public boolean isIdempotent() {
+ return components.stream().allMatch(Term::isIdempotent);
+ }
+
+ @Override
+ public void appendTo(@NotNull StringBuilder builder) {
+ EnclosingLiterals literals = this.enclosingLiterals();
+
+ if (components.isEmpty()) {
+ builder.append(literals.prefix).append(literals.postfix);
+ } else {
+ CqlHelper.append(components, builder, literals.prefix, ",", literals.postfix);
+ }
+ }
+
+ protected static class EnclosingLiterals {
+
+ private final String prefix;
+ private final String postfix;
+
+ protected EnclosingLiterals(String prefix, String postfix) {
+ this.prefix = prefix;
+ this.postfix = postfix;
+ }
+
+ protected static EnclosingLiterals of(String prefix, String postfix) {
+ return new EnclosingLiterals(prefix, postfix);
+ }
+ }
+}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/ListTerm.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/ListTerm.java
new file mode 100644
index 0000000000..67eb059090
--- /dev/null
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/ListTerm.java
@@ -0,0 +1,17 @@
+package org.springframework.data.cassandra.core.cql.util;
+
+import java.util.Collection;
+
+import com.datastax.oss.driver.api.querybuilder.term.Term;
+
+public class ListTerm extends AbstractCollectionTerm {
+
+ public ListTerm(Collection extends Term> components) {
+ super(components);
+ }
+
+ @Override
+ public EnclosingLiterals enclosingLiterals() {
+ return EnclosingLiterals.of("[", "]");
+ }
+}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/SetTerm.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/SetTerm.java
new file mode 100644
index 0000000000..0763304e1b
--- /dev/null
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/SetTerm.java
@@ -0,0 +1,17 @@
+package org.springframework.data.cassandra.core.cql.util;
+
+import java.util.Collection;
+
+import com.datastax.oss.driver.api.querybuilder.term.Term;
+
+public class SetTerm extends AbstractCollectionTerm {
+
+ public SetTerm(Collection extends Term> components) {
+ super(components);
+ }
+
+ @Override
+ public EnclosingLiterals enclosingLiterals() {
+ return EnclosingLiterals.of("{", "}");
+ }
+}
diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/StatementBuilder.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/StatementBuilder.java
index dee29937f5..0618876639 100644
--- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/StatementBuilder.java
+++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/StatementBuilder.java
@@ -37,12 +37,11 @@
import com.datastax.oss.driver.api.querybuilder.BuildableQuery;
import com.datastax.oss.driver.api.querybuilder.QueryBuilder;
import com.datastax.oss.driver.api.querybuilder.term.Term;
-import com.datastax.oss.driver.internal.querybuilder.CqlHelper;
/**
* Functional builder for Cassandra {@link BuildableQuery statements}. Statements are built by applying
* {@link UnaryOperator builder functions} that get applied when {@link #build() building} the actual
- * {@link SimpleStatement statement}. The {@code StatementBuilder} provides a mutable container for statement creation
+ * {@link SimpleStatement statement}. The {@link StatementBuilder} provides a mutable container for statement creation
* allowing a functional declaration of actions that are necessary to build a statement. This class helps building CQL
* statements as a {@link BuildableQuery} classes are typically immutable and require return value tracking across
* methods that want to apply modifications to a statement.
@@ -66,6 +65,7 @@
* All methods returning {@link StatementBuilder} point to the same instance. This class is intended for internal use.
*
* @author Mark Paluch
+ * @author Mikhail Polivakha
* @param Statement type
* @since 3.0
*/
@@ -289,13 +289,13 @@ public T ifBoundOrInline(Function bindingFunction, Supplier
private SimpleStatement build(SimpleStatementBuilder builder) {
- SimpleStatement statmentToUse = onBuild(builder).build();
+ SimpleStatement statementToUse = onBuild(builder).build();
for (UnaryOperator operator : onBuilt) {
- statmentToUse = operator.apply(statmentToUse);
+ statementToUse = operator.apply(statementToUse);
}
- return statmentToUse;
+ return statementToUse;
}
private SimpleStatementBuilder onBuild(SimpleStatementBuilder statementBuilder) {
@@ -308,26 +308,13 @@ private SimpleStatementBuilder onBuild(SimpleStatementBuilder statementBuilder)
@SuppressWarnings("unchecked")
private static Term toLiteralTerms(@Nullable Object value, CodecRegistry codecRegistry) {
- if (value instanceof List) {
+ if (value instanceof Collection> c) {
- List terms = new ArrayList<>();
+ List mappedTerms = c.stream()
+ .map(o -> toLiteralTerms(o, codecRegistry))
+ .toList();
- for (Object o : (List