Skip to content

Commit e42f72e

Browse files
committed
DATACASS-594 added schema validation capabilities
1 parent ccd2fba commit e42f72e

19 files changed

+510
-8
lines changed

spring-data-cassandra/pom.xml

+6
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,12 @@
243243
<scope>test</scope>
244244
</dependency>
245245

246+
<dependency>
247+
<groupId>org.springframework.boot</groupId>
248+
<artifactId>spring-boot-test</artifactId>
249+
<version>3.1.6</version>
250+
</dependency>
251+
246252
</dependencies>
247253

248254
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package org.springframework.data.cassandra;
2+
3+
import org.springframework.dao.NonTransientDataAccessException;
4+
5+
/**
6+
* The exception to be thrown when keyspace that expected to be present is missing in the cluster
7+
*
8+
* @author Mikhail Polivakha
9+
*/
10+
public class CassandraKeyspaceDoesNotExistsException extends NonTransientDataAccessException {
11+
12+
public CassandraKeyspaceDoesNotExistsException(String keyspace) {
13+
super("Keyspace %s does not exists in the cluster".formatted(keyspace));
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package org.springframework.data.cassandra;
2+
3+
import org.springframework.dao.NonTransientDataAccessException;
4+
5+
import com.datastax.oss.driver.api.core.CqlSession;
6+
7+
/**
8+
* Exception that is thrown in case {@link CqlSession} has no active keyspace set. This should not
9+
* typically happen. This exception means some misconfiguration within framework.
10+
*
11+
* @author Mikhail Polivakha
12+
*/
13+
public class CassandraNoActiveKeyspaceSetForCqlSessionException extends NonTransientDataAccessException {
14+
15+
public CassandraNoActiveKeyspaceSetForCqlSessionException() {
16+
super("There is no active keyspace set for CqlSession");
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package org.springframework.data.cassandra;
2+
3+
import org.springframework.dao.NonTransientDataAccessException;
4+
5+
/**
6+
* The exception that is thrown in case cassandra schema in the particular keyspace does not match
7+
* the configuration of the entities inside application.
8+
*
9+
* @author Mikhail Polivakha
10+
*/
11+
public class CassandraSchemaValidationException extends NonTransientDataAccessException {
12+
13+
public CassandraSchemaValidationException(String message) {
14+
super(message);
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.springframework.data.cassandra.config;
2+
3+
import java.util.LinkedList;
4+
import java.util.List;
5+
import java.util.stream.Collectors;
6+
7+
import org.springframework.util.Assert;
8+
import org.springframework.util.CollectionUtils;
9+
10+
/**
11+
* Class that encapsulates all the problems encountered during cassandra schema validation
12+
*
13+
* @author Mikhail Polivakha
14+
*/
15+
public class CassandraSchemaValidationProfile {
16+
17+
private final List<ValidationError> validationErrors;
18+
19+
public CassandraSchemaValidationProfile(List<ValidationError> validationErrors) {
20+
this.validationErrors = validationErrors;
21+
}
22+
23+
public static CassandraSchemaValidationProfile empty() {
24+
return new CassandraSchemaValidationProfile(new LinkedList<>());
25+
}
26+
27+
public void addValidationErrors(List<String> message) {
28+
if (!CollectionUtils.isEmpty(message)) {
29+
this.validationErrors.addAll(message.stream().map(ValidationError::new).collect(Collectors.toSet()));
30+
}
31+
}
32+
33+
public record ValidationError(String errorMessage) { }
34+
35+
public boolean validationFailed() {
36+
return !validationErrors.isEmpty();
37+
}
38+
39+
public String renderExceptionMessage() {
40+
41+
Assert.state(validationFailed(), "Schema validation was successful but error message rendering requested");
42+
43+
StringBuilder constructedMessage = new StringBuilder("The following errors were encountered during cassandra schema validation:\n");
44+
validationErrors.forEach(validationError -> constructedMessage.append("\t- %s\n".formatted(validationError.errorMessage())));
45+
return constructedMessage.toString();
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package org.springframework.data.cassandra.config;
2+
3+
import java.util.Collection;
4+
import java.util.LinkedList;
5+
import java.util.List;
6+
import java.util.Objects;
7+
import java.util.Optional;
8+
9+
import org.apache.commons.logging.Log;
10+
import org.apache.commons.logging.LogFactory;
11+
import org.springframework.beans.factory.SmartInitializingSingleton;
12+
import org.springframework.data.cassandra.CassandraKeyspaceDoesNotExistsException;
13+
import org.springframework.data.cassandra.CassandraNoActiveKeyspaceSetForCqlSessionException;
14+
import org.springframework.data.cassandra.CassandraSchemaValidationException;
15+
import org.springframework.data.cassandra.core.mapping.BasicCassandraPersistentEntity;
16+
import org.springframework.data.cassandra.core.mapping.CassandraMappingContext;
17+
import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity;
18+
import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty;
19+
import org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder;
20+
import org.springframework.data.mapping.PropertyHandler;
21+
import org.springframework.util.Assert;
22+
23+
import com.datastax.oss.driver.api.core.CqlIdentifier;
24+
import com.datastax.oss.driver.api.core.CqlSession;
25+
import com.datastax.oss.driver.api.core.metadata.schema.ColumnMetadata;
26+
import com.datastax.oss.driver.api.core.metadata.schema.KeyspaceMetadata;
27+
import com.datastax.oss.driver.api.core.metadata.schema.TableMetadata;
28+
import com.datastax.oss.driver.api.core.type.DataType;
29+
30+
/**
31+
* Class that is responsible to validate cassandra schema inside {@link CqlSession} keyspace.
32+
*
33+
* @author Mikhail Polivakha
34+
*/
35+
public class CassandraSchemaValidator implements SmartInitializingSingleton {
36+
37+
private static final Log logger = LogFactory.getLog(CassandraSchemaValidator.class);
38+
39+
private final CqlSessionFactoryBean cqlSessionFactoryBean;
40+
41+
private final CassandraMappingContext cassandraMappingContext;
42+
43+
private final boolean strictValidation;
44+
45+
public CassandraSchemaValidator(
46+
CqlSessionFactoryBean cqlSessionFactoryBean,
47+
CassandraMappingContext cassandraMappingContext,
48+
boolean strictValidation
49+
) {
50+
this.strictValidation = strictValidation;
51+
this.cqlSessionFactoryBean = cqlSessionFactoryBean;
52+
this.cassandraMappingContext = cassandraMappingContext;
53+
}
54+
55+
/**
56+
* Here, we only consider {@link CqlSession#getKeyspace() current session keyspace},
57+
* because for now there is no way to customize keyspace for {@link CassandraPersistentEntity}.
58+
* <p>
59+
* See <a href="https://github.com/spring-projects/spring-data-cassandra/issues/921">related issue</a>
60+
*/
61+
@Override
62+
public void afterSingletonsInstantiated() {
63+
CqlSession session = cqlSessionFactoryBean.getSession();
64+
65+
CqlIdentifier activeKeyspace = session
66+
.getKeyspace()
67+
.orElseThrow(CassandraNoActiveKeyspaceSetForCqlSessionException::new);
68+
69+
KeyspaceMetadata keyspaceMetadata = session
70+
.getMetadata()
71+
.getKeyspace(activeKeyspace)
72+
.orElseThrow(() -> new CassandraKeyspaceDoesNotExistsException(activeKeyspace.asInternal()));
73+
74+
Collection<BasicCassandraPersistentEntity<?>> persistentEntities = cassandraMappingContext.getPersistentEntities();
75+
76+
CassandraSchemaValidationProfile validationProfile = CassandraSchemaValidationProfile.empty();
77+
78+
for (BasicCassandraPersistentEntity<?> persistentEntity : persistentEntities) {
79+
validationProfile.addValidationErrors(validatePersistentEntity(keyspaceMetadata, persistentEntity));
80+
}
81+
82+
evaluateValidationResult(validationProfile);
83+
}
84+
85+
private void evaluateValidationResult(CassandraSchemaValidationProfile validationProfile) {
86+
if (validationProfile.validationFailed()) {
87+
if (strictValidation) {
88+
throw new CassandraSchemaValidationException(validationProfile.renderExceptionMessage());
89+
} else {
90+
if (logger.isErrorEnabled()) {
91+
logger.error(validationProfile.renderExceptionMessage());
92+
}
93+
}
94+
} else {
95+
if (logger.isDebugEnabled()) {
96+
logger.debug("Cassandra schema validation completed successfully");
97+
}
98+
}
99+
}
100+
101+
private List<String> validatePersistentEntity(
102+
KeyspaceMetadata keyspaceMetadata,
103+
BasicCassandraPersistentEntity<?> entity
104+
) {
105+
106+
if (entity.isTupleType() || entity.isUserDefinedType()) {
107+
return List.of();
108+
}
109+
110+
if (logger.isDebugEnabled()) {
111+
logger.debug("Validating persistent entity '%s'".formatted(keyspaceMetadata.getName()));
112+
}
113+
114+
Optional<TableMetadata> table = keyspaceMetadata.getTable(entity.getTableName());
115+
116+
if (table.isPresent()) {
117+
return this.validateProperties(table.get(), entity);
118+
} else {
119+
return List.of(
120+
"Unable to locate target table for persistent entity '%s'. Expected table name is '%s', but no such table in keyspace '%s'".formatted(
121+
entity.getName(),
122+
entity.getTableName(),
123+
keyspaceMetadata.getName()
124+
)
125+
);
126+
}
127+
}
128+
129+
private List<String> validateProperties(TableMetadata tableMetadata, BasicCassandraPersistentEntity<?> entity) {
130+
131+
List<String> validationErrors = new LinkedList<>();
132+
133+
entity.doWithProperties((PropertyHandler<CassandraPersistentProperty>) persistentProperty -> {
134+
CqlIdentifier expectedColumnName = persistentProperty.getColumnName();
135+
136+
Assert.notNull(expectedColumnName, "Column cannot not be null at this point");
137+
138+
Optional<ColumnMetadata> column = tableMetadata.getColumn(expectedColumnName);
139+
140+
if (column.isPresent()) {
141+
ColumnMetadata columnMetadata = column.get();
142+
DataType dataTypeExpected = CassandraSimpleTypeHolder.getDataTypeFor(persistentProperty.getRawType());
143+
144+
if (dataTypeExpected == null) {
145+
validationErrors.add(
146+
"Unable to deduce cassandra data type for property '%s' inside the persistent entity '%s'".formatted(
147+
persistentProperty.getName(),
148+
entity.getName()
149+
)
150+
);
151+
} else {
152+
if (!Objects.equals(dataTypeExpected.getProtocolCode(), columnMetadata.getType().getProtocolCode())) {
153+
validationErrors.add(
154+
"Expected '%s' data type for '%s' property in the '%s' persistent entity, but actual data type is '%s'".formatted(
155+
dataTypeExpected,
156+
persistentProperty.getName(),
157+
entity.getName(),
158+
columnMetadata.getType()
159+
)
160+
);
161+
}
162+
}
163+
} else {
164+
validationErrors.add(
165+
"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(
166+
persistentProperty.getName(),
167+
entity.getName(),
168+
expectedColumnName,
169+
entity.getTableName()
170+
)
171+
);
172+
}
173+
});
174+
175+
return validationErrors;
176+
}
177+
178+
public boolean isStrictValidation() {
179+
return strictValidation;
180+
}
181+
}

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/AnnotatedCassandraConstructorProperty.java

+5
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ public boolean isWritable() {
221221
return delegate.isWritable();
222222
}
223223

224+
@Override
225+
public boolean isReadable() {
226+
return true;
227+
}
228+
224229
@Override
225230
public boolean isImmutable() {
226231
return delegate.isImmutable();

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/convert/CassandraConstructorProperty.java

+5
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ public boolean isWritable() {
210210
return false;
211211
}
212212

213+
@Override
214+
public boolean isReadable() {
215+
return true;
216+
}
217+
213218
@Override
214219
public boolean isImmutable() {
215220
return false;

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/CassandraAccessor.java

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
* @author Mark Paluch
4646
* @author John Blum
4747
* @author Tomasz Lelek
48+
* @author Mikhail Polivakha
4849
* @see org.springframework.beans.factory.InitializingBean
4950
* @see com.datastax.oss.driver.api.core.CqlSession
5051
*/

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/util/StatementBuilder.java

+1-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
import java.util.function.Consumer;
2525
import java.util.function.Function;
2626
import java.util.function.UnaryOperator;
27-
import java.util.stream.Collectors;
2827

2928
import org.springframework.lang.NonNull;
3029
import org.springframework.lang.Nullable;
@@ -36,7 +35,6 @@
3635
import com.datastax.oss.driver.api.querybuilder.BuildableQuery;
3736
import com.datastax.oss.driver.api.querybuilder.QueryBuilder;
3837
import com.datastax.oss.driver.api.querybuilder.term.Term;
39-
import com.datastax.oss.driver.internal.querybuilder.CqlHelper;
4038

4139
/**
4240
* Functional builder for Cassandra {@link BuildableQuery statements}. Statements are built by applying
@@ -65,6 +63,7 @@
6563
* All methods returning {@link StatementBuilder} point to the same instance. This class is intended for internal use.
6664
*
6765
* @author Mark Paluch
66+
* @author Mikhail Polivakha
6867
* @param <S> Statement type
6968
* @since 3.0
7069
*/

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/EmbeddedEntityOperations.java

+5
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,11 @@ public boolean isWritable() {
563563
return delegate.isWritable();
564564
}
565565

566+
@Override
567+
public boolean isReadable() {
568+
return false;
569+
}
570+
566571
@Override
567572
public boolean isImmutable() {
568573
return delegate.isImmutable();

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/mapping/PrimaryKeyClassEntityMetadataVerifier.java

-2
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@ public void verify(CassandraPersistentEntity<?> entity) throws MappingException
4545
List<CassandraPersistentProperty> partitionKeyColumns = new ArrayList<>();
4646
List<CassandraPersistentProperty> primaryKeyColumns = new ArrayList<>();
4747

48-
Class<?> entityType = entity.getType();
49-
5048
// @Indexed not allowed on type level
5149
if (entity.isAnnotationPresent(Indexed.class)) {
5250
exceptions.add(new MappingException("@Indexed cannot be used on primary key classes"));

0 commit comments

Comments
 (0)