Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 7d1d9e4

Browse files
committed
DATACASS-594 added schema validation capabilities
1 parent 8045003 commit 7d1d9e4

File tree

16 files changed

+495
-8
lines changed

16 files changed

+495
-8
lines changed

‎spring-data-cassandra/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@
233233
<scope>test</scope>
234234
</dependency>
235235

236+
<dependency>
237+
<groupId>org.springframework.boot</groupId>
238+
<artifactId>spring-boot-test</artifactId>
239+
<version>3.1.6</version>
240+
</dependency>
241+
236242
</dependencies>
237243

238244
</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+
}
Lines changed: 181 additions & 0 deletions
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/cql/CassandraAccessor.java

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import java.util.function.Function;
2626
import java.util.function.Supplier;
2727
import java.util.function.UnaryOperator;
28-
import java.util.stream.Collectors;
2928

3029
import org.springframework.lang.NonNull;
3130
import org.springframework.lang.Nullable;
@@ -38,7 +37,6 @@
3837
import com.datastax.oss.driver.api.querybuilder.BuildableQuery;
3938
import com.datastax.oss.driver.api.querybuilder.QueryBuilder;
4039
import com.datastax.oss.driver.api.querybuilder.term.Term;
41-
import com.datastax.oss.driver.internal.querybuilder.CqlHelper;
4240

4341
/**
4442
* Functional builder for Cassandra {@link BuildableQuery statements}. Statements are built by applying
@@ -67,6 +65,7 @@
6765
* All methods returning {@link StatementBuilder} point to the same instance. This class is intended for internal use.
6866
*
6967
* @author Mark Paluch
68+
* @author Mikhail Polivakha
7069
* @param <S> Statement type
7170
* @since 3.0
7271
*/

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

Lines changed: 0 additions & 2 deletions
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"));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.springframework.data.cassandra.config;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import java.util.List;
7+
8+
import org.junit.jupiter.api.Test;
9+
10+
/**
11+
* @author Mikhail Polivakha
12+
*/
13+
class CassandraSchemaValidationProfileUnitTest {
14+
15+
@Test
16+
void testRenderingValidationErrorMessageOnSuccessfulValidation() {
17+
CassandraSchemaValidationProfile empty = CassandraSchemaValidationProfile.empty();
18+
19+
assertThatThrownBy(empty::renderExceptionMessage).isInstanceOf(IllegalStateException.class);
20+
}
21+
22+
@Test
23+
void testRenderingValidationErrorMessageOnFailedValidation() {
24+
CassandraSchemaValidationProfile empty = CassandraSchemaValidationProfile.empty();
25+
26+
empty.addValidationErrors(List.of("Something went wrong"));
27+
28+
assertThat(empty.renderExceptionMessage()).contains("- Something went wrong");
29+
}
30+
}

0 commit comments

Comments
(0)

AltStyle によって変換されたページ (->オリジナル) /