diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ArgumentPreparedStatementBinder.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ArgumentPreparedStatementBinder.java new file mode 100644 index 000000000..e4dda65db --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ArgumentPreparedStatementBinder.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016 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.cassandra.core; + +import com.datastax.driver.core.BoundStatement; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Simple adapter for {@link PreparedStatementBinder} that applies a given array of arguments. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ArgumentPreparedStatementBinder implements PreparedStatementBinder { + + private final Object[] args; + + /** + * Create a new {@link ArgumentPreparedStatementBinder} for the given arguments. + * + * @param args the arguments to set. May be empty or {@link null} if no arguments are provided. + */ + public ArgumentPreparedStatementBinder(Object[] args) { + this.args = args; + } + + @Override + public BoundStatement bindValues(PreparedStatement ps) throws DriverException { + return args != null ? ps.bind(args) : ps.bind(); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ColumnMapRowMapper.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ColumnMapRowMapper.java new file mode 100644 index 000000000..86b21e0cf --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ColumnMapRowMapper.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.util.Map; + +import org.springframework.util.LinkedCaseInsensitiveMap; + +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.Row; + +/** + * {@link RowMapper} implementation that creates a {@code java.util.Map} for each row, representing all columns as + * key-value pairs: one entry for each column, with the column name as key. + *

+ * The Map implementation to use and the key to use for each column in the column Map can be customized through + * overriding {@link #createColumnMap} and {@link #getColumnKey}, respectively. + *

+ * Note: By default, ColumnMapRowMapper will try to build a linked Map with case-insensitive keys, to preserve + * column order as well as allow any casing to be used for column names. This requires Commons Collections on the + * classpath (which will be autodetected). Else, the fallback is a standard linked HashMap, which will still preserve + * column order but requires the application to specify the column names in the same casing as exposed by the driver. + * + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate#queryForFlux(String) + * @see ReactiveCqlTemplate#queryForMap(String) + */ +public class ColumnMapRowMapper implements RowMapper> { + + @Override + public Map mapRow(Row rs, int rowNum) { + + ColumnDefinitions columnDefinitions = rs.getColumnDefinitions(); + int columnCount = columnDefinitions.size(); + Map mapOfColValues = createColumnMap(columnCount); + + for (int i = 0; i < columnCount; i++) { + String key = getColumnKey(columnDefinitions.getName(i)); + Object obj = getColumnValue(rs, i); + mapOfColValues.put(key, obj); + } + return mapOfColValues; + } + + /** + * Create a {@link Map} instance to be used as column map. + *

+ * By default, a linked case-insensitive Map will be created. + * + * @param columnCount the column count, to be used as initial capacity for the {@link Map}, must not be {@literal null}. + * @return the new Map instance. + * @see org.springframework.util.LinkedCaseInsensitiveMap + */ + protected Map createColumnMap(int columnCount) { + return new LinkedCaseInsensitiveMap<>(columnCount); + } + + /** + * Determine the key to use for the given column in the column Map. + * + * @param columnName the column name as returned by the {@link Row}, must not be {@literal null}. + * @return the column key to use. + * @see ColumnDefinitions#getName(int) + */ + protected String getColumnKey(String columnName) { + return columnName; + } + + /** + * Retrieve a CQL object value for the specified column. + *

+ * The default implementation uses the {@code getObject} method. + * + * @param row is the {@link Row} holding the data, must not be {@literal null}. + * @param index is the column index. + * @return the Object returned + */ + protected Object getColumnValue(Row row, int index) { + return row.getObject(index); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/CqlProvider.java b/spring-cql/src/main/java/org/springframework/cassandra/core/CqlProvider.java new file mode 100644 index 000000000..52af64a27 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/CqlProvider.java @@ -0,0 +1,38 @@ +/* + * Copyright 2016 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.cassandra.core; + +/** + * Interface to be implemented by objects that can provide CQL strings. + *

+ * Typically implemented by {@link PreparedStatementCreator}s and statement callbacks that want to expose the CQL they + * use to create their statements, to allow for better contextual information in case of exceptions. + * + * @author Mark Paluch + * @since 2.0 + * @see PreparedStatementCreator + * @see ReactivePreparedStatementCreator + * @see ReactiveStatementCallback + */ +public interface CqlProvider { + + /** + * Return the CQL string for this object, i.e. typically the CQL used for creating statements. + * + * @return the CQL string, or {@literal null}. + */ + String getCql(); +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/CqlTemplate.java b/spring-cql/src/main/java/org/springframework/cassandra/core/CqlTemplate.java index 2a5a53bac..bf97804aa 100644 --- a/spring-cql/src/main/java/org/springframework/cassandra/core/CqlTemplate.java +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/CqlTemplate.java @@ -55,19 +55,7 @@ import org.springframework.dao.QueryTimeoutException; import org.springframework.dao.support.PersistenceExceptionTranslator; import org.springframework.util.Assert; -import com.datastax.driver.core.BoundStatement; -import com.datastax.driver.core.CodecRegistry; -import com.datastax.driver.core.ColumnDefinitions; -import com.datastax.driver.core.Host; -import com.datastax.driver.core.PreparedStatement; -import com.datastax.driver.core.ProtocolVersion; -import com.datastax.driver.core.ResultSet; -import com.datastax.driver.core.ResultSetFuture; -import com.datastax.driver.core.Row; -import com.datastax.driver.core.Session; -import com.datastax.driver.core.SimpleStatement; -import com.datastax.driver.core.Statement; -import com.datastax.driver.core.TypeCodec; +import com.datastax.driver.core.*; import com.datastax.driver.core.ColumnDefinitions.Definition; import com.datastax.driver.core.exceptions.DriverException; import com.datastax.driver.core.querybuilder.Batch; diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultBridgedReactiveSession.java b/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultBridgedReactiveSession.java new file mode 100644 index 000000000..cd6a70130 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultBridgedReactiveSession.java @@ -0,0 +1,278 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +import com.datastax.driver.core.*; +import com.google.common.util.concurrent.ListenableFuture; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * Default implementation of a {@link ReactiveSession}. This implementation bridges asynchronous {@link Session} methods + * to reactive execution patterns. + *

+ * Calls are deferred until a subscriber subscribes to the resulting {@link org.reactivestreams.Publisher}. The calls + * are executed by subscribing to {@link ListenableFuture} and returning the result as calls complete. + *

+ * {@link ResultSet} implements transparent paging that invokes in the middle of result streaming blocking calls to + * Cassandra. {@link DefaultBridgedReactiveSession} uses therefore {@link ReactiveResultSet} to avoid client thread + * blocking. Elements are emitted on netty EventLoop threads and transported by the provided {@link Scheduler}. However, + * this is an intermediate solution until Datastax can provide a fully reactive driver. + *

+ * All CQL operations performed by this class are logged at debug level, using + * "org.springframework.cassandra.core.DefaultBridgedReactiveSession" as log category. + *

+ * + * @author Mark Paluch + * @since 2.0 + * @see Mono + * @see ReactiveResultSet + * @see Scheduler + * @see ReactiveSession + */ +public class DefaultBridgedReactiveSession implements ReactiveSession { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Session session; + private final Scheduler scheduler; + + /** + * Creates a new {@link DefaultBridgedReactiveSession} for a {@link Session} and {@link Scheduler}. + * + * @param session must not be {@literal null}. + * @param scheduler must not be {@literal null}. + */ + public DefaultBridgedReactiveSession(Session session, Scheduler scheduler) { + + Assert.notNull(session, "Session must not be null"); + Assert.notNull(scheduler, "Scheduler must not be null"); + + this.session = session; + this.scheduler = scheduler; + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#execute(java.lang.String) + */ + @Override + public Mono execute(String query) { + + Assert.hasText(query, "Query must not be empty"); + + return execute(new SimpleStatement(query)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#execute(java.lang.String, java.lang.Object[]) + */ + @Override + public Mono execute(String query, Object... values) { + + Assert.hasText(query, "Query must not be empty"); + + return execute(new SimpleStatement(query, values)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#execute(java.lang.String, java.util.Map) + */ + @Override + public Mono execute(String query, Map values) { + + Assert.hasText(query, "Query must not be empty"); + + return execute(new SimpleStatement(query, values)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#execute(com.datastax.driver.core.Statement) + */ + @Override + public Mono execute(Statement statement) { + + Assert.notNull(statement, "Statement must not be null"); + + return Mono.defer(() -> { + + try { + + if (logger.isDebugEnabled()) { + logger.debug("Executing Statement [{}]", statement); + } + + CompletableFuture future = new CompletableFuture<>(); + ResultSetFuture resultSetFuture = session.executeAsync(statement); + + resultSetFuture.addListener(() -> { + + if (resultSetFuture.isDone()) { + + try { + future.complete(new DefaultReactiveResultSet(resultSetFuture.getUninterruptibly(), scheduler)); + } catch (Exception e) { + future.completeExceptionally(e); + } + } + }, Runnable::run); + + return Mono.fromFuture(future); + } catch (Exception e) { + return Mono.error(e); + } + + }).subscribeOn(scheduler); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#prepare(java.lang.String) + */ + @Override + public Mono prepare(String query) { + + Assert.hasText(query, "Query must not be empty"); + + return prepare(new SimpleStatement(query)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#prepare(com.datastax.driver.core.RegularStatement) + */ + @Override + public Mono prepare(RegularStatement statement) { + + Assert.notNull(statement, "Statement must not be null"); + + return Mono.defer(() -> { + + try { + + if (logger.isDebugEnabled()) { + logger.debug("Preparing Statement [{}]", statement); + } + + CompletableFuture future = new CompletableFuture<>(); + ListenableFuture resultSetFuture = session.prepareAsync(statement); + + resultSetFuture.addListener(() -> { + + if (resultSetFuture.isDone()) { + try { + future.complete(resultSetFuture.get()); + } catch (Exception e) { + future.completeExceptionally(e); + } + } + }, Runnable::run); + + return Mono.fromFuture(future); + } catch (Exception e) { + return Mono.error(e); + } + + }).subscribeOn(scheduler); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#close() + */ + @Override + public void close() { + session.close(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#isClosed() + */ + @Override + public boolean isClosed() { + return session.isClosed(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveSession#getCluster() + */ + @Override + public Cluster getCluster() { + return session.getCluster(); + } + + private static class DefaultReactiveResultSet implements ReactiveResultSet { + + private final ResultSet resultSet; + private final Scheduler scheduler; + + DefaultReactiveResultSet(ResultSet resultSet, Scheduler scheduler) { + this.resultSet = resultSet; + this.scheduler = scheduler; + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSet#rows() + */ + @Override + public Flux rows() { + + int prefetch = Math.max(1, resultSet.getAvailableWithoutFetching()); + return Flux.fromIterable(resultSet) // + .subscribeOn(scheduler) // + .publishOn(Schedulers.immediate(), prefetch); // limit prefetching to available size + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSet#getColumnDefinitions() + */ + @Override + public ColumnDefinitions getColumnDefinitions() { + return resultSet.getColumnDefinitions(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSet#wasApplied() + */ + @Override + public boolean wasApplied() { + return resultSet.wasApplied(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSet#getExecutionInfo() + */ + @Override + public ExecutionInfo getExecutionInfo() { + return resultSet.getExecutionInfo(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSet#getAllExecutionInfo() + */ + @Override + public List getAllExecutionInfo() { + return resultSet.getAllExecutionInfo(); + } + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultReactiveSessionFactory.java b/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultReactiveSessionFactory.java new file mode 100644 index 000000000..7cfc9b62d --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/DefaultReactiveSessionFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright 2016 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.cassandra.core; + +/** + * Default implementation of {@link ReactiveSessionFactory}. + *

+ * This implementation returns always the same {@link ReactiveSession}. + * + * @author Mark Paluch + * @since 2.0 + */ +public class DefaultReactiveSessionFactory implements ReactiveSessionFactory { + + private final ReactiveSession session; + + /** + * Create a new {@link ReactiveRowMapperResultSetExtractor}. + * + * @param session the {@link ReactiveSession} provides connections to Cassandra, must not be {@literal null}. + */ + public DefaultReactiveSessionFactory(ReactiveSession session) { + this.session = session; + } + + @Override + public ReactiveSession getSession() { + return session; + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/PreparedStatementBinder.java b/spring-cql/src/main/java/org/springframework/cassandra/core/PreparedStatementBinder.java index da3a25b4b..8548c1e06 100644 --- a/spring-cql/src/main/java/org/springframework/cassandra/core/PreparedStatementBinder.java +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/PreparedStatementBinder.java @@ -20,10 +20,31 @@ import com.datastax.driver.core.PreparedStatement; import com.datastax.driver.core.exceptions.DriverException; /** + * General callback interface used by the {@link CqlTemplate} and {@link ReactiveCqlTemplate} classes. + *

+ * This interface binds values on a {@link PreparedStatement} provided by the {@link CqlTemplate} class, for each of a + * number of updates in a batch using the same CQL. Implementations are responsible for setting any necessary + * parameters. CQL with placeholders will already have been supplied. + *

+ * It's easier to use this interface than {@link PreparedStatementCreator}: The {@link CqlTemplate} will create the + * {@link PreparedStatement}, with the callback only being responsible for setting parameter values. + *

+ * Implementations do not need to concern themselves with {@link DriverException}s that may be thrown from + * operations they attempt. The {@link CqlTemplate} class will catch and handle {@link DriverException} appropriately. + * * @author David Webb + * @author Mark Paluch + * @see CqlTemplate#query(String, PreparedStatementBinder, ResultSetExtractor) + * @see ReactiveCqlTemplate#query(String, PreparedStatementBinder, ReactiveResultSetExtractor) */ public interface PreparedStatementBinder { + /** + * Bind parameter values on the given {@link PreparedStatement}. + * + * @param ps the PreparedStatement to invoke setter methods on + * @throws DriverException if a {@link DriverException} is encountered (i.e. there is no need to catch + * {@link DriverException}) + */ BoundStatement bindValues(PreparedStatement ps) throws DriverException; - } diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlOperations.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlOperations.java new file mode 100644 index 000000000..1ad4b9cba --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlOperations.java @@ -0,0 +1,709 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.util.Map; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.Statement; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Interface specifying a basic set of CQL operations executed in a reactive fashion. Implemented by + * {@link ReactiveCqlTemplate}. Not often used directly, but a useful option to enhance testability, as it can easily be + * mocked or stubbed. + * + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate + * @see Mono + * @see Flux + */ +public interface ReactiveCqlOperations { + + // ------------------------------------------------------------------------- + // Methods dealing with a plain ReactiveSession + // ------------------------------------------------------------------------- + + /** + * Execute a CQL data access operation, implemented as callback action working on a {@link ReactiveSession}. This + * allows for implementing arbitrary data access operations, within Spring's managed CQL environment: that is, + * converting CQL {@link com.datastax.driver.core.exceptions.DriverException}s into Spring's + * {@link DataAccessException} hierarchy. + *

+ * The callback action can return a result object, for example a domain object or a collection of domain objects. + * + * @param action the callback object that specifies the action. + * @return a result object returned by the action, or {@literal null}. + * @throws DataAccessException if there is any problem executing the query. + */ + Flux execute(ReactiveSessionCallback action) throws DataAccessException; + + // ------------------------------------------------------------------------- + // Methods dealing with static CQL + // ------------------------------------------------------------------------- + + /** + * Issue a single CQL execute, typically a DDL statement, insert, update or delete statement. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem executing the query. + */ + Mono execute(String cql) throws DataAccessException; + + /** + * Execute a query given static CQL, reading the {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code query} method with {@literal null} as argument array. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rse object that will extract all rows of results, must not be {@literal null}. + * @return an arbitrary result object, as returned by the ReactiveResultSetExtractor. + * @throws DataAccessException if there is any problem executing the query. + * @see #query(String, ReactiveResultSetExtractor, Object...) + */ + Flux query(String cql, ReactiveResultSetExtractor rse) throws DataAccessException; + + /** + * Execute a query given static CQL, mapping each row to a Java object via a {@link RowMapper}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code query} method with {@literal null} as argument array. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the result {@link Flux}, containing mapped objects. + * @throws DataAccessException if there is any problem executing the query + * @see #query(String, RowMapper, Object[]) + */ + Flux query(String cql, RowMapper rowMapper) throws DataAccessException; + + /** + * Execute a query given static CQL, mapping a single result row to a Java object via a {@link RowMapper}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForObject(String, RowMapper, Object...)} method with + * {@literal null} as argument array. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the single mapped object. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForObject(String, RowMapper, Object[]) + */ + Mono queryForObject(String cql, RowMapper rowMapper) throws DataAccessException; + + /** + * Execute a query for a result object, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForObject(String, Class, Object...)} method with + * {@literal null} as argument array. + *

+ * This method is useful for running static CQL with a known outcome. The query is expected to be a single row/single + * column query; the returned result will be directly mapped to the corresponding object type. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param requiredType the type that the result object is expected to match, must not be {@literal null}. + * @return the result object of the required type, or {@link Mono#empty()} in case of CQL NULL. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row, or does not return + * exactly one column in that row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForObject(String, Class, Object[]) + */ + Mono queryForObject(String cql, Class requiredType) throws DataAccessException; + + /** + * Execute a query for a result Map, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForMap(String, Object...)} method with {@literal null} + * as argument array. + *

+ * The query is expected to be a single row query; the result row will be mapped to a Map (one entry for each column, + * using the column name as the key). + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @return the result Map (one entry for each column, using the column name as the key), must not be {@literal null}. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForMap(String, Object[]) + * @see ColumnMapRowMapper + */ + Mono> queryForMap(String cql) throws DataAccessException; + + /** + * Execute a query for a result {@link Flux}, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForFlux} method with {@literal null} as argument array. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of result objects, each of them matching the + * specified element type. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param elementType the required type of element in the result {@link Flux} (for example, {@code Integer.class}), + * must not be {@literal null}. + * @return a {@link Flux} of objects that match the specified element type. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String, Class, Object[]) + * @see SingleColumnRowMapper + */ + Flux queryForFlux(String cql, Class elementType) throws DataAccessException; + + /** + * Execute a query for a result {@link Flux}, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForFlux} method with {@literal null} as argument array. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of {@link Map}s (one entry for each column + * using the column name as the key). Each item in the {@link Flux} will be of the form returned by this interface's + * queryForMap() methods. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @return a {@link Flux} that contains a {@link Map} per row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String, Object[]) + */ + Flux> queryForFlux(String cql) throws DataAccessException; + + /** + * Execute a query for a ResultSet, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForResultSet} method with {@literal null} as argument + * array. + *

+ * The results will be mapped to an {@link ReactiveResultSet}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @return a {@link ReactiveResultSet} representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String, Object[]) + */ + Mono queryForResultSet(String cql) throws DataAccessException; + + /** + * Execute a query for Rows, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForResultSet} method with {@literal null} as argument + * array. + *

+ * The results will be mapped to {@link Row}s. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @return a Row representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String, Object[]) + */ + Flux queryForRows(String cql) throws DataAccessException; + + /** + * Issue multiple CQL statements from a CQL statement {@link Publisher}. + * + * @param statementPublisher defining a {@link Publisher} of CQL statements that will be executed. + * @return an array of the number of rows affected by each statement + * @throws DataAccessException if there is any problem executing the batch + */ + Flux execute(Publisher statementPublisher) throws DataAccessException; + + // ------------------------------------------------------------------------- + // Methods dealing with com.datastax.driver.core.Statement + // ------------------------------------------------------------------------- + + /** + * Issue a single CQL execute, typically a DDL statement, insert, update or delete statement. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem executing the query. + */ + Mono execute(Statement statement) throws DataAccessException; + + /** + * Execute a query given static CQL, reading the {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code query} method with {@literal null} as argument array. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @param rse object that will extract all rows of results, must not be {@literal null}. + * @return an arbitrary result object, as returned by the ReactiveResultSetExtractor. + * @throws DataAccessException if there is any problem executing the query. + * @see #query(String, ReactiveResultSetExtractor, Object...) + */ + Flux query(Statement statement, ReactiveResultSetExtractor rse) throws DataAccessException; + + /** + * Execute a query given static CQL, mapping each row to a Java object via a {@link RowMapper}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code query} method with {@literal null} as argument array. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the result {@link Flux}, containing mapped objects. + * @throws DataAccessException if there is any problem executing the query + * @see #query(String, RowMapper, Object[]) + */ + Flux query(Statement statement, RowMapper rowMapper) throws DataAccessException; + + /** + * Execute a query given static CQL, mapping a single result row to a Java object via a {@link RowMapper}. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForObject(String, RowMapper, Object...)} method with + * {@literal null} as argument array. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the single mapped object. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForObject(String, RowMapper, Object[]) + */ + Mono queryForObject(Statement statement, RowMapper rowMapper) throws DataAccessException; + + /** + * Execute a query for a result object, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForObject(String, Class, Object...)} method with + * {@literal null} as argument array. + *

+ * This method is useful for running static CQL with a known outcome. The query is expected to be a single row/single + * column query; the returned result will be directly mapped to the corresponding object type. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @param requiredType the type that the result object is expected to match, must not be {@literal null}. + * @return the result object of the required type, or {@link Mono#empty()} in case of CQL NULL. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row, or does not return + * exactly one column in that row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForObject(String, Class, Object[]) + */ + Mono queryForObject(Statement statement, Class requiredType) throws DataAccessException; + + /** + * Execute a query for a result Map, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@link #queryForMap(String, Object...)} method with {@literal null} + * as argument array. + *

+ * The query is expected to be a single row query; the result row will be mapped to a Map (one entry for each column, + * using the column name as the key). + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @return the result Map (one entry for each column, using the column name as the key), must not be {@literal null}. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForMap(String, Object[]) + * @see ColumnMapRowMapper + */ + Mono> queryForMap(Statement statement) throws DataAccessException; + + /** + * Execute a query for a result {@link Flux}, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForFlux} method with {@literal null} as argument array. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of result objects, each of them matching the + * specified element type. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @param elementType the required type of element in the result {@link Flux} (for example, {@code Integer.class}), + * must not be {@literal null}. + * @return a {@link Flux} of objects that match the specified element type. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String, Class, Object[]) + * @see SingleColumnRowMapper + */ + Flux queryForFlux(Statement statement, Class elementType) throws DataAccessException; + + /** + * Execute a query for a result {@link Flux}, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForFlux} method with {@literal null} as argument array. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of {@link Map}s (one entry for each column + * using the column name as the key). Each item in the {@link Flux} will be of the form returned by this interface's + * queryForMap() methods. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @return a {@link Flux} that contains a {@link Map} per row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String, Object[]) + */ + Flux> queryForFlux(Statement statement) throws DataAccessException; + + /** + * Execute a query for a ResultSet, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForResultSet} method with {@literal null} as argument + * array. + *

+ * The results will be mapped to an {@link ReactiveResultSet}. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @return a {@link ReactiveResultSet} representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String, Object[]) + */ + Mono queryForResultSet(Statement statement) throws DataAccessException; + + /** + * Execute a query for Rows, given static CQL. + *

+ * Uses a CQL Statement, not a {@link PreparedStatement}. If you want to execute a static query with a + * {@link PreparedStatement}, use the overloaded {@code queryForResultSet} method with {@literal null} as argument + * array. + *

+ * The results will be mapped to {@link Row}s. + * + * @param statement static CQL {@link Statement}, must not be {@literal null}. + * @return a Row representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String, Object[]) + */ + Flux queryForRows(Statement statement) throws DataAccessException; + + // ------------------------------------------------------------------------- + // Methods dealing with prepared statements + // ------------------------------------------------------------------------- + + /** + * Execute a CQL data access operation, implemented as callback action working on a CQL {@link PreparedStatement}. + * This allows for implementing arbitrary data access operations on a single {@link PreparedStatement}, within + * Spring's managed CQL environment: that is, participating in Spring-managed transactions and converting CQL + * {@link com.datastax.driver.core.exceptions.DriverException}s into Spring's {@link DataAccessException} hierarchy. + *

+ * The callback action can return a result object, for example a domain object or a collection of domain objects. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link ReactiveSession}, must not be + * {@literal null}. + * @param action callback object that specifies the action, must not be {@literal null}. + * @return a result object returned by the action, or {@literal null}. + * @throws DataAccessException if there is any problem + */ + Flux execute(ReactivePreparedStatementCreator psc, ReactivePreparedStatementCallback action) + throws DataAccessException; + + /** + * Execute a CQL data access operation, implemented as callback action working on a CQL {@link PreparedStatement}. + * This allows for implementing arbitrary data access operations on a single Statement, within Spring's managed CQL + * environment: that is, participating in Spring-managed transactions and converting CQL + * {@link com.datastax.driver.core.exceptions.DriverException}s into Spring's {@link DataAccessException} hierarchy. + *

+ * The callback action can return a result object, for example a domain object or a collection of domain objects. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param action callback object that specifies the action, must not be {@literal null}. + * @return a result object returned by the action, or {@literal null} + * @throws DataAccessException if there is any problem + */ + Flux execute(String cql, ReactivePreparedStatementCallback action) throws DataAccessException; + + /** + * Query using a prepared statement, reading the {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link ReactiveSession}, must not be + * {@literal null}. + * @param rse object that will extract results, must not be {@literal null}. + * @return an arbitrary result object, as returned by the {@link ReactiveResultSetExtractor} + * @throws DataAccessException if there is any problem + */ + Flux query(ReactivePreparedStatementCreator psc, ReactiveResultSetExtractor rse) throws DataAccessException; + + /** + * Query using a prepared statement, reading the {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. Even if there are no bind parameters, this object may be used to + * set fetch size and other performance options. + * @param rse object that will extract results, must not be {@literal null}. + * @return an arbitrary result object, as returned by the {@link ReactiveResultSetExtractor}. + * @throws DataAccessException if there is any problem + */ + Flux query(String cql, PreparedStatementBinder psb, ReactiveResultSetExtractor rse) + throws DataAccessException; + + /** + * Query using a prepared statement and a {@link PreparedStatementBinder} implementation that knows how to bind values + * to the query, reading the {@link ReactiveResultSet} with a {@link ResultSetExtractor}. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link com.datastax.driver.core.Session}, + * must not be {@literal null}. + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. Even if there are no bind parameters, this object may be used to + * set fetch size and other performance options. + * @param rse object that will extract results, must not be {@literal null}. + * @return an arbitrary result object, as returned by the {@link ResultSetExtractor}. + * @throws DataAccessException if there is any problem + */ + Flux query(ReactivePreparedStatementCreator psc, PreparedStatementBinder psb, + ReactiveResultSetExtractor rse) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, reading the + * {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rse object that will extract results, must not be {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return an arbitrary result object, as returned by the {@link ReactiveResultSetExtractor} + * @throws DataAccessException if there is any problem executing the query. + */ + Flux query(String cql, ReactiveResultSetExtractor rse, Object... args) throws DataAccessException; + + /** + * Query using a prepared statement, mapping each row to a Java object via a {@link RowMapper}. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link ReactiveSession}, must not be + * {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the result {@link Flux}, containing mapped objects. + * @throws DataAccessException if there is any problem executing the query. + */ + Flux query(ReactivePreparedStatementCreator psc, RowMapper rowMapper) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a {@link PreparedStatement}Binder implementation that + * knows how to bind values to the query, mapping each row to a Java object via a {@link RowMapper}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. Even if there are no bind parameters, this object may be used to + * set fetch size and other performance options. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the result {@link Flux}, containing mapped objects. + * @throws DataAccessException if there is any problem executing the query. + */ + Flux query(String cql, PreparedStatementBinder psb, RowMapper rowMapper) throws DataAccessException; + + /** + * Query using a prepared statement and a {@link PreparedStatementBinder} implementation that knows how to bind values + * to the query, mapping each row to a Java object via a {@link RowMapper}. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link com.datastax.driver.core.Session}, + * must not be {@literal null}. + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. Even if there are no bind parameters, this object may be used to + * set fetch size and other performance options. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @return the result {@link Flux}, containing mapped objects. + * @throws DataAccessException if there is any problem executing the query. + */ + Flux query(ReactivePreparedStatementCreator psc, PreparedStatementBinder psb, RowMapper rowMapper) + throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, mapping each + * row to a Java object via a {@link RowMapper}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rowMapper object that will map one object per row + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type) + * @return the result {@link Flux}, containing mapped objects + * @throws DataAccessException if there is any problem executing the query. + */ + Flux query(String cql, RowMapper rowMapper, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, mapping a + * single result row to a Java object via a {@link RowMapper}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param rowMapper object that will map one object per row, must not be {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type) + * @return the single mapped object + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row. + * @throws DataAccessException if there is any problem executing the query. + */ + Mono queryForObject(String cql, RowMapper rowMapper, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting a + * result object. + *

+ * The query is expected to be a single row/single column query; the returned result will be directly mapped to the + * corresponding object type. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param requiredType the type that the result object is expected to match, must not be {@literal null}. + * @param args arguments to bind to the query (leaving it to the PreparedStatement to guess the corresponding CQL + * type) + * @return the result object of the required type, or {@link Mono#empty()} in case of CQL NULL. + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row, or does not return + * exactly one column in that row. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForObject(String, Class) + */ + Mono queryForObject(String cql, Class requiredType, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting a + * result Map. The queryForMap() methods defined by this interface are appropriate when you don't have a domain model. + * Otherwise, consider using one of the queryForObject() methods. + *

+ * The query is expected to be a single row query; the result row will be mapped to a Map (one entry for each column, + * using the column name as the key). + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return the result Map (one entry for each column, using the column name as the key). + * @throws IncorrectResultSizeDataAccessException if the query does not return exactly one row + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForMap(String) + * @see ColumnMapRowMapper + */ + Mono> queryForMap(String cql, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting a + * result {@link Flux}. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of result objects, each of them matching the + * specified element type. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param elementType the required type of element in the result {@link Flux} (for example, {@code Integer.class}), + * must not be {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return a {@link Flux} of objects that match the specified element type. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String, Class) + * @see SingleColumnRowMapper + */ + Flux queryForFlux(String cql, Class elementType, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting a + * result {@link Flux}. + *

+ * The results will be mapped to a {@link Flux} (one item for each row) of {@link Map}s (one entry for each column, + * using the column name as the key). Each item in the {@link Flux} will be of the form returned by this interface's + * queryForMap() methods. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return a {@link Flux} that contains a {@link Map} per row + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForFlux(String) + */ + Flux> queryForFlux(String cql, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting a + * ResultSet. + *

+ * The results will be mapped to an {@link ReactiveResultSet}. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return a {@link ReactiveResultSet} representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String) + */ + Mono queryForResultSet(String cql, Object... args) throws DataAccessException; + + /** + * Query given CQL to create a prepared statement from CQL and a list of arguments to bind to the query, expecting + * Rows. + *

+ * The results will be mapped to {@link Row}s. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return a {@link Row} representation. + * @throws DataAccessException if there is any problem executing the query. + * @see #queryForResultSet(String) + */ + Flux queryForRows(String cql, Object... args) throws DataAccessException; + + /** + * Issue a single CQL execute operation (such as an insert, update or delete statement) using a + * {@link ReactivePreparedStatementCreator} to provide CQL and any required parameters. + * + * @param psc object that provides CQL and any necessary parameters, must not be {@literal null}. + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem issuing the execution. + */ + // TODO: Interferes with execute(session callback lambda) + Mono execute(ReactivePreparedStatementCreator psc) throws DataAccessException; + + /** + * Issue an statement using a {@link PreparedStatementBinder} to set bind parameters, with given CQL. Simpler than + * using a {@link ReactivePreparedStatementCreator} as this method will create the {@link PreparedStatement}: The + * {@link PreparedStatementBinder} just needs to set parameters. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. Even if there are no bind parameters, this object may be used to + * set fetch size and other performance options. + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono execute(String cql, PreparedStatementBinder psb) throws DataAccessException; + + /** + * Issue a single CQL operation (such as an insert, update or delete statement) via a prepared statement, binding the + * given arguments. + * + * @param cql static CQL to execute, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono execute(String cql, Object... args) throws DataAccessException; + + /** + * Issue a single CQL operation (such as an insert, update or delete statement) via a prepared statement, binding the + * given arguments. + * + * @param cql static CQL to execute containing bind parameters, must not be empty or {@literal null}. + * @param args arguments to bind to the query (leaving it to the {@link PreparedStatement} to guess the corresponding + * CQL type). + * @return boolean value whether the statement was applied. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux execute(String cql, Publisher args) throws DataAccessException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlTemplate.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlTemplate.java new file mode 100644 index 000000000..5c92b5ac9 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveCqlTemplate.java @@ -0,0 +1,869 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.util.Map; +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.springframework.cassandra.support.ReactiveCassandraAccessor; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.support.DataAccessUtils; +import org.springframework.util.Assert; + +import com.datastax.driver.core.BoundStatement; +import com.datastax.driver.core.ConsistencyLevel; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.DriverException; +import com.datastax.driver.core.policies.RetryPolicy; +import com.datastax.driver.core.querybuilder.QueryBuilder; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * This is the central class in the CQL core package for reactive Cassandra data access. It simplifies the use of + * CQL and helps to avoid common errors. It executes core CQL workflow, leaving application code to provide CQL and + * extract results. This class executes CQL queries or updates, initiating iteration over {@link ReactiveResultSet}s and + * catching {@link DriverException} exceptions and translating them to the generic, more informative exception hierarchy + * defined in the {@code org.springframework.dao} package. + *

+ * Code using this class need only implement callback interfaces, giving them a clearly defined contract. The + * {@link PreparedStatementCreator} callback interface creates a prepared statement given a Connection, providing CQL + * and any necessary parameters. The {@link ResultSetExtractor} interface extracts values from a + * {@link ReactiveResultSet}. See also {@link PreparedStatementBinder} and {@link RowMapper} for two popular alternative + * callback interfaces. + *

+ * Can be used within a service implementation via direct instantiation with a {@link ReactiveSessionFactory} reference, + * or get prepared in an application context and given to services as bean reference. Note: The + * {@link ReactiveSessionFactory} should always be configured as a bean in the application context, in the first case + * given to the service directly, in the second case to the prepared template. + *

+ * Because this class is parameterizable by the callback interfaces and the + * {@link org.springframework.dao.support.PersistenceExceptionTranslator} interface, there should be no need to subclass + * it. + *

+ * All CQL operations performed by this class are logged at debug level, using + * "org.springframework.cassandra.core.ReactiveCqlTemplate" as log category. + *

+ * NOTE: An instance of this class is thread-safe once configured. + * + * @author Mark Paluch + * @since 2.0 + * @see PreparedStatementCreator + * @see PreparedStatementBinder + * @see PreparedStatementCallback + * @see ResultSetExtractor + * @see RowCallbackHandler + * @see RowMapper + * @see org.springframework.dao.support.PersistenceExceptionTranslator + */ +@SuppressWarnings("WeakerAccess") +public class ReactiveCqlTemplate extends ReactiveCassandraAccessor implements ReactiveCqlOperations { + + /** + * Placeholder for default values. + */ + private final static Statement DEFAULTS = QueryBuilder.select().from("DEFAULT"); + + /** + * If this variable is set to a non-negative value, it will be used for setting the {@code fetchSize} property on + * statements used for query processing. + */ + private int fetchSize = -1; + + /** + * If this variable is set to a value, it will be used for setting the {@code retryPolicy} property on statements used + * for query processing. + */ + private RetryPolicy retryPolicy; + + /** + * If this variable is set to a value, it will be used for setting the {@code consistencyLevel} property on statements + * used for query processing. + */ + private com.datastax.driver.core.ConsistencyLevel consistencyLevel; + + /** + * Construct a new {@link ReactiveCqlTemplate Note: The {@link ReactiveSessionFactory} has to be set before using the + * instance. + * + * @see #setSessionFactory + */ + public ReactiveCqlTemplate() {} + + /** + * Construct a new {@link ReactiveCqlTemplate}, given a {@link ReactiveSession}. + * + * @param reactiveSession the {@link ReactiveSession}, must not be {@literal null}. + */ + public ReactiveCqlTemplate(ReactiveSession reactiveSession) { + + Assert.notNull(reactiveSession, "ReactiveSession must not be null"); + + setSessionFactory(new DefaultReactiveSessionFactory(reactiveSession)); + afterPropertiesSet(); + } + + /** + * Construct a new {@link ReactiveCqlTemplate}, given a {@link ReactiveSessionFactory} to obtain + * {@link ReactiveSession}s from. + * + * @param reactiveSessionFactory the {@link ReactiveSessionFactory} to obtain {@link ReactiveSession}s from, must not + * be {@literal null}. + */ + public ReactiveCqlTemplate(ReactiveSessionFactory reactiveSessionFactory) { + setSessionFactory(reactiveSessionFactory); + afterPropertiesSet(); + } + + /** + * Set the fetch size for this {@link ReactiveCqlTemplate}. This is important for processing large result sets: + * Setting this higher than the default value will increase processing speed at the cost of memory consumption; + * setting this lower can avoid transferring row data that will never be read by the application. Default is -1, + * indicating to use the CQL driver's default configuration (i.e. to not pass a specific fetch size setting on to the + * driver). + * + * @see Statement#setFetchSize(int) + */ + public void setFetchSize(int fetchSize) { + this.fetchSize = fetchSize; + } + + /** + * @return the fetch size specified for this {@link ReactiveCqlTemplate}. + */ + public int getFetchSize() { + return this.fetchSize; + } + + /** + * Set the retry policy for this {@link ReactiveCqlTemplate}. This is important for defining behavior when a request + * fails. + * + * @see Statement#setRetryPolicy(RetryPolicy) + * @see RetryPolicy + */ + public void setRetryPolicy(RetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + } + + /** + * @return the {@link RetryPolicy} specified for this {@link ReactiveCqlTemplate}. + */ + public RetryPolicy getRetryPolicy() { + return retryPolicy; + } + + /** + * Set the consistency level for this {@link ReactiveCqlTemplate}. Consistency level defines the number of nodes + * involved into query processing. Relaxed consistency level settings use fewer nodes but eventual consistency is more + * likely to occur while a higher consistency level involves more nodes to obtain results with a higher consistency + * guarantee. + * + * @see Statement#setConsistencyLevel(ConsistencyLevel) + * @see RetryPolicy + */ + public void setConsistencyLevel(ConsistencyLevel consistencyLevel) { + this.consistencyLevel = consistencyLevel; + } + + /** + * @return the {@link ConsistencyLevel} specified for this {@link ReactiveCqlTemplate}. + */ + public ConsistencyLevel getConsistencyLevel() { + return consistencyLevel; + } + + // ------------------------------------------------------------------------- + // Methods dealing with a plain org.springframework.cassandra.core.ReactiveSession + // ------------------------------------------------------------------------- + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(org.springframework.cassandra.core.ReactiveSessionCallback) + */ + @Override + public Flux execute(ReactiveSessionCallback action) throws DataAccessException { + + Assert.notNull(action, "Callback object must not be null"); + + return createFlux(action).onErrorResumeWith(translateException("ReactiveSessionCallback", getCql(action))); + } + + // ------------------------------------------------------------------------- + // Methods dealing with static CQL + // ------------------------------------------------------------------------- + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(java.lang.String) + */ + @Override + public Mono execute(String cql) throws DataAccessException { + + Assert.hasText(cql, "CQL must not be empty"); + + return queryForResultSet(cql).map(ReactiveResultSet::wasApplied); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.ReactiveResultSetExtractor) + */ + @Override + public Flux query(String cql, ReactiveResultSetExtractor rse) throws DataAccessException { + + Assert.hasText(cql, "CQL must not be empty"); + Assert.notNull(rse, "ReactiveResultSetExtractor must not be null"); + + return createFlux(new SimpleStatement(cql), (session, stmt) -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing CQL Statement [{}]", cql); + } + + return session.execute(stmt).flatMap(rse::extractData); + }).onErrorResumeWith(translateException("Query", cql)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Flux query(String cql, RowMapper rowMapper) throws DataAccessException { + return query(cql, new ReactiveRowMapperResultSetExtractor<>(rowMapper)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(java.lang.String, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Mono queryForObject(String cql, RowMapper rowMapper) throws DataAccessException { + return query(cql, rowMapper).buffer(2).flatMap(list -> Mono.just(DataAccessUtils.requiredSingleResult(list))) + .next(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(java.lang.String, java.lang.Class) + */ + @Override + public Mono queryForObject(String cql, Class requiredType) throws DataAccessException { + return queryForObject(cql, getSingleColumnRowMapper(requiredType)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForMap(java.lang.String) + */ + @Override + public Mono> queryForMap(String cql) throws DataAccessException { + return queryForObject(cql, getColumnMapRowMapper()); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(java.lang.String, java.lang.Class) + */ + @Override + public Flux queryForFlux(String cql, Class elementType) throws DataAccessException { + return query(cql, getSingleColumnRowMapper(elementType)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(java.lang.String) + */ + @Override + public Flux> queryForFlux(String cql) throws DataAccessException { + return query(cql, getColumnMapRowMapper()); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForResultSet(java.lang.String) + */ + @Override + public Mono queryForResultSet(String cql) throws DataAccessException { + + Assert.hasText(cql, "CQL must not be empty"); + + return createMono(new SimpleStatement(cql), (session, statement) -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing CQL [{}]", cql); + + } + return session.execute(statement); + }).otherwise(translateException("QueryForResultSet", cql)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForRows(java.lang.String) + */ + @Override + public Flux queryForRows(String cql) throws DataAccessException { + return queryForResultSet(cql).flatMap(ReactiveResultSet::rows) + .onErrorResumeWith(translateException("QueryForRows", cql)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(org.reactivestreams.Publisher) + */ + @Override + public Flux execute(Publisher statementPublisher) throws DataAccessException { + + Assert.notNull(statementPublisher, "CQL Publisher must not be null"); + + return Flux.from(statementPublisher).flatMap(this::execute); + } + + // ------------------------------------------------------------------------- + // Methods dealing with com.datastax.driver.core.Statement + // ------------------------------------------------------------------------- + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(com.datastax.driver.core.Statement) + */ + @Override + public Mono execute(Statement statement) throws DataAccessException { + + Assert.notNull(statement, "CQL Statement must not be null"); + + return queryForResultSet(statement).map(ReactiveResultSet::wasApplied); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(com.datastax.driver.core.Statement, org.springframework.cassandra.core.ReactiveResultSetExtractor) + */ + @Override + public Flux query(Statement statement, ReactiveResultSetExtractor rse) throws DataAccessException { + + Assert.notNull(statement, "CQL Statement must not be null"); + Assert.notNull(rse, "ReactiveResultSetExtractor must not be null"); + + return createFlux(statement, (session, stmt) -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing CQL Statement [{}]", statement); + } + + return session.execute(stmt).flatMap(rse::extractData); + }).onErrorResumeWith(translateException("Query", statement.toString())); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(com.datastax.driver.core.Statement, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Flux query(Statement statement, RowMapper rowMapper) throws DataAccessException { + return query(statement, new ReactiveRowMapperResultSetExtractor<>(rowMapper)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(com.datastax.driver.core.Statement, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Mono queryForObject(Statement statement, RowMapper rowMapper) throws DataAccessException { + return query(statement, rowMapper).buffer(2).flatMap(list -> Mono.just(DataAccessUtils.requiredSingleResult(list))) + .next(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(com.datastax.driver.core.Statement, java.lang.Class) + */ + @Override + public Mono queryForObject(Statement statement, Class requiredType) throws DataAccessException { + return queryForObject(statement, getSingleColumnRowMapper(requiredType)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForMap(com.datastax.driver.core.Statement) + */ + @Override + public Mono> queryForMap(Statement statement) throws DataAccessException { + return queryForObject(statement, getColumnMapRowMapper()); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(com.datastax.driver.core.Statement, java.lang.Class) + */ + @Override + public Flux queryForFlux(Statement statement, Class elementType) throws DataAccessException { + return query(statement, getSingleColumnRowMapper(elementType)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(com.datastax.driver.core.Statement) + */ + @Override + public Flux> queryForFlux(Statement statement) throws DataAccessException { + return query(statement, getColumnMapRowMapper()); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForResultSet(com.datastax.driver.core.Statement) + */ + @Override + public Mono queryForResultSet(Statement statement) throws DataAccessException { + + Assert.notNull(statement, "CQL Statement must not be null"); + + return createMono(statement, (session, executedStatement) -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing CQL [{}]", executedStatement); + + } + return session.execute(executedStatement); + }).otherwise(translateException("QueryForResultSet", statement.toString())); + } + + @Override + public Flux queryForRows(Statement statement) throws DataAccessException { + return queryForResultSet(statement).flatMap(ReactiveResultSet::rows) + .onErrorResumeWith(translateException("QueryForRows", statement.toString())); + } + + // ------------------------------------------------------------------------- + // Methods dealing with prepared statements + // ------------------------------------------------------------------------- + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(org.springframework.cassandra.core.ReactivePreparedStatementCreator, org.springframework.cassandra.core.ReactivePreparedStatementCallback) + */ + @Override + public Flux execute(ReactivePreparedStatementCreator psc, ReactivePreparedStatementCallback action) + throws DataAccessException { + + Assert.notNull(psc, "ReactivePreparedStatementCreator must not be null"); + Assert.notNull(action, "ReactivePreparedStatementCallback object must not be null"); + + return createFlux(session -> { + + logger.debug("Preparing statement [{}] using {}", getCql(psc), psc); + + return psc.createPreparedStatement(session).doOnNext(this::applyStatementSettings) + .flatMap(ps -> action.doInPreparedStatement(session, ps)); + }).onErrorResumeWith(translateException("ReactivePreparedStatementCallback", getCql(psc))); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(java.lang.String, org.springframework.cassandra.core.ReactivePreparedStatementCallback) + */ + @Override + public Flux execute(String cql, ReactivePreparedStatementCallback action) throws DataAccessException { + return execute(new SimpleReactivePreparedStatementCreator(cql), action); + } + + /** + * Query using a prepared statement, reading the {@link ReactiveResultSet} with a {@link ReactiveResultSetExtractor}. + * + * @param psc object that can create a {@link PreparedStatement} given a {@link ReactiveSession} + * @param psb object that knows how to set values on the prepared statement. If this is {@literal null}, the CQL will + * be assumed to contain no bind parameters. + * @param rse object that will extract results + * @return an arbitrary result object, as returned by the {@link ReactiveResultSetExtractor} + * @throws DataAccessException if there is any problem + */ + public Flux query(ReactivePreparedStatementCreator psc, PreparedStatementBinder psb, + ReactiveResultSetExtractor rse) throws DataAccessException { + + Assert.notNull(psc, "ReactivePreparedStatementCreator must not be null"); + Assert.notNull(rse, "ReactiveResultSetExtractor object must not be null"); + + return execute(psc, (session, ps) -> Mono.just(ps).flatMap(pps -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing Prepared CQL Statement [{}]", ps.getQueryString()); + } + + BoundStatement boundStatement = psb != null ? psb.bindValues(ps) : ps.bind(); + + applyStatementSettings(boundStatement); + return session.execute(boundStatement); + }).flatMap(rse::extractData)).onErrorResumeWith(translateException("Query", getCql(psc))); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(org.springframework.cassandra.core.ReactivePreparedStatementCreator, org.springframework.cassandra.core.ReactiveResultSetExtractor) + */ + @Override + public Flux query(ReactivePreparedStatementCreator psc, ReactiveResultSetExtractor rse) + throws DataAccessException { + return query(psc, null, rse); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.PreparedStatementBinder, org.springframework.cassandra.core.ReactiveResultSetExtractor) + */ + @Override + public Flux query(String cql, PreparedStatementBinder psb, ReactiveResultSetExtractor rse) + throws DataAccessException { + return query(new SimpleReactivePreparedStatementCreator(cql), psb, rse); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.ReactiveResultSetExtractor, java.lang.Object[]) + */ + @Override + public Flux query(String cql, ReactiveResultSetExtractor rse, Object... args) throws DataAccessException { + return query(new SimpleReactivePreparedStatementCreator(cql), newArgPreparedStatementBinder(args), rse); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(org.springframework.cassandra.core.ReactivePreparedStatementCreator, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Flux query(ReactivePreparedStatementCreator psc, RowMapper rowMapper) throws DataAccessException { + return query(psc, null, new ReactiveRowMapperResultSetExtractor<>(rowMapper)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.PreparedStatementBinder, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Flux query(String cql, PreparedStatementBinder psb, RowMapper rowMapper) throws DataAccessException { + return query(cql, psb, new ReactiveRowMapperResultSetExtractor<>(rowMapper)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(org.springframework.cassandra.core.ReactivePreparedStatementCreator, org.springframework.cassandra.core.PreparedStatementBinder, org.springframework.cassandra.core.RowMapper) + */ + @Override + public Flux query(ReactivePreparedStatementCreator psc, PreparedStatementBinder psb, RowMapper rowMapper) + throws DataAccessException { + return query(psc, psb, new ReactiveRowMapperResultSetExtractor<>(rowMapper)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#query(java.lang.String, org.springframework.cassandra.core.RowMapper, java.lang.Object[]) + */ + @Override + public Flux query(String cql, RowMapper rowMapper, Object... args) throws DataAccessException { + return query(cql, newArgPreparedStatementBinder(args), rowMapper); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(java.lang.String, org.springframework.cassandra.core.RowMapper, java.lang.Object[]) + */ + @Override + public Mono queryForObject(String cql, RowMapper rowMapper, Object... args) throws DataAccessException { + return query(cql, rowMapper, args).buffer(2).flatMap(list -> Mono.just(DataAccessUtils.requiredSingleResult(list))) + .next(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForObject(java.lang.String, java.lang.Class, java.lang.Object[]) + */ + @Override + public Mono queryForObject(String cql, Class requiredType, Object... args) throws DataAccessException { + return queryForObject(cql, getSingleColumnRowMapper(requiredType), args); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForMap(java.lang.String, java.lang.Object[]) + */ + @Override + public Mono> queryForMap(String cql, Object... args) throws DataAccessException { + return queryForObject(cql, getColumnMapRowMapper(), args); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(java.lang.String, java.lang.Class, java.lang.Object[]) + */ + @Override + public Flux queryForFlux(String cql, Class elementType, Object... args) throws DataAccessException { + return query(cql, getSingleColumnRowMapper(elementType), args); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForFlux(java.lang.String, java.lang.Object[]) + */ + @Override + public Flux> queryForFlux(String cql, Object... args) throws DataAccessException { + return query(cql, getColumnMapRowMapper(), args); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForResultSet(java.lang.String, java.lang.Object[]) + */ + @Override + public Mono queryForResultSet(String cql, Object... args) throws DataAccessException { + + Assert.hasText(cql, "CQL must not be empty"); + + return query(new SimpleReactivePreparedStatementCreator(cql), newArgPreparedStatementBinder(args), Mono::just) + .next(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#queryForRows(java.lang.String, java.lang.Object[]) + */ + @Override + public Flux queryForRows(String cql, Object... args) throws DataAccessException { + return queryForResultSet(cql, args).flatMap(ReactiveResultSet::rows) + .onErrorResumeWith(translateException("QueryForRows", cql)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(org.springframework.cassandra.core.ReactivePreparedStatementCreator) + */ + @Override + public Mono execute(ReactivePreparedStatementCreator psc) throws DataAccessException { + return query(psc, resultSet -> Mono.just(resultSet.wasApplied())).last(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(java.lang.String, org.springframework.cassandra.core.PreparedStatementBinder) + */ + @Override + public Mono execute(String cql, PreparedStatementBinder psb) throws DataAccessException { + return query(new SimpleReactivePreparedStatementCreator(cql), psb, resultSet -> Mono.just(resultSet.wasApplied())) + .next(); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(java.lang.String, java.lang.Object[]) + */ + @Override + public Mono execute(String cql, Object... args) throws DataAccessException { + return execute(cql, newArgPreparedStatementBinder(args)); + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveCqlOperations#execute(java.lang.String, org.reactivestreams.Publisher) + */ + @Override + public Flux execute(String cql, Publisher args) throws DataAccessException { + + Assert.notNull(args, "Args Publisher must not be null"); + + SimpleReactivePreparedStatementCreator psc = new SimpleReactivePreparedStatementCreator(cql); + + return execute(psc, (session, ps) -> Flux.from(args).flatMap(objects -> { + + if (logger.isDebugEnabled()) { + logger.debug("Executing Prepared CQL Statement [{}]", cql); + } + + BoundStatement boundStatement = newArgPreparedStatementBinder(objects).bindValues(ps); + applyStatementSettings(boundStatement); + return session.execute(boundStatement); + + }).map(ReactiveResultSet::wasApplied)); + } + + // ------------------------------------------------------------------------- + // Implementation hooks and helper methods + // ------------------------------------------------------------------------- + + /** + * Create a reusable {@link Flux} given a {@link ReactiveStatementCallback} without exception translation. + * + * @param callback must not be {@literal null}. + * @return a reusable {@link Flux} wrapping the {@link ReactiveStatementCallback}. + */ + protected Flux createFlux(Statement statement, ReactiveStatementCallback callback) { + + Assert.notNull(callback); + + applyStatementSettings(statement); + + ReactiveSession session = getSession(); + + return Flux.defer(() -> callback.doInStatement(session, statement)); + } + + /** + * Create a reusable {@link Mono} given a {@link ReactiveStatementCallback} without exception translation. + * + * @param callback must not be {@literal null}. + * @return a reusable {@link Mono} wrapping the {@link ReactiveStatementCallback }. + */ + protected Mono createMono(Statement statement, ReactiveStatementCallback callback) { + + Assert.notNull(callback); + + applyStatementSettings(statement); + + ReactiveSession session = getSession(); + + return Mono.defer(() -> Mono.from(callback.doInStatement(session, statement))); + } + + /** + * Create a reusable {@link Flux} given a {@link ReactiveSessionCallback} without exception translation. + * + * @param callback must not be {@literal null}. + * @return a reusable {@link Flux} wrapping the {@link ReactiveSessionCallback}. + */ + protected Flux createFlux(ReactiveSessionCallback callback) { + + Assert.notNull(callback); + + ReactiveSession session = getSession(); + + return Flux.defer(() -> callback.doInSession(session)); + } + + /** + * Exception translation {@link Function} intended for {@link Mono#otherwise(Function)} usage. + * + * @return the exception translation {@link Function} + */ + @SuppressWarnings("ThrowableResultOfMethodCallIgnored") + protected Function> translateException() { + + return throwable -> Mono.error( + throwable instanceof DriverException ? translateExceptionIfPossible((DriverException) throwable) : throwable); + } + + /** + * Exception translation {@link Function} intended for {@link Mono#otherwise(Function)} usage. + * + * @param task readable text describing the task being attempted + * @param cql CQL query or update that caused the problem (may be {@code null}) + * @return the exception translation {@link Function} + * @see CqlProvider + */ + @SuppressWarnings("ThrowableResultOfMethodCallIgnored") + protected Function> translateException(String task, String cql) { + + return throwable -> Mono.error(throwable instanceof DriverException + ? ReactiveCqlTemplate.this.translate(task, cql, (DriverException) throwable) : throwable); + } + + /** + * Create a new RowMapper for reading columns as key-value pairs. + * + * @return the RowMapper to use + * @see ColumnMapRowMapper + */ + protected RowMapper> getColumnMapRowMapper() { + return new ColumnMapRowMapper(); + } + + /** + * Create a new RowMapper for reading result objects from a single column. + * + * @param requiredType the type that each result object is expected to match + * @return the RowMapper to use + * @see SingleColumnRowMapper + */ + protected RowMapper getSingleColumnRowMapper(Class requiredType) { + return SingleColumnRowMapper.newInstance(requiredType); + } + + /** + * Prepare the given CQL Statement (or {@link com.datastax.driver.core.PreparedStatement}), applying statement + * settings such as fetch size, retry policy, and consistency level. + * + * @param stmt the CQL Statement to prepare + * @see #setFetchSize(int) + * @see #setRetryPolicy(RetryPolicy) + * @see #setConsistencyLevel(ConsistencyLevel) + */ + protected void applyStatementSettings(Statement stmt) { + + int fetchSize = getFetchSize(); + if (fetchSize != -1 && stmt.getFetchSize() == DEFAULTS.getFetchSize()) { + stmt.setFetchSize(fetchSize); + } + + RetryPolicy retryPolicy = getRetryPolicy(); + if (retryPolicy != null && stmt.getRetryPolicy() == DEFAULTS.getRetryPolicy()) { + stmt.setRetryPolicy(retryPolicy); + } + + ConsistencyLevel consistencyLevel = getConsistencyLevel(); + if (consistencyLevel != null && stmt.getConsistencyLevel() == DEFAULTS.getConsistencyLevel()) { + stmt.setConsistencyLevel(consistencyLevel); + } + } + + /** + * Prepare the given CQL Statement (or {@link com.datastax.driver.core.PreparedStatement}), applying statement + * settings such as retry policy and consistency level. + * + * @param stmt the CQL Statement to prepare + * @see #setRetryPolicy(RetryPolicy) + * @see #setConsistencyLevel(ConsistencyLevel) + */ + protected void applyStatementSettings(PreparedStatement stmt) { + + RetryPolicy retryPolicy = getRetryPolicy(); + if (retryPolicy != null) { + stmt.setRetryPolicy(retryPolicy); + } + + ConsistencyLevel consistencyLevel = getConsistencyLevel(); + if (consistencyLevel != null) { + stmt.setConsistencyLevel(consistencyLevel); + } + } + + /** + * Create a new arg-based PreparedStatementSetter using the args passed in. + *

+ * By default, we'll create an {@link ArgumentPreparedStatementBinder}. This method allows for the creation to be + * overridden by subclasses. + * + * @param args object array with arguments + * @return the new {@link PreparedStatementBinder} to use + */ + protected PreparedStatementBinder newArgPreparedStatementBinder(Object[] args) { + return new ArgumentPreparedStatementBinder(args); + } + + private ReactiveSession getSession() { + return getSessionFactory().getSession(); + } + + /** + * Determine CQL from potential provider object. + * + * @param cqlProvider object that's potentially a {@link CqlProvider} + * @return the CQL string, or {@code null} + * @see CqlProvider + */ + private static String getCql(Object cqlProvider) { + + if (cqlProvider instanceof CqlProvider) { + return ((CqlProvider) cqlProvider).getCql(); + } else { + return null; + } + } + + private class SimpleReactivePreparedStatementCreator implements ReactivePreparedStatementCreator, CqlProvider { + + private final String cql; + + SimpleReactivePreparedStatementCreator(String cql) { + + Assert.notNull(cql, "CQL must not be null"); + + this.cql = cql; + } + + @Override + public Mono createPreparedStatement(ReactiveSession session) throws DriverException { + return session.prepare(cql); + } + + @Override + public String getCql() { + return cql; + } + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCallback.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCallback.java new file mode 100644 index 000000000..799367dfb --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCallback.java @@ -0,0 +1,62 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Generic callback interface for code that operates on a {@link PreparedStatement}. Allows to execute any number of + * operations on a single {@link PreparedStatement}, for example a single {@link ReactiveSession#execute(Statement). + *

+ * Used internally by {@link ReactiveCqlTemplate}, but also useful for application code. Note that the passed-in + * {@link PreparedStatement} can have been created by the framework or by a custom + * {@link ReactivePreparedStatementCreator}. However, the latter is hardly ever necessary, as most custom callback + * actions will perform updates in which case a standard {@link PreparedStatement is fine. Custom actions will always + * set parameter values themselves, so that {@link ReactivePreparedStatementCreator} capability is not needed either. + * + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate#execute(ReactivePreparedStatementCreator, ReactivePreparedStatementCallback) + * @see ReactiveCqlTemplate#execute(String, ReactivePreparedStatementCallback) + */ +public interface ReactivePreparedStatementCallback { + + /** + * Gets called by {@link ReactiveCqlTemplate#execute(String, ReactivePreparedStatementCallback)} with an active CQL + * session and {@link PreparedStatement}. Does not need to care about closing the session: this will all be handled by + * Spring's {@link ReactiveCqlTemplate}. + *

+ * Allows for returning a result object created within the callback, i.e. a domain object or a collection of domain + * objects. Note that there's special support for single step actions: see + * {@link ReactiveCqlTemplate#queryForObject(String, Class, Object...)} etc. A thrown RuntimeException is treated as + * application exception, it gets propagated to the caller of the template. + * + * @param session active Cassandra session, must not be {@literal null}. + * @param ps the {@link PreparedStatement}, must not be {@literal null}. + * @return a result object publisher. + * @throws DriverException if thrown by a session method, to be auto-converted to a DataAccessException. + * @throws DataAccessException in case of custom exceptions. + * @see ReactiveCqlTemplate#queryForObject(String, Class, Object...) + * @see ReactiveCqlTemplate#queryForFlux(String, Object...) + */ + Publisher doInPreparedStatement(ReactiveSession session, PreparedStatement ps) + throws DriverException, DataAccessException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCreator.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCreator.java new file mode 100644 index 000000000..47e45021d --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactivePreparedStatementCreator.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 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.cassandra.core; + +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.exceptions.DriverException; + +import reactor.core.publisher.Mono; + +/** + * One of the two central callback interfaces used by the {@link ReactiveCqlTemplate} class. This interface creates a + * {@link PreparedStatement} given a {@link ReactiveSession}, provided by the {@link ReactiveCqlTemplate} class. + *

+ * Implementations may either create new prepared statements or reuse cached instances. Implementations do not need to + * concern themselves with {@link DriverException}s that may be thrown from operations they attempt. The + * {@link ReactiveCqlTemplate} class will catch and handle {@link DriverException}s appropriately. + *

+ * A {@link ReactivePreparedStatementCreator} should also implement the {@link CqlProvider} interface if it is able to + * provide the CQL it uses for {@link PreparedStatement} creation. This allows for better contextual information in case + * of exceptions. + * + * @author Mark Paluch + * @since 2.0 + */ +public interface ReactivePreparedStatementCreator { + + /** + * Create a statement in this session. Allows implementations to use {@link PreparedStatement}s. The + * {@link ReactiveCqlTemplate} will attempt to cache the {@link PreparedStatement}s for future use without the + * overhead of re-preparing on the entire cluster. + * + * @param session Session to use to create statement, must not be {@literal null}. + * @return a prepared statement + * @throws DriverException there is no need to catch DriverException that may be thrown in the implementation of this + * method. The {@link ReactiveCqlTemplate} class will handle them. + */ + Mono createPreparedStatement(ReactiveSession session) throws DriverException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSet.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSet.java new file mode 100644 index 000000000..baca0d8ab --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSet.java @@ -0,0 +1,109 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.util.List; + +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.ExecutionInfo; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.Statement; + +import reactor.core.publisher.Flux; + +/** + * The reactive result of a query. + *

+ * The retrieval of the rows of a {@link ReactiveResultSet} is generally paged (a first page of result is fetched and + * the next one is only fetched once all the results of the first one has been consumed). The size of the pages can be + * configured either globally through {@link QueryOptions#setFetchSize} or per-statement with + * {@link Statement#setFetchSize}. + *

+ * Please note however that this {@link ReactiveResultSet} paging is not available with the version 1 of the native + * protocol (i.e. with Cassandra 1.2 or if version 1 has been explicitly requested through + * {@link com.datastax.driver.core.Cluster.Builder#withProtocolVersion}). If the protocol version 1 is in use, a + * {@link ReactiveResultSet} is always fetched in it's entirely and it's up to the client to make sure that no query can + * yield {@link ReactiveResultSet} that won't hold in memory. + *

+ * Note that this class is not thread-safe. + * + * @author Mark Paluch + * @since 2.0 + * @see Flux + * @see ReactiveSession + * @see com.datastax.driver.core.ResultSet + */ +public interface ReactiveResultSet { + + /** + * Returns a {@link Flux} over the rows contained in this result set. + *

+ * The {@link Flux} will stream over all records that in this {@link ReactiveResultSet} according to the reactive + * demand. + *

+ * + * @return a {@link Flux} of rows that will stream over all {@link Row rows} in this {@link ReactiveResultSet}. + */ + Flux rows(); + + /** + * Returns the columns returned in this ResultSet. + * + * @return the columns returned in this ResultSet. + */ + public ColumnDefinitions getColumnDefinitions(); + + /** + * If the query that produced this ResultSet was a conditional update, return whether it was successfully applied. + *

+ * For consistency, this method always returns {@code true} for non-conditional queries (although there is no reason + * to call the method in that case). This is also the case for conditional DDL statements + * ({@code CREATE KEYSPACE... IF NOT EXISTS}, {@code CREATE TABLE... IF NOT EXISTS}), for which Cassandra doesn't + * return an {@code [applied]} column. + *

+ * Note that, for versions of Cassandra strictly lower than 2.0.9 and 2.1.0-rc2, a server-side bug (CASSANDRA-7337) + * causes this method to always return {@code true} for batches containing conditional queries. + * + * @return if the query was a conditional update, whether it was applied. {@code true} for other types of queries. + * @see CASSANDRA-7337 + */ + public boolean wasApplied(); + + /** + * Returns information on the execution of the last query made for this result set. + *

+ * Note that in most cases, a result set is fetched with only one query, but large result sets can be paged and thus + * be retrieved by multiple queries. In that case this method return the {@link ExecutionInfo} for the last query + * performed. To retrieve the information for all queries, use {@link #getAllExecutionInfo}. + *

+ * The returned object includes basic information such as the queried hosts, but also the Cassandra query trace if + * tracing was enabled for the query. + * + * @return the execution info for the last query made for this result set. + */ + ExecutionInfo getExecutionInfo(); + + /** + * Return the execution information for all queries made to retrieve this result set. + *

+ * Unless the result set is large enough to get paged underneath, the returned list will be singleton. If paging has + * been used however, the returned list contains the {@link ExecutionInfo} objects for all the queries done to obtain + * this result set (at the time of the call) in the order those queries were made. + * + * @return a list of the execution info for all the queries made for this result set. + */ + List getAllExecutionInfo(); +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSetExtractor.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSetExtractor.java new file mode 100644 index 000000000..3882d3852 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveResultSetExtractor.java @@ -0,0 +1,54 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; + +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Callback interface used by {@link ReactiveCqlTemplate}'s query methods. Implementations of this interface perform the + * actual work of extracting results from a {@link ReactiveResultSet}, but don't need to worry about exception handling. + * {@link DriverException}s will be caught and handled by the calling {@link ReactiveCqlTemplate}. + *

+ * This interface is mainly used within the CQL framework itself. A {@link RowMapper} is usually a simpler choice for + * {@link ReactiveResultSet} processing, mapping one result object per row instead of one result object for the entire + * {@link ReactiveResultSet}. + *

+ * Note: {@link ReactiveResultSetExtractor} object is typically stateless and thus reusable, as long as it doesn't + * access stateful resources or keep result state within the object. + * + * @param + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate + * @see RowCallbackHandler + * @see RowMapper + */ +public interface ReactiveResultSetExtractor { + + /** + * Implementations must implement this method to process the entire {@link ReactiveResultSet}. + * + * @param resultSet {@link ReactiveResultSet} to extract data from, must not be {@literal null}. + * @return an arbitrary result object {@link Publisher}. + * @throws DriverException if a {@link DriverException} is encountered getting column values or navigating (that is, + * there's no need to catch {@link DriverException}). + * @throws DataAccessException in case of custom exceptions. + */ + Publisher extractData(ReactiveResultSet resultSet) throws DriverException, DataAccessException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveRowMapperResultSetExtractor.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveRowMapperResultSetExtractor.java new file mode 100644 index 000000000..bd4eed455 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveRowMapperResultSetExtractor.java @@ -0,0 +1,73 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; +import org.springframework.util.Assert; + +import com.datastax.driver.core.exceptions.DriverException; + +import reactor.core.publisher.Mono; + +/** + * Adapter implementation of the {@link ReactiveResultSetExtractor} interface that delegates to a {@link RowMapper} + * which is supposed to create an object for each row. Each object is emitted through the {@link Publisher} of this + * {@link ReactiveResultSetExtractor}. + *

+ * Useful for the typical case of one object per row in the database table. The number of entries in the results will + * match the number of rows. + *

+ * Note that a {@link RowMapper} object is typically stateless and thus reusable. + * + * @author Mark Paluch + * @since 2.0 + * @see RowMapper + * @see ReactiveCqlTemplate + */ +public class ReactiveRowMapperResultSetExtractor implements ReactiveResultSetExtractor { + + private final RowMapper rowMapper; + + /** + * Create a new {@link ReactiveRowMapperResultSetExtractor}. + * + * @param rowMapper the {@link RowMapper} which creates an object for each row, must not be {@literal null}. + */ + public ReactiveRowMapperResultSetExtractor(RowMapper rowMapper) { + + Assert.notNull(rowMapper, "RowMapper is must not be null"); + + this.rowMapper = rowMapper; + } + + /* (non-Javadoc) + * @see org.springframework.cassandra.core.ReactiveResultSetExtractor#extractData(org.springframework.cassandra.core.ReactiveResultSet) + */ + @Override + public Publisher extractData(ReactiveResultSet resultSet) throws DriverException, DataAccessException { + + return resultSet.rows().flatMap(row -> { + + T value = this.rowMapper.mapRow(row, 0); + + if (value == null) { + return Mono.empty(); + } + return Mono.just(value); + }); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSession.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSession.java new file mode 100644 index 000000000..27c0861cd --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSession.java @@ -0,0 +1,200 @@ +/* + * Copyright 2016 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.cassandra.core; + +import java.io.Closeable; +import java.util.Map; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.RegularStatement; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.NoHostAvailableException; +import com.datastax.driver.core.exceptions.QueryExecutionException; +import com.datastax.driver.core.exceptions.QueryValidationException; +import com.datastax.driver.core.exceptions.UnsupportedFeatureException; + +import reactor.core.publisher.Mono; + +/** + * A session holds connections to a Cassandra cluster, allowing it to be queried. {@link ReactiveSession} executes + * queries and prepares statements in a reactive style returning results wrapped in {@link Mono} and + * {@link reactor.core.publisher.Flux}. + *

+ * Each session maintains multiple connections to the cluster nodes, provides policies to choose which node to use for + * each query (round-robin on all nodes of the cluster by default), and handles retries for failed queries (when it + * makes sense). + *

+ * Session instances are thread-safe and usually a single instance is enough per application. As a given session can + * only be "logged" into one keyspace at a time (where the "logged" keyspace is the one used by queries that don't + * explicitly use a fully qualified table name), it can make sense to create one session per keyspace used. This is + * however not necessary when querying multiple keyspaces since it is always possible to use a single session with fully + * qualified table names in queries. + * + * @author Mark Paluch + * @since 2.0 + * @see org.reactivestreams.Publisher + * @see Mono + * @see ReactiveResultSet + */ +public interface ReactiveSession extends Closeable { + + /** + * Executes the provided query. + *

+ * This is a convenience method for {@code execute(new SimpleStatement(query))}. + * + * @param query the CQL query to execute. + * @return the result of the query. That result will never be null but can be empty (and will be for any non SELECT + * query). + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to execute this query. + * @throws QueryExecutionException if the query triggered an execution exception, i.e. an exception thrown by + * Cassandra when it cannot execute the query with the requested consistency level successfully. + * @throws QueryValidationException if the query if invalid (syntax error, unauthorized or any other validation + * problem). + */ + Mono execute(String query); + + /** + * Executes the provided query using the provided values. + *

+ * This is a convenience method for {@code execute(new SimpleStatement(query, values))}. + * + * @param query the CQL query to execute. + * @param values values required for the execution of {@code query}. See + * {@link SimpleStatement#SimpleStatement(String, Object...)} for more details. + * @return the result of the query. That result will never be null but can be empty (and will be for any non SELECT + * query). + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to execute this query. + * @throws QueryExecutionException if the query triggered an execution exception, i.e. an exception thrown by + * Cassandra when it cannot execute the query with the requested consistency level successfully. + * @throws QueryValidationException if the query if invalid (syntax error, unauthorized or any other validation + * problem). + * @throws UnsupportedFeatureException if version 1 of the protocol is in use (i.e. if you've forced version 1 through + * {@link Cluster.Builder#withProtocolVersion} or you use Cassandra 1.2). + */ + Mono execute(String query, Object... values); + + /** + * Executes the provided query using the provided named values. + *

+ * This is a convenience method for {@code execute(new SimpleStatement(query, values))}. + * + * @param query the CQL query to execute. + * @param values values required for the execution of {@code query}. See + * {@link SimpleStatement#SimpleStatement(String, Map)} for more details. + * @return the result of the query. That result will never be null but can be empty (and will be for any non SELECT + * query). + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to execute this query. + * @throws QueryExecutionException if the query triggered an execution exception, i.e. an exception thrown by + * Cassandra when it cannot execute the query with the requested consistency level successfully. + * @throws QueryValidationException if the query if invalid (syntax error, unauthorized or any other validation + * problem). + * @throws UnsupportedFeatureException if version 1 or 2 of the protocol is in use (i.e. if you've forced it through + * {@link Cluster.Builder#withProtocolVersion} or you use Cassandra 1.2 or 2.0). + */ + Mono execute(String query, Map values); + + /** + * Executes the provided query. + *

+ * This method blocks until at least some result has been received from the database. However, for SELECT queries, it + * does not guarantee that the result has been received in full. But it does guarantee that some response has been + * received from the database, and in particular guarantees that if the request is invalid, an exception will be + * thrown by this method. + * + * @param statement the CQL query to execute (that can be any {@link Statement}). + * @return the result of the query. That result will never be null but can be empty (and will be for any non SELECT + * query). + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to execute this query. + * @throws QueryExecutionException if the query triggered an execution exception, i.e. an exception thrown by + * Cassandra when it cannot execute the query with the requested consistency level successfully. + * @throws QueryValidationException if the query if invalid (syntax error, unauthorized or any other validation + * problem). + * @throws UnsupportedFeatureException if the protocol version 1 is in use and a feature not supported has been used. + * Features that are not supported by the version protocol 1 include: BatchStatement, ReactiveResultSet + * paging and binary values in RegularStatement. + */ + Mono execute(Statement statement); + + /** + * Prepares the provided query string. + * + * @param query the CQL query string to prepare + * @return the prepared statement corresponding to {@code query}. + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to prepare this query. + */ + Mono prepare(String query); + + /** + * Prepares the provided query. + *

+ * This method behaves like {@link #prepare(String)}, but note that the resulting {@code PreparedStatement} will + * inherit the query properties set on {@code statement}. Concretely, this means that in the following code: + * + *

+	 * RegularStatement toPrepare = new SimpleStatement("SELECT * FROM test WHERE k=?")
+	 * 		.setConsistencyLevel(ConsistencyLevel.QUORUM);
+	 * PreparedStatement prepared = session.prepare(toPrepare);
+	 * session.execute(prepared.bind("someValue"));
+	 * 
+ * + * the final execution will be performed with Quorum consistency. + *

+ * Please note that if the same CQL statement is prepared more than once, all calls to this method will return the + * same {@code PreparedStatement} object but the method will still apply the properties of the prepared + * {@code Statement} to this object. + * + * @param statement the statement to prepare + * @return the prepared statement corresponding to {@code statement}. + * @throws NoHostAvailableException if no host in the cluster can be contacted successfully to prepare this statement. + * @throws IllegalArgumentException if {@code statement.getValues() != null} (values for executing a prepared + * statement should be provided after preparation though the {@link PreparedStatement#bind} method or + * through a corresponding {@link BoundStatement}). + */ + Mono prepare(RegularStatement statement); + + /** + * Initiates a shutdown of this session instance and blocks until that shutdown completes. + *

+ * This method is a shortcut for {@code closeAsync().get()}. + *

+ * Note that this method does not close the corresponding {@code Cluster} instance (which holds additional resources, + * in particular internal executors that must be shut down in order for the client program to terminate). If you want + * to do so, use {@link Cluster#close}, but note that it will close all sessions created from that cluster. + */ + @Override + void close(); + + /** + * Whether this Session instance has been closed. + *

+ * Note that this method returns true as soon as the closing of this Session has started but it does not guarantee + * that the closing is done. If you want to guarantee that the closing is done, you can call {@code close()} and wait + * until it returns (or call the get method on {@code closeAsync()} with a very short timeout and check this doesn't + * timeout). + * + * @return {@code true} if this Session instance has been closed, {@code false} otherwise. + */ + boolean isClosed(); + + /** + * Returns the {@code Cluster} object this session is part of. + * + * @return the {@code Cluster} object this session is part of. + */ + Cluster getCluster(); +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionCallback.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionCallback.java new file mode 100644 index 000000000..7457613ff --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionCallback.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; + +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Generic callback interface for code that operates on a CQL {@link ReactiveSession}. Allows to execute any number of + * operations on a single {@link ReactiveSession}, using any type and number of Statements. + *

+ * This is particularly useful for delegating to existing data access code that expects a {@link ReactiveSession} to + * work on and throws {@link DriverException}. For newly written code, it is strongly recommended to use + * {@link CqlTemplate}'s more specific operations, for example a query or update variant. + * + * @param + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate#execute(ReactiveSessionCallback) + */ +@FunctionalInterface +public interface ReactiveSessionCallback { + + /** + * Gets called by {@link ReactiveCqlTemplate#execute(ReactiveSessionCallback)} with an active Cassandra session. Does not + * need to care about activating or closing the {@link ReactiveSession}. + *

+ * Allows for returning a result object created within the callback, i.e. a domain object or a collection of domain + * objects. Note that there's special support for single step actions: see + * {@link ReactiveCqlTemplate#queryForObject(Statement, Class)} etc. A thrown {@link RuntimeException} is treated as + * application exception: it gets propagated to the caller of the template. + * + * @param session active Cassandra session. + * @return a result object publisher + * @throws DriverException if thrown by a session method, to be auto-converted to a DataAccessException + * @throws DataAccessException in case of custom exceptions + */ + Publisher doInSession(ReactiveSession session) throws DriverException, DataAccessException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionFactory.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionFactory.java new file mode 100644 index 000000000..233937ad0 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveSessionFactory.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 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.cassandra.core; + +/** + * Strategy interface to produce {@link ReactiveSession} instances. + *

+ * Spring provides a {@link DefaultReactiveSessionFactory} implementation that just returns the same + * {@link ReactiveSession} instance. Implementations are free to return the same session or route calls to different + * sessions. + * + * @author Mark Paluch + * @see 2.0 + * @see ReactiveSession + * @see DefaultReactiveSessionFactory + * @see ReactiveCqlTemplate + */ +public interface ReactiveSessionFactory { + + /** + * Return a {@link ReactiveSession} to be used directly or inside a callback inside {@link ReactiveCqlTemplate}. + * + * @return a {@link ReactiveSession}. + */ + ReactiveSession getSession(); +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveStatementCallback.java b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveStatementCallback.java new file mode 100644 index 000000000..8fac20054 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/ReactiveStatementCallback.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; + +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Generic callback interface for code that operates on a CQL {@link Statement}. Allows to execute any number of + * operations on a single {@link Statement}, for example a single {@link ReactiveSession#execute(Statement)}. + *

+ * Used internally by {@link ReactiveCqlTemplate}, but also useful for application code. + * + * @param + * @author Mark Paluch + * @since 2.0 + */ +@FunctionalInterface +public interface ReactiveStatementCallback { + + /** + * Gets called by {@link ReactiveCqlTemplate#execute(String)} with an active Cassandra session. Does not need to care about + * closing the the session: this will all be handled by Spring's {@link ReactiveCqlTemplate}. + *

+ * Allows for returning a result object created within the callback, i.e. a domain object or a collection of domain + * objects. Note that there's special support for single step actions: see + * {@link ReactiveCqlTemplate#queryForObject(String, Class, Object...)} etc. A thrown RuntimeException is treated as + * application exception, it gets propagated to the caller of the template. + * + * @param session active Cassandra session. + * @param stmt CQL Statement + * @return a result object publisher + * @throws DriverException if thrown by a session method, to be auto-converted to a DataAccessException + * @throws DataAccessException in case of custom exceptions + * @see ReactiveCqlTemplate#queryForObject(String, Class) + * @see ReactiveCqlTemplate#queryForResultSet(String) + */ + Publisher doInStatement(ReactiveSession session, Statement stmt) throws DriverException; +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/SingleColumnRowMapper.java b/spring-cql/src/main/java/org/springframework/cassandra/core/SingleColumnRowMapper.java new file mode 100644 index 000000000..2ac6b89d3 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/SingleColumnRowMapper.java @@ -0,0 +1,192 @@ +/* + * Copyright 2016 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.cassandra.core; + +import org.springframework.cassandra.support.RowUtils; +import org.springframework.cassandra.support.exception.IncorrectResultSetColumnCountException; +import org.springframework.dao.TypeMismatchDataAccessException; +import org.springframework.util.ClassUtils; +import org.springframework.util.NumberUtils; + +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.exceptions.DriverException; + +/** + * {@link RowMapper} implementation that converts a single column into a single result value per row. Expects to operate + * on a {@link com.datastax.driver.core.Row} that just contains a single column. + *

+ * The type of the result value for each row can be specified. The value for the single column will be extracted from a + * {@link Row} and converted into the specified target type. + * + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCqlTemplate#queryForFlux(String, Class) + * @see ReactiveCqlTemplate#queryForObject(String, Class) + */ +public class SingleColumnRowMapper implements RowMapper { + + private Class requiredType; + + /** + * Create a new {@link SingleColumnRowMapper} for bean-style configuration. + * + * @see #setRequiredType + */ + public SingleColumnRowMapper() {} + + /** + * Create a new {@code SingleColumnRowMapper}. + *

+ * Consider using the {@link #newInstance} factory method instead, which allows for specifying the required type once + * only. + * + * @param requiredType the type that each result object is expected to match + */ + public SingleColumnRowMapper(Class requiredType) { + setRequiredType(requiredType); + } + + /** + * Set the type that each result object is expected to match. + *

+ * If not specified, the column value will be exposed as returned by the {@link Row}. + */ + public void setRequiredType(Class requiredType) { + this.requiredType = ClassUtils.resolvePrimitiveIfNecessary(requiredType); + } + + /** + * Extract a value for the single column in the current row. + *

+ * Validates that there is only one column selected, then delegates to {@code getColumnValue()} and also + * {@code convertValueToRequiredType}, if necessary. + * + * @see ColumnDefinitions#size() + * @see #getColumnValue(Row, int, Class) + * @see #convertValueToRequiredType(Object, Class) + */ + @SuppressWarnings("unchecked") + @Override + public T mapRow(Row row, int rowNum) throws DriverException { + + // Validate column count. + ColumnDefinitions definitions = row.getColumnDefinitions(); + int nrOfColumns = definitions.size(); + if (nrOfColumns != 1) { + throw new IncorrectResultSetColumnCountException(1, nrOfColumns); + } + + // Extract column value from CQL ResultSet. + Object result = getColumnValue(row, 0, this.requiredType); + if (result != null && this.requiredType != null && !this.requiredType.isInstance(result)) { + // Extracted value does not match already: try to convert it. + try { + return (T) convertValueToRequiredType(result, this.requiredType); + } catch (IllegalArgumentException ex) { + throw new TypeMismatchDataAccessException( + String.format("Type mismatch affecting row number %d and column type '%s': %s", rowNum, + definitions.getType(0), ex.getMessage())); + } + } + return (T) result; + } + + /** + * Retrieve a CQL object value for the specified column. + *

+ * The default implementation calls {@link RowUtils#getRowValue(Row, int, Class)}. If no required type has been + * specified, this method delegates to {@code getColumnValue(rs, index)}, which basically calls + * {@link Row#getObject(int)} but applies some additional default conversion to appropriate value types. + * + * @param row is the {@link Row} holding the data, must not be {@literal null}. + * @param index is the column index + * @param requiredType the type that each result object is expected to match (or {@code null} if none specified). + * @return the Object value. + * @throws DriverException in case of extraction failure + * @see RowUtils#getRowValue(Row, int, Class) + * @see #getColumnValue(Row, int) + */ + protected Object getColumnValue(Row row, int index, Class requiredType) throws DriverException { + + if (requiredType != null) { + return RowUtils.getRowValue(row, index, requiredType); + } else { + // No required type specified -> perform default extraction. + return getColumnValue(row, index); + } + } + + /** + * Retrieve a object value for the specified column, using the most appropriate value type. Called if no required type + * has been specified. + *

+ * The default implementation delegates to {@link RowUtils#getRowValue(Row, int, Class)}, which uses the + * {@link Row#getObject(int)} method. + * + * @param row is the {@link Row} holding the data, must not be {@literal null}. + * @param index is the column index + * @return the Object value. + * @throws DriverException in case of extraction failure. + * @see RowUtils#getRowValue(Row, int, Class) + */ + protected Object getColumnValue(Row row, int index) { + return RowUtils.getRowValue(row, index, null); + } + + /** + * Convert the given column value to the specified required type. Only called if the extracted column value does not + * match already. + *

+ * If the required type is String, the value will simply get stringified via {@code toString()}. In case of a Number, + * the value will be converted into a Number, either through number conversion or through String parsing (depending on + * the value type). + * + * @param value the column value as extracted from {@code getColumnValue()} (never {@code null}) + * @param requiredType the type that each result object is expected to match (never {@code null}) + * @return the converted value + * @see #getColumnValue(Row, int, Class) + */ + @SuppressWarnings("unchecked") + protected Object convertValueToRequiredType(Object value, Class requiredType) { + + if (String.class == requiredType) { + return value.toString(); + } else if (Number.class.isAssignableFrom(requiredType)) { + + if (value instanceof Number) { + // Convert original Number to target Number class. + return NumberUtils.convertNumberToTargetClass(((Number) value), (Class) requiredType); + } else { + // Convert stringified value to target Number class. + return NumberUtils.parseNumber(value.toString(), (Class) requiredType); + } + } else { + throw new IllegalArgumentException( + String.format("Value [%s] is of type [%s] and cannot be converted to required type [%s]", value, + value.getClass().getName(), requiredType.getName())); + } + } + + /** + * Static factory method to create a new {@code SingleColumnRowMapper} (with the required type specified only once). + * + * @param requiredType the type that each result object is expected to match + */ + public static SingleColumnRowMapper newInstance(Class requiredType) { + return new SingleColumnRowMapper<>(requiredType); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/core/support/CQLExceptionTranslator.java b/spring-cql/src/main/java/org/springframework/cassandra/core/support/CQLExceptionTranslator.java new file mode 100644 index 000000000..33b601e62 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/core/support/CQLExceptionTranslator.java @@ -0,0 +1,51 @@ +/* + * Copyright 2016 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.cassandra.core.support; + +import org.springframework.dao.DataAccessException; +import org.springframework.dao.support.PersistenceExceptionTranslator; + +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Strategy interface for translating between {@link DriverException DriverExceptios} and Spring's data access + * strategy-agnostic {@link DataAccessException} hierarchy. + * + * @author Mark Paluch + * @see org.springframework.dao.DataAccessException + * @see 2.0 + */ +@FunctionalInterface +public interface CQLExceptionTranslator extends PersistenceExceptionTranslator { + + /** + * Translate the given {@link DriverException} into a generic {@link DataAccessException}. + *

+ * The returned {@link DataAccessException} is supposed to contain the original {@code DriverException} as root cause. + * However, client code may not generally rely on this due to {@link DataAccessException}s possibly being caused by + * other resource APIs as well. That said, a {@code getRootCause() instanceof DataAccessException} check (and + * subsequent cast) is considered reliable when expecting Cassandra-based access to have happened. + * + * @param task readable text describing the task being attempted + * @param cql CQL query or update that caused the problem (may be {@code null}) + * @param ex the offending {@link DriverException} + * @return the DataAccessException, wrapping the {@code DriverException} + * @see org.springframework.dao.DataAccessException#getRootCause() + */ + default DataAccessException translate(String task, String cql, DriverException ex) { + return translateExceptionIfPossible(ex); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraAccessor.java b/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraAccessor.java index f0eebdab3..d245437b1 100644 --- a/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraAccessor.java +++ b/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraAccessor.java @@ -37,7 +37,7 @@ import com.datastax.driver.core.Session; */ public class CassandraAccessor implements InitializingBean { - CassandraExceptionTranslator exceptionTranslator = new CassandraExceptionTranslator(); + protected CassandraExceptionTranslator exceptionTranslator = new CassandraExceptionTranslator(); protected final Logger logger = LoggerFactory.getLogger(getClass()); diff --git a/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraExceptionTranslator.java b/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraExceptionTranslator.java index 3872a4c41..f09a5b70d 100644 --- a/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraExceptionTranslator.java +++ b/spring-cql/src/main/java/org/springframework/cassandra/support/CassandraExceptionTranslator.java @@ -15,6 +15,7 @@ */ package org.springframework.cassandra.support; +import org.springframework.cassandra.core.support.CQLExceptionTranslator; import org.springframework.cassandra.support.exception.CassandraAuthenticationException; import org.springframework.cassandra.support.exception.CassandraConnectionFailureException; import org.springframework.cassandra.support.exception.CassandraInsufficientReplicasAvailableException; @@ -33,6 +34,7 @@ import org.springframework.cassandra.support.exception.CassandraUncategorizedExc import org.springframework.cassandra.support.exception.CassandraWriteTimeoutException; import org.springframework.dao.DataAccessException; import org.springframework.dao.support.PersistenceExceptionTranslator; +import org.springframework.util.StringUtils; import com.datastax.driver.core.WriteType; import com.datastax.driver.core.exceptions.AlreadyExistsException; @@ -52,80 +54,110 @@ import com.datastax.driver.core.exceptions.UnavailableException; import com.datastax.driver.core.exceptions.WriteTimeoutException; /** - * Simple {@link PersistenceExceptionTranslator} for Cassandra. Convert the given runtime exception to an appropriate - * exception from the {@code org.springframework.dao} hierarchy. Return {@literal null} if no translation is - * appropriate: any other exception may have resulted from user code, and should not be translated. + * Simple {@link PersistenceExceptionTranslator} for Cassandra. + *

+ * Convert the given runtime exception to an appropriate exception from the {@code org.springframework.dao} hierarchy. + * Return {@literal null} if no translation is appropriate: any other exception may have resulted from user code, and + * should not be translated. * * @author Alex Shvid * @author Matthew T. Adams + * @author Mark Paluch */ - -public class CassandraExceptionTranslator implements PersistenceExceptionTranslator { +public class CassandraExceptionTranslator implements CQLExceptionTranslator { @Override - public DataAccessException translateExceptionIfPossible(RuntimeException x) { + public DataAccessException translateExceptionIfPossible(RuntimeException ex) { - if (x instanceof DataAccessException) { - return (DataAccessException) x; + if (ex instanceof DataAccessException) { + return (DataAccessException) ex; } - if (!(x instanceof DriverException)) { + if (!(ex instanceof DriverException)) { return null; } + return translate(null, null, (DriverException) ex); + } + + @Override + public DataAccessException translate(String task, String cql, DriverException ex) { + + String message = buildMessage(task, cql, ex); + // Remember: subclasses must come before superclasses, otherwise the // superclass would match before the subclass! - if (x instanceof AuthenticationException) { - return new CassandraAuthenticationException(((AuthenticationException) x).getHost(), x.getMessage(), x); + if (ex instanceof AuthenticationException) { + return new CassandraAuthenticationException(((AuthenticationException) ex).getHost(), message, ex); } - if (x instanceof DriverInternalError) { - return new CassandraInternalException(x.getMessage(), x); + if (ex instanceof DriverInternalError) { + return new CassandraInternalException(message, ex); } - if (x instanceof InvalidTypeException) { - return new CassandraTypeMismatchException(x.getMessage(), x); + if (ex instanceof InvalidTypeException) { + return new CassandraTypeMismatchException(message, ex); } - if (x instanceof NoHostAvailableException) { - return new CassandraConnectionFailureException(((NoHostAvailableException) x).getErrors(), x.getMessage(), x); + if (ex instanceof NoHostAvailableException) { + return new CassandraConnectionFailureException(((NoHostAvailableException) ex).getErrors(), message, ex); } - if (x instanceof ReadTimeoutException) { - return new CassandraReadTimeoutException(((ReadTimeoutException) x).wasDataRetrieved(), x.getMessage(), x); + if (ex instanceof ReadTimeoutException) { + return new CassandraReadTimeoutException(((ReadTimeoutException) ex).wasDataRetrieved(), message, ex); } - if (x instanceof WriteTimeoutException) { - WriteType writeType = ((WriteTimeoutException) x).getWriteType(); - return new CassandraWriteTimeoutException(writeType == null ? null : writeType.name(), x.getMessage(), x); + if (ex instanceof WriteTimeoutException) { + WriteType writeType = ((WriteTimeoutException) ex).getWriteType(); + return new CassandraWriteTimeoutException(writeType == null ? null : writeType.name(), message, ex); } - if (x instanceof TruncateException) { - return new CassandraTruncateException(x.getMessage(), x); + if (ex instanceof TruncateException) { + return new CassandraTruncateException(message, ex); } - if (x instanceof UnavailableException) { - UnavailableException ux = (UnavailableException) x; + if (ex instanceof UnavailableException) { + UnavailableException ux = (UnavailableException) ex; return new CassandraInsufficientReplicasAvailableException(ux.getRequiredReplicas(), ux.getAliveReplicas(), - x.getMessage(), x); + message, ex); } - if (x instanceof AlreadyExistsException) { - AlreadyExistsException aex = (AlreadyExistsException) x; + if (ex instanceof AlreadyExistsException) { + AlreadyExistsException aex = (AlreadyExistsException) ex; - return aex.wasTableCreation() ? new CassandraTableExistsException(aex.getTable(), x.getMessage(), x) - : new CassandraKeyspaceExistsException(aex.getKeyspace(), x.getMessage(), x); + return aex.wasTableCreation() ? new CassandraTableExistsException(aex.getTable(), message, ex) + : new CassandraKeyspaceExistsException(aex.getKeyspace(), message, ex); } - if (x instanceof InvalidConfigurationInQueryException) { - return new CassandraInvalidConfigurationInQueryException(x.getMessage(), x); + if (ex instanceof InvalidConfigurationInQueryException) { + return new CassandraInvalidConfigurationInQueryException(message, ex); } - if (x instanceof InvalidQueryException) { - return new CassandraInvalidQueryException(x.getMessage(), x); + if (ex instanceof InvalidQueryException) { + return new CassandraInvalidQueryException(message, ex); } - if (x instanceof SyntaxError) { - return new CassandraQuerySyntaxException(x.getMessage(), x); + if (ex instanceof SyntaxError) { + return new CassandraQuerySyntaxException(message, ex); } - if (x instanceof UnauthorizedException) { - return new CassandraUnauthorizedException(x.getMessage(), x); + if (ex instanceof UnauthorizedException) { + return new CassandraUnauthorizedException(message, ex); } - if (x instanceof TraceRetrievalException) { - return new CassandraTraceRetrievalException(x.getMessage(), x); + if (ex instanceof TraceRetrievalException) { + return new CassandraTraceRetrievalException(message, ex); } // unknown or unhandled exception - return new CassandraUncategorizedException(x.getMessage(), x); + return new CassandraUncategorizedException(message, ex); + } + + /** + * Build a message {@code String} for the given {@link DriverException}. + *

+ * To be called by translator subclasses when creating an instance of a generic + * {@link org.springframework.dao.DataAccessException} class. + * + * @param task readable text describing the task being attempted + * @param cql the CQL statement that caused the problem (may be {@code null}) + * @param ex the offending {@code DriverException} + * @return the message {@code String} to use + */ + protected String buildMessage(String task, String cql, DriverException ex) { + + if (StringUtils.hasText(task) || StringUtils.hasText(cql)) { + return task + "; CQL [" + cql + "]; " + ex.getMessage(); + } + + return ex.getMessage(); } } diff --git a/spring-cql/src/main/java/org/springframework/cassandra/support/ReactiveCassandraAccessor.java b/spring-cql/src/main/java/org/springframework/cassandra/support/ReactiveCassandraAccessor.java new file mode 100644 index 000000000..bd8962b82 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/support/ReactiveCassandraAccessor.java @@ -0,0 +1,151 @@ +/* + * Copyright 2016 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.cassandra.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.cassandra.core.ReactiveSessionFactory; +import org.springframework.cassandra.core.support.CQLExceptionTranslator; +import org.springframework.dao.DataAccessException; +import org.springframework.util.Assert; + +import com.datastax.driver.core.exceptions.DriverException; + +/** + * Base class for {@link org.springframework.cassandra.core.ReactiveCqlTemplate} and other CQL-accessing DAO helpers, + * defining common properties such as {@link org.springframework.cassandra.core.ReactiveSessionFactory} and exception + * translator. + *

+ * Not intended to be used directly. + * + * @author Mark Paluch + * @since 2.0 + * @see InitializingBean + * @see org.springframework.cassandra.core.ReactiveSession + * @see org.springframework.cassandra.core.ReactiveCqlTemplate + */ +public abstract class ReactiveCassandraAccessor implements InitializingBean { + + /** Logger available to subclasses */ + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + private CQLExceptionTranslator exceptionTranslator = new CassandraExceptionTranslator(); + + private ReactiveSessionFactory sessionFactory; + + /** + * Sets the {@link ReactiveSessionFactory} to use. + * + * @param sessionFactory must not be {@literal null}. + */ + public void setSessionFactory(ReactiveSessionFactory sessionFactory) { + + Assert.notNull(sessionFactory, "ReactiveSessionFactory must not be null"); + + this.sessionFactory = sessionFactory; + } + + /** + * Returns the configured {@link ReactiveSessionFactory}. + * + * @return the configured {@link ReactiveSessionFactory}. + */ + public ReactiveSessionFactory getSessionFactory() { + return sessionFactory; + } + + /** + * Sets the exception translator used by this template to translate Cassandra specific exceptions into Spring DAO's + * Exception Hierarchy. + * + * @param exceptionTranslator exception translator to set; must not be {@literal null}. + * @see CassandraExceptionTranslator + * @see DataAccessException + */ + public void setExceptionTranslator(CQLExceptionTranslator exceptionTranslator) { + + Assert.notNull(exceptionTranslator, "CQLExceptionTranslator must not be null"); + + this.exceptionTranslator = exceptionTranslator; + } + + /** + * Returns the exception translator for this instance. + * + * @return the Cassandra exception translator. + * @see CassandraExceptionTranslator + */ + public CQLExceptionTranslator getExceptionTranslator() { + return this.exceptionTranslator; + } + + /** + * Ensures the Cassandra {@link ReactiveSessionFactory} and exception translator has been properly set. + */ + @Override + public void afterPropertiesSet() { + + Assert.notNull(sessionFactory != null, "ReactiveSessionFactory must not be null"); + Assert.notNull(exceptionTranslator != null, "CassandraExceptionTranslator must not be null"); + } + + /** + * Translate the given {@link DriverException} into a generic {@link DataAccessException}. + *

+ * The returned {@link DataAccessException} is supposed to contain the original {@code DriverException} as root cause. + * However, client code may not generally rely on this due to {@link DataAccessException}s possibly being caused by + * other resource APIs as well. That said, a {@code getRootCause() instanceof DataAccessException} check (and + * subsequent cast) is considered reliable when expecting Cassandra-based access to have happened. + * + * @param ex the offending {@link DriverException} + * @return the DataAccessException, wrapping the {@code DriverException} + * @see Consistent + * exception hierarchy + * @see DataAccessException + */ + protected DataAccessException translateExceptionIfPossible(DriverException ex) { + + Assert.notNull(ex, "DriverException must not be null"); + + return getExceptionTranslator().translateExceptionIfPossible(ex); + } + + /** + * Translate the given {@link DriverException} into a generic {@link DataAccessException}. + *

+ * The returned {@link DataAccessException} is supposed to contain the original {@code DriverException} as root cause. + * However, client code may not generally rely on this due to {@link DataAccessException}s possibly being caused by + * other resource APIs as well. That said, a {@code getRootCause() instanceof DataAccessException} check (and + * subsequent cast) is considered reliable when expecting Cassandra-based access to have happened. + * + * @param task readable text describing the task being attempted + * @param cql CQL query or update that caused the problem (may be {@code null}) + * @param ex the offending {@link DriverException} + * @return the DataAccessException, wrapping the {@code DriverException} + * @see org.springframework.dao.DataAccessException#getRootCause() + * @see Consistent + * exception hierarchy + */ + protected DataAccessException translate(String task, String cql, DriverException ex) { + + Assert.notNull(ex, "DriverException must not be null"); + + return getExceptionTranslator().translate(task, cql, ex); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/support/RowUtils.java b/spring-cql/src/main/java/org/springframework/cassandra/support/RowUtils.java new file mode 100644 index 000000000..9dcdb6f13 --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/support/RowUtils.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016 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.cassandra.support; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.UUID; + +import com.datastax.driver.core.LocalDate; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.TupleValue; +import com.datastax.driver.core.UDTValue; + +/** + * Generic utility methods for working with Cassandra. Mainly for internal use within the framework, but also useful for + * custom CQL access code. + * + * @author Mark Paluch + * @since 2.0 + */ +public abstract class RowUtils { + + /** + * Retrieve a CQL column value from a {@link Row}, using the specified value type. + *

+ * Uses the specifically typed {@link Row} accessor methods, falling back to {@link Row#getObject(int)} for unknown + * types. + *

+ * Note that the returned value may not be assignable to the specified required type, in case of an unknown type. + * Calling code needs to deal with this case appropriately, e.g. throwing a corresponding exception. + * + * @param row is the {@link Row} holding the data + * @param index is the column index + * @param requiredType the required value type (may be {@code null}) + * @return the value object + */ + public static Object getRowValue(Row row, int index, Class requiredType) { + + if (requiredType == null) { + return row.getObject(index); + } + + Object value; + + // Explicitly extract typed value, as far as possible. + if (String.class == requiredType) { + return row.getString(index); + } else if (boolean.class == requiredType || Boolean.class == requiredType) { + value = row.getBool(index); + } else if (byte.class == requiredType || Byte.class == requiredType) { + value = row.getByte(index); + } else if (short.class == requiredType || Short.class == requiredType) { + value = row.getShort(index); + } else if (int.class == requiredType || Integer.class == requiredType) { + value = row.getInt(index); + } else if (long.class == requiredType || Long.class == requiredType) { + value = row.getLong(index); + } else if (float.class == requiredType || Float.class == requiredType) { + value = row.getFloat(index); + } else if (double.class == requiredType || Double.class == requiredType || Number.class == requiredType) { + value = row.getDouble(index); + } else if (BigDecimal.class == requiredType) { + return row.getDecimal(index); + } else if (LocalDate.class == requiredType) { + return row.getDate(index); + } else if (java.util.Date.class == requiredType) { + return row.getTimestamp(index); + } else if (ByteBuffer.class == requiredType) { + return row.getBytes(index); + } else if (TupleValue.class == requiredType) { + return row.getTupleValue(index); + } else if (UDTValue.class == requiredType) { + return row.getUDTValue(index); + } else if (UUID.class == requiredType) { + return row.getUUID(index); + } else { + // Some unknown type desired -> rely on getObject. + return row.getObject(index); + } + + return (row.isNull(index) ? null : value); + } +} diff --git a/spring-cql/src/main/java/org/springframework/cassandra/support/exception/IncorrectResultSetColumnCountException.java b/spring-cql/src/main/java/org/springframework/cassandra/support/exception/IncorrectResultSetColumnCountException.java new file mode 100644 index 000000000..81ca4137a --- /dev/null +++ b/spring-cql/src/main/java/org/springframework/cassandra/support/exception/IncorrectResultSetColumnCountException.java @@ -0,0 +1,63 @@ +package org.springframework.cassandra.support.exception; + +import org.springframework.dao.DataRetrievalFailureException; + +/** + * Data access exception thrown when a result set did not have the correct column count, for example when expecting a + * single column but getting 0 or more than 1 columns. + * + * @author Mark Paluch + * @since 2.0 + * @see org.springframework.dao.IncorrectResultSizeDataAccessException + */ +@SuppressWarnings("serial") +public class IncorrectResultSetColumnCountException extends DataRetrievalFailureException { + + private int expectedCount; + + private int actualCount; + + /** + * Constructor for IncorrectResultSetColumnCountException. + * + * @param expectedCount the expected column count + * @param actualCount the actual column count + */ + public IncorrectResultSetColumnCountException(int expectedCount, int actualCount) { + + super("Incorrect column count: expected " + expectedCount + ", actual " + actualCount); + + this.expectedCount = expectedCount; + this.actualCount = actualCount; + } + + /** + * Constructor for IncorrectResultCountDataAccessException. + * + * @param msg the detail message + * @param expectedCount the expected column count + * @param actualCount the actual column count + */ + public IncorrectResultSetColumnCountException(String msg, int expectedCount, int actualCount) { + + super(msg); + + this.expectedCount = expectedCount; + this.actualCount = actualCount; + } + + /** + * Return the expected column count. + */ + public int getExpectedCount() { + return this.expectedCount; + } + + /** + * Return the actual column count. + */ + public int getActualCount() { + return this.actualCount; + } + +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionIntegrationTests.java b/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionIntegrationTests.java new file mode 100644 index 000000000..345e6edcd --- /dev/null +++ b/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionIntegrationTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2016 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.cassandra.core; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; + +import com.datastax.driver.core.KeyspaceMetadata; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.exceptions.SyntaxError; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Integration tests for {@link DefaultBridgedReactiveSession}. + * + * @author Mark Paluch + */ +public class DefaultBridgedReactiveSessionIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest { + + private DefaultBridgedReactiveSession reactiveSession; + + @Before + public void before() throws Exception { + + this.session.execute("DROP TABLE IF EXISTS users;"); + + this.reactiveSession = new DefaultBridgedReactiveSession(this.session, Schedulers.elastic()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldExecuteDeferred() throws Exception { + + Mono execution = reactiveSession + .execute("CREATE TABLE users (\n" + " userid text PRIMARY KEY,\n" + " first_name text\n" + ");"); + + KeyspaceMetadata keyspace = getKeyspaceMetadata(); + + assertThat(keyspace.getTable("users")).isNull(); + + ReactiveResultSet resultSet = execution.block(); + assertThat(resultSet.wasApplied()).isTrue(); + assertThat(keyspace.getTable("users")).isNotNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldTransportExceptionsInMono() throws Exception { + + Mono execution = reactiveSession.execute("INSERT INTO dummy;"); + + try { + execution.block(); + fail("Missing SyntaxError"); + } catch (SyntaxError e) { + assertThat(e).isInstanceOf(SyntaxError.class); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldReturnRows() throws Exception { + + session.execute("CREATE TABLE users (\n" + " userid text PRIMARY KEY,\n" + " first_name text\n" + ");"); + session.execute("INSERT INTO users (userid, first_name) VALUES ('White', 'Walter');"); + + Mono execution = reactiveSession.execute("SELECT * FROM users;"); + ReactiveResultSet resultSet = execution.block(); + Row row = resultSet.rows().blockFirst(); + + assertThat(row).isNotNull(); + assertThat(row.getString("userid")).isEqualTo("White"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldPrepareStatement() throws Exception { + + session.execute("CREATE TABLE users (\n" + " userid text PRIMARY KEY,\n" + " first_name text\n" + ");"); + + Mono execution = reactiveSession + .prepare("INSERT INTO users (userid, first_name) VALUES (?, ?);"); + PreparedStatement preparedStatement = execution.block(); + + assertThat(preparedStatement).isNotNull(); + assertThat(preparedStatement.getQueryString()).isEqualTo("INSERT INTO users (userid, first_name) VALUES (?, ?);"); + } + + private KeyspaceMetadata getKeyspaceMetadata() { + return cluster.getMetadata().getKeyspace(this.session.getLoggedKeyspace()); + } +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionUnitTests.java b/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionUnitTests.java new file mode 100644 index 000000000..29ea6d254 --- /dev/null +++ b/spring-cql/src/test/java/org/springframework/cassandra/core/DefaultBridgedReactiveSessionUnitTests.java @@ -0,0 +1,178 @@ +/* + * Copyright 2016 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.cassandra.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; + +import org.hamcrest.core.IsEqual; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.Statement; + +import reactor.core.scheduler.Schedulers; + +/** + * Unit tests for {@link DefaultBridgedReactiveSession}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultBridgedReactiveSessionUnitTests { + + @Mock private Session sessionMock; + + private DefaultBridgedReactiveSession reactiveSession; + + @Before + public void before() throws Exception { + reactiveSession = new DefaultBridgedReactiveSession(sessionMock, Schedulers.immediate()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeStatementShouldForwardStatementToSession() throws Exception { + + SimpleStatement statement = new SimpleStatement("SELECT *"); + reactiveSession.execute(statement).subscribe(); + + verify(sessionMock).executeAsync(statement); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldForwardStatementToSession() throws Exception { + + reactiveSession.execute("SELECT *").subscribe(); + + verify(sessionMock).executeAsync(eq(new SimpleStatement("SELECT *"))); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeWithValuesShouldForwardStatementToSession() throws Exception { + + reactiveSession.execute("SELECT * WHERE a = ? and b = ?", "A", "B").subscribe(); + + verify(sessionMock).executeAsync(eq(new SimpleStatement("SELECT * WHERE a = ? and b = ?", "A", "B"))); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeWithValueMapShouldForwardStatementToSession() throws Exception { + + reactiveSession.execute("SELECT * WHERE a = ?", Collections.singletonMap("a", "value")).subscribe(); + + verify(sessionMock) + .executeAsync(eq(new SimpleStatement("SELECT * WHERE a = ?", Collections.singletonMap("a", "value")))); + } + + /** + * @see DATACASS-335 + */ + @Test + public void testPrepareQuery() throws Exception { + + reactiveSession.prepare("SELECT *").subscribe(); + + verify(sessionMock).prepareAsync(eq(new SimpleStatement("SELECT *"))); + } + + /** + * @see DATACASS-335 + */ + @Test + public void testPrepareStatement() throws Exception { + + SimpleStatement statement = new SimpleStatement("SELECT *"); + reactiveSession.prepare(statement).subscribe(); + + verify(sessionMock).prepareAsync(statement); + } + + /** + * @see DATACASS-335 + */ + @Test + public void testClose() throws Exception { + + reactiveSession.close(); + + verify(sessionMock).close(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void testIsClosed() throws Exception { + + when(reactiveSession.isClosed()).thenReturn(true); + + boolean result = reactiveSession.isClosed(); + + assertThat(result).isTrue(); + verify(sessionMock).isClosed(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void testGetCluster() throws Exception { + + Cluster clusterMock = mock(Cluster.class); + when(sessionMock.getCluster()).thenReturn(clusterMock); + + Cluster result = reactiveSession.getCluster(); + + assertThat(result).isSameAs(clusterMock); + } + + private static T eq(T value) { + + return Matchers.argThat(new IsEqual(value) { + + @Override + public boolean matches(Object actualValue) { + + if (actualValue instanceof Statement) { + return value.toString().equals(actualValue.toString()); + } + + return super.matches(actualValue); + } + }); + } +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateIntegrationTests.java b/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateIntegrationTests.java new file mode 100644 index 000000000..791fc96b6 --- /dev/null +++ b/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateIntegrationTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2016 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.cassandra.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; + +import com.datastax.driver.core.querybuilder.QueryBuilder; + +import reactor.core.scheduler.Schedulers; + +/** + * Integration tests for {@link ReactiveCqlTemplate}. + * + * @author Mark Paluch + */ +public class ReactiveCqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest { + + private static final AtomicBoolean initialized = new AtomicBoolean(); + private ReactiveSession reactiveSession; + private ReactiveCqlTemplate template; + + @Before + public void before() throws Exception { + + reactiveSession = new DefaultBridgedReactiveSession(getSession(), Schedulers.elastic()); + + if (initialized.compareAndSet(false, true)) { + getSession().execute("CREATE TABLE IF NOT EXISTS user (id text PRIMARY KEY, username text);"); + } else { + getSession().execute("TRUNCATE user;"); + } + + getSession().execute("INSERT INTO user (id, username) VALUES ('WHITE', 'Walter');"); + + template = new ReactiveCqlTemplate(new DefaultReactiveSessionFactory(reactiveSession)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeShouldRemoveRecords() throws Exception { + + template.execute("DELETE FROM user WHERE id = 'WHITE'").block(); + + assertThat(getSession().execute("SELECT * FROM user").one()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectShouldReturnFirstColumn() throws Exception { + + String id = template.queryForObject("SELECT id FROM user;", String.class).block(); + + assertThat(id).isEqualTo("WHITE"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectShouldReturnMap() throws Exception { + + Map map = template.queryForMap("SELECT * FROM user;").block(); + + assertThat(map).containsEntry("id", "WHITE").containsEntry("username", "Walter"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeStatementShouldRemoveRecords() throws Exception { + + template.execute(QueryBuilder.delete().from("user").where(QueryBuilder.eq("id", "WHITE"))).block(); + + assertThat(getSession().execute("SELECT * FROM user").one()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldReturnFirstColumn() throws Exception { + + String id = template.queryForObject(QueryBuilder.select("id").from("user"), String.class).block(); + + assertThat(id).isEqualTo("WHITE"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldReturnMap() throws Exception { + + Map map = template.queryForMap(QueryBuilder.select().from("user")).block(); + + assertThat(map).containsEntry("id", "WHITE").containsEntry("username", "Walter"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeWithArgsShouldRemoveRecords() throws Exception { + + template.execute("DELETE FROM user WHERE id = ?", "WHITE").block(); + + assertThat(getSession().execute("SELECT * FROM user").one()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectWithArgsShouldReturnFirstColumn() throws Exception { + + String id = template.queryForObject("SELECT id FROM user WHERE id = ?;", String.class, "WHITE").block(); + + assertThat(id).isEqualTo("WHITE"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectWithArgsShouldReturnMap() throws Exception { + + Map map = template.queryForMap("SELECT * FROM user WHERE id = ?;", "WHITE").block(); + + assertThat(map).containsEntry("id", "WHITE").containsEntry("username", "Walter"); + } +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateUnitTests.java b/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateUnitTests.java new file mode 100644 index 000000000..31255f1ab --- /dev/null +++ b/spring-cql/src/test/java/org/springframework/cassandra/core/ReactiveCqlTemplateUnitTests.java @@ -0,0 +1,1028 @@ +/* + * Copyright 2016 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.cassandra.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.cassandra.support.exception.CassandraConnectionFailureException; +import org.springframework.cassandra.support.exception.CassandraInvalidQueryException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; + +import com.datastax.driver.core.BoundStatement; +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.ConsistencyLevel; +import com.datastax.driver.core.PreparedStatement; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.InvalidQueryException; +import com.datastax.driver.core.exceptions.NoHostAvailableException; +import com.datastax.driver.core.policies.DowngradingConsistencyRetryPolicy; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Unit tests for {@link ReactiveCqlTemplate}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveCqlTemplateUnitTests { + + @Mock private ReactiveSession session; + @Mock private ReactiveResultSet reactiveResultSet; + @Mock private Row row; + @Mock private PreparedStatement preparedStatement; + @Mock private BoundStatement boundStatement; + @Mock private ColumnDefinitions columnDefinitions; + + private ReactiveCqlTemplate template; + private ReactiveSessionFactory sessionFactory; + + @Before + public void setup() throws Exception { + + this.sessionFactory = new DefaultReactiveSessionFactory(session); + this.template = new ReactiveCqlTemplate(sessionFactory); + } + + // ------------------------------------------------------------------------- + // Tests dealing with a plain org.springframework.cassandra.core.ReactiveSession + // ------------------------------------------------------------------------- + + /** + * @see DATACASS-335 + */ + @Test + public void executeCallbackShouldExecuteDeferred() { + + Flux flux = template.execute((ReactiveSessionCallback) session -> { + session.close(); + return Mono.just("OK"); + }); + + verify(session, never()).close(); + assertThat(flux.blockLast()).isEqualTo("OK"); + verify(session).close(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCallbackShouldTranslateExceptions() { + + Flux flux = template.execute((ReactiveSessionCallback) session -> { + throw new InvalidQueryException("wrong query"); + }); + + try { + flux.blockLast(); + + fail("Missing CassandraInvalidQueryException"); + } catch (CassandraInvalidQueryException e) { + assertThat(e).hasMessageContaining("wrong query"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlShouldExecuteDeferred() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + + Mono mono = template.execute("UPDATE user SET a = 'b';"); + + verifyZeroInteractions(session); + assertThat(mono.block()).isFalse(); + verify(session).execute(any(Statement.class)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlShouldTranslateExceptions() { + + when(session.execute(any(Statement.class))).thenThrow(new NoHostAvailableException(Collections.emptyMap())); + + Mono mono = template.execute("UPDATE user SET a = 'b';"); + + try { + mono.block(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasMessageContaining("tried for query failed"); + } + } + + // ------------------------------------------------------------------------- + // Tests dealing with static CQL + // ------------------------------------------------------------------------- + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + reactiveCqlTemplate.execute("SELECT * from USERS").block(); + + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlWithArgumentsShouldCallExecution() { + + doTestStrings(5, ConsistencyLevel.ONE, DowngradingConsistencyRetryPolicy.INSTANCE, reactiveCqlTemplate -> { + + reactiveCqlTemplate.execute("SELECT * from USERS").block(); + + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForResultSetShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Mono mono = reactiveCqlTemplate.queryForResultSet("SELECT * from USERS"); + + List rows = mono.block().rows().collectList().block(); + + assertThat(rows).hasSize(3); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryWithResultSetExtractorShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Flux flux = reactiveCqlTemplate.query("SELECT * from USERS", (row, index) -> row.getString(0)); + + List rows = flux.collectList().block(); + + assertThat(rows).hasSize(3).contains("Walter", "Hank", " Jesse"); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryWithResultSetExtractorWithArgumentsShouldCallExecution() { + + doTestStrings(5, ConsistencyLevel.ONE, DowngradingConsistencyRetryPolicy.INSTANCE, reactiveCqlTemplate -> { + + Flux flux = reactiveCqlTemplate.query("SELECT * from USERS", (row, index) -> row.getString(0)); + + List rows = flux.collectList().block(); + + assertThat(rows).hasSize(3).contains("Walter", "Hank", " Jesse"); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryCqlShouldExecuteDeferred() { + + when(reactiveResultSet.wasApplied()).thenReturn(true); + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + + Flux flux = template.query("UPDATE user SET a = 'b';", resultSet -> Mono.just(resultSet.wasApplied())); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(true); + verify(session).execute(any(Statement.class)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryCqlShouldTranslateExceptions() { + + when(session.execute(any(Statement.class))).thenThrow(new NoHostAvailableException(Collections.emptyMap())); + + Flux flux = template.query("UPDATE user SET a = 'b';", resultSet -> Mono.just(resultSet.wasApplied())); + + try { + flux.blockLast(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasMessageContaining("tried for query failed"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectCqlShouldBeEmpty() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.empty()); + + Mono mono = template.queryForObject("SELECT * FROM user", (row, rowNum) -> "OK"); + assertThat(mono.hasElement().block()).isFalse(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectCqlShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Mono mono = template.queryForObject("SELECT * FROM user", (row, rowNum) -> "OK"); + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectCqlShouldReturnNullValue() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Mono mono = template.queryForObject("SELECT * FROM user", (row, rowNum) -> null); + assertThat(mono.hasElement().block()).isFalse(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectCqlShouldFailReturningManyRecords() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Mono mono = template.queryForObject("SELECT * FROM user", (row, rowNum) -> "OK"); + + try { + mono.block(); + + fail("Missing IncorrectResultSizeDataAccessException"); + } catch (IncorrectResultSizeDataAccessException e) { + assertThat(e).hasMessageContaining("expected 1, actual 2"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectCqlWithTypeShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK"); + + Mono mono = template.queryForObject("SELECT * FROM user", String.class); + + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForFluxCqlWithTypeShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK", "NOT OK"); + + Flux flux = template.queryForFlux("SELECT * FROM user", String.class); + + assertThat(flux.collectList().block()).contains("OK", "NOT OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForRowsCqlReturnRows() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Flux flux = template.queryForRows("SELECT * FROM user"); + + assertThat(flux.collectList().block()).hasSize(2).contains(row); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlShouldReturnWasApplied() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Mono mono = template.execute("UPDATE user SET a = 'b';"); + + assertThat(mono.block()).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeCqlPublisherShouldReturnWasApplied() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true, false); + + Flux flux = template.execute(Flux.just("UPDATE user SET a = 'b';", "UPDATE user SET x = 'y';")); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(2).contains(true, false); + verify(session, times(2)).execute(any(Statement.class)); + } + + // ------------------------------------------------------------------------- + // Tests dealing with com.datastax.driver.core.Statement + // ------------------------------------------------------------------------- + + /** + * @see DATACASS-335 + */ + @Test + public void executeStatementShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + reactiveCqlTemplate.execute(new SimpleStatement("SELECT * from USERS")).block(); + + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeStatementWithArgumentsShouldCallExecution() { + + doTestStrings(5, ConsistencyLevel.ONE, DowngradingConsistencyRetryPolicy.INSTANCE, reactiveCqlTemplate -> { + + reactiveCqlTemplate.execute(new SimpleStatement("SELECT * from USERS")).block(); + + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForResultStatementSetShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Mono mono = reactiveCqlTemplate.queryForResultSet(new SimpleStatement("SELECT * from USERS")); + + List rows = mono.block().rows().collectList().block(); + + assertThat(rows).hasSize(3); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryWithResultSetStatementExtractorShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Flux flux = reactiveCqlTemplate.query(new SimpleStatement("SELECT * from USERS"), + (row, index) -> row.getString(0)); + + List rows = flux.collectList().block(); + + assertThat(rows).hasSize(3).contains("Walter", "Hank", " Jesse"); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryWithResultSetStatementExtractorWithArgumentsShouldCallExecution() { + + doTestStrings(5, ConsistencyLevel.ONE, DowngradingConsistencyRetryPolicy.INSTANCE, reactiveCqlTemplate -> { + + Flux flux = reactiveCqlTemplate.query(new SimpleStatement("SELECT * from USERS"), + (row, index) -> row.getString(0)); + + List rows = flux.collectList().block(); + + assertThat(rows).hasSize(3).contains("Walter", "Hank", " Jesse"); + verify(session).execute(any(Statement.class)); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryStatementShouldExecuteDeferred() { + + when(reactiveResultSet.wasApplied()).thenReturn(true); + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + + Flux flux = template.query(new SimpleStatement("UPDATE user SET a = 'b';"), + resultSet -> Mono.just(resultSet.wasApplied())); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(true); + verify(session).execute(any(Statement.class)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryStatementShouldTranslateExceptions() { + + when(session.execute(any(Statement.class))).thenThrow(new NoHostAvailableException(Collections.emptyMap())); + + Flux flux = template.query(new SimpleStatement("UPDATE user SET a = 'b';"), + resultSet -> Mono.just(resultSet.wasApplied())); + + try { + flux.blockLast(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasMessageContaining("tried for query failed"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldBeEmpty() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.empty()); + + Mono mono = template.queryForObject(new SimpleStatement("SELECT * FROM user"), (row, rowNum) -> "OK"); + assertThat(mono.hasElement().block()).isFalse(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Mono mono = template.queryForObject(new SimpleStatement("SELECT * FROM user"), (row, rowNum) -> "OK"); + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldReturnNullValue() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Mono mono = template.queryForObject(new SimpleStatement("SELECT * FROM user"), (row, rowNum) -> null); + assertThat(mono.hasElement().block()).isFalse(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementShouldFailReturningManyRecords() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Mono mono = template.queryForObject(new SimpleStatement("SELECT * FROM user"), (row, rowNum) -> "OK"); + + try { + mono.block(); + + fail("Missing IncorrectResultSizeDataAccessException"); + } catch (IncorrectResultSizeDataAccessException e) { + assertThat(e).hasMessageContaining("expected 1, actual 2"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectStatementWithTypeShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK"); + + Mono mono = template.queryForObject(new SimpleStatement("SELECT * FROM user"), String.class); + + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForFluxStatementWithTypeShouldReturnRecord() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK", "NOT OK"); + + Flux flux = template.queryForFlux(new SimpleStatement("SELECT * FROM user"), String.class); + + assertThat(flux.collectList().block()).contains("OK", "NOT OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForRowsStatementReturnRows() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Flux flux = template.queryForRows(new SimpleStatement("SELECT * FROM user")); + + assertThat(flux.collectList().block()).hasSize(2).contains(row); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executeStatementShouldReturnWasApplied() { + + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Mono mono = template.execute(new SimpleStatement("UPDATE user SET a = 'b';")); + + assertThat(mono.block()).isTrue(); + } + + // ------------------------------------------------------------------------- + // Methods dealing with prepared statements + // ------------------------------------------------------------------------- + + /** + * @see DATACASS-335 + */ + @Test + public void queryPreparedStatementWithCallbackShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Flux flux = reactiveCqlTemplate.execute("SELECT * from USERS", (session, ps) -> { + + return session.execute(ps.bind("A")).flatMap(ReactiveResultSet::rows); + }); + + List rows = flux.collectList().block(); + + assertThat(rows).hasSize(3); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executePreparedStatementWithCallbackShouldCallExecution() { + + doTestStrings(null, null, null, reactiveCqlTemplate -> { + + Mono applied = reactiveCqlTemplate.execute("UPDATE users SET name = ?", "White"); + when(this.preparedStatement.bind("White")).thenReturn(this.boundStatement); + when(this.reactiveResultSet.wasApplied()).thenReturn(true); + + assertThat(applied.block()).isTrue(); + }); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executePreparedStatementCallbackShouldExecuteDeferred() { + + when(session.prepare(anyString())).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind()).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Flux flux = template.execute("UPDATE user SET a = 'b';", + (session, ps) -> session.execute(ps.bind())); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(reactiveResultSet); + verify(session).prepare(anyString()); + verify(session).execute(boundStatement); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executePreparedStatementCreatorShouldExecuteDeferred() { + + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Flux flux = template.execute(session -> Mono.just(preparedStatement), + (session, ps) -> session.execute(boundStatement)); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(reactiveResultSet); + verify(session).execute(boundStatement); + } + + /** + * @see DATACASS-335 + */ + @Test + public void executePreparedStatementCreatorShouldTranslateStatementCreationExceptions() { + + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Flux flux = template.execute(session -> { + throw new NoHostAvailableException(Collections.emptyMap()); + }, (session, ps) -> session.execute(boundStatement)); + + try { + flux.blockLast(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasMessageContaining("tried for query"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void executePreparedStatementCreatorShouldTranslateStatementCallbackExceptions() { + + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Flux flux = template.execute(session -> Mono.just(preparedStatement), (session, ps) -> { + throw new NoHostAvailableException(Collections.emptyMap()); + }); + + try { + flux.blockLast(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasMessageContaining("tried for query"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryPreparedStatementCreatorShouldReturnResult() { + + when(session.prepare(anyString())).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind()).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Flux flux = template.query(session -> Mono.just(preparedStatement), ReactiveResultSet::rows); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(row); + verify(preparedStatement).bind(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryPreparedStatementCreatorAndBinderShouldReturnResult() { + + when(session.prepare(anyString())).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind()).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Flux flux = template.query(session -> Mono.just(preparedStatement), ps -> { + ps.bind("a", "b"); + return boundStatement; + }, ReactiveResultSet::rows); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(row); + verify(preparedStatement).bind("a", "b"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryPreparedStatementCreatorAndBinderAndMapperShouldReturnResult() { + + when(session.prepare(anyString())).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind()).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Flux flux = template.query(session -> Mono.just(preparedStatement), ps -> { + ps.bind("a", "b"); + return boundStatement; + }, (row, rowNum) -> row); + + verifyZeroInteractions(session); + assertThat(flux.collectList().block()).hasSize(1).contains(row); + verify(preparedStatement).bind("a", "b"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectPreparedStatementShouldBeEmpty() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.empty()); + + Mono mono = template.queryForObject("SELECT * FROM user WHERE username = ?", (row, rowNum) -> "OK", + "Walter"); + assertThat(mono.hasElement().block()).isFalse(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectPreparedStatementShouldReturnRecord() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + + Mono mono = template.queryForObject("SELECT * FROM user WHERE username = ?", (row, rowNum) -> "OK", + "Walter"); + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectPreparedStatementShouldFailReturningManyRecords() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Mono mono = template.queryForObject("SELECT * FROM user WHERE username = ?", (row, rowNum) -> "OK", + "Walter"); + try { + mono.block(); + + fail("Missing IncorrectResultSizeDataAccessException"); + } catch (IncorrectResultSizeDataAccessException e) { + assertThat(e).hasMessageContaining("expected 1, actual 2"); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForObjectPreparedStatementWithTypeShouldReturnRecord() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK"); + + Mono mono = template.queryForObject("SELECT * FROM user WHERE username = ?", String.class, "Walter"); + + assertThat(mono.block()).isEqualTo("OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForFluxPreparedStatementWithTypeShouldReturnRecord() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + when(columnDefinitions.size()).thenReturn(1); + when(row.getString(0)).thenReturn("OK", "NOT OK"); + + Flux flux = template.queryForFlux("SELECT * FROM user WHERE username = ?", String.class, "Walter"); + + assertThat(flux.collectList().block()).contains("OK", "NOT OK"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void queryForRowsPreparedStatementReturnRows() { + + when(session.prepare("SELECT * FROM user WHERE username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.rows()).thenReturn(Flux.just(row, row)); + + Flux flux = template.queryForRows("SELECT * FROM user WHERE username = ?", "Walter"); + + assertThat(flux.collectList().block()).hasSize(2).contains(row); + } + + /** + * @see DATACASS-335 + */ + @Test + public void updatePreparedStatementShouldReturnApplied() { + + when(session.prepare("UPDATE user SET username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Mono mono = template.execute("UPDATE user SET username = ?", "Walter"); + + assertThat(mono.block()).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void updatePreparedStatementArgsPublisherShouldReturnApplied() { + + when(session.prepare("UPDATE user SET username = ?")).thenReturn(Mono.just(preparedStatement)); + when(preparedStatement.bind("Walter")).thenReturn(boundStatement); + when(preparedStatement.bind("Hank")).thenReturn(boundStatement); + when(session.execute(boundStatement)).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Flux flux = template.execute("UPDATE user SET username = ?", + Flux.just(new Object[] { "Walter" }, new Object[] { "Hank" })); + + assertThat(flux.collectList().block()).hasSize(2).contains(true); + verify(session, atMost(1)).prepare("UPDATE user SET username = ?"); + verify(session, times(2)).execute(boundStatement); + } + + private void doTestStrings(Integer fetchSize, com.datastax.driver.core.ConsistencyLevel consistencyLevel, + com.datastax.driver.core.policies.RetryPolicy retryPolicy, Consumer cqlTemplateConsumer) { + + String[] results = { "Walter", "Hank", " Jesse" }; + + when(this.session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(this.reactiveResultSet.rows()).thenReturn(Flux.just(row, row, row)); + + when(this.row.getString(0)).thenReturn(results[0], results[1], results[2]); + when(this.session.prepare(anyString())).thenReturn(Mono.just(this.preparedStatement)); + + ReactiveCqlTemplate template = new ReactiveCqlTemplate(); + template.setSessionFactory(this.sessionFactory); + + if (fetchSize != null) { + template.setFetchSize(fetchSize); + } + if (retryPolicy != null) { + template.setRetryPolicy(retryPolicy); + } + if (consistencyLevel != null) { + template.setConsistencyLevel(consistencyLevel); + } + + cqlTemplateConsumer.accept(template); + + ArgumentCaptor statementArgumentCaptor = ArgumentCaptor.forClass(Statement.class); + verify(this.session).execute(statementArgumentCaptor.capture()); + + Statement statement = statementArgumentCaptor.getValue(); + + if (statement instanceof PreparedStatement || statement instanceof BoundStatement) { + + if (fetchSize != null) { + verify(statement).setFetchSize(fetchSize.intValue()); + } + + if (retryPolicy != null) { + verify(statement).setRetryPolicy(retryPolicy); + } + + if (consistencyLevel != null) { + verify(statement).setConsistencyLevel(consistencyLevel); + } + } else { + + if (fetchSize != null) { + assertThat(statement.getFetchSize()).isEqualTo(fetchSize.intValue()); + } + + if (retryPolicy != null) { + assertThat(statement.getRetryPolicy()).isEqualTo(retryPolicy); + } + + if (consistencyLevel != null) { + assertThat(statement.getConsistencyLevel()).isEqualTo(consistencyLevel); + } + } + } +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/core/SingleColumnRowMapperUnitTests.java b/spring-cql/src/test/java/org/springframework/cassandra/core/SingleColumnRowMapperUnitTests.java new file mode 100644 index 000000000..c22e56eee --- /dev/null +++ b/spring-cql/src/test/java/org/springframework/cassandra/core/SingleColumnRowMapperUnitTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2016 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.cassandra.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.cassandra.support.exception.IncorrectResultSetColumnCountException; +import org.springframework.dao.TypeMismatchDataAccessException; + +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.DataType; +import com.datastax.driver.core.Row; + +/** + * Unit tests for {@link SingleColumnRowMapper}. + * + * @author Mark Paluch + * @soundtrack Kos Vs Michael Buffer - Go For It All (Rubberboot Mix) + */ +@RunWith(MockitoJUnitRunner.class) +public class SingleColumnRowMapperUnitTests { + + @Mock private Row row; + @Mock private ColumnDefinitions columnDefinitions; + + private SingleColumnRowMapper rowMapper; + + @Before + public void before() throws Exception { + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + } + + /** + * @see DATACASS-335 + */ + @Test + public void getColumnValueWithType() { + + when(row.getDouble(2)).thenReturn(42d); + + rowMapper = new SingleColumnRowMapper(); + + assertThat(rowMapper.getColumnValue(row, 2, Number.class)).isEqualTo(42d); + } + + /** + * @see DATACASS-335 + */ + @Test + public void getColumnValue() { + + when(row.getObject(2)).thenReturn(42d); + + rowMapper = new SingleColumnRowMapper(); + + assertThat(rowMapper.getColumnValue(row, 2)).isEqualTo(42d); + } + + /** + * @see DATACASS-335 + */ + @Test + public void convertValueToRequiredTypeForNumber() { + + rowMapper = new SingleColumnRowMapper(); + + assertThat(rowMapper.convertValueToRequiredType(1234, Integer.class)).isEqualTo(1234); + assertThat(rowMapper.convertValueToRequiredType(1234.2, Integer.class)).isEqualTo(1234); + assertThat(rowMapper.convertValueToRequiredType(1234.2, Double.class)).isEqualTo(1234.2); + } + + /** + * @see DATACASS-335 + */ + @Test + public void convertValueToRequiredTypeForString() { + + rowMapper = new SingleColumnRowMapper(); + + assertThat(rowMapper.convertValueToRequiredType("1234", Integer.class)).isEqualTo(1234); + assertThat(rowMapper.convertValueToRequiredType("1234.2", Double.class)).isEqualTo(1234.2); + } + + /** + * @see DATACASS-335 + */ + @Test(expected = IllegalArgumentException.class) + public void convertValueToRequiredTypeShouldFail() { + + rowMapper = new SingleColumnRowMapper<>(); + + rowMapper.convertValueToRequiredType("1234", Object.class); + } + + /** + * @see DATACASS-335 + */ + @Test + public void mapRowSingleColumn() { + + when(columnDefinitions.size()).thenReturn(1); + when(row.getInt(0)).thenReturn(42); + + rowMapper = SingleColumnRowMapper.newInstance(Integer.class); + + assertThat(rowMapper.mapRow(row, 2)).isEqualTo(42); + } + + /** + * @see DATACASS-335 + */ + @Test + public void mapRowSingleColumnNullValue() { + + when(columnDefinitions.size()).thenReturn(1); + when(row.getObject(0)).thenReturn(null); + + rowMapper = SingleColumnRowMapper.newInstance(Object.class); + + assertThat(rowMapper.mapRow(row, 2)).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test(expected = TypeMismatchDataAccessException.class) + public void mapRowSingleColumnWrongType() { + + when(columnDefinitions.size()).thenReturn(1); + when(columnDefinitions.getType(0)).thenReturn(DataType.blob()); + when(row.getObject(0)).thenReturn("hello"); + + rowMapper = SingleColumnRowMapper.newInstance(ColumnDefinitions.class); + rowMapper.mapRow(row, 2); + } + + /** + * @see DATACASS-335 + */ + @Test(expected = IncorrectResultSetColumnCountException.class) + public void tooManyColumns() { + + when(columnDefinitions.size()).thenReturn(2); + + rowMapper = SingleColumnRowMapper.newInstance(ColumnDefinitions.class); + rowMapper.mapRow(row, 1); + } +} diff --git a/spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorTest.java b/spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorUnitTests.java similarity index 84% rename from spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorTest.java rename to spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorUnitTests.java index c2d216b80..2f5a5089c 100755 --- a/spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorTest.java +++ b/spring-cql/src/test/java/org/springframework/cassandra/support/CassandraExceptionTranslatorUnitTests.java @@ -15,7 +15,8 @@ */ package org.springframework.cassandra.support; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.*; import org.junit.Test; import org.springframework.cassandra.support.exception.CassandraInvalidConfigurationInQueryException; @@ -33,8 +34,9 @@ import com.datastax.driver.core.exceptions.InvalidQueryException; * Unit tests for {@link CassandraExceptionTranslator} * * @author Matthew T. Adams + * @author Mark Paluch */ -public class CassandraExceptionTranslatorTest { +public class CassandraExceptionTranslatorUnitTests { CassandraExceptionTranslator tx = new CassandraExceptionTranslator(); @@ -85,4 +87,17 @@ public class CassandraExceptionTranslatorTest { assertThat(dax instanceof CassandraInvalidQueryException).isTrue(); assertThat(dax.getCause()).isEqualTo(cx); } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldTranslateWithCqlMessage() { + + InvalidQueryException cx = new InvalidConfigurationInQueryException(null, "err"); + DataAccessException dax = tx.translate("Query", "SELECT * FROM person", cx); + + assertThat(dax).hasRootCauseInstanceOf(InvalidQueryException.class).hasMessage( + "Query; CQL [SELECT * FROM person]; err; nested exception is com.datastax.driver.core.exceptions.InvalidConfigurationInQueryException: err"); + } } diff --git a/spring-data-cassandra/pom.xml b/spring-data-cassandra/pom.xml index fe4138104..94a4f95fe 100644 --- a/spring-data-cassandra/pom.xml +++ b/spring-data-cassandra/pom.xml @@ -79,6 +79,13 @@ true + + io.reactivex + rxjava + ${rxjava} + true + + javax.enterprise diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/java/AbstractReactiveCassandraConfiguration.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/java/AbstractReactiveCassandraConfiguration.java new file mode 100644 index 000000000..e57aa7b22 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/config/java/AbstractReactiveCassandraConfiguration.java @@ -0,0 +1,86 @@ +/* + * Copyright 2016 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.config.java; + +import org.springframework.cassandra.core.DefaultBridgedReactiveSession; +import org.springframework.cassandra.core.DefaultReactiveSessionFactory; +import org.springframework.cassandra.core.ReactiveCqlTemplate; +import org.springframework.cassandra.core.ReactiveSession; +import org.springframework.cassandra.core.ReactiveSessionFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.data.cassandra.core.CassandraAdminTemplate; +import org.springframework.data.cassandra.core.ReactiveCassandraTemplate; + +import reactor.core.scheduler.Schedulers; + +/** + * Extension to {@link AbstractCassandraConfiguration} providing Spring Data Cassandra configuration for Spring Data's + * Reactive Cassandra support using JavaConfig. + * + * @author Mark Paluch + * @since 2.0 + */ +public abstract class AbstractReactiveCassandraConfiguration extends AbstractCassandraConfiguration { + + /** + * Creates a {@link ReactiveSession} object. This wraps a {@link com.datastax.driver.core.Session} to expose Cassandra + * access in a reactive style. + * + * @return + * @see #session() + * @see DefaultBridgedReactiveSession + */ + @Bean + public ReactiveSession reactiveSession() throws Exception { + return new DefaultBridgedReactiveSession(session().getObject(), Schedulers.elastic()); + } + + /** + * Creates a {@link ReactiveSessionFactory} to be used by the {@link ReactiveCassandraTemplate}. Will use the + * {@link ReactiveSession} instance configured in {@link #reactiveSession()}. + * + * @return + * @see #reactiveSession() + * @see #reactiveCassandraTemplate() + */ + @Bean + public ReactiveSessionFactory reactiveSessionFactory() throws Exception { + return new DefaultReactiveSessionFactory(reactiveSession()); + } + + /** + * Creates a {@link CassandraAdminTemplate}. + * + * @return + * @see #reactiveSessionFactory() + * @see #cassandraConverter() + */ + @Bean + public ReactiveCassandraTemplate reactiveCassandraTemplate() throws Exception { + return new ReactiveCassandraTemplate(reactiveSessionFactory(), cassandraConverter()); + } + + /** + * Creates a {@link ReactiveCqlTemplate} using the configured {@link ReactiveSessionFactory}. + * + * @return + * @see #reactiveSessionFactory() + */ + @Bean + public ReactiveCqlTemplate reactiveCqlTemplate() throws Exception { + return new ReactiveCqlTemplate(reactiveSessionFactory()); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraOperations.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraOperations.java new file mode 100644 index 000000000..8aa502b83 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraOperations.java @@ -0,0 +1,269 @@ +/* + * Copyright 2016 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; + +import org.reactivestreams.Publisher; +import org.springframework.cassandra.core.QueryOptions; +import org.springframework.cassandra.core.ReactiveCqlOperations; +import org.springframework.cassandra.core.WriteOptions; +import org.springframework.dao.DataAccessException; +import org.springframework.data.cassandra.convert.CassandraConverter; + +import com.datastax.driver.core.Statement; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Interface specifying a basic set of reactive Cassandra operations. Implemented by {@link ReactiveCassandraTemplate}. + * Not often used directly, but a useful option to enhance testability, as it can easily be mocked or stubbed. + * + * @author Mark Paluch + * @since 2.0 + * @see ReactiveCassandraTemplate + * @see Flux + * @see Mono + */ +public interface ReactiveCassandraOperations { + + // ------------------------------------------------------------------------- + // Methods dealing with static CQL + // ------------------------------------------------------------------------- + + /** + * Execute a {@code SELECT} query and convert the resulting items to a stream of entities. + * + * @param cql must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return the converted results + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux select(String cql, Class entityClass) throws DataAccessException; + + /** + * Execute a {@code SELECT} query and convert the resulting item to an entity. + * + * @param cql must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return the result object returned by the action or {@link Mono#empty()} + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono selectOne(String cql, Class entityClass) throws DataAccessException; + + // ------------------------------------------------------------------------- + // Methods dealing with com.datastax.driver.core.Statement + // ------------------------------------------------------------------------- + + /** + * Execute a {@code SELECT} query and convert the resulting items to a stream of entities. + * + * @param statement must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return the result objects returned by the action. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux select(Statement statement, Class entityClass) throws DataAccessException; + + /** + * Execute a {@code SELECT} query and convert the resulting item to an entity. + * + * @param statement must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return the result object returned by the action or {@link Mono#empty()} + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono selectOne(Statement statement, Class entityClass) throws DataAccessException; + + // ------------------------------------------------------------------------- + // Methods dealing with entities + // ------------------------------------------------------------------------- + + /** + * Execute the Select by {@code id} for the given {@code entityClass}. + * + * @param id must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return the result object returned by the action or {@link Mono#empty()} + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono selectOneById(Object id, Class entityClass) throws DataAccessException; + + /** + * Determine whether the row {@code entityClass} with the given {@code id} exists. + * + * @param id must not be {@literal null}. + * @param entityClass must not be {@literal null}. + * @return {@literal true} if the object exists. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono exists(Object id, Class entityClass) throws DataAccessException; + + /** + * Returns the number of rows for the given entity class. + * + * @param entityClass must not be {@literal null}. + * @return the number of existing entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono count(Class entityClass) throws DataAccessException; + + /** + * Insert the given entity and emit the entity if the insert was applied. + * + * @param entity The entity to insert, must not be {@literal null}. + * @return the inserted entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono insert(T entity) throws DataAccessException; + + /** + * Insert the given entity applying {@link WriteOptions} and emit the entity if the insert was applied. + * + * @param entity The entity to insert, must not be {@literal null}. + * @param options may be {@literal null}. + * @@return the inserted entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono insert(T entity, WriteOptions options) throws DataAccessException; + + /** + * Insert the given entities and emit the entity if the insert was applied. + * + * @param entities The entities to insert, must not be {@literal null}. + * @return the inserted entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux insert(Publisher entities) throws DataAccessException; + + /** + * Insert the given entities applying {@link WriteOptions} and emit the entity if the insert was applied. + * + * @param entities The entities to insert, must not be {@literal null}. + * @param options may be {@literal null}. + * @return the inserted entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux insert(Publisher entities, WriteOptions options) throws DataAccessException; + + /** + * Update the given entity and emit the entity if the update was applied. + * + * @param entity The entity to update, must not be {@literal null}. + * @return the updated entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono update(T entity) throws DataAccessException; + + /** + * Update the given entity applying {@link WriteOptions} and emit the entity if the update was applied. + * + * @param entity The entity to update, must not be {@literal null}. + * @param options may be {@literal null}. + * @return the updated entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono update(T entity, WriteOptions options) throws DataAccessException; + + /** + * Update the given entities and emit the entity if the update was applied. + * + * @param entities The entities to update, must not be {@literal null}. + * @return the updated entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux update(Publisher entities) throws DataAccessException; + + /** + * Update the given entities applying {@link WriteOptions} and emit the entity if the update was applied. + * + * @param entities The entities to update. + * @param options may be {@literal null}. + * @return the updated entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux update(Publisher entities, WriteOptions options) throws DataAccessException; + + /** + * Remove the given object from the table by id. + * + * @param id must not be {@literal null}. + * @param entityClass The entity type must not be {@literal null}. + * @return {@literal true} if the deletion was applied. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono deleteById(Object id, Class entityClass) throws DataAccessException; + + /** + * Delete the given entity and emit the entity if the delete was applied. + * + * @param entity must not be {@literal null}. + * @return the deleted entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono delete(T entity) throws DataAccessException; + + /** + * Delete the given entity applying {@link QueryOptions} and emit the entity if the delete was applied. + * + * @param entity must not be {@literal null}. + * @param options may be {@literal null}. + * @return the deleted entity. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono delete(T entity, QueryOptions options) throws DataAccessException; + + /** + * Delete the given entities and emit the entity if the delete was applied. + * + * @param entities must not be {@literal null}. + * @return the deleted entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux delete(Publisher entities) throws DataAccessException; + + /** + * Delete the given entities applying {@link QueryOptions} and emit the entity if the delete was applied. + * + * @param entities must not be {@literal null}. + * @param options may be {@literal null}. + * @return the deleted entities. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Flux delete(Publisher entities, QueryOptions options) throws DataAccessException; + + /** + * Execute a {@code TRUNCATE} query to remove all entities of a given class. + * + * @param entityClass The entity type must not be {@literal null}. + * @throws DataAccessException if there is any problem issuing the execution. + */ + Mono truncate(Class entityClass) throws DataAccessException; + + /** + * Returns the underlying {@link CassandraConverter}. + * + * @return the underlying {@link CassandraConverter}. + */ + CassandraConverter getConverter(); + + /** + * Expose the underlying {@link ReactiveCqlOperations} to allow CQL operations. + * + * @return the underlying {@link ReactiveCqlOperations}. + * @see ReactiveCqlOperations + */ + ReactiveCqlOperations getReactiveCqlOperations(); +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java new file mode 100644 index 000000000..0d55d3f1c --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplate.java @@ -0,0 +1,456 @@ +/* + * Copyright 2016 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; + +import static org.springframework.data.cassandra.core.CassandraTemplate.*; + +import org.reactivestreams.Publisher; +import org.springframework.cassandra.core.DefaultReactiveSessionFactory; +import org.springframework.cassandra.core.QueryOptions; +import org.springframework.cassandra.core.ReactiveCqlOperations; +import org.springframework.cassandra.core.ReactiveCqlTemplate; +import org.springframework.cassandra.core.ReactiveResultSet; +import org.springframework.cassandra.core.ReactiveSession; +import org.springframework.cassandra.core.ReactiveSessionCallback; +import org.springframework.cassandra.core.ReactiveSessionFactory; +import org.springframework.cassandra.core.WriteOptions; +import org.springframework.cassandra.core.cql.CqlIdentifier; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.cassandra.convert.CassandraConverter; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +import com.datastax.driver.core.Session; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.querybuilder.Delete; +import com.datastax.driver.core.querybuilder.Insert; +import com.datastax.driver.core.querybuilder.QueryBuilder; +import com.datastax.driver.core.querybuilder.Select; +import com.datastax.driver.core.querybuilder.Truncate; +import com.datastax.driver.core.querybuilder.Update; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Primary implementation of {@link ReactiveCassandraOperations}. It simplifies the use of Reactive Cassandra usage and + * helps to avoid common errors. It executes core Cassandra workflow. This class executes CQL queries or updates, + * initiating iteration over {@link ReactiveResultSet} and catching Cassandra exceptions and translating them to the + * generic, more informative exception hierarchy defined in the {@code org.springframework.dao} package. + *

+ * Can be used within a service implementation via direct instantiation with a {@link ReactiveSessionFactory} reference, + * or get prepared in an application context and given to services as bean reference. + *

+ * Note: The {@link ReactiveSessionFactory} should always be configured as a bean in the application context, in the + * first case given to the service directly, in the second case to the prepared template. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ReactiveCassandraTemplate implements ReactiveCassandraOperations { + + private final CassandraConverter converter; + private final CassandraMappingContext mappingContext; + private final ReactiveCqlOperations cqlOperations; + + /** + * Creates an instance of {@link ReactiveCassandraTemplate} initialized with the given {@link ReactiveSession} and a + * default {@link MappingCassandraConverter}. + * + * @param session {@link ReactiveSession} used to interact with Cassandra; must not be {@literal null}. + * @see CassandraConverter + * @see Session + */ + public ReactiveCassandraTemplate(ReactiveSession session) { + this(session, newConverter()); + } + + /** + * Create an instance of {@link CassandraTemplate} initialized with the given {@link ReactiveSession} and + * {@link CassandraConverter}. + * + * @param session {@link ReactiveSession} used to interact with Cassandra; must not be {@literal null}. + * @param converter {@link CassandraConverter} used to convert between Java and Cassandra types; must not be + * {@literal null}. + * @see org.springframework.data.cassandra.convert.CassandraConverter + * @see com.datastax.driver.core.Session + */ + public ReactiveCassandraTemplate(ReactiveSession session, CassandraConverter converter) { + this(new DefaultReactiveSessionFactory(session), converter); + } + + /** + * Create an instance of {@link ReactiveCassandraTemplate} initialized with the given {@link ReactiveSessionFactory} + * and {@link CassandraConverter}. + * + * @param sessionFactory {@link ReactiveSessionFactory} used to interact with Cassandra; must not be {@literal null}. + * @param converter {@link CassandraConverter} used to convert between Java and Cassandra types; must not be + * {@literal null}. + * @see org.springframework.data.cassandra.convert.CassandraConverter + * @see com.datastax.driver.core.Session + */ + public ReactiveCassandraTemplate(ReactiveSessionFactory sessionFactory, CassandraConverter converter) { + + Assert.notNull(sessionFactory, "ReactiveSessionFactory must not be null"); + Assert.notNull(converter, "CassandraConverter must not be null"); + + this.converter = converter; + this.mappingContext = this.converter.getMappingContext(); + this.cqlOperations = new ReactiveCqlTemplate(sessionFactory); + } + + /** + * Create an instance of {@link ReactiveCassandraTemplate} initialized with the given {@link ReactiveCqlOperations} + * and {@link CassandraConverter}. + * + * @param reactiveCqlOperations {@link ReactiveCqlOperations} used to interact with Cassandra; must not be + * {@literal null}. + * @param converter {@link CassandraConverter} used to convert between Java and Cassandra types; must not be + * {@literal null}. + * @see org.springframework.data.cassandra.convert.CassandraConverter + * @see com.datastax.driver.core.Session + */ + public ReactiveCassandraTemplate(ReactiveCqlOperations reactiveCqlOperations, CassandraConverter converter) { + + Assert.notNull(reactiveCqlOperations, "ReactiveCqlOperations must not be null"); + Assert.notNull(converter, "CassandraConverter must not be null"); + + this.converter = converter; + this.mappingContext = this.converter.getMappingContext(); + this.cqlOperations = reactiveCqlOperations; + } + + private static MappingCassandraConverter newConverter() { + + MappingCassandraConverter converter = new MappingCassandraConverter(); + converter.afterPropertiesSet(); + + return converter; + } + + // ------------------------------------------------------------------------- + // Methods dealing with static CQL + // ------------------------------------------------------------------------- + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#select(java.lang.String, java.lang.Class) + */ + @Override + public Flux select(String cql, Class entityClass) { + + Assert.hasText(cql, "Statement must not be empty"); + + return select(new SimpleStatement(cql), entityClass); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#selectOne(java.lang.String, java.lang.Class) + */ + @Override + public Mono selectOne(String cql, Class entityClass) { + return select(cql, entityClass).next(); + } + + // ------------------------------------------------------------------------- + // Methods dealing with com.datastax.driver.core.Statement + // ------------------------------------------------------------------------- + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#select(com.datastax.driver.core.Statement, java.lang.Class) + */ + @Override + public Flux select(Statement cql, Class entityClass) { + + Assert.notNull(cql, "Statement must not be null"); + Assert.notNull(entityClass, "Entity type must not be null"); + + return cqlOperations.query(cql, (row, rowNum) -> converter.read(entityClass, row)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#selectOne(com.datastax.driver.core.Statement, java.lang.Class) + */ + @Override + public Mono selectOne(Statement statement, Class entityClass) { + return select(statement, entityClass).next(); + } + + // ------------------------------------------------------------------------- + // Methods dealing with entities + // ------------------------------------------------------------------------- + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#selectOneById(java.lang.Object, java.lang.Class) + */ + @Override + public Mono selectOneById(Object id, Class entityClass) { + + Assert.notNull(id, "Id must not be null"); + Assert.notNull(entityClass, "Entity type must not be null"); + + CassandraPersistentEntity entity = getPersistentEntity(entityClass); + Select select = QueryBuilder.select().all().from(entity.getTableName().toCql()); + + converter.write(id, select.where(), entity); + + return selectOne(select, entityClass); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#exists(java.lang.Object, java.lang.Class) + */ + @Override + public Mono exists(Object id, Class entityClass) { + + Assert.notNull(id, "Id must not be null"); + Assert.notNull(entityClass, "Entity type must not be null"); + + CassandraPersistentEntity entity = getPersistentEntity(entityClass); + Select select = QueryBuilder.select().from(entity.getTableName().toCql()); + converter.write(id, select.where(), entity); + + return cqlOperations.queryForRows(select).hasElements(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#count(java.lang.Class) + */ + @Override + public Mono count(Class entityClass) { + + Assert.notNull(entityClass, "Entity type must not be null"); + + Select select = QueryBuilder.select().countAll().from(getPersistentEntity(entityClass).getTableName().toCql()); + + return cqlOperations.queryForObject(select, Long.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#insert(java.lang.Object) + */ + @Override + public Mono insert(T entity) { + return insert(entity, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#insert(java.lang.Object, org.springframework.cassandra.core.WriteOptions) + */ + @Override + public Mono insert(T entity, WriteOptions options) { + + Assert.notNull(entity, "Entity must not be null"); + + CqlIdentifier tableName = getTableName(entity); + + Insert insertQuery = createInsertQuery(tableName.toCql(), entity, options, converter); + + return cqlOperations.execute((ReactiveSessionCallback) session -> (Publisher) session.execute(insertQuery) + .flatMap(reactiveResultSet -> reactiveResultSet.wasApplied() ? Mono.just(entity) : Mono.empty())).next(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#insert(org.reactivestreams.Publisher) + */ + @Override + public Flux insert(Publisher entities) { + return insert(entities, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#insert(org.reactivestreams.Publisher, org.springframework.cassandra.core.WriteOptions) + */ + @Override + public Flux insert(Publisher entities, WriteOptions options) { + + Assert.notNull(entities, "Entity publisher must not be null"); + return Flux.from(entities).flatMap(entity -> insert(entity, options)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#update(java.lang.Object) + */ + @Override + public Mono update(T entity) { + return update(entity, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#update(java.lang.Object, org.springframework.cassandra.core.WriteOptions) + */ + @Override + public Mono update(T entity, WriteOptions options) { + + Assert.notNull(entity, "Entity must not be null"); + + CqlIdentifier tableName = getTableName(entity); + + Update update = createUpdateQuery(tableName.toCql(), entity, options, converter); + + return cqlOperations.execute((ReactiveSessionCallback) session -> (Publisher) session.execute(update) + .flatMap(reactiveResultSet -> reactiveResultSet.wasApplied() ? Mono.just(entity) : Mono.empty())).next(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#update(org.reactivestreams.Publisher) + */ + @Override + public Flux update(Publisher entities) { + return update(entities, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#update(org.reactivestreams.Publisher, org.springframework.cassandra.core.WriteOptions) + */ + @Override + public Flux update(Publisher entities, WriteOptions options) { + + Assert.notNull(entities, "Entity publisher must not be null"); + return Flux.from(entities).flatMap(entity -> update(entity, options)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#deleteById(java.lang.Object, java.lang.Class) + */ + @Override + public Mono deleteById(Object id, Class entityClass) { + + Assert.notNull(id, "Id must not be null"); + Assert.notNull(entityClass, "Entity type must not be null"); + + CassandraPersistentEntity entity = getPersistentEntity(entityClass); + Delete delete = QueryBuilder.delete().from(entity.getTableName().toCql()); + + converter.write(id, delete.where(), entity); + + return cqlOperations.execute(delete); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#delete(java.lang.Object) + */ + @Override + public Mono delete(T entity) { + return delete(entity, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#delete(java.lang.Object, org.springframework.cassandra.core.QueryOptions) + */ + @Override + public Mono delete(T entity, QueryOptions options) { + + Assert.notNull(entity, "Entity must not be null"); + + CqlIdentifier tableName = getTableName(entity); + + Delete delete = createDeleteQuery(tableName.toCql(), entity, options, converter); + + return cqlOperations.execute((ReactiveSessionCallback) session -> (Publisher) session.execute(delete) + .flatMap(reactiveResultSet -> reactiveResultSet.wasApplied() ? Mono.just(entity) : Mono.empty())).next(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#delete(org.reactivestreams.Publisher) + */ + @Override + public Flux delete(Publisher entities) { + return delete(entities, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#delete(org.reactivestreams.Publisher, org.springframework.cassandra.core.QueryOptions) + */ + @Override + public Flux delete(Publisher entities, QueryOptions options) { + + Assert.notNull(entities, "Entity publisher must not be null"); + return Flux.from(entities).flatMap(entity -> delete(entity, options)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#truncate(java.lang.Class) + */ + @Override + public Mono truncate(Class entityClass) { + + Assert.notNull(entityClass, "Entity type must not be null"); + Truncate truncate = QueryBuilder.truncate(getPersistentEntity(entityClass).getTableName().toCql()); + + return cqlOperations.execute(truncate).then(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#getConverter() + */ + @Override + public CassandraConverter getConverter() { + return converter; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.core.ReactiveCassandraOperations#getReactiveCqlOperations() + */ + @Override + public ReactiveCqlOperations getReactiveCqlOperations() { + return cqlOperations; + } + + private CassandraPersistentEntity getPersistentEntity(Class entityClass) { + + Assert.notNull(entityClass, "Entity type must not be null"); + + CassandraPersistentEntity entity = mappingContext.getPersistentEntity(entityClass); + + if (entity == null) { + throw new InvalidDataAccessApiUsageException( + String.format("No Persistent Entity information found for the class [%s]", entityClass.getName())); + } + + return entity; + } + + private CqlIdentifier getTableName(Object entity) { + return getPersistentEntity(ClassUtils.getUserClass(entity)).getTableName(); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepository.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepository.java new file mode 100644 index 000000000..91b1e692b --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepository.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016 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.repository; + +import java.io.Serializable; + +import org.reactivestreams.Publisher; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Cassandra specific {@link org.springframework.data.repository.Repository} interface with reactive support. + * + * @author Mark Paluch + * @since 2.0 + */ +@NoRepositoryBean +public interface ReactiveCassandraRepository extends ReactiveCrudRepository { + + /** + * Inserts the given entity. Assumes the instance to be new to be able to apply insertion optimizations. Use the + * returned instance for further operations as the save operation might have changed the entity instance completely. + * Prefer using {@link #save(Object)} instead to avoid the usage of store-specific API. + * + * @param entity must not be {@literal null}. + * @return the saved entity + */ + Mono insert(S entity); + + /** + * Inserts the given entities. Assumes the instance to be new to be able to apply insertion optimizations. Use the + * returned instance for further operations as the save operation might have changed the entity instance completely. + * Prefer using {@link #save(Object)} instead to avoid the usage of store-specific API. + * + * @param entities must not be {@literal null}. + * @return the saved entity + */ + Flux insert(Iterable entities); + + /** + * Inserts the given a given entities. Assumes the instance to be new to be able to apply insertion optimizations. Use + * the returned instance for further operations as the save operation might have changed the entity instance + * completely. Prefer using {@link #save(Object)} instead to avoid the usage of store-specific API. + * + * @param entities must not be {@literal null}. + * @return the saved entity + */ + Flux insert(Publisher entities); +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java index e1e6c6998..813154878 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtension.java @@ -18,18 +18,23 @@ package org.springframework.data.cassandra.repository.config; import java.lang.annotation.Annotation; import java.util.Collection; import java.util.Collections; +import java.util.stream.Collectors; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.cassandra.config.xml.ParsingUtils; import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.io.ResourceLoader; import org.springframework.data.cassandra.config.DefaultBeanNames; import org.springframework.data.cassandra.mapping.Table; import org.springframework.data.cassandra.repository.CassandraRepository; import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryConfiguration; import org.springframework.data.repository.config.RepositoryConfigurationExtension; import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; +import org.springframework.data.repository.config.RepositoryConfigurationSource; import org.springframework.data.repository.config.XmlRepositoryConfigurationSource; +import org.springframework.data.repository.query.ReactiveWrappers; import org.springframework.util.StringUtils; import org.w3c.dom.Element; @@ -44,6 +49,15 @@ public class CassandraRepositoryConfigurationExtension extends RepositoryConfigu private static final String CASSANDRA_TEMPLATE_REF = "cassandra-template-ref"; + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getModuleName() + */ + @Override + public String getModuleName() { + return "Reactive Cassandra"; + } + @Override protected String getModulePrefix() { return "cassandra"; @@ -92,4 +106,24 @@ public class CassandraRepositoryConfigurationExtension extends RepositoryConfigu return Collections.> singleton(CassandraRepository.class); } + @Override + public Collection> getRepositoryConfigurations( + T configSource, ResourceLoader loader, boolean strictMatchesOnly) { + + Collection> repositoryConfigurations = super.getRepositoryConfigurations(configSource, + loader, strictMatchesOnly); + + if (ReactiveWrappers.isAvailable()) { + + return repositoryConfigurations.stream().filter(configuration -> { + + Class repositoryInterface = super.loadRepositoryInterface(configuration, loader); + return !RepositoryType.isReactiveRepository(repositoryInterface); + + }).collect(Collectors.toList()); + } + + return repositoryConfigurations; + } + } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/EnableReactiveCassandraRepositories.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/EnableReactiveCassandraRepositories.java new file mode 100644 index 000000000..3568f67fd --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/EnableReactiveCassandraRepositories.java @@ -0,0 +1,133 @@ +/* + * Copyright 2016 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.repository.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Import; +import org.springframework.data.cassandra.repository.support.CassandraRepositoryFactoryBean; +import org.springframework.data.cassandra.repository.support.ReactiveCassandraRepositoryFactoryBean; +import org.springframework.data.repository.config.DefaultRepositoryBaseClass; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; + +/** + * Annotation to activate reactive Cassandra repositories. If no base package is configured through either + * {@link #value()}, {@link #basePackages()} or {@link #basePackageClasses()} it will trigger scanning of the package of + * annotated class. + * + * @author Mark Paluch + * @since 2.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(ReactiveCassandraRepositoriesRegistrar.class) +public @interface EnableReactiveCassandraRepositories { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation declarations e.g.: + * {@code @EnableCassandraRepositories("org.my.pkg")} instead of + * {@code @EnableCassandraRepositories(basePackages="org.my.pkg")}. + */ + String[] value() default {}; + + /** + * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually exclusive with) this + * attribute. Use {@link #basePackageClasses()} for a type-safe alternative to String-based package names. + */ + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to scan for annotated components. The + * package of each class specified will be scanned. Consider creating a special no-op marker class or interface in + * each package that serves no purpose other than being referenced by this attribute. + */ + Class[] basePackageClasses() default {}; + + /** + * Specifies which types are eligible for component scanning. Further narrows the set of candidate components from + * everything in {@link #basePackages()} to everything in the base packages that matches the given filter or filters. + */ + Filter[] includeFilters() default {}; + + /** + * Specifies which types are not eligible for component scanning. + */ + Filter[] excludeFilters() default {}; + + /** + * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So + * for a repository named {@code UserRepository} the corresponding implementation class will be looked up scanning for + * {@code UserRepositoryImpl}. + * + * @return + */ + String repositoryImplementationPostfix() default "Impl"; + + /** + * Configures the location of where to find the Spring Data named queries properties file. Will default to + * {@code META-INF/cassandra-named-queries.properties}. + * + * @return + */ + String namedQueriesLocation() default ""; + + /** + * Returns the key of the {@link QueryLookupStrategy} to be used for lookup queries for query methods. Defaults to + * {@link Key#CREATE_IF_NOT_FOUND}. + * + * @return + */ + Key queryLookupStrategy() default Key.CREATE_IF_NOT_FOUND; + + /** + * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to + * {@link ReactiveCassandraRepositoryFactoryBean}. + * + * @return + */ + Class repositoryFactoryBeanClass() default ReactiveCassandraRepositoryFactoryBean.class; + + /** + * Configure the repository base class to be used to create repository proxies for this particular configuration. + * + * @return + */ + Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + + /** + * Configures the name of the {@link org.springframework.data.cassandra.core.ReactiveCassandraTemplate} bean to be + * used with the repositories detected. + * + * @return + */ + String reactiveCassandraTemplateRef() default "reactiveCassandraTemplate"; + + /** + * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the + * repositories infrastructure. + */ + boolean considerNestedRepositories() default false; +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrar.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrar.java new file mode 100644 index 000000000..dda1195db --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrar.java @@ -0,0 +1,50 @@ +/* + * Copyright 2016 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.repository.config; + +import java.lang.annotation.Annotation; + +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; + +/** + * {@link ImportBeanDefinitionRegistrar} to setup Cassandra repositories via + * {@link EnableReactiveCassandraRepositories}. + * + * @author Mark Paluch + * @since 2.0 + */ +class ReactiveCassandraRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableReactiveCassandraRepositories.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getExtension() + */ + @Override + protected RepositoryConfigurationExtension getExtension() { + return new ReactiveCassandraRepositoryConfigurationExtension(); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtension.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtension.java new file mode 100644 index 000000000..fb192beb3 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtension.java @@ -0,0 +1,130 @@ +/* + * Copyright 2016 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.repository.config; + +import java.lang.annotation.Annotation; +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.io.ResourceLoader; +import org.springframework.data.cassandra.mapping.Table; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.data.cassandra.repository.support.ReactiveCassandraRepositoryFactoryBean; +import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryConfiguration; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; +import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.config.XmlRepositoryConfigurationSource; +import org.springframework.util.StringUtils; + +/** + * {@link RepositoryConfigurationExtension} for Cassandra. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ReactiveCassandraRepositoryConfigurationExtension extends RepositoryConfigurationExtensionSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getModuleName() + */ + @Override + public String getModuleName() { + return "Reactive Cassandra"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getModulePrefix() + */ + @Override + protected String getModulePrefix() { + return "cassandra"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getRepositoryFactoryClassName() + */ + @Override + public String getRepositoryFactoryClassName() { + return ReactiveCassandraRepositoryFactoryBean.class.getName(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#postProcess(org.springframework.beans.factory.support.BeanDefinitionBuilder, org.springframework.data.repository.config.XmlRepositoryConfigurationSource) + */ + @Override + public void postProcess(BeanDefinitionBuilder builder, XmlRepositoryConfigurationSource config) {} + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#postProcess(org.springframework.beans.factory.support.BeanDefinitionBuilder, org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource) + */ + @Override + public void postProcess(BeanDefinitionBuilder builder, AnnotationRepositoryConfigurationSource config) { + + AnnotationAttributes attributes = config.getAttributes(); + + String reactiveCassandraTemplateRef = attributes.getString("reactiveCassandraTemplateRef"); + if (StringUtils.hasText(reactiveCassandraTemplateRef)) { + builder.addPropertyReference("reactiveCassandraOperations", reactiveCassandraTemplateRef); + } + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getIdentifyingAnnotations() + */ + @Override + protected Collection> getIdentifyingAnnotations() { + return Collections.>singleton(Table.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getIdentifyingTypes() + */ + @Override + protected Collection> getIdentifyingTypes() { + return Collections.>singleton(ReactiveCassandraRepository.class); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport#getRepositoryConfigurations(T, org.springframework.core.io.ResourceLoader, boolean) + */ + @Override + public Collection> getRepositoryConfigurations( + T configSource, ResourceLoader loader, boolean strictMatchesOnly) { + + Collection> repositoryConfigurations = super.getRepositoryConfigurations(configSource, + loader, strictMatchesOnly); + + return repositoryConfigurations.stream().filter(configuration -> { + + Class repositoryInterface = super.loadRepositoryInterface(configuration, loader); + return RepositoryType.isReactiveRepository(repositoryInterface); + + }).collect(Collectors.toList()); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/RepositoryType.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/RepositoryType.java new file mode 100644 index 000000000..3a23e2d0b --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/config/RepositoryType.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016 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.repository.config; + +import java.lang.reflect.Method; + +import org.springframework.data.repository.query.ReactiveWrappers; + +import lombok.experimental.UtilityClass; + +/** + * Utility class to discover whether a repository interface uses reactive wrapper types. + * + * @author Mark Paluch + * @since 2.0 + */ +@UtilityClass +class RepositoryType { + + /** + * Check whether {@code repositoryInterface} uses reactive wrapper types as return type or parameter types in its + * methods. + * + * @param repositoryInterface must not be {@literal null}. + * @return {@literal true} if the {@code repositoryInterface} uses reactive wrapper types. + * @see ReactiveWrappers + * @see ReactiveWrappers#isAvailable() + */ + public static boolean isReactiveRepository(Class repositoryInterface) { + + if (!ReactiveWrappers.isAvailable()) { + return false; + } + + Method[] methods = repositoryInterface.getMethods(); + + for (Method method : methods) { + + if (usesReactiveWrappers(method)) { + return true; + } + } + + return false; + } + + private static boolean usesReactiveWrappers(Method method) { + + if (ReactiveWrappers.supports(method.getReturnType())) { + return true; + } + + for (Class parameterType : method.getParameterTypes()) { + if (ReactiveWrappers.supports(parameterType)) { + return true; + } + } + + return false; + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractReactiveCassandraQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractReactiveCassandraQuery.java new file mode 100644 index 000000000..7465cb200 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/AbstractReactiveCassandraQuery.java @@ -0,0 +1,158 @@ +/* + * Copyright 2016 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.repository.query; + +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.CollectionExecution; +import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.ResultProcessingConverter; +import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.ResultProcessingExecution; +import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.SingleEntityExecution; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.ReactiveWrapperConverters; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.util.Assert; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Base class for reactive {@link RepositoryQuery} implementations for Cassandra. + * + * @author Mark Paluch + * @since 2.0 + */ +public abstract class AbstractReactiveCassandraQuery implements RepositoryQuery { + + protected static Logger log = LoggerFactory.getLogger(AbstractReactiveCassandraQuery.class); + + private final CassandraQueryMethod method; + private final ReactiveCassandraOperations operations; + + /** + * Creates a new {@link AbstractReactiveCassandraQuery} from the given {@link CassandraQueryMethod} and + * {@link CassandraOperations}. + * + * @param method must not be {@literal null}. + * @param operations must not be {@literal null}. + */ + public AbstractReactiveCassandraQuery(CassandraQueryMethod method, ReactiveCassandraOperations operations) { + + Assert.notNull(method, "CassandraQueryMethod must not be null"); + Assert.notNull(operations, "ReactiveCassandraOperations must not be null"); + + this.method = method; + this.operations = operations; + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.RepositoryQuery#getQueryMethod() + */ + @Override + public CassandraQueryMethod getQueryMethod() { + return method; + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.RepositoryQuery#execute(java.lang.Object[]) + */ + @Override + public Object execute(Object[] parameters) { + + if (hasReactiveWrapperParameter()) { + return executeDeferred(parameters); + } + + return execute(new ReactiveCassandraParameterAccessor(method, parameters)); + } + + @SuppressWarnings("unchecked") + private Object executeDeferred(Object[] parameters) { + + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor(method, parameters); + + if (getQueryMethod().isCollectionQuery()) { + return Flux.defer(() -> (Publisher) execute(accessor)); + } + + return Mono.defer(() -> (Mono) execute(accessor)); + } + + private Object execute(CassandraParameterAccessor parameterAccessor) { + + CassandraParameterAccessor convertingParameterAccessor = new ConvertingParameterAccessor(operations.getConverter(), + parameterAccessor); + + String query = createQuery(convertingParameterAccessor); + + ResultProcessor resultProcessor = method.getResultProcessor().withDynamicProjection(convertingParameterAccessor); + + ReactiveCassandraQueryExecution queryExecution = getExecution(query, convertingParameterAccessor, + new ResultProcessingConverter(resultProcessor)); + + CassandraReturnedType returnedType = new CassandraReturnedType(resultProcessor.getReturnedType(), + operations.getConverter().getCustomConversions()); + + Class resultType = (returnedType.isProjecting() ? returnedType.getDomainType() : returnedType.getReturnedType()); + + return queryExecution.execute(query, resultType); + } + + /** + * Returns the execution instance to use. + * + * @param query must not be {@literal null}. + * @param accessor must not be {@literal null}. + * @param resultProcessing must not be {@literal null}. @return + */ + private ReactiveCassandraQueryExecution getExecution(String query, CassandraParameterAccessor accessor, + Converter resultProcessing) { + + return new ResultProcessingExecution(getExecutionToWrap(accessor, resultProcessing), resultProcessing); + } + + private ReactiveCassandraQueryExecution getExecutionToWrap(CassandraParameterAccessor accessor, + Converter resultProcessing) { + + if (method.isCollectionQuery()) { + return new CollectionExecution(operations); + } else { + return new SingleEntityExecution(operations); + } + } + + private boolean hasReactiveWrapperParameter() { + + for (CassandraParameters.CassandraParameter cassandraParameter : method.getParameters()) { + if (ReactiveWrapperConverters.supports(cassandraParameter.getType())) { + return true; + } + } + return false; + } + + /** + * Creates a string query using the given {@link ParameterAccessor} + * + * @param accessor must not be {@literal null}. + */ + protected abstract String createQuery(CassandraParameterAccessor accessor); +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java index 7c780261e..e6f6b2017 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParameters.java @@ -19,10 +19,13 @@ import java.lang.reflect.Method; import java.util.List; import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.data.cassandra.mapping.CassandraType; import org.springframework.data.cassandra.repository.query.CassandraParameters.CassandraParameter; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.ReactiveWrappers; +import org.springframework.data.repository.util.QueryExecutionConverters; import org.springframework.util.Assert; /** @@ -67,9 +70,10 @@ public class CassandraParameters extends Parameters parameterType; protected CassandraParameter(MethodParameter parameter) { @@ -78,23 +82,75 @@ public class CassandraParameters extends Parameters getType() { + return parameterType; + } + + /** + * Returns the component type if the given {@link MethodParameter} is a wrapper type and the wrapper should be + * unwrapped. + * + * @param parameter must not be {@literal null}. + * @return + */ + private static Class potentiallyUnwrapParameterType(MethodParameter parameter) { + + Class originalType = parameter.getParameterType(); + + if (isWrapped(parameter) && shouldUnwrap(parameter)) { + return ResolvableType.forMethodParameter(parameter).getGeneric(0).getRawClass(); + } + + return originalType; + } + + /** + * Returns whether the {@link MethodParameter} is wrapped in a wrapper type. + * + * @param parameter must not be {@literal null}. + * @return + * @see QueryExecutionConverters + */ + private static boolean isWrapped(MethodParameter parameter) { + return QueryExecutionConverters.supports(parameter.getParameterType()); + } + + /** + * Returns whether the {@link MethodParameter} should be unwrapped. + * + * @param parameter must not be {@literal null}. + * @return + * @see QueryExecutionConverters + */ + private static boolean shouldUnwrap(MethodParameter parameter) { + return QueryExecutionConverters.supportsUnwrapping(parameter.getParameterType()) + || ReactiveWrappers.supports(parameter.getParameterType()); + } } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java index 4d8da9344..650d724b5 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraParametersParameterAccessor.java @@ -67,8 +67,8 @@ public class CassandraParametersParameterAccessor extends ParametersParameterAcc CassandraType cassandraType = findCassandraType(index); - return (cassandraType != null ? CassandraSimpleTypeHolder.getDataTypeFor(cassandraType.type()) - : CassandraSimpleTypeHolder.getDataTypeFor(getParameterType(index))); + return (cassandraType != null ? CassandraSimpleTypeHolder.getDataTypeFor(cassandraType.type()) + : CassandraSimpleTypeHolder.getDataTypeFor(getParameterType(index))); } /* diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java index 36dc4f4a5..a134e0457 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraQueryMethod.java @@ -58,8 +58,8 @@ public class CassandraQueryMethod extends QueryMethod { * @param projectionFactory must not be {@literal null}. * @param mappingContext must not be {@literal null}. */ - public CassandraQueryMethod(Method method, RepositoryMetadata repositoryMetadata, - ProjectionFactory projectionFactory, CassandraMappingContext mappingContext) { + public CassandraQueryMethod(Method method, RepositoryMetadata repositoryMetadata, ProjectionFactory projectionFactory, + CassandraMappingContext mappingContext) { super(method, repositoryMetadata, projectionFactory); @@ -83,6 +83,10 @@ public class CassandraQueryMethod extends QueryMethod { } } + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryMethod#getEntityInformation() + */ @Override @SuppressWarnings("unchecked") public CassandraEntityMetadata getEntityInformation() { @@ -93,21 +97,21 @@ public class CassandraQueryMethod extends QueryMethod { if (ClassUtils.isPrimitiveOrWrapper(returnedObjectType)) { this.entityMetadata = new SimpleCassandraEntityMetadata((Class) domainClass, - mappingContext.getPersistentEntity(domainClass)); + mappingContext.getPersistentEntity(domainClass)); } else { CassandraPersistentEntity returnedEntity = mappingContext.getPersistentEntity(returnedObjectType); CassandraPersistentEntity managedEntity = mappingContext.getPersistentEntity(domainClass); - returnedEntity = (returnedEntity == null || returnedEntity.getType().isInterface() - ? managedEntity : returnedEntity); + returnedEntity = (returnedEntity == null || returnedEntity.getType().isInterface() ? managedEntity + : returnedEntity); // TODO collectionEntity? CassandraPersistentEntity collectionEntity = domainClass.isAssignableFrom(returnedObjectType) - ? returnedEntity : managedEntity; + ? returnedEntity : managedEntity; - this.entityMetadata = new SimpleCassandraEntityMetadata( - (Class) returnedEntity.getType(), collectionEntity); + this.entityMetadata = new SimpleCassandraEntityMetadata((Class) returnedEntity.getType(), + collectionEntity); } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraReturnedType.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraReturnedType.java new file mode 100644 index 000000000..2b21eb1f6 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/CassandraReturnedType.java @@ -0,0 +1,67 @@ +/* + * Copyright 2016 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.repository.query; + +import java.util.Map; + +import org.springframework.data.cassandra.convert.CustomConversions; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.util.ClassUtils; + +/** + * Represents a {@link ReturnedType} in the context of Spring Data Cassandra. + * + * @author Mark Paluch + */ +class CassandraReturnedType { + + private final ReturnedType returnedType; + private final CustomConversions customConversions; + + CassandraReturnedType(ReturnedType returnedType, CustomConversions customConversions) { + this.returnedType = returnedType; + this.customConversions = customConversions; + } + + boolean isProjecting() { + + if (!returnedType.isProjecting()) { + return false; + } + + // Spring Data Cassandra allows List and Map declarations + // on query methods so we don't want to let projection kick in + if (ClassUtils.isAssignable(Map.class, returnedType.getReturnedType())) { + return false; + } + + // Type conversion using registered conversions is handled on template level + if (customConversions.hasCustomWriteTarget(returnedType.getReturnedType())) { + return false; + } + + // Don't apply projection on Cassandra simple types + return !customConversions.isSimpleType(returnedType.getReturnedType()); + } + + Class getDomainType() { + return returnedType.getDomainType(); + } + + Class getReturnedType() { + return returnedType.getReturnedType(); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java index 91cc70ac5..cbb4429f8 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ConvertingParameterAccessor.java @@ -90,6 +90,9 @@ class ConvertingParameterAccessor implements CassandraParameterAccessor { return potentiallyConvert(index, delegate.getBindableValue(index), null); } + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.CassandraParameterAccessor#findCassandraType(int) + */ @Override public CassandraType findCassandraType(int index) { return delegate.findCassandraType(index); @@ -324,6 +327,9 @@ class ConvertingParameterAccessor implements CassandraParameterAccessor { delegate.remove(); } + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.ConvertingParameterAccessor.PotentiallyConvertingIterator#nextConverted(org.springframework.data.cassandra.mapping.CassandraPersistentProperty) + */ @Override public Object nextConverted(CassandraPersistentProperty property) { return potentiallyConvert(index++, delegate.next(), property); diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ExpressionEvaluatingParameterBinder.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ExpressionEvaluatingParameterBinder.java index 3ff45c0c7..4b806b56c 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ExpressionEvaluatingParameterBinder.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ExpressionEvaluatingParameterBinder.java @@ -19,7 +19,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import org.springframework.data.cassandra.repository.query.StringBasedCassandraQuery.ParameterBinding; import org.springframework.data.repository.query.EvaluationContextProvider; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; @@ -183,4 +182,60 @@ class ExpressionEvaluatingParameterBinder { return queryMethod; } } + + /** + * A generic parameter binding with name or position information. + * + * @author Mark Paluch + */ + static class ParameterBinding { + + private final boolean quoted; + private final int parameterIndex; + private final String expression; + private final String parameterName; + + private ParameterBinding(int parameterIndex, boolean quoted, String expression, String parameterName) { + this.parameterIndex = parameterIndex; + this.quoted = quoted; + this.expression = expression; + this.parameterName = parameterName; + } + + public static ParameterBinding expression(String expression, boolean quoted) { + return new ParameterBinding(-1, quoted, expression, null); + } + + public static ParameterBinding indexed(int parameterIndex) { + return new ParameterBinding(parameterIndex, false, null, null); + } + + public static ParameterBinding named(String name) { + return new ParameterBinding(-1, false, null, name); + } + + public boolean isNamed() { + return (parameterName != null); + } + + public int getParameterIndex() { + return parameterIndex; + } + + public String getParameter() { + return ("?" + (isExpression() ? "expr" : "") + parameterIndex); + } + + public String getExpression() { + return expression; + } + + public boolean isExpression() { + return (this.expression != null); + } + + public String getParameterName() { + return parameterName; + } + } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessor.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessor.java new file mode 100644 index 000000000..2ae914aee --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessor.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016 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.repository.query; + +import org.springframework.data.repository.query.ReactiveWrapperConverters; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +/** + * Reactive {@link org.springframework.data.repository.query.ParametersParameterAccessor} implementation that subscribes + * to reactive parameter wrapper types upon creation. This class performs synchronization when acessing parameters. + * + * @author Mark Paluch + */ +class ReactiveCassandraParameterAccessor extends CassandraParametersParameterAccessor { + + private final Object[] values; + private final MonoProcessor[] subscriptions; + + public ReactiveCassandraParameterAccessor(CassandraQueryMethod method, Object[] values) { + + super(method, values); + + this.values = values; + this.subscriptions = new MonoProcessor[values.length]; + + for (int i = 0; i < values.length; i++) { + + Object value = values[i]; + + if (value == null) { + continue; + } + + if (!ReactiveWrapperConverters.supports(value.getClass())) { + continue; + } + + if (ReactiveWrapperConverters.isSingleLike(value.getClass())) { + subscriptions[i] = ReactiveWrapperConverters.toWrapper(value, Mono.class).subscribe(); + } else { + subscriptions[i] = ReactiveWrapperConverters.toWrapper(value, Flux.class).collectList().subscribe(); + } + } + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.ParametersParameterAccessor#getValue(int) + */ + @SuppressWarnings("unchecked") + @Override + protected T getValue(int index) { + + if (subscriptions[index] != null) { + return (T) subscriptions[index].block(); + } + + return super.getValue(index); + } + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.CassandraParametersParameterAccessor#getValues() + */ + @Override + public Object[] getValues() { + + Object[] result = new Object[values.length]; + for (int i = 0; i < result.length; i++) { + result[i] = getValue(i); + } + return result; + } + + /* (non-Javadoc) + * @see org.springframework.data.repository.query.ParametersParameterAccessor#getBindableValue(int) + */ + public Object getBindableValue(int index) { + return getValue(getParameters().getBindableParameter(index).getIndex()); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryExecution.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryExecution.java new file mode 100644 index 000000000..d6b08b878 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryExecution.java @@ -0,0 +1,125 @@ +/* + * Copyright 2016 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.repository.query; + +import java.util.function.Function; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.cassandra.core.CassandraOperations; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.util.StreamUtils; +import org.springframework.util.ClassUtils; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * Reactive query executions for Cassandra. + * + * @author Mark Paluch + * @since 2.0 + */ +interface ReactiveCassandraQueryExecution { + + Object execute(String query, Class type); + + /** + * {@link ReactiveCassandraQueryExecution} for collection returning queries. + * + * @author Mark Paluch + */ + @RequiredArgsConstructor + final class CollectionExecution implements ReactiveCassandraQueryExecution { + + private final @NonNull ReactiveCassandraOperations operations; + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution#execute(java.lang.String, java.lang.Class) + */ + @Override + public Object execute(String query, Class type) { + return operations.select(query, type); + } + } + + /** + * {@link ReactiveCassandraQueryExecution} to return a single entity. + * + * @author Mark Paluch + */ + @RequiredArgsConstructor + final class SingleEntityExecution implements ReactiveCassandraQueryExecution { + + private final @NonNull ReactiveCassandraOperations operations; + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution#execute(java.lang.String, java.lang.Class) + */ + @Override + public Object execute(String query, Class type) { + return operations.selectOne(query, type); + } + } + + /** + * An {@link ReactiveCassandraQueryExecution} that wraps the results of the given delegate with the given result processing. + * + * @author Mark Paluch + */ + @RequiredArgsConstructor + final class ResultProcessingExecution implements ReactiveCassandraQueryExecution { + + private final @NonNull ReactiveCassandraQueryExecution delegate; + private final @NonNull Converter converter; + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution#execute(java.lang.String, java.lang.Class) + */ + @Override + public Object execute(String query, Class type) { + return converter.convert(delegate.execute(query, type)); + } + } + + /** + * A {@link Converter} to post-process all source objects using the given {@link ResultProcessor}. + * + * @author Mark Paluch + */ + @RequiredArgsConstructor + final class ResultProcessingConverter implements Converter { + + private final @NonNull ResultProcessor processor; + + /* (non-Javadoc) + * @see org.springframework.core.convert.converter.Converter#convert(java.lang.Object) + */ + @Override + public Object convert(Object source) { + + ReturnedType returnedType = processor.getReturnedType(); + + if (ClassUtils.isPrimitiveOrWrapper(returnedType.getReturnedType())) { + return source; + } + + return processor.processResult(source); + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethod.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethod.java new file mode 100644 index 000000000..7d9fe9af6 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethod.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016 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.repository.query; + +import static org.springframework.data.repository.query.ReactiveWrappers.*; + +import java.lang.reflect.Method; + +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; + +/** + * Reactive specific implementation of {@link CassandraQueryMethod}. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ReactiveCassandraQueryMethod extends CassandraQueryMethod { + + private final Method method; + + /** + * Creates a new {@link ReactiveCassandraQueryMethod} from the given {@link Method}. + * + * @param method must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @param projectionFactory must not be {@literal null}. + * @param mappingContext must not be {@literal null}. + */ + public ReactiveCassandraQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory projectionFactory, + CassandraMappingContext mappingContext) { + + super(method, metadata, projectionFactory, mappingContext); + + this.method = method; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryMethod#isCollectionQuery() + */ + @Override + public boolean isCollectionQuery() { + return !(isPageQuery() || isSliceQuery()) && isMultiType(method.getReturnType()); + } + + /* + * All reactive query methods are streaming queries. + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryMethod#isStreamQuery() + */ + @Override + public boolean isStreamQuery() { + return true; + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQuery.java new file mode 100644 index 000000000..42685089f --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQuery.java @@ -0,0 +1,72 @@ +/* + * Copyright 2016 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.repository.query; + +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.PartTree; + +/** + * Reactive PartTree {@link RepositoryQuery} implementation for Cassandra. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ReactivePartTreeCassandraQuery extends AbstractReactiveCassandraQuery { + + private final CassandraMappingContext mappingContext; + + private final PartTree tree; + + /** + * Creates a new {@link ReactivePartTreeCassandraQuery} from the given {@link QueryMethod} and + * {@link ReactiveCassandraOperations}. + * + * @param queryMethod must not be {@literal null}. + * @param operations must not be {@literal null}. + */ + public ReactivePartTreeCassandraQuery(CassandraQueryMethod queryMethod, ReactiveCassandraOperations operations) { + + super(queryMethod, operations); + + this.tree = new PartTree(queryMethod.getName(), queryMethod.getEntityInformation().getJavaType()); + this.mappingContext = operations.getConverter().getMappingContext(); + } + + /** + * Return the {@link PartTree} backing the query. + * + * @return the tree + */ + public PartTree getTree() { + return tree; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.AbstractCassandraQuery#createQuery(org.springframework.data.cassandra.repository.query.CassandraParameterAccessor, boolean) + */ + @Override + protected String createQuery(CassandraParameterAccessor parameterAccessor) { + + CassandraQueryCreator queryCreator = new CassandraQueryCreator(tree, parameterAccessor, mappingContext, + getQueryMethod().getEntityInformation()); + + return queryCreator.createQuery().toString(); + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQuery.java new file mode 100644 index 000000000..eacfc78e6 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQuery.java @@ -0,0 +1,110 @@ +/* + * Copyright 2016 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.repository.query; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cassandra.core.ReactiveSessionCallback; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.repository.query.EvaluationContextProvider; +import org.springframework.data.repository.query.QueryCreationException; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.util.Assert; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.CodecRegistry; + +import reactor.core.publisher.Flux; + +/** + * String-based {@link AbstractCassandraQuery} implementation. + *

+ * A {@link ReactiveStringBasedCassandraQuery} expects a query method to be annotated with + * {@link org.springframework.data.cassandra.repository.Query} with a CQL query. String-based queries support named, + * index-based and expression parameters that are resolved during query execution. + * + * @author Mark Paluch + * @since 2.0 + * @see org.springframework.data.cassandra.repository.Query + */ +public class ReactiveStringBasedCassandraQuery extends AbstractReactiveCassandraQuery { + + private static final Logger LOG = LoggerFactory.getLogger(ReactiveStringBasedCassandraQuery.class); + + private final StringBasedQuery stringBasedQuery; + + /** + * Creates a new {@link ReactiveStringBasedCassandraQuery} for the given {@link CassandraQueryMethod}, + * {@link ReactiveCassandraOperations}, {@link SpelExpressionParser}, and {@link EvaluationContextProvider}. + * + * @param queryMethod {@link CassandraQueryMethod} on which this query is based. + * @param operations {@link ReactiveCassandraOperations} used to perform data access in Cassandra. + * @param expressionParser {@link SpelExpressionParser} used to parse expressions in the query. + * @param evaluationContextProvider {@link EvaluationContextProvider} used to access the potentially shared + * {@link org.springframework.expression.spel.support.StandardEvaluationContext}. + */ + public ReactiveStringBasedCassandraQuery(CassandraQueryMethod queryMethod, ReactiveCassandraOperations operations, + SpelExpressionParser expressionParser, EvaluationContextProvider evaluationContextProvider) { + this(queryMethod.getAnnotatedQuery(), queryMethod, operations, expressionParser, evaluationContextProvider); + } + + /** + * Creates a new {@link ReactiveStringBasedCassandraQuery} for the given {@code query}, {@link CassandraQueryMethod}, + * {@link ReactiveCassandraOperations}, {@link SpelExpressionParser}, and {@link EvaluationContextProvider}. + * + * @param queryMethod {@link CassandraQueryMethod} on which this query is based. + * @param operations {@link ReactiveCassandraOperations} used to perform data access in Cassandra. + * @param expressionParser {@link SpelExpressionParser} used to parse expressions in the query. + * @param evaluationContextProvider {@link EvaluationContextProvider} used to access the potentially shared + * {@link org.springframework.expression.spel.support.StandardEvaluationContext}. + */ + public ReactiveStringBasedCassandraQuery(String query, CassandraQueryMethod queryMethod, + ReactiveCassandraOperations operations, SpelExpressionParser expressionParser, + EvaluationContextProvider evaluationContextProvider) { + + super(queryMethod, operations); + + Assert.hasText(query, "Query must not be empty"); + + // this blocking operation is to retrieve the underlying Cluster and does not include any I/O here. + Cluster cluster = operations.getReactiveCqlOperations() + .execute((ReactiveSessionCallback) session -> Flux.just(session.getCluster())).blockFirst(); + + CodecRegistry codecRegistry = cluster.getConfiguration().getCodecRegistry(); + + this.stringBasedQuery = new StringBasedQuery(query, + new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider), codecRegistry); + } + + /* (non-Javadoc) + * @see org.springframework.data.cassandra.repository.query.AbstractCassandraQuery#createQuery(org.springframework.data.cassandra.repository.query.CassandraParameterAccessor) + */ + @Override + public String createQuery(CassandraParameterAccessor parameterAccessor) { + + try { + String boundQuery = stringBasedQuery.bindQuery(parameterAccessor, getQueryMethod()); + + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("Created query [%s].", boundQuery)); + } + + return boundQuery; + } catch (RuntimeException e) { + throw QueryCreationException.create(getQueryMethod(), e); + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQuery.java index 86b7e3f1c..7d2e82405 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQuery.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQuery.java @@ -15,28 +15,14 @@ */ package org.springframework.data.cassandra.repository.query; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.cassandra.core.CassandraOperations; -import org.springframework.data.cassandra.repository.query.ExpressionEvaluatingParameterBinder.BindingContext; import org.springframework.data.repository.query.EvaluationContextProvider; import org.springframework.data.repository.query.QueryCreationException; import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import com.datastax.driver.core.CodecRegistry; -import com.datastax.driver.core.TypeCodec; -import com.datastax.driver.core.querybuilder.BindMarker; /** * String-based {@link AbstractCassandraQuery} implementation. @@ -52,15 +38,8 @@ import com.datastax.driver.core.querybuilder.BindMarker; public class StringBasedCassandraQuery extends AbstractCassandraQuery { private static final Logger LOG = LoggerFactory.getLogger(StringBasedCassandraQuery.class); - private static final ParameterBindingParser BINDING_PARSER = ParameterBindingParser.INSTANCE; - private final CodecRegistry codecRegistry; - - private final ExpressionEvaluatingParameterBinder parameterBinder; - - private final List queryParameterBindings; - - private final String query; + private final StringBasedQuery stringBasedQuery; /** * Creates a new {@link StringBasedCassandraQuery} for the given {@link CassandraQueryMethod}, @@ -70,7 +49,7 @@ public class StringBasedCassandraQuery extends AbstractCassandraQuery { * @param operations {@link CassandraOperations} used to perform data access in Cassandra. * @param expressionParser {@link SpelExpressionParser} used to parse expressions in the query. * @param evaluationContextProvider {@link EvaluationContextProvider} used to access the potentially shared - * {@link org.springframework.expression.spel.support.StandardEvaluationContext}. + * {@link org.springframework.expression.spel.support.StandardEvaluationContext}. */ public StringBasedCassandraQuery(CassandraQueryMethod queryMethod, CassandraOperations operations, SpelExpressionParser expressionParser, EvaluationContextProvider evaluationContextProvider) { @@ -83,21 +62,20 @@ public class StringBasedCassandraQuery extends AbstractCassandraQuery { * {@link CassandraOperations}, {@link SpelExpressionParser}, and {@link EvaluationContextProvider}. * * @param query - * @param queryMethod - * @param operations - * @param expressionParser - * @param evaluationContextProvider + * @param queryMethod {@link CassandraQueryMethod} on which this query is based. + * @param operations {@link CassandraOperations} used to perform data access in Cassandra. + * @param expressionParser {@link SpelExpressionParser} used to parse expressions in the query. + * @param evaluationContextProvider {@link EvaluationContextProvider} used to access the potentially shared + * {@link org.springframework.expression.spel.support.StandardEvaluationContext}. */ public StringBasedCassandraQuery(String query, CassandraQueryMethod queryMethod, CassandraOperations operations, SpelExpressionParser expressionParser, EvaluationContextProvider evaluationContextProvider) { super(queryMethod, operations); - this.queryParameterBindings = new ArrayList(); - this.query = BINDING_PARSER.parseAndCollectParameterBindingsFromQueryIntoBindings(query, - this.queryParameterBindings); - this.parameterBinder = new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider); - this.codecRegistry = operations.getSession().getCluster().getConfiguration().getCodecRegistry(); + CodecRegistry codecRegistry = operations.getSession().getCluster().getConfiguration().getCodecRegistry(); + this.stringBasedQuery = new StringBasedQuery(query, + new ExpressionEvaluatingParameterBinder(expressionParser, evaluationContextProvider), codecRegistry); } /* (non-Javadoc) @@ -107,10 +85,7 @@ public class StringBasedCassandraQuery extends AbstractCassandraQuery { public String createQuery(CassandraParameterAccessor parameterAccessor) { try { - List arguments = this.parameterBinder.bind(parameterAccessor, - new BindingContext(getQueryMethod(), queryParameterBindings)); - - String boundQuery = bind(query, arguments); + String boundQuery = stringBasedQuery.bindQuery(parameterAccessor, getQueryMethod()); if (LOG.isDebugEnabled()) { LOG.debug(String.format("Created query [%s].", boundQuery)); @@ -121,356 +96,4 @@ public class StringBasedCassandraQuery extends AbstractCassandraQuery { throw QueryCreationException.create(getQueryMethod(), e); } } - - private String bind(String query, List arguments) { - return ParameterBinder.INSTANCE.bind(query, codecRegistry, arguments); - } - - /** - * A parser that extracts the parameter bindings from a given query string. - * - * @author Mark Paluch - */ - enum ParameterBinder { - - INSTANCE; - - private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; - private static final Pattern ARGUMENT_PLACEHOLDER_PATTERN = Pattern.compile(Pattern.quote(ARGUMENT_PLACEHOLDER)); - - public String bind(String input, CodecRegistry codecRegistry, List parameters) { - - if (parameters.isEmpty()) { - return input; - } - - StringBuilder result = new StringBuilder(); - - int startIndex = 0; - int currentPosition = 0; - int parameterIndex = 0; - - Matcher matcher = ARGUMENT_PLACEHOLDER_PATTERN.matcher(input); - - while (currentPosition < input.length()) { - - if (!matcher.find()) { - break; - } - - int exprStart = matcher.start(); - - result.append(input.subSequence(startIndex, exprStart)); - result = appendValue(parameters.get(parameterIndex++), codecRegistry, result); - - currentPosition = matcher.end(); - startIndex = currentPosition; - } - - return result.append(input.subSequence(currentPosition, input.length())).toString(); - } - - static StringBuilder appendValue(Object value, CodecRegistry codecRegistry, StringBuilder builder) { - - if (value == null) { - builder.append("null"); - } else if (value instanceof BindMarker) { - builder.append(value); - } else if (value instanceof List && isSerializable(value)) { - // bind variables are not supported inside collection literals - appendList((List) value, codecRegistry, builder); - } else if (value instanceof Set && isSerializable(value)) { - // bind variables are not supported inside collection literals - appendSet((Set) value, codecRegistry, builder); - } else if (value instanceof Map && isSerializable(value)) { - // bind variables are not supported inside collection literals - appendMap((Map) value, codecRegistry, builder); - } else if (isSerializable(value)) { - TypeCodec codec = codecRegistry.codecFor(value); - builder.append(codec.format(value)); - } else { - throw new IllegalArgumentException(String.format("Argument value [%s] is not serializable", value.toString())); - } - - return builder; - } - - private static StringBuilder appendList(List list, CodecRegistry codecRegistry, StringBuilder builder) { - - for (int index = 0, size = list.size(); index < size; index++) { - builder.append(index > 0 ? "," : ""); - appendValue(list.get(index), codecRegistry, builder); - } - - return builder; - } - - private static StringBuilder appendSet(Set set, CodecRegistry codecRegistry, StringBuilder builder) { - - boolean first = true; - - for (Object element : set) { - builder.append(first ? "" : ","); - appendValue(element, codecRegistry, builder); - first = false; - } - - return builder; - } - - private static StringBuilder appendMap(Map map, CodecRegistry codecRegistry, StringBuilder builder) { - - builder.append('{'); - - boolean first = true; - - for (Map.Entry entry : map.entrySet()) { - builder.append(first ? "" : ","); - appendValue(entry.getKey(), codecRegistry, builder); - builder.append(':'); - appendValue(entry.getValue(), codecRegistry, builder); - first = false; - } - - builder.append('}'); - - return builder; - } - - /** - * Return true if the given value is likely to find a suitable codec to be serialized as a query parameter. If the - * value is not serializable, it must be included in the query string. Non serializable values include special - * values such as function calls, column names and bind markers, and collections thereof. We also don't serialize - * fixed size number types. The reason is that if we do it, we will force a particular size (4 bytes for ints, ...) - * and for the query builder, we don't want users to have to bother with that. - * - * @param value the value to inspect. - * @return true if the value is serializable, false otherwise. - */ - static boolean isSerializable(Object value) { - - if (containsSpecialValue(value)) { - return false; - } - - if (value instanceof Collection) { - for (Object element : (Collection) value) { - if (!isSerializable(element)) { - return false; - } - } - } - - if (value instanceof Map) { - for (Map.Entry entry : ((Map) value).entrySet()) { - if (!isSerializable(entry.getKey()) || !isSerializable(entry.getValue())) { - return false; - } - } - } - - return true; - } - - static boolean containsSpecialValue(Object value) { - - if (value instanceof BindMarker) { - return true; - } - - if (value instanceof Collection) { - for (Object element : (Collection) value) { - if (containsSpecialValue(element)) { - return true; - } - } - } - - if (value instanceof Map) { - for (Map.Entry entry : ((Map) value).entrySet()) { - if (containsSpecialValue(entry.getKey()) || containsSpecialValue(entry.getValue())) { - return true; - } - } - } - - return false; - } - } - - /** - * A parser that extracts the parameter bindings from a given query string. - * - * @author Mark Paluch - */ - enum ParameterBindingParser { - - INSTANCE; - - private static final char CURRLY_BRACE_OPEN = '{'; - private static final char CURRLY_BRACE_CLOSE = '}'; - private static final Pattern INDEX_PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); - private static final Pattern NAMED_PARAMETER_BINDING_PATTERN = Pattern.compile("\\:(\\w+)"); - - private static final Pattern INDEX_BASED_EXPRESSION_PATTERN = Pattern.compile("\\?\\#\\{"); - private static final Pattern NAME_BASED_EXPRESSION_PATTERN = Pattern.compile("\\:\\#\\{"); - private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; - - /** - * Returns a list of {@link ParameterBinding}s found in the given {@code input}. - * - * @param input can be {@literal null} or empty. - * @param bindings must not be {@literal null}. - * @return a list of {@link ParameterBinding}s found in the given {@code input}. - */ - public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input, - List bindings) { - - if (!StringUtils.hasText(input)) { - return input; - } - - Assert.notNull(bindings, "Parameter bindings must not be null"); - - return transformQueryAndCollectExpressionParametersIntoBindings(input, bindings); - } - - private static String transformQueryAndCollectExpressionParametersIntoBindings(String input, - List bindings) { - - StringBuilder result = new StringBuilder(); - - int startIndex = 0; - int currentPosition = 0; - - while (currentPosition < input.length()) { - - Matcher matcher = findNextBindingOrExpression(input, currentPosition); - - // no expression parameter found - if (matcher == null) { - break; - } - - int exprStart = matcher.start(); - currentPosition = exprStart; - - if (matcher.pattern() == NAME_BASED_EXPRESSION_PATTERN || matcher.pattern() == INDEX_BASED_EXPRESSION_PATTERN) { - // eat parameter expression - int curlyBraceOpenCount = 1; - currentPosition += 3; - - while (curlyBraceOpenCount > 0 && currentPosition < input.length()) { - switch (input.charAt(currentPosition++)) { - case CURRLY_BRACE_OPEN: - curlyBraceOpenCount++; - break; - case CURRLY_BRACE_CLOSE: - curlyBraceOpenCount--; - break; - default: - } - } - - result.append(input.subSequence(startIndex, exprStart)); - } else { - result.append(input.subSequence(startIndex, exprStart)); - } - - result.append(ARGUMENT_PLACEHOLDER); - - if (matcher.pattern() == NAME_BASED_EXPRESSION_PATTERN || matcher.pattern() == INDEX_BASED_EXPRESSION_PATTERN) { - bindings.add(ParameterBinding.expression(input.substring(exprStart + 3, currentPosition - 1), true)); - } else { - if (matcher.pattern() == INDEX_PARAMETER_BINDING_PATTERN) { - bindings.add(ParameterBinding.indexed(Integer.parseInt(matcher.group(1)))); - } else { - bindings.add(ParameterBinding.named(matcher.group(1))); - } - - currentPosition = matcher.end(); - } - - startIndex = currentPosition; - } - - return result.append(input.subSequence(currentPosition, input.length())).toString(); - } - - private static Matcher findNextBindingOrExpression(String input, int position) { - - List matchers = new ArrayList(); - - matchers.add(INDEX_PARAMETER_BINDING_PATTERN.matcher(input)); - matchers.add(NAMED_PARAMETER_BINDING_PATTERN.matcher(input)); - matchers.add(INDEX_BASED_EXPRESSION_PATTERN.matcher(input)); - matchers.add(NAME_BASED_EXPRESSION_PATTERN.matcher(input)); - - TreeMap matcherMap = new TreeMap(); - - for (Matcher matcher : matchers) { - if (matcher.find(position)) { - matcherMap.put(matcher.start(), matcher); - } - } - - return (matcherMap.isEmpty() ? null : matcherMap.values().iterator().next()); - } - } - - /** - * A generic parameter binding with name or position information. - * - * @author Mark Paluch - */ - static class ParameterBinding { - - private final boolean quoted; - private final int parameterIndex; - private final String expression; - private final String parameterName; - - private ParameterBinding(int parameterIndex, boolean quoted, String expression, String parameterName) { - this.parameterIndex = parameterIndex; - this.quoted = quoted; - this.expression = expression; - this.parameterName = parameterName; - } - - public static ParameterBinding expression(String expression, boolean quoted) { - return new ParameterBinding(-1, quoted, expression, null); - } - - public static ParameterBinding indexed(int parameterIndex) { - return new ParameterBinding(parameterIndex, false, null, null); - } - - public static ParameterBinding named(String name) { - return new ParameterBinding(-1, false, null, name); - } - - public boolean isNamed() { - return (parameterName != null); - } - - public int getParameterIndex() { - return parameterIndex; - } - - public String getParameter() { - return ("?" + (isExpression() ? "expr" : "") + parameterIndex); - } - - public String getExpression() { - return expression; - } - - public boolean isExpression() { - return (this.expression != null); - } - - public String getParameterName() { - return parameterName; - } - } } diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java new file mode 100644 index 000000000..d7d81f817 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/query/StringBasedQuery.java @@ -0,0 +1,382 @@ +/* + * Copyright 2016 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.repository.query; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.data.cassandra.repository.query.ExpressionEvaluatingParameterBinder.BindingContext; +import org.springframework.data.cassandra.repository.query.ExpressionEvaluatingParameterBinder.ParameterBinding; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.datastax.driver.core.CodecRegistry; +import com.datastax.driver.core.TypeCodec; +import com.datastax.driver.core.querybuilder.BindMarker; + +/** + * String-based Query abstracting a CQL query with parameter bindings. + * + * @author Mark Paluch + * @since 2.0 + */ +class StringBasedQuery { + + private final CodecRegistry codecRegistry; + private final ExpressionEvaluatingParameterBinder parameterBinder; + private final List queryParameterBindings = new ArrayList<>(); + private final String query; + + /** + * Creates a new {@link StringBasedQuery} given {@code query}, {@link ExpressionEvaluatingParameterBinder} and + * {@link CodecRegistry}. + * + * @param query must not be empty. + * @param parameterBinder must not be {@literal null}. + * @param codecRegistry must not be {@literal null}. + */ + public StringBasedQuery(String query, ExpressionEvaluatingParameterBinder parameterBinder, + CodecRegistry codecRegistry) { + + Assert.hasText(query, "Query must not be empty"); + Assert.notNull(parameterBinder, "ExpressionEvaluatingParameterBinder must not be null"); + Assert.notNull(codecRegistry, "CodecRegistry must not be null"); + + this.codecRegistry = codecRegistry; + this.parameterBinder = parameterBinder; + + this.query = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + this.queryParameterBindings); + + } + + /** + * Bind the query to actual parameters using {@link CassandraParameterAccessor}, + * + * @param parameterAccessor must not be {@literal null}. + * @param queryMethod must not be {@literal null}. + * @return the bound String query containing formatted parameters. + */ + public String bindQuery(CassandraParameterAccessor parameterAccessor, CassandraQueryMethod queryMethod) { + + Assert.notNull(parameterAccessor, "CassandraParameterAccessor must not be null"); + Assert.notNull(queryMethod, "CassandraQueryMethod must not be null"); + + List arguments = parameterBinder.bind(parameterAccessor, + new BindingContext(queryMethod, queryParameterBindings)); + + return ParameterBinder.INSTANCE.bind(query, codecRegistry, arguments); + } + + /** + * A parser that extracts the parameter bindings from a given query string. + * + * @author Mark Paluch + */ + enum ParameterBinder { + + INSTANCE; + + private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; + private static final Pattern ARGUMENT_PLACEHOLDER_PATTERN = Pattern.compile(Pattern.quote(ARGUMENT_PLACEHOLDER)); + + public String bind(String input, CodecRegistry codecRegistry, List parameters) { + + if (parameters.isEmpty()) { + return input; + } + + StringBuilder result = new StringBuilder(); + + int startIndex = 0; + int currentPosition = 0; + int parameterIndex = 0; + + Matcher matcher = ARGUMENT_PLACEHOLDER_PATTERN.matcher(input); + + while (currentPosition < input.length()) { + + if (!matcher.find()) { + break; + } + + int exprStart = matcher.start(); + + result.append(input.subSequence(startIndex, exprStart)); + result = appendValue(parameters.get(parameterIndex++), codecRegistry, result); + + currentPosition = matcher.end(); + startIndex = currentPosition; + } + + return result.append(input.subSequence(currentPosition, input.length())).toString(); + } + + static StringBuilder appendValue(Object value, CodecRegistry codecRegistry, StringBuilder builder) { + + if (value == null) { + builder.append("null"); + } else if (value instanceof BindMarker) { + builder.append(value); + } else if (value instanceof List && isSerializable(value)) { + // bind variables are not supported inside collection literals + appendList((List) value, codecRegistry, builder); + } else if (value instanceof Set && isSerializable(value)) { + // bind variables are not supported inside collection literals + appendSet((Set) value, codecRegistry, builder); + } else if (value instanceof Map && isSerializable(value)) { + // bind variables are not supported inside collection literals + appendMap((Map) value, codecRegistry, builder); + } else if (isSerializable(value)) { + TypeCodec codec = codecRegistry.codecFor(value); + builder.append(codec.format(value)); + } else { + throw new IllegalArgumentException(String.format("Argument value [%s] is not serializable", value.toString())); + } + + return builder; + } + + private static StringBuilder appendList(List list, CodecRegistry codecRegistry, StringBuilder builder) { + + for (int index = 0, size = list.size(); index < size; index++) { + builder.append(index > 0 ? "," : ""); + appendValue(list.get(index), codecRegistry, builder); + } + + return builder; + } + + private static StringBuilder appendSet(Set set, CodecRegistry codecRegistry, StringBuilder builder) { + + boolean first = true; + + for (Object element : set) { + builder.append(first ? "" : ","); + appendValue(element, codecRegistry, builder); + first = false; + } + + return builder; + } + + private static StringBuilder appendMap(Map map, CodecRegistry codecRegistry, StringBuilder builder) { + + builder.append('{'); + + boolean first = true; + + for (Map.Entry entry : map.entrySet()) { + builder.append(first ? "" : ","); + appendValue(entry.getKey(), codecRegistry, builder); + builder.append(':'); + appendValue(entry.getValue(), codecRegistry, builder); + first = false; + } + + builder.append('}'); + + return builder; + } + + /** + * Return true if the given value is likely to find a suitable codec to be serialized as a query parameter. If the + * value is not serializable, it must be included in the query string. Non serializable values include special + * values such as function calls, column names and bind markers, and collections thereof. We also don't serialize + * fixed size number types. The reason is that if we do it, we will force a particular size (4 bytes for ints, ...) + * and for the query builder, we don't want users to have to bother with that. + * + * @param value the value to inspect. + * @return true if the value is serializable, false otherwise. + */ + static boolean isSerializable(Object value) { + + if (containsSpecialValue(value)) { + return false; + } + + if (value instanceof Collection) { + for (Object element : (Collection) value) { + if (!isSerializable(element)) { + return false; + } + } + } + + if (value instanceof Map) { + for (Map.Entry entry : ((Map) value).entrySet()) { + if (!isSerializable(entry.getKey()) || !isSerializable(entry.getValue())) { + return false; + } + } + } + + return true; + } + + static boolean containsSpecialValue(Object value) { + + if (value instanceof BindMarker) { + return true; + } + + if (value instanceof Collection) { + for (Object element : (Collection) value) { + if (containsSpecialValue(element)) { + return true; + } + } + } + + if (value instanceof Map) { + for (Map.Entry entry : ((Map) value).entrySet()) { + if (containsSpecialValue(entry.getKey()) || containsSpecialValue(entry.getValue())) { + return true; + } + } + } + + return false; + } + } + + /** + * A parser that extracts the parameter bindings from a given query string. + * + * @author Mark Paluch + */ + enum ParameterBindingParser { + + INSTANCE; + + private static final char CURRLY_BRACE_OPEN = '{'; + private static final char CURRLY_BRACE_CLOSE = '}'; + private static final Pattern INDEX_PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); + private static final Pattern NAMED_PARAMETER_BINDING_PATTERN = Pattern.compile("\\:(\\w+)"); + + private static final Pattern INDEX_BASED_EXPRESSION_PATTERN = Pattern.compile("\\?\\#\\{"); + private static final Pattern NAME_BASED_EXPRESSION_PATTERN = Pattern.compile("\\:\\#\\{"); + private static final String ARGUMENT_PLACEHOLDER = "?_param_?"; + + /** + * Returns a list of {@link ParameterBinding}s found in the given {@code input}. + * + * @param input can be {@literal null} or empty. + * @param bindings must not be {@literal null}. + * @return a list of {@link ParameterBinding}s found in the given {@code input}. + */ + public String parseAndCollectParameterBindingsFromQueryIntoBindings(String input, List bindings) { + + if (!StringUtils.hasText(input)) { + return input; + } + + Assert.notNull(bindings, "Parameter bindings must not be null"); + + return transformQueryAndCollectExpressionParametersIntoBindings(input, bindings); + } + + private static String transformQueryAndCollectExpressionParametersIntoBindings(String input, + List bindings) { + + StringBuilder result = new StringBuilder(); + + int startIndex = 0; + int currentPosition = 0; + + while (currentPosition < input.length()) { + + Matcher matcher = findNextBindingOrExpression(input, currentPosition); + + // no expression parameter found + if (matcher == null) { + break; + } + + int exprStart = matcher.start(); + currentPosition = exprStart; + + if (matcher.pattern() == NAME_BASED_EXPRESSION_PATTERN || matcher.pattern() == INDEX_BASED_EXPRESSION_PATTERN) { + // eat parameter expression + int curlyBraceOpenCount = 1; + currentPosition += 3; + + while (curlyBraceOpenCount > 0 && currentPosition < input.length()) { + switch (input.charAt(currentPosition++)) { + case CURRLY_BRACE_OPEN: + curlyBraceOpenCount++; + break; + case CURRLY_BRACE_CLOSE: + curlyBraceOpenCount--; + break; + default: + } + } + + result.append(input.subSequence(startIndex, exprStart)); + } else { + result.append(input.subSequence(startIndex, exprStart)); + } + + result.append(ARGUMENT_PLACEHOLDER); + + if (matcher.pattern() == NAME_BASED_EXPRESSION_PATTERN || matcher.pattern() == INDEX_BASED_EXPRESSION_PATTERN) { + bindings.add(ExpressionEvaluatingParameterBinder.ParameterBinding + .expression(input.substring(exprStart + 3, currentPosition - 1), true)); + } else { + if (matcher.pattern() == INDEX_PARAMETER_BINDING_PATTERN) { + bindings + .add(ExpressionEvaluatingParameterBinder.ParameterBinding.indexed(Integer.parseInt(matcher.group(1)))); + } else { + bindings.add(ExpressionEvaluatingParameterBinder.ParameterBinding.named(matcher.group(1))); + } + + currentPosition = matcher.end(); + } + + startIndex = currentPosition; + } + + return result.append(input.subSequence(currentPosition, input.length())).toString(); + } + + private static Matcher findNextBindingOrExpression(String input, int position) { + + List matchers = new ArrayList(); + + matchers.add(INDEX_PARAMETER_BINDING_PATTERN.matcher(input)); + matchers.add(NAMED_PARAMETER_BINDING_PATTERN.matcher(input)); + matchers.add(INDEX_BASED_EXPRESSION_PATTERN.matcher(input)); + matchers.add(NAME_BASED_EXPRESSION_PATTERN.matcher(input)); + + TreeMap matcherMap = new TreeMap(); + + for (Matcher matcher : matchers) { + if (matcher.find(position)) { + matcherMap.put(matcher.start(), matcher); + } + } + + return (matcherMap.isEmpty() ? null : matcherMap.values().iterator().next()); + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactory.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactory.java new file mode 100644 index 000000000..c45fd6e11 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactory.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016 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.repository.support; + +import java.io.Serializable; +import java.lang.reflect.Method; + +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; +import org.springframework.data.cassandra.repository.query.CassandraEntityInformation; +import org.springframework.data.cassandra.repository.query.CassandraQueryMethod; +import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryMethod; +import org.springframework.data.cassandra.repository.query.ReactivePartTreeCassandraQuery; +import org.springframework.data.cassandra.repository.query.ReactiveStringBasedCassandraQuery; +import org.springframework.data.mapping.model.MappingException; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.data.repository.query.EvaluationContextProvider; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.util.QueryExecutionConverters; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.util.Assert; + +/** + * Factory to create {@link org.springframework.data.cassandra.repository.ReactiveCassandraRepository} instances. + * + * @author Mark Paluch + * @since 2.0 + */ +public class ReactiveCassandraRepositoryFactory extends RepositoryFactorySupport { + + private static final SpelExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + + private final ReactiveCassandraOperations operations; + private final CassandraMappingContext mappingContext; + private final ConversionService conversionService; + + /** + * Creates a new {@link ReactiveCassandraRepositoryFactory} with the given {@link ReactiveCassandraOperations}. + * + * @param cassandraOperations must not be {@literal null}. + */ + public ReactiveCassandraRepositoryFactory(ReactiveCassandraOperations cassandraOperations) { + + Assert.notNull(cassandraOperations); + + this.operations = cassandraOperations; + this.mappingContext = cassandraOperations.getConverter().getMappingContext(); + + DefaultConversionService conversionService = new DefaultConversionService(); + QueryExecutionConverters.registerConvertersIn(conversionService); + + this.conversionService = conversionService; + setConversionService(conversionService); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getRepositoryBaseClass(org.springframework.data.repository.core.RepositoryMetadata) + */ + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + return SimpleReactiveCassandraRepository.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getTargetRepository(org.springframework.data.repository.core.RepositoryInformation) + */ + @Override + protected Object getTargetRepository(RepositoryInformation information) { + + CassandraEntityInformation entityInformation = getEntityInformation(information.getDomainType(), + information); + return getTargetRepositoryViaReflection(information, entityInformation, operations); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getQueryLookupStrategy(org.springframework.data.repository.query.QueryLookupStrategy.Key, org.springframework.data.repository.query.EvaluationContextProvider) + */ + @Override + protected QueryLookupStrategy getQueryLookupStrategy(Key key, EvaluationContextProvider evaluationContextProvider) { + return new CassandraQueryLookupStrategy(operations, evaluationContextProvider, mappingContext, conversionService); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactorySupport#getEntityInformation(java.lang.Class) + */ + public CassandraEntityInformation getEntityInformation(Class domainClass) { + return getEntityInformation(domainClass, null); + } + + @SuppressWarnings("unchecked") + private CassandraEntityInformation getEntityInformation(Class domainClass, + RepositoryInformation information) { + + CassandraPersistentEntity entity = mappingContext.getPersistentEntity(domainClass); + + if (entity == null) { + throw new MappingException( + String.format("Could not lookup mapping metadata for domain class %s!", domainClass.getName())); + } + + return new MappingCassandraEntityInformation((CassandraPersistentEntity) entity, + operations.getConverter()); + } + + /** + * {@link QueryLookupStrategy} to create + * {@link org.springframework.data.cassandra.repository.query.PartTreeCassandraQuery} instances. + * + * @author Mark Paluch + */ + private static class CassandraQueryLookupStrategy implements QueryLookupStrategy { + + private final EvaluationContextProvider evaluationContextProvider; + private final ReactiveCassandraOperations operations; + private final CassandraMappingContext mappingContext; + private final ConversionService conversionService; + + CassandraQueryLookupStrategy(ReactiveCassandraOperations operations, EvaluationContextProvider evaluationContextProvider, CassandraMappingContext mappingContext, + ConversionService conversionService) { + + this.evaluationContextProvider = evaluationContextProvider; + this.operations = operations; + this.mappingContext = mappingContext; + this.conversionService = conversionService; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.QueryLookupStrategy#resolveQuery(java.lang.reflect.Method, org.springframework.data.repository.core.RepositoryMetadata, org.springframework.data.projection.ProjectionFactory, org.springframework.data.repository.core.NamedQueries) + */ + @Override + public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + NamedQueries namedQueries) { + + CassandraQueryMethod queryMethod = new ReactiveCassandraQueryMethod(method, metadata, factory, mappingContext); + String namedQueryName = queryMethod.getNamedQueryName(); + + if (namedQueries.hasQuery(namedQueryName)) { + String namedQuery = namedQueries.getQuery(namedQueryName); + return new ReactiveStringBasedCassandraQuery(namedQuery, queryMethod, operations, EXPRESSION_PARSER, + evaluationContextProvider); + } else if (queryMethod.hasAnnotatedQuery()) { + return new ReactiveStringBasedCassandraQuery(queryMethod, operations, EXPRESSION_PARSER, + evaluationContextProvider); + } else { + return new ReactivePartTreeCassandraQuery(queryMethod, operations); + } + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryBean.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryBean.java new file mode 100644 index 000000000..ee0e93f44 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryBean.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016 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.repository.support; + +import java.io.Serializable; + +import org.springframework.data.cassandra.core.CassandraTemplate; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.util.Assert; + +/** + * {@link org.springframework.beans.factory.FactoryBean} to create + * {@link org.springframework.data.cassandra.repository.ReactiveCassandraRepository} instances. + * + * @author Mark Paluch + * @since 2.0 + * @see org.springframework.data.repository.reactive.ReactivePagingAndSortingRepository + * @see org.springframework.data.repository.reactive.RxJavaPagingAndSortingRepository + */ +public class ReactiveCassandraRepositoryFactoryBean, S, ID extends Serializable> + extends RepositoryFactoryBeanSupport { + + private ReactiveCassandraOperations operations; + private boolean mappingContextConfigured = false; + + /** + * Configures the {@link ReactiveCassandraOperations} used for Cassandra data access operations. + * + * @param operations {@link ReactiveCassandraOperations} used to perform CRUD, Query and general data access operations + * on Apache Cassandra. + */ + public void setReactiveCassandraOperations(ReactiveCassandraOperations operations) { + this.operations = operations; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport#setMappingContext(org.springframework.data.mapping.context.MappingContext) + */ + @Override + protected void setMappingContext(MappingContext mappingContext) { + + super.setMappingContext(mappingContext); + this.mappingContextConfigured = true; + } + + /* + * (non-Javadoc) + * + * @see + * org.springframework.data.repository.support.RepositoryFactoryBeanSupport + * #createRepositoryFactory() + */ + @Override + protected final RepositoryFactorySupport createRepositoryFactory() { + return getFactoryInstance(operations); + } + + /** + * Creates and initializes a {@link RepositoryFactorySupport} instance. + * + * @param operations + * @return + */ + protected RepositoryFactorySupport getFactoryInstance(ReactiveCassandraOperations operations) { + return new ReactiveCassandraRepositoryFactory(operations); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.support.RepositoryFactoryBeanSupport#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() { + + super.afterPropertiesSet(); + Assert.notNull(operations, "ReactiveCassandraOperations must not be null!"); + + if (!mappingContextConfigured) { + setMappingContext(operations.getConverter().getMappingContext()); + } + } +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepository.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepository.java new file mode 100644 index 000000000..9e24988a3 --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepository.java @@ -0,0 +1,217 @@ +/* + * Copyright 2016 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.repository.support; + +import java.io.Serializable; + +import org.reactivestreams.Publisher; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.data.cassandra.repository.query.CassandraEntityInformation; +import org.springframework.util.Assert; + +import com.datastax.driver.core.querybuilder.QueryBuilder; +import com.datastax.driver.core.querybuilder.Select; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Reactive repository base implementation for Cassandra. + * + * @author Mark Paluch + * @since 2.0 + */ +public class SimpleReactiveCassandraRepository + implements ReactiveCassandraRepository { + + protected ReactiveCassandraOperations operations; + protected CassandraEntityInformation entityInformation; + + /** + * Creates a new {@link SimpleReactiveCassandraRepository} for the given {@link CassandraEntityInformation} and + * {@link ReactiveCassandraOperations}. + * + * @param metadata must not be {@literal null}. + * @param operations must not be {@literal null}. + */ + public SimpleReactiveCassandraRepository(CassandraEntityInformation metadata, + ReactiveCassandraOperations operations) { + + Assert.notNull(metadata, "CassandraEntityInformation must not be null"); + Assert.notNull(operations, "ReactiveCassandraOperations must not be null"); + + this.entityInformation = metadata; + this.operations = operations; + } + + @Override + public Mono save(S entity) { + + Assert.notNull(entity, "Entity must not be null"); + + if (entityInformation.isNew(entity)) { + return operations.insert(entity); + } + + return operations.update(entity); + + } + + @Override + public Flux save(Iterable entities) { + + Assert.notNull(entities, "The given Iterable of entities must not be null"); + + return save(Flux.fromIterable(entities)); + } + + @Override + public Flux save(Publisher entityStream) { + + Assert.notNull(entityStream, "The given Publisher of entities must not be null"); + + return Flux.from(entityStream).flatMap(entity -> { + + if (entityInformation.isNew(entity)) { + return operations.insert(entity); + } + + return operations.update(entity); + }); + } + + @Override + public Mono insert(S entity) { + + Assert.notNull(entity, "Entity must not be null"); + + return operations.insert(entity); + } + + @Override + public Flux insert(Iterable entities) { + + Assert.notNull(entities, "The given Iterable of entities must not be null"); + + return operations.insert(Flux.fromIterable(entities)); + } + + @Override + public Flux insert(Publisher entityStream) { + + Assert.notNull(entityStream, "The given Publisher of entities must not be null"); + + return operations.insert(entityStream); + } + + @Override + public Mono findOne(ID id) { + + Assert.notNull(id, "The given id must not be null"); + + return operations.selectOneById(id, entityInformation.getJavaType()); + } + + @Override + public Mono findOne(Mono mono) { + + Assert.notNull(mono, "The given id must not be null"); + + return mono.then(id -> operations.selectOneById(id, entityInformation.getJavaType())); + } + + @Override + public Mono exists(ID id) { + + Assert.notNull(id, "The given id must not be null"); + + return operations.exists(id, entityInformation.getJavaType()); + } + + @Override + public Mono exists(Mono mono) { + + Assert.notNull(mono, "The given id must not be null"); + + return mono.then(id -> operations.exists(id, entityInformation.getJavaType())); + } + + @Override + public Flux findAll() { + + Select select = QueryBuilder.select().from(entityInformation.getTableName().toCql()); + return operations.select(select, entityInformation.getJavaType()); + } + + @Override + public Flux findAll(Iterable iterable) { + + Assert.notNull(iterable, "The given Iterable of id's must not be null"); + + return findAll(Flux.fromIterable(iterable)); + } + + @Override + public Flux findAll(Publisher idStream) { + + Assert.notNull(idStream, "The given Publisher of id's must not be null"); + + return Flux.from(idStream).flatMap(id -> operations.selectOneById(id, entityInformation.getJavaType())); + } + + @Override + public Mono count() { + return operations.count(entityInformation.getJavaType()); + } + + @Override + public Mono delete(ID id) { + + Assert.notNull(id, "The given id must not be null"); + + return operations.deleteById(id, entityInformation.getJavaType()).then(); + } + + @Override + public Mono delete(T entity) { + + Assert.notNull(entity, "The given entity must not be null"); + + return operations.delete(entity).then(); + } + + @Override + public Mono delete(Iterable entities) { + + Assert.notNull(entities, "The given Iterable of entities must not be null"); + + return operations.delete(Flux.fromIterable(entities)).then(); + } + + @Override + public Mono delete(Publisher entityStream) { + + Assert.notNull(entityStream, "The given Publisher of entities must not be null"); + + return operations.delete(entityStream).then(); + } + + @Override + public Mono deleteAll() { + return operations.truncate(entityInformation.getJavaType()); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateIntegrationTests.java new file mode 100644 index 000000000..ecdedd86b --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateIntegrationTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2016 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; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.cassandra.core.DefaultBridgedReactiveSession; +import org.springframework.cassandra.core.ReactiveCqlTemplate; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.test.integration.support.SchemaTestUtils; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Integration tests for {@link ReactiveCassandraTemplate}. + * + * @author Mark Paluch + */ +public class ReactiveCassandraTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest { + + private ReactiveCassandraTemplate template; + + @Before + public void setUp() throws Exception { + + MappingCassandraConverter converter = new MappingCassandraConverter(); + CassandraTemplate cassandraTemplate = new CassandraTemplate(session, converter); + + DefaultBridgedReactiveSession session = new DefaultBridgedReactiveSession(this.session, Schedulers.elastic()); + template = new ReactiveCassandraTemplate(new ReactiveCqlTemplate(session), converter); + + SchemaTestUtils.potentiallyCreateTableFor(Person.class, cassandraTemplate); + SchemaTestUtils.truncate(Person.class, cassandraTemplate); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertShouldInsertEntity() { + + Person person = new Person("heisenberg", "Walter", "White"); + + Mono insert = template.insert(person); + + Mono oneById = template.selectOneById(person.getId(), Person.class); + assertThat(oneById.hasElement().block()).isFalse(); + + Person saved = insert.block(); + assertThat(saved).isNotNull().isEqualTo(person); + assertThat(oneById.block()).isNotNull().isEqualTo(saved); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldInsertAndCountEntities() { + + Person person = new Person("heisenberg", "Walter", "White"); + + template.insert(person).block(); + + Mono count = template.count(Person.class); + assertThat(count.block()).isEqualTo(1L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void updateShouldUpdateEntity() { + + Person person = new Person("heisenberg", "Walter", "White"); + template.insert(person).block(); + + person.setFirstname("Walter Hartwell"); + Person updated = template.update(person).block(); + assertThat(updated).isNotNull(); + + Mono oneById = template.selectOneById(person.getId(), Person.class); + assertThat(oneById.block()).isEqualTo(person); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteShouldRemoveEntity() { + + Person person = new Person("heisenberg", "Walter", "White"); + template.insert(person).block(); + + Person deleted = template.delete(person).block(); + assertThat(deleted).isNotNull(); + + Mono oneById = template.selectOneById(person.getId(), Person.class); + assertThat(oneById.block()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteByIdShouldRemoveEntity() { + + Person person = new Person("heisenberg", "Walter", "White"); + template.insert(person).block(); + + Boolean deleted = template.deleteById(person.getId(), Person.class).block(); + assertThat(deleted).isTrue(); + + Mono oneById = template.selectOneById(person.getId(), Person.class); + assertThat(oneById.block()).isNull(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java new file mode 100644 index 000000000..17279cd34 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/ReactiveCassandraTemplateUnitTests.java @@ -0,0 +1,359 @@ +/* + * Copyright 2016 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; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyInt; + +import java.util.Collections; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.cassandra.core.ReactiveResultSet; +import org.springframework.cassandra.core.ReactiveSession; +import org.springframework.cassandra.support.exception.CassandraConnectionFailureException; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.domain.Person; + +import com.datastax.driver.core.ColumnDefinitions; +import com.datastax.driver.core.DataType; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.exceptions.NoHostAvailableException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Unit tests for {@link ReactiveCassandraTemplate}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveCassandraTemplateUnitTests { + + @Mock ReactiveSession session; + @Mock ReactiveResultSet reactiveResultSet; + @Mock Row row; + @Mock ColumnDefinitions columnDefinitions; + @Captor ArgumentCaptor statementCaptor; + + private ReactiveCassandraTemplate template; + + @Before + public void setUp() { + + template = new ReactiveCassandraTemplate(session); + when(session.execute(anyString())).thenReturn(Mono.just(reactiveResultSet)); + when(session.execute(any(Statement.class))).thenReturn(Mono.just(reactiveResultSet)); + when(reactiveResultSet.getColumnDefinitions()).thenReturn(columnDefinitions); + when(row.getColumnDefinitions()).thenReturn(columnDefinitions); + } + + /** + * @see DATACASS-335 + */ + @Test + public void selectUsingCqlShouldReturnMappedResults() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(columnDefinitions.contains(anyString())).thenReturn(true); + when(columnDefinitions.getType(anyInt())).thenReturn(DataType.ascii()); + + when(columnDefinitions.getIndexOf("id")).thenReturn(0); + when(columnDefinitions.getIndexOf("firstname")).thenReturn(1); + when(columnDefinitions.getIndexOf("lastname")).thenReturn(2); + + when(row.getObject(0)).thenReturn("myid"); + when(row.getObject(1)).thenReturn("Walter"); + when(row.getObject(2)).thenReturn("White"); + + Flux flux = template.select("SELECT * FROM person", Person.class); + + assertThat(flux.collectList().block()).hasSize(1).contains(new Person("myid", "Walter", "White")); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("SELECT * FROM person"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void selectShouldTranslateException() { + + when(reactiveResultSet.rows()).thenThrow(new NoHostAvailableException(Collections.emptyMap())); + + Flux flux = template.select("SELECT * FROM person", Person.class); + + try { + flux.last().block(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasRootCauseInstanceOf(NoHostAvailableException.class); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void selectOneByIdShouldReturnMappedResults() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(columnDefinitions.contains(anyString())).thenReturn(true); + when(columnDefinitions.getType(anyInt())).thenReturn(DataType.ascii()); + + when(columnDefinitions.getIndexOf("id")).thenReturn(0); + when(columnDefinitions.getIndexOf("firstname")).thenReturn(1); + when(columnDefinitions.getIndexOf("lastname")).thenReturn(2); + + when(row.getObject(0)).thenReturn("myid"); + when(row.getObject(1)).thenReturn("Walter"); + when(row.getObject(2)).thenReturn("White"); + + Mono mono = template.selectOneById("myid", Person.class); + + assertThat(mono.block()).isEqualTo(new Person("myid", "Walter", "White")); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("SELECT * FROM person WHERE id='myid';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsShouldReturnExistingElement() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(columnDefinitions.contains(anyString())).thenReturn(true); + when(columnDefinitions.getType(anyInt())).thenReturn(DataType.ascii()); + + Mono mono = template.exists("myid", Person.class); + + assertThat(mono.block()).isTrue(); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("SELECT * FROM person WHERE id='myid';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsShouldReturnNonExistingElement() { + + when(reactiveResultSet.rows()).thenReturn(Flux.empty()); + + Mono mono = template.exists("myid", Person.class); + + assertThat(mono.block()).isFalse(); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("SELECT * FROM person WHERE id='myid';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void countShouldExecuteCountQueryElement() { + + when(reactiveResultSet.rows()).thenReturn(Flux.just(row)); + when(row.getLong(0)).thenReturn(42L); + when(columnDefinitions.size()).thenReturn(1); + + Mono mono = template.count(Person.class); + + assertThat(mono.block()).isEqualTo(42L); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("SELECT count(*) FROM person;"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertShouldInsertEntity() { + + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Person person = new Person("heisenberg", "Walter", "White"); + Mono mono = template.insert(person); + + assertThat(mono.block()).isEqualTo(person); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("INSERT INTO person (firstname,id,lastname) VALUES ('Walter','heisenberg','White');"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertShouldTranslateException() { + + reset(session); + when(session.execute(any(Statement.class))) + .thenReturn(Mono.error(new NoHostAvailableException(Collections.emptyMap()))); + + Mono mono = template.insert(new Person("heisenberg", "Walter", "White")); + + try { + mono.block(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasRootCauseInstanceOf(NoHostAvailableException.class); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertShouldNotApplyInsert() { + + when(reactiveResultSet.wasApplied()).thenReturn(false); + + Person person = new Person("heisenberg", "Walter", "White"); + Mono mono = template.insert(person); + + assertThat(mono.block()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void updateShouldUpdateEntity() { + + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Person person = new Person("heisenberg", "Walter", "White"); + Mono mono = template.update(person); + + assertThat(mono.block()).isEqualTo(person); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()) + .isEqualTo("UPDATE person SET firstname='Walter',lastname='White' WHERE id='heisenberg';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void updateShouldTranslateException() { + + reset(session); + when(session.execute(any(Statement.class))) + .thenReturn(Mono.error(new NoHostAvailableException(Collections.emptyMap()))); + + Mono mono = template.update(new Person("heisenberg", "Walter", "White")); + + try { + mono.block(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasRootCauseInstanceOf(NoHostAvailableException.class); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void updateShouldNotApplyUpdate() { + + when(reactiveResultSet.wasApplied()).thenReturn(false); + + Person person = new Person("heisenberg", "Walter", "White"); + Mono mono = template.update(person); + + assertThat(mono.block()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteShouldRemoveEntity() { + + when(reactiveResultSet.wasApplied()).thenReturn(true); + + Person person = new Person("heisenberg", "Walter", "White"); + + Mono mono = template.delete(person); + + assertThat(mono.block()).isEqualTo(person); + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("DELETE FROM person WHERE id='heisenberg';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteShouldTranslateException() { + + reset(session); + when(session.execute(any(Statement.class))) + .thenReturn(Mono.error(new NoHostAvailableException(Collections.emptyMap()))); + + Mono mono = template.delete(new Person("heisenberg", "Walter", "White")); + + try { + mono.block(); + + fail("Missing CassandraConnectionFailureException"); + } catch (CassandraConnectionFailureException e) { + assertThat(e).hasRootCauseInstanceOf(NoHostAvailableException.class); + } + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteShouldNotApplyRemoval() { + + when(reactiveResultSet.wasApplied()).thenReturn(false); + + Person person = new Person("heisenberg", "Walter", "White"); + Mono mono = template.delete(person); + + assertThat(mono.block()).isNull(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void truncateShouldRemoveEntities() { + + template.truncate(Person.class).block(); + + verify(session).execute(statementCaptor.capture()); + assertThat(statementCaptor.getValue().toString()).isEqualTo("TRUNCATE person;"); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/domain/Person.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/domain/Person.java index 706ec31f5..00d15ee3e 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/domain/Person.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/domain/Person.java @@ -18,13 +18,17 @@ package org.springframework.data.cassandra.domain; import org.springframework.data.annotation.Id; import org.springframework.data.cassandra.mapping.Table; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; /** * @author Mark Paluch */ @Table @Data +@AllArgsConstructor +@NoArgsConstructor public class Person { @Id String id; diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ConvertingReactiveCassandraRepositoryTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ConvertingReactiveCassandraRepositoryTests.java new file mode 100644 index 000000000..0de3b8953 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ConvertingReactiveCassandraRepositoryTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2016 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.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.cassandra.core.ReactiveCassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.config.EnableReactiveCassandraRepositories; +import org.springframework.data.cassandra.test.integration.support.IntegrationTestConfig; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.repository.reactive.RxJavaCrudRepository; +import org.springframework.stereotype.Repository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import com.datastax.driver.core.KeyspaceMetadata; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.TableMetadata; + +import reactor.core.publisher.Mono; +import reactor.test.TestSubscriber; +import rx.Observable; +import rx.Single; + +/** + * Test for {@link ReactiveCassandraRepository} using reactive wrapper type conversion. + * + * @author Mark Paluch + * @soundtrack Dj Marc - Euromix 97 Part 1 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = ConvertingReactiveCassandraRepositoryTests.Config.class) +public class ConvertingReactiveCassandraRepositoryTests extends AbstractKeyspaceCreatingIntegrationTest { + + @EnableReactiveCassandraRepositories(includeFilters = @Filter(value = Repository.class), + considerNestedRepositories = true) + @Configuration + public static class Config extends IntegrationTestConfig { + + @Override + public String[] getEntityBasePackages() { + return new String[] { Person.class.getPackage().getName() }; + } + } + + @Autowired Session session; + @Autowired ReactiveCassandraTemplate template; + @Autowired MixedPersonRepostitory reactiveRepository; + @Autowired PersonRepostitory reactivePersonRepostitory; + @Autowired RxJavaPersonRepostitory rxJavaPersonRepostitory; + + Person dave, oliver, carter, boyd; + + @Before + public void setUp() throws Exception { + + KeyspaceMetadata keyspace = session.getCluster().getMetadata().getKeyspace(session.getLoggedKeyspace()); + TableMetadata person = keyspace.getTable("person"); + + if (person.getIndex("IX_person_lastname") == null) { + + session.execute("CREATE INDEX IX_person_lastname ON person (lastname);"); + Thread.sleep(500); + } + + reactiveRepository.deleteAll().block(); + + dave = new Person("42", "Dave", "Matthews"); + oliver = new Person("4", "Oliver August", "Matthews"); + carter = new Person("49", "Carter", "Beauford"); + boyd = new Person("45", "Boyd", "Tinsley"); + + TestSubscriber subscriber = TestSubscriber.create(); + reactiveRepository.save(Arrays.asList(oliver, dave, carter, boyd)).subscribe(subscriber); + + subscriber.await().assertComplete().assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void reactiveStreamsMethodsShouldWork() throws InterruptedException { + + TestSubscriber subscriber = TestSubscriber.subscribe(reactivePersonRepostitory.exists(dave.getId())); + + subscriber.awaitAndAssertNextValueCount(1).assertNoError().assertValues(true); + } + + /** + * @see DATACASS-335 + */ + @Test + public void reactiveStreamsQueryMethodsShouldWork() { + + TestSubscriber subscriber = TestSubscriber + .subscribe(reactivePersonRepostitory.findByLastname(boyd.getLastname())); + + subscriber.awaitAndAssertNextValueCount(1).assertValues(boyd); + } + + /** + * @see DATACASS-335 + */ + @Test + public void simpleRxJavaMethodsShouldWork() { + + rx.observers.TestSubscriber subscriber = new rx.observers.TestSubscriber<>(); + rxJavaPersonRepostitory.exists(dave.getId()).subscribe(subscriber); + + subscriber.awaitTerminalEvent(); + subscriber.assertCompleted(); + subscriber.assertNoErrors(); + subscriber.assertValue(true); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsWithSingleRxJavaIdMethodsShouldWork() { + + rx.observers.TestSubscriber subscriber = new rx.observers.TestSubscriber<>(); + rxJavaPersonRepostitory.exists(Single.just(dave.getId())).subscribe(subscriber); + + subscriber.awaitTerminalEvent(); + subscriber.assertCompleted(); + subscriber.assertNoErrors(); + subscriber.assertValue(true); + } + + /** + * @see DATACASS-335 + */ + @Test + public void singleRxJavaQueryMethodShouldWork() { + + rx.observers.TestSubscriber subscriber = new rx.observers.TestSubscriber<>(); + rxJavaPersonRepostitory.findManyByLastname(dave.getLastname()).subscribe(subscriber); + + subscriber.awaitTerminalEvent(); + subscriber.assertNoErrors(); + subscriber.assertCompleted(); + subscriber.assertValueCount(2); + } + + /** + * @see DATACASS-335 + */ + @Test + public void singleProjectedRxJavaQueryMethodShouldWork() { + + rx.observers.TestSubscriber subscriber = new rx.observers.TestSubscriber<>(); + rxJavaPersonRepostitory.findProjectedByLastname(carter.getLastname()).subscribe(subscriber); + + subscriber.awaitTerminalEvent(); + subscriber.assertCompleted(); + subscriber.assertNoErrors(); + + ProjectedPerson projectedPerson = subscriber.getOnNextEvents().get(0); + assertThat(projectedPerson.getFirstname()).isEqualTo(carter.getFirstname()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void observableRxJavaQueryMethodShouldWork() { + + rx.observers.TestSubscriber subscriber = new rx.observers.TestSubscriber<>(); + rxJavaPersonRepostitory.findByLastname(boyd.getLastname()).subscribe(subscriber); + + subscriber.awaitTerminalEvent(); + subscriber.assertCompleted(); + subscriber.assertNoErrors(); + subscriber.assertValue(boyd); + } + + /** + * @see DATACASS-335 + */ + @Test + public void mixedRepositoryShouldWork() { + + Person value = reactiveRepository.findByLastname(boyd.getLastname()).toBlocking().value(); + + assertThat(value).isEqualTo(boyd); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindOneByPublisherOfLastName() { + + Person carter = reactiveRepository.findByLastname(Single.just(this.carter.getLastname())).block(); + + assertThat(carter.getFirstname()).isEqualTo(this.carter.getFirstname()); + } + + @Repository + interface PersonRepostitory extends ReactiveCrudRepository { + + Publisher findByLastname(String lastname); + } + + @Repository + interface RxJavaPersonRepostitory extends RxJavaCrudRepository { + + Observable findManyByLastname(String lastname); + + Single findByLastname(String lastname); + + Single findProjectedByLastname(String lastname); + } + + @Repository + interface MixedPersonRepostitory extends ReactiveCassandraRepository { + + Single findByLastname(String lastname); + + Mono findByLastname(Single lastname); + } + + interface ProjectedPerson { + + String getId(); + + String getFirstname(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepositoryIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepositoryIntegrationTests.java new file mode 100644 index 000000000..ff07637d5 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/ReactiveCassandraRepositoryIntegrationTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2016 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.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.reactivestreams.Publisher; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.domain.Group; +import org.springframework.data.cassandra.domain.GroupKey; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.support.ReactiveCassandraRepositoryFactory; +import org.springframework.data.cassandra.repository.support.SimpleReactiveCassandraRepository; +import org.springframework.data.cassandra.test.integration.support.IntegrationTestConfig; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.repository.query.DefaultEvaluationContextProvider; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import com.datastax.driver.core.KeyspaceMetadata; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.TableMetadata; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Test for {@link ReactiveCassandraRepository} query methods. + * + * @author Mark Paluch + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +public class ReactiveCassandraRepositoryIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest + implements BeanClassLoaderAware, BeanFactoryAware { + + @Configuration + public static class Config extends IntegrationTestConfig { + + @Override + public String[] getEntityBasePackages() { + return new String[] { Person.class.getPackage().getName() }; + } + } + + @Autowired ReactiveCassandraOperations operations; + @Autowired Session session; + + ReactiveCassandraRepositoryFactory factory; + ClassLoader classLoader; + BeanFactory beanFactory; + PersonRepository repository; + GroupRepository groupRepostitory; + + Person dave, oliver, carter, boyd; + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader == null ? org.springframework.util.ClassUtils.getDefaultClassLoader() : classLoader; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Before + public void setUp() throws Exception { + + KeyspaceMetadata keyspace = session.getCluster().getMetadata().getKeyspace(session.getLoggedKeyspace()); + TableMetadata person = keyspace.getTable("person"); + + if (person.getIndex("IX_lastname") == null) { + session.execute("CREATE INDEX IX_lastname ON person (lastname);"); + Thread.sleep(500); + } + + factory = new ReactiveCassandraRepositoryFactory(operations); + factory.setRepositoryBaseClass(SimpleReactiveCassandraRepository.class); + factory.setBeanClassLoader(classLoader); + factory.setBeanFactory(beanFactory); + factory.setEvaluationContextProvider(DefaultEvaluationContextProvider.INSTANCE); + + repository = factory.getRepository(PersonRepository.class); + groupRepostitory = factory.getRepository(GroupRepository.class); + + repository.deleteAll().block(); + groupRepostitory.deleteAll().block(); + + dave = new Person("42", "Dave", "Matthews"); + oliver = new Person("4", "Oliver August", "Matthews"); + carter = new Person("49", "Carter", "Beauford"); + boyd = new Person("45", "Boyd", "Tinsley"); + + repository.save(Arrays.asList(oliver, dave, carter, boyd)).last().block(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindByLastName() { + + List list = repository.findByLastname("Matthews").collectList().block(); + + assertThat(list).hasSize(2).contains(dave, oliver); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindOneByLastName() { + + Person carter = repository.findOneByLastname("Beauford").block(); + + assertThat(carter.getFirstname()).isEqualTo("Carter"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindOneByPublisherOfLastName() { + + Person carter = repository.findByLastname(Mono.just("Beauford")).block(); + + assertThat(carter.getFirstname()).isEqualTo("Carter"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindUsingPublishersInStringQuery() { + + List persons = repository.findStringQuery(Mono.just("Matthews")).collectList().block(); + + assertThat(persons).contains(dave); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldFindByLastNameAndSort() { + + GroupKey key1 = new GroupKey("Simpsons", "hash", "Bart"); + GroupKey key2 = new GroupKey("Simpsons", "hash", "Homer"); + + groupRepostitory.save(Flux.just(new Group(key1), new Group(key2))).blockLast(); + + List persons = groupRepostitory + .findByIdGroupnameAndIdHashPrefix("Simpsons", "hash", new Sort(Direction.ASC, "id.username")).collectList() + .block(); + assertThat(persons).containsSequence(new Group(key1), new Group(key2)); + + List reversed = groupRepostitory + .findByIdGroupnameAndIdHashPrefix("Simpsons", "hash", new Sort(Direction.DESC, "id.username")).collectList() + .block(); + assertThat(reversed).containsSequence(new Group(key2), new Group(key1)); + } + + interface PersonRepository extends ReactiveCassandraRepository { + + Flux findByLastname(String lastname); + + Mono findOneByLastname(String lastname); + + Mono findByLastname(Publisher lastname); + + @Query("SELECT * FROM person WHERE lastname = ?0") + Flux findStringQuery(Mono lastname); + } + + interface GroupRepository extends ReactiveCassandraRepository { + + Flux findByIdGroupnameAndIdHashPrefix(String groupname, String hashPrefix, Sort sort); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtensionUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtensionUnitTests.java index 796b98a06..009f65e85 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtensionUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/CassandraRepositoryConfigurationExtensionUnitTests.java @@ -58,7 +58,7 @@ public class CassandraRepositoryConfigurationExtensionUnitTests { * @see DATACASS-257 */ @Test - public void isStrictMatchIfDomainTypeIsAnnotatedWithDocument() { + public void isStrictMatchIfDomainTypeIsAnnotatedWithTable() { assertHasRepo(SampleRepository.class, extension.getRepositoryConfigurations(configurationSource, loader, true)); } @@ -74,7 +74,7 @@ public class CassandraRepositoryConfigurationExtensionUnitTests { * @see DATACASS-257 */ @Test - public void isNotStrictMatchIfDomainTypeIsNotAnnotatedWithDocument() { + public void isNotStrictMatchIfDomainTypeIsNotAnnotatedWithTable() { assertDoesNotHaveRepo(UnannotatedRepository.class, extension.getRepositoryConfigurations(configurationSource, loader, true)); @@ -101,8 +101,8 @@ public class CassandraRepositoryConfigurationExtensionUnitTests { } } - fail("Expected to find config for repository interface ".concat(repositoryInterface.getName()).concat(" but got ") - .concat(configs.toString())); + fail(String.format("Expected to find config for repository interface %s but got %s", repositoryInterface.getName(), + configs.toString())); } @EnableCassandraRepositories(considerNestedRepositories = true) diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrarUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrarUnitTests.java new file mode 100644 index 000000000..ef6c12a6d --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoriesRegistrarUnitTests.java @@ -0,0 +1,70 @@ +/* + * Copyright 2016 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.repository.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.Arrays; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cassandra.core.ReactiveSession; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.core.ReactiveCassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * Unit tests for {@link ReactiveCassandraRepositoriesRegistrar}. + * + * @author Mark Paluch + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +public class ReactiveCassandraRepositoriesRegistrarUnitTests { + + @Configuration + @EnableReactiveCassandraRepositories(basePackages = "org.springframework.data.cassandra.repository.config", + considerNestedRepositories = true, + includeFilters = @Filter(pattern = ".*ReactivePersonRepository", type = FilterType.REGEX)) + static class Config { + + @Bean + public ReactiveCassandraTemplate reactiveCassandraTemplate() throws Exception { + return new ReactiveCassandraTemplate(mock(ReactiveSession.class), new MappingCassandraConverter()); + } + } + + @Autowired ReactivePersonRepository personRepository; + @Autowired ApplicationContext context; + + /** + * @see DATACASS-335 + */ + @Test + public void testConfiguration() {} + + static interface ReactivePersonRepository extends ReactiveCassandraRepository {} +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtensionUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtensionUnitTests.java new file mode 100644 index 000000000..a027bf061 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/config/ReactiveCassandraRepositoryConfigurationExtensionUnitTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2016 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.repository.config; + +import static org.junit.Assert.*; + +import java.util.Collection; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.type.StandardAnnotationMetadata; +import org.springframework.data.cassandra.mapping.Table; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryConfiguration; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.data.repository.reactive.RxJavaCrudRepository; + +/** + * Unit tests for {@link ReactiveCassandraRepositoryConfigurationExtension}. + * + * @author Mark Paluch + */ +public class ReactiveCassandraRepositoryConfigurationExtensionUnitTests { + + StandardAnnotationMetadata metadata = new StandardAnnotationMetadata(Config.class, true); + ResourceLoader loader = new PathMatchingResourcePatternResolver(); + Environment environment = new StandardEnvironment(); + RepositoryConfigurationSource configurationSource = new AnnotationRepositoryConfigurationSource(metadata, + EnableReactiveCassandraRepositories.class, loader, environment); + + ReactiveCassandraRepositoryConfigurationExtension extension; + + @Before + public void setUp() { + extension = new ReactiveCassandraRepositoryConfigurationExtension(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void isStrictMatchIfDomainTypeIsAnnotatedWithTable() { + assertHasRepo(SampleRepository.class, extension.getRepositoryConfigurations(configurationSource, loader, true)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void isStrictMatchIfRepositoryExtendsStoreSpecificBase() { + assertHasRepo(StoreRepository.class, extension.getRepositoryConfigurations(configurationSource, loader, true)); + } + + /** + * @see DATACASS-335 + */ + @Test + public void isNotStrictMatchIfDomainTypeIsNotAnnotatedWithDocument() { + + assertDoesNotHaveRepo(UnannotatedRepository.class, + extension.getRepositoryConfigurations(configurationSource, loader, true)); + } + + private static void assertDoesNotHaveRepo(Class repositoryInterface, + Collection> configs) { + + try { + + assertHasRepo(repositoryInterface, configs); + fail("Expected not to find config for repository interface " + repositoryInterface.getName()); + } catch (AssertionError error) { + // repo not there. we're fine. + } + } + + private static void assertHasRepo(Class repositoryInterface, + Collection> configs) { + + for (RepositoryConfiguration config : configs) { + if (config.getRepositoryInterface().equals(repositoryInterface.getName())) { + return; + } + } + + fail(String.format("Expected to find config for repository interface %s but got %s", repositoryInterface.getName(), + configs.toString())); + } + + @EnableReactiveCassandraRepositories(considerNestedRepositories = true) + static class Config { + + } + + @Table + static class Sample {} + + interface SampleRepository extends RxJavaCrudRepository {} + + interface UnannotatedRepository extends ReactiveCrudRepository {} + + interface StoreRepository extends ReactiveCassandraRepository {} +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java index 9deba0a83..fd937388b 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ParameterBindingParserUnitTests.java @@ -16,17 +16,16 @@ package org.springframework.data.cassandra.repository.query; import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.cassandra.repository.query.StringBasedCassandraQuery.ParameterBindingParser.*; import java.util.ArrayList; import java.util.List; import org.junit.Test; -import org.springframework.data.cassandra.repository.query.StringBasedCassandraQuery.ParameterBinding; +import org.springframework.data.cassandra.repository.query.ExpressionEvaluatingParameterBinder.ParameterBinding; +import org.springframework.data.cassandra.repository.query.StringBasedQuery.ParameterBindingParser; /** - * Unit tests for - * {@link org.springframework.data.cassandra.repository.query.StringBasedCassandraQuery.ParameterBindingParser}. + * Unit tests for {@link ParameterBindingParser}. * * @author Mark Paluch */ @@ -39,9 +38,10 @@ public class ParameterBindingParserUnitTests { public void parseWithoutParameters() { String query = "SELECT * FROM hello_world"; - List bindings = new ArrayList(); + List bindings = new ArrayList<>(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo(query); assertThat(bindings).isEmpty(); @@ -56,7 +56,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE a = 1 AND b = {'list'} AND c = {'key':'value'}"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo(query); assertThat(bindings).isEmpty(); @@ -71,7 +72,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE a = ?0 and b = ?13"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); assertThat(bindings).hasSize(2); @@ -89,7 +91,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE a = :hello and b = :world"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); assertThat(bindings).hasSize(2); @@ -104,7 +107,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE a = ?#{[0]} and b = ?#{[2]}"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); assertThat(bindings).hasSize(2); @@ -119,7 +123,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE a = :#{#a} and b = :#{#b}"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo("SELECT * FROM hello_world WHERE a = ?_param_? and b = ?_param_?"); assertThat(bindings).hasSize(2); @@ -134,7 +139,8 @@ public class ParameterBindingParserUnitTests { String query = "SELECT * FROM hello_world WHERE (a = ?1 and b = :name) and c = (:#{#a}) and (d = ?#{[1]})"; List bindings = new ArrayList(); - String transformed = INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, bindings); + String transformed = ParameterBindingParser.INSTANCE.parseAndCollectParameterBindingsFromQueryIntoBindings(query, + bindings); assertThat(transformed).isEqualTo( "SELECT * FROM hello_world WHERE (a = ?_param_? and b = ?_param_?) and c = (?_param_?) and (d = ?_param_?)"); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessorUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessorUnitTests.java new file mode 100644 index 000000000..e07269a34 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraParameterAccessorUnitTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2016 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.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.cassandra.domain.AllPossibleTypes; +import org.springframework.data.cassandra.mapping.BasicCassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraType; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.threeten.bp.LocalDateTime; + +import com.datastax.driver.core.DataType; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Single; + +/** + * Unit tests for {@link ReactiveCassandraParameterAccessor}. + * + * @author Mark Paluch + * @soundtrack Ace Of Base - Cruel Summer (Album Edit) + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveCassandraParameterAccessorUnitTests { + + private ReactiveCassandraParameterAccessor accessor; + private GenericConversionService conversionService = new GenericConversionService(); + + @Mock ProjectionFactory projectionFactory; + + RepositoryMetadata metadata = new DefaultRepositoryMetadata(PossibleRepository.class); + CassandraMappingContext context = new BasicCassandraMappingContext(); + + /** + * @see DATACASS-335 + */ + @Test + public void returnsCassandraSimpleType() throws Exception { + + Method method = PossibleRepository.class.getMethod("findByFirstname", Flux.class); + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor( + getCassandraQueryMethod(method), new Object[] { Flux.just("firstname") }); + + assertThat(accessor.getDataType(0)).isEqualTo(DataType.varchar()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldReturnNoTypeForComplexTypes() throws Exception { + + Method method = PossibleRepository.class.getMethod("findByLocalDateTime", Mono.class); + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor( + getCassandraQueryMethod(method), new Object[] { Flux.just(LocalDateTime.of(2000, 10, 11, 12, 13, 14)) }); + + assertThat(accessor.getDataType(0)).isNull(); + + } + + /** + * @see DATACASS-335 + */ + @Test + public void returnTypeForAnnotatedParameter() throws Exception { + + Method method = PossibleRepository.class.getMethod("findByAnnotatedByLocalDateTime", Single.class); + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor( + getCassandraQueryMethod(method), new Object[] { Single.just(LocalDateTime.of(2000, 10, 11, 12, 13, 14)) }); + + assertThat(accessor.getDataType(0)).isEqualTo(DataType.date()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void returnTypeForAnnotatedParameterWhenUsingStringValue() throws Exception { + + Method method = PossibleRepository.class.getMethod("findByAnnotatedObject", Mono.class); + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor( + getCassandraQueryMethod(method), new Object[] { Mono.just("") }); + + assertThat(accessor.getDataType(0)).isEqualTo(DataType.date()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void returnTypeForAnnotatedParameterWhenUsingNullValue() throws Exception { + + Method method = PossibleRepository.class.getMethod("findByAnnotatedObject", Mono.class); + ReactiveCassandraParameterAccessor accessor = new ReactiveCassandraParameterAccessor( + getCassandraQueryMethod(method), new Object[] { Mono.just("") }); + + assertThat(accessor.getDataType(0)).isEqualTo(DataType.date()); + } + + private CassandraQueryMethod getCassandraQueryMethod(Method method) { + return new ReactiveCassandraQueryMethod(method, metadata, projectionFactory, context); + } + + interface PossibleRepository extends Repository { + + Flux findByFirstname(Flux firstname); + + Flux findByLocalDateTime(Mono dateTime); + + Flux findByAnnotatedByLocalDateTime( + @CassandraType(type = DataType.Name.DATE) Single dateTime); + + Flux findByAnnotatedObject(@CassandraType(type = DataType.Name.DATE) Mono dateTime); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethodUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethodUnitTests.java new file mode 100644 index 000000000..a3b423bd8 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveCassandraQueryMethodUnitTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016 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.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.mapping.BasicCassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Single; + +/** + * Unit tests for {@link ReactiveCassandraQueryMethod}. + * + * @author Mark Paluch + */ +public class ReactiveCassandraQueryMethodUnitTests { + + CassandraMappingContext context; + + @Before + public void setUp() { + context = new BasicCassandraMappingContext(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void considersMethodAsStreamQuery() throws Exception { + + ReactiveCassandraQueryMethod queryMethod = queryMethod(SampleRepository.class, "method"); + + assertThat(queryMethod.isStreamQuery()).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void considersMethodAsCollectionQuery() throws Exception { + + ReactiveCassandraQueryMethod queryMethod = queryMethod(SampleRepository.class, "method"); + + assertThat(queryMethod.isCollectionQuery()).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void considersMonoMethodAsEntityQuery() throws Exception { + + ReactiveCassandraQueryMethod queryMethod = queryMethod(SampleRepository.class, "mono"); + + assertThat(queryMethod.isCollectionQuery()).isFalse(); + assertThat(queryMethod.isQueryForEntity()).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void considersSingleMethodAsEntityQuery() throws Exception { + + ReactiveCassandraQueryMethod queryMethod = queryMethod(SampleRepository.class, "single"); + + assertThat(queryMethod.isCollectionQuery()).isFalse(); + assertThat(queryMethod.isQueryForEntity()).isTrue(); + } + + private ReactiveCassandraQueryMethod queryMethod(Class repository, String name, Class... parameters) + throws Exception { + + Method method = repository.getMethod(name, parameters); + ProjectionFactory factory = new SpelAwareProxyProjectionFactory(); + return new ReactiveCassandraQueryMethod(method, new DefaultRepositoryMetadata(repository), factory, context); + } + + @SuppressWarnings("unused") + interface SampleRepository extends Repository { + + Flux method(); + + Single single(); + + Mono mono(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQueryUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQueryUnitTests.java new file mode 100644 index 000000000..02e9d3060 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactivePartTreeCassandraQueryUnitTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2016 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.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.cassandra.convert.CassandraConverter; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.mapping.BasicCassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.repository.CassandraRepository; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import rx.Single; + +/** + * Unit tests for {@link ReactivePartTreeCassandraQuery}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactivePartTreeCassandraQueryUnitTests { + + @Rule public ExpectedException exception = ExpectedException.none(); + + @Mock ReactiveCassandraOperations mockCassandraOperations; + + private CassandraMappingContext mappingContext; + private CassandraConverter converter; + + @Before + public void setUp() { + mappingContext = new BasicCassandraMappingContext(); + converter = new MappingCassandraConverter(mappingContext); + + when(mockCassandraOperations.getConverter()).thenReturn(converter); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldDeriveSimpleQuery() { + String query = deriveQueryFromMethod("findByLastname", "foo"); + + assertThat(query).isEqualTo("SELECT * FROM person WHERE lastname='foo';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldDeriveSimpleQueryWithoutNames() { + String query = deriveQueryFromMethod("findPersonBy"); + + assertThat(query).isEqualTo("SELECT * FROM person;"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void shouldDeriveAndQuery() { + String query = deriveQueryFromMethod("findByFirstnameAndLastname", "foo", "bar"); + + assertThat(query).isEqualTo("SELECT * FROM person WHERE firstname='foo' AND lastname='bar';"); + } + + /** + * @see DATACASS-335 + */ + @Test + public void usesDynamicProjection() { + String query = deriveQueryFromMethod("findDynamicallyProjectedBy", PersonProjection.class); + + assertThat(query).isEqualTo("SELECT * FROM person;"); + } + + private String deriveQueryFromMethod(String method, Object... args) { + Class[] types = new Class[args.length]; + + for (int i = 0; i < args.length; i++) { + types[i] = args[i].getClass(); + } + + ReactivePartTreeCassandraQuery partTreeQuery = createQueryForMethod(method, types); + + CassandraParameterAccessor accessor = new CassandraParametersParameterAccessor(partTreeQuery.getQueryMethod(), + args); + + return partTreeQuery.createQuery(new ConvertingParameterAccessor(mockCassandraOperations.getConverter(), accessor)); + } + + private ReactivePartTreeCassandraQuery createQueryForMethod(String methodName, Class... paramTypes) { + try { + Method method = Repo.class.getMethod(methodName, paramTypes); + ProjectionFactory factory = new SpelAwareProxyProjectionFactory(); + CassandraQueryMethod queryMethod = new CassandraQueryMethod(method, new DefaultRepositoryMetadata(Repo.class), + factory, mappingContext); + + return new ReactivePartTreeCassandraQuery(queryMethod, mockCassandraOperations); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } catch (SecurityException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + @SuppressWarnings("unused") + interface Repo extends CassandraRepository { + + @Query() + Flux findByLastname(String lastname); + + Flux findByFirstnameAndLastname(String firstname, String lastname); + + Flux findPersonByFirstnameAndLastname(String firstname, String lastname); + + Flux findByAge(Integer age); + + Flux findPersonBy(); + + Mono findPersonProjectedBy(); + + Single findDynamicallyProjectedBy(Class type); + + } + + interface PersonProjection { + + String getFirstname(); + + String getLastname(); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQueryUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQueryUnitTests.java new file mode 100644 index 000000000..a670d0499 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/ReactiveStringBasedCassandraQueryUnitTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2016 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.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.cassandra.core.ReactiveCqlOperations; +import org.springframework.cassandra.core.ReactiveSession; +import org.springframework.cassandra.core.ReactiveSessionCallback; +import org.springframework.data.cassandra.convert.MappingCassandraConverter; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.mapping.BasicCassandraMappingContext; +import org.springframework.data.cassandra.repository.Query; +import org.springframework.data.cassandra.test.integration.repository.querymethods.declared.Person; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; +import org.springframework.data.repository.query.ExtensionAwareEvaluationContextProvider; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.util.ReflectionUtils; + +import com.datastax.driver.core.Cluster; +import com.datastax.driver.core.CodecRegistry; +import com.datastax.driver.core.Configuration; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.querybuilder.QueryBuilder; +import com.datastax.driver.core.querybuilder.Select; + +/** + * Unit tests for {@link StringBasedCassandraQuery}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveStringBasedCassandraQueryUnitTests { + + SpelExpressionParser PARSER = new SpelExpressionParser(); + + @Mock ReactiveCassandraOperations operations; + @Mock ReactiveCqlOperations cqlOperations; + @Mock ReactiveSession reactiveSession; + @Mock Cluster cluster; + @Mock Configuration configuration; + + RepositoryMetadata metadata; + MappingCassandraConverter converter; + ProjectionFactory factory; + + @Before + public void setUp() { + + when(operations.getConverter()).thenReturn(converter); + when(operations.getReactiveCqlOperations()).thenReturn(cqlOperations); + when(cqlOperations.execute(any(ReactiveSessionCallback.class))).thenAnswer( + invocation -> ((ReactiveSessionCallback) invocation.getArguments()[0]).doInSession(reactiveSession)); + when(reactiveSession.getCluster()).thenReturn(cluster); + when(cluster.getConfiguration()).thenReturn(configuration); + when(configuration.getCodecRegistry()).thenReturn(CodecRegistry.DEFAULT_INSTANCE); + + this.metadata = AbstractRepositoryMetadata.getMetadata(SampleRepository.class); + this.converter = new MappingCassandraConverter(new BasicCassandraMappingContext()); + this.factory = new SpelAwareProxyProjectionFactory(); + + this.converter.afterPropertiesSet(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void bindsSimplePropertyCorrectly() throws Exception { + + ReactiveStringBasedCassandraQuery cassandraQuery = getQueryMethod("findByLastname", String.class); + CassandraParametersParameterAccessor accessor = new CassandraParametersParameterAccessor( + cassandraQuery.getQueryMethod(), "White"); + + String stringQuery = cassandraQuery.createQuery(accessor); + SimpleStatement actual = new SimpleStatement(stringQuery); + + String table = Person.class.getSimpleName().toLowerCase(); + Select expected = QueryBuilder.select().all().from(table); + expected.setForceNoValues(true); + expected.where(QueryBuilder.eq("lastname", "White")); + + assertThat(actual.getQueryString()).isEqualTo(expected.getQueryString()); + } + + private ReactiveStringBasedCassandraQuery getQueryMethod(String name, Class... args) { + + Method method = ReflectionUtils.findMethod(SampleRepository.class, name, args); + CassandraQueryMethod queryMethod = new CassandraQueryMethod(method, metadata, factory, + converter.getMappingContext()); + return new ReactiveStringBasedCassandraQuery(queryMethod, operations, PARSER, + new ExtensionAwareEvaluationContextProvider()); + } + + private interface SampleRepository extends Repository { + + @Query("SELECT * FROM person WHERE lastname=?0;") + Person findByLastname(String lastname); + } +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQueryUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQueryUnitTests.java index baf13d3ab..8ab67e5b3 100755 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQueryUnitTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StringBasedCassandraQueryUnitTests.java @@ -382,6 +382,7 @@ public class StringBasedCassandraQueryUnitTests { } private StringBasedCassandraQuery getQueryMethod(String name, Class... args) { + Method method = ReflectionUtils.findMethod(SampleRepository.class, name, args); CassandraQueryMethod queryMethod = new CassandraQueryMethod(method, metadata, factory, converter.getMappingContext()); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StubParameterAccessor.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StubParameterAccessor.java index 408185079..54b0f712e 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StubParameterAccessor.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/query/StubParameterAccessor.java @@ -83,11 +83,6 @@ class StubParameterAccessor implements CassandraParameterAccessor { return values[index]; } - @Override - public Object[] getValues() { - return new Object[0]; - } - @Override public boolean hasBindableNullValue() { return false; @@ -102,4 +97,9 @@ class StubParameterAccessor implements CassandraParameterAccessor { public CassandraType findCassandraType(int index) { return null; } + + @Override + public Object[] getValues() { + return new Object[0]; + } } diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryUnitTests.java new file mode 100644 index 000000000..035b187e2 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/ReactiveCassandraRepositoryFactoryUnitTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016 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.repository.support; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.Serializable; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.data.cassandra.convert.CassandraConverter; +import org.springframework.data.cassandra.core.ReactiveCassandraTemplate; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.mapping.CassandraMappingContext; +import org.springframework.data.cassandra.mapping.CassandraPersistentEntity; +import org.springframework.data.cassandra.repository.query.CassandraEntityInformation; +import org.springframework.data.repository.Repository; + +/** + * Unit tests for {@link ReactiveCassandraRepositoryFactory}. + * + * @author Mark Paluch + */ +@RunWith(MockitoJUnitRunner.class) +@SuppressWarnings({ "rawtypes", "unchecked" }) +public class ReactiveCassandraRepositoryFactoryUnitTests { + + @Mock CassandraConverter converter; + @Mock CassandraMappingContext mappingContext; + @Mock CassandraPersistentEntity entity; + @Mock ReactiveCassandraTemplate template; + + @Before + public void setUp() { + when(template.getConverter()).thenReturn(converter); + when(converter.getMappingContext()).thenReturn(mappingContext); + } + + /** + * @see DATACASS-335 + */ + @Test + public void usesMappingCassandraEntityInformationIfMappingContextSet() { + + when(mappingContext.getPersistentEntity(Person.class)).thenReturn(entity); + when(entity.getType()).thenReturn(Person.class); + + ReactiveCassandraRepositoryFactory repositoryFactory = new ReactiveCassandraRepositoryFactory(template); + + CassandraEntityInformation entityInformation = repositoryFactory + .getEntityInformation(Person.class); + + assertThat(entityInformation).isInstanceOf(MappingCassandraEntityInformation.class); + } + + /** + * @see DATACASS-335 + */ + @Test + public void createsRepositoryWithIdTypeLong() { + + when(mappingContext.getPersistentEntity(Person.class)).thenReturn(entity); + when(entity.getType()).thenReturn(Person.class); + + ReactiveCassandraRepositoryFactory repositoryFactory = new ReactiveCassandraRepositoryFactory(template); + MyPersonRepository repository = repositoryFactory.getRepository(MyPersonRepository.class); + + assertThat(repository).isNotNull(); + } + + interface MyPersonRepository extends Repository {} +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepositoryIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepositoryIntegrationTests.java new file mode 100644 index 000000000..65016e417 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/repository/support/SimpleReactiveCassandraRepositoryIntegrationTests.java @@ -0,0 +1,467 @@ +/* + * Copyright 2016 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.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cassandra.test.integration.AbstractKeyspaceCreatingIntegrationTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.cassandra.core.ReactiveCassandraOperations; +import org.springframework.data.cassandra.domain.Person; +import org.springframework.data.cassandra.repository.ReactiveCassandraRepository; +import org.springframework.data.cassandra.test.integration.support.IntegrationTestConfig; +import org.springframework.data.repository.query.DefaultEvaluationContextProvider; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.TestSubscriber; + +/** + * Integration tests for {@link SimpleReactiveCassandraRepository}. + * + * @author Mark Paluch + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration +public class SimpleReactiveCassandraRepositoryIntegrationTests extends AbstractKeyspaceCreatingIntegrationTest + implements BeanClassLoaderAware, BeanFactoryAware { + + @Configuration + public static class Config extends IntegrationTestConfig { + + @Override + public String[] getEntityBasePackages() { + return new String[] { Person.class.getPackage().getName() }; + } + } + + @Autowired private ReactiveCassandraOperations operations; + + ReactiveCassandraRepositoryFactory factory; + ClassLoader classLoader; + BeanFactory beanFactory; + PersonRepostitory repository; + + Person dave, oliver, carter, boyd; + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader == null ? org.springframework.util.ClassUtils.getDefaultClassLoader() : classLoader; + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Before + public void setUp() { + + factory = new ReactiveCassandraRepositoryFactory(operations); + factory.setRepositoryBaseClass(SimpleReactiveCassandraRepository.class); + factory.setBeanClassLoader(classLoader); + factory.setBeanFactory(beanFactory); + factory.setEvaluationContextProvider(DefaultEvaluationContextProvider.INSTANCE); + + repository = factory.getRepository(PersonRepostitory.class); + + repository.deleteAll().block(); + + dave = new Person("42", "Dave", "Matthews"); + oliver = new Person("4", "Oliver August", "Matthews"); + carter = new Person("49", "Carter", "Beauford"); + boyd = new Person("45", "Boyd", "Tinsley"); + + repository.save(Arrays.asList(oliver, dave, carter, boyd)).last().block(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsByIdShouldReturnTrueForExistingObject() { + + Boolean exists = repository.exists(dave.getId()).block(); + + assertThat(exists).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsByIdShouldReturnFalseForAbsentObject() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.exists("unknown")); + + testSubscriber.await().assertComplete().assertValues(false).assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsByMonoOfIdShouldReturnTrueForExistingObject() { + + Boolean exists = repository.exists(Mono.just(dave.getId())).block(); + assertThat(exists).isTrue(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void existsByEmptyMonoOfIdShouldReturnEmptyMono() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.exists(Mono.empty())); + + testSubscriber.await().assertComplete().assertNoValues().assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findOneShouldReturnObject() { + + Person person = repository.findOne(dave.getId()).block(); + + assertThat(person).isEqualTo(dave); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findOneShouldCompleteWithoutValueForAbsentObject() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.findOne("unknown")); + + testSubscriber.await().assertComplete().assertNoValues().assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findOneByMonoOfIdShouldReturnTrueForExistingObject() { + + Person person = repository.findOne(Mono.just(dave.getId())).block(); + + assertThat(person).isEqualTo(dave); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findOneByEmptyMonoOfIdShouldReturnEmptyMono() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.findOne(Mono.empty())); + + testSubscriber.await().assertComplete().assertNoValues().assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findAllShouldReturnAllResults() { + + List persons = repository.findAll().collectList().block(); + + assertThat(persons).hasSize(4); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findAllByIterableOfIdShouldReturnResults() { + + List persons = repository.findAll(Arrays.asList(dave.getId(), boyd.getId())).collectList().block(); + + assertThat(persons).hasSize(2); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findAllByPublisherOfIdShouldReturnResults() { + + List persons = repository.findAll(Flux.just(dave.getId(), boyd.getId())).collectList().block(); + + assertThat(persons).hasSize(2); + } + + /** + * @see DATACASS-335 + */ + @Test + public void findAllByEmptyPublisherOfIdShouldReturnResults() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.findAll(Flux.empty())); + + testSubscriber.await().assertComplete().assertNoValues().assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void countShouldReturnNumberOfRecords() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.count()); + + testSubscriber.await().assertComplete().assertValueCount(1).assertValues(4L).assertNoError(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertEntityShouldInsertEntity() { + + repository.deleteAll().block(); + + Person person = new Person("36", "Homer", "Simpson"); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.insert(person)); + + testSubscriber.await().assertComplete().assertValueCount(1).assertValues(person); + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(1L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertShouldDeferredWrite() { + + repository.deleteAll().block(); + + Person person = new Person("36", "Homer", "Simpson"); + + repository.insert(person); + + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(0L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertIterableOfEntitiesShouldInsertEntity() { + + repository.deleteAll().block(); + + TestSubscriber testSubscriber = TestSubscriber + .subscribe(repository.insert(Arrays.asList(dave, oliver, boyd))); + + testSubscriber.await().assertComplete().assertValueCount(3); + + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(3L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void insertPublisherOfEntitiesShouldInsertEntity() { + + repository.deleteAll().block(); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.insert(Flux.just(dave, oliver, boyd))); + + testSubscriber.await().assertComplete().assertValueCount(3); + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(3L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void saveEntityShouldUpdateExistingEntity() { + + dave.setFirstname("Hello, Dave"); + dave.setLastname("Bowman"); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.save(dave)); + + testSubscriber.await().assertComplete().assertValueCount(1).assertValues(dave); + + Person loaded = repository.findOne(dave.getId()).block(); + + assertThat(loaded.getFirstname()).isEqualTo(dave.getFirstname()); + assertThat(loaded.getLastname()).isEqualTo(dave.getLastname()); + } + + /** + * @see DATACASS-335 + */ + @Test + public void saveEntityShouldInsertNewEntity() { + + Person person = new Person("36", "Homer", "Simpson"); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.save(person)); + + testSubscriber.await().assertComplete().assertValueCount(1).assertValues(person); + + Person loaded = repository.findOne(person.getId()).block(); + + assertThat(loaded).isEqualTo(person); + } + + /** + * @see DATACASS-335 + */ + @Test + public void saveIterableOfNewEntitiesShouldInsertEntity() { + + repository.deleteAll().block(); + + TestSubscriber testSubscriber = TestSubscriber + .subscribe(repository.save(Arrays.asList(dave, oliver, boyd))); + + testSubscriber.await().assertComplete().assertValueCount(3); + + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(3L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void saveIterableOfMixedEntitiesShouldInsertEntity() { + + Person person = new Person("36", "Homer", "Simpson"); + + dave.setFirstname("Hello, Dave"); + dave.setLastname("Bowman"); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.save(Arrays.asList(person, dave))); + + testSubscriber.await().assertComplete().assertValueCount(2); + + Person persistentDave = repository.findOne(dave.getId()).block(); + assertThat(persistentDave).isEqualTo(dave); + + Person persistentHomer = repository.findOne(person.getId()).block(); + assertThat(persistentHomer).isEqualTo(person); + } + + /** + * @see DATACASS-335 + */ + @Test + public void savePublisherOfEntitiesShouldInsertEntity() { + + repository.deleteAll().block(); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.save(Flux.just(dave, oliver, boyd))); + + testSubscriber.await().assertComplete().assertValueCount(3); + repository.findAll().count().subscribeWith(TestSubscriber.create()).awaitAndAssertNextValues(3L); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteAllShouldRemoveEntities() { + + repository.deleteAll().block(); + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.findAll()); + + testSubscriber.await().assertComplete().assertValueCount(0); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteByIdShouldRemoveEntity() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.delete(dave.getId())); + + testSubscriber.await().assertComplete().assertNoValues(); + + TestSubscriber verificationSubscriber = TestSubscriber.subscribe(repository.findOne(dave.getId())); + + verificationSubscriber.await().assertComplete().assertNoValues(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteShouldRemoveEntity() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.delete(dave)); + + testSubscriber.await().assertComplete().assertNoValues(); + + TestSubscriber verificationSubscriber = TestSubscriber.subscribe(repository.findOne(dave.getId())); + + verificationSubscriber.await().assertComplete().assertNoValues(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deleteIterableOfEntitiesShouldRemoveEntities() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.delete(Arrays.asList(dave, boyd))); + + testSubscriber.await().assertComplete().assertNoValues(); + + TestSubscriber verificationSubscriber = TestSubscriber.subscribe(repository.findOne(boyd.getId())); + verificationSubscriber.await().assertComplete().assertNoValues(); + } + + /** + * @see DATACASS-335 + */ + @Test + public void deletePublisherOfEntitiesShouldRemoveEntities() { + + TestSubscriber testSubscriber = TestSubscriber.subscribe(repository.delete(Flux.just(dave, boyd))); + + testSubscriber.await().assertComplete().assertNoValues(); + + TestSubscriber verificationSubscriber = TestSubscriber.subscribe(repository.findOne(boyd.getId())); + verificationSubscriber.await().assertComplete().assertNoValues(); + } + + static interface PersonRepostitory extends ReactiveCassandraRepository {} +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java index 36b0d1ed0..51fdfbc95 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/repository/querymethods/derived/QueryDerivationIntegrationTests.java @@ -16,7 +16,6 @@ package org.springframework.data.cassandra.test.integration.repository.querymethods.derived; import static org.assertj.core.api.Assertions.*; -import static org.hamcrest.Matchers.*; import static org.junit.Assume.*; import java.time.LocalDate; @@ -188,7 +187,7 @@ public class QueryDerivationIntegrationTests extends AbstractSpringDataEmbeddedC @Test public void shouldFindByNumberOfChildren() throws Exception { - assumeThat(SpringVersion.getVersion(), startsWith("4.3")); + assumeTrue(Version.parse(SpringVersion.getVersion()).isGreaterThanOrEqualTo(Version.parse("4.3"))); template.execute("CREATE INDEX IF NOT EXISTS person_number_of_children ON person (numberofchildren);"); diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/support/IntegrationTestConfig.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/support/IntegrationTestConfig.java index c633e2bbf..c8de462e9 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/support/IntegrationTestConfig.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/test/integration/support/IntegrationTestConfig.java @@ -28,6 +28,7 @@ import org.springframework.cassandra.test.integration.support.FastShutdownNettyO import org.springframework.context.annotation.Configuration; import org.springframework.data.cassandra.config.SchemaAction; import org.springframework.data.cassandra.config.java.AbstractCassandraConfiguration; +import org.springframework.data.cassandra.config.java.AbstractReactiveCassandraConfiguration; import com.datastax.driver.core.NettyOptions; import com.datastax.driver.core.QueryOptions; @@ -40,7 +41,7 @@ import com.datastax.driver.core.QueryOptions; * @author Mark Paluch */ @Configuration -public class IntegrationTestConfig extends AbstractCassandraConfiguration { +public class IntegrationTestConfig extends AbstractReactiveCassandraConfiguration { public static final CassandraConnectionProperties PROPS = new CassandraConnectionProperties(); public static final int PORT = PROPS.getCassandraPort(); diff --git a/spring-data-cassandra/src/test/java/reactor/test/TestSubscriber.java b/spring-data-cassandra/src/test/java/reactor/test/TestSubscriber.java new file mode 100644 index 000000000..25ec29451 --- /dev/null +++ b/spring-data-cassandra/src/test/java/reactor/test/TestSubscriber.java @@ -0,0 +1,1180 @@ +/* + * Copyright 2016 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 reactor.test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLongFieldUpdater; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.Fuseable; +import reactor.core.Receiver; +import reactor.core.Trackable; +import reactor.core.publisher.Operators; + + + +/** + *
+ *  ###############################################################
+ *  ###############################################################
+ *  ###############################################################
+ *
+ *  	THIS CODE IS IMPORTED FROM REACTOR-CORE BECAUSE OF
+ *  	https://github.com/reactor/reactor-core/issues/135
+ *
+ *  ###############################################################
+ *  ###############################################################
+ *  ###############################################################
+ * 
+ * + * + * A Subscriber implementation that hosts assertion tests for its state and allows + * asynchronous cancellation and requesting. + * + *

To create a new instance of {@link TestSubscriber}, you have the choice between + * these static methods: + *

    + *
  • {@link TestSubscriber#subscribe(Publisher)}: create a new {@link TestSubscriber}, + * subscribe to it with the specified {@link Publisher} and requests an unbounded + * number of elements.
  • + *
  • {@link TestSubscriber#subscribe(Publisher, long)}: create a new {@link TestSubscriber}, + * subscribe to it with the specified {@link Publisher} and requests {@code n} elements + * (can be 0 if you want no initial demand). + *
  • {@link TestSubscriber#create()}: create a new {@link TestSubscriber} and requests + * an unbounded number of elements.
  • + *
  • {@link TestSubscriber#create(long)}: create a new {@link TestSubscriber} and + * requests {@code n} elements (can be 0 if you want no initial demand). + *
+ * + *

If you are testing asynchronous publishers, don't forget to use one of the + * {@code await*()} methods to wait for the data to assert. + * + *

You can extend this class but only the onNext, onError and onComplete can be overridden. + * You can call {@link #request(long)} and {@link #cancel()} from any thread or from within + * the overridable methods but you should avoid calling the assertXXX methods asynchronously. + * + *

Usage: + *

+ * {@code
+ * TestSubscriber
+ *   .subscribe(publisher)
+ *   .await()
+ *   .assertValues("ABC", "DEF");
+ * }
+ * 
+ * + * @param the value type. + * + * @author Sebastien Deleuze + * @author David Karnok + * @author Anatoly Kadyshev + * @author Stephane Maldini + * @author Brian Clozel + */ +public class TestSubscriber + implements Subscriber, Subscription, Trackable, Receiver { + + /** + * Default timeout for waiting next values to be received + */ + public static final Duration DEFAULT_VALUES_TIMEOUT = Duration.ofSeconds(3); + + @SuppressWarnings("rawtypes") + private static final AtomicLongFieldUpdater REQUESTED = + AtomicLongFieldUpdater.newUpdater(TestSubscriber.class, "requested"); + + @SuppressWarnings("rawtypes") + private static final AtomicReferenceFieldUpdater NEXT_VALUES = + AtomicReferenceFieldUpdater.newUpdater(TestSubscriber.class, List.class, + "values"); + + @SuppressWarnings("rawtypes") + private static final AtomicReferenceFieldUpdater S = + AtomicReferenceFieldUpdater.newUpdater(TestSubscriber.class, Subscription.class, "s"); + + + private final List errors = new LinkedList<>(); + + private final CountDownLatch cdl = new CountDownLatch(1); + + volatile Subscription s; + + volatile long requested; + + volatile List values = new LinkedList<>(); + + /** + * The fusion mode to request. + */ + private int requestedFusionMode = -1; + + /** + * The established fusion mode. + */ + private volatile int establishedFusionMode = -1; + + /** + * The fuseable QueueSubscription in case a fusion mode was specified. + */ + private Fuseable.QueueSubscription qs; + + private int subscriptionCount = 0; + + private int completionCount = 0; + + private volatile long valueCount = 0L; + + private volatile long nextValueAssertedCount = 0L; + + private Duration valuesTimeout = DEFAULT_VALUES_TIMEOUT; + + private boolean valuesStorage = true; + +// ============================================================================================================== +// Static methods +// ============================================================================================================== + + /** + * Blocking method that waits until {@code conditionSupplier} returns true, or if it + * does not before the specified timeout, throws an {@link AssertionError} with the + * specified error message supplier. + * + * @param timeout the timeout duration + * @param errorMessageSupplier the error message supplier + * @param conditionSupplier condition to break out of the wait loop + * + * @throws AssertionError + */ + public static void await(Duration timeout, Supplier errorMessageSupplier, + BooleanSupplier conditionSupplier) { + + Objects.requireNonNull(errorMessageSupplier); + Objects.requireNonNull(conditionSupplier); + Objects.requireNonNull(timeout); + + long timeoutNs = timeout.toNanos(); + long startTime = System.nanoTime(); + do { + if (conditionSupplier.getAsBoolean()) { + return; + } + try { + Thread.sleep(100); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + while (System.nanoTime() - startTime < timeoutNs); + throw new AssertionError(errorMessageSupplier.get()); + } + + /** + * Blocking method that waits until {@code conditionSupplier} returns true, or if it + * does not before the specified timeout, throw an {@link AssertionError} with the + * specified error message. + * + * @param timeout the timeout duration + * @param errorMessage the error message + * @param conditionSupplier condition to break out of the wait loop + * + * @throws AssertionError + */ + public static void await(Duration timeout, + final String errorMessage, + BooleanSupplier conditionSupplier) { + await(timeout, new Supplier() { + @Override + public String get() { + return errorMessage; + } + }, conditionSupplier); + } + + /** + * Create a new {@link TestSubscriber} that requests an unbounded number of elements. + *

Be sure at least a publisher has subscribed to it via {@link Publisher#subscribe(Subscriber)} + * before use assert methods. + * @see #subscribe(Publisher) + * @param the observed value type + * @return a fresh TestSubscriber instance + */ + public static TestSubscriber create() { + return new TestSubscriber<>(); + } + + /** + * Create a new {@link TestSubscriber} that requests initially {@code n} elements. You + * can then manage the demand with {@link Subscription#request(long)}. + *

Be sure at least a publisher has subscribed to it via {@link Publisher#subscribe(Subscriber)} + * before use assert methods. + * @param n Number of elements to request (can be 0 if you want no initial demand). + * @see #subscribe(Publisher, long) + * @param the observed value type + * @return a fresh TestSubscriber instance + */ + public static TestSubscriber create(long n) { + return new TestSubscriber<>(n); + } + + /** + * Create a new {@link TestSubscriber} that requests an unbounded number of elements, + * and make the specified {@code publisher} subscribe to it. + * @param publisher The publisher to subscribe with + * @param the observed value type + * @return a fresh TestSubscriber instance + */ + public static TestSubscriber subscribe(Publisher publisher) { + TestSubscriber subscriber = new TestSubscriber<>(); + publisher.subscribe(subscriber); + return subscriber; + } + + /** + * Create a new {@link TestSubscriber} that requests initially {@code n} elements, + * and make the specified {@code publisher} subscribe to it. You can then manage the + * demand with {@link Subscription#request(long)}. + * @param publisher The publisher to subscribe with + * @param n Number of elements to request (can be 0 if you want no initial demand). + * @param the observed value type + * @return a fresh TestSubscriber instance + */ + public static TestSubscriber subscribe(Publisher publisher, long n) { + TestSubscriber subscriber = new TestSubscriber<>(n); + publisher.subscribe(subscriber); + return subscriber; + } + +// ============================================================================================================== +// Private constructors +// ============================================================================================================== + + private TestSubscriber() { + this(Long.MAX_VALUE); + } + + private TestSubscriber(long n) { + if (n < 0) { + throw new IllegalArgumentException("initialRequest >= required but it was " + n); + } + REQUESTED.lazySet(this, n); + } + +// ============================================================================================================== +// Configuration +// ============================================================================================================== + + + /** + * Enable or disabled the values storage. It is enabled by default, and can be disable + * in order to be able to perform performance benchmarks or tests with a huge amount + * values. + * @param enabled enable value storage? + * @return this + */ + public final TestSubscriber configureValuesStorage(boolean enabled) { + this.valuesStorage = enabled; + return this; + } + + /** + * Configure the timeout in seconds for waiting next values to be received (3 seconds + * by default). + * @param timeout the new default value timeout duration + * @return this + */ + public final TestSubscriber configureValuesTimeout(Duration timeout) { + this.valuesTimeout = timeout; + return this; + } + + /** + * Returns the established fusion mode or -1 if it was not enabled + * + * @return the fusion mode, see Fuseable constants + */ + public final int establishedFusionMode() { + return establishedFusionMode; + } + +// ============================================================================================================== +// Assertions +// ============================================================================================================== + + /** + * Assert a complete successfully signal has been received. + * @return this + */ + public final TestSubscriber assertComplete() { + assertNoError(); + int c = completionCount; + if (c == 0) { + throw new AssertionError("Not completed", null); + } + if (c > 1) { + throw new AssertionError("Multiple completions: " + c, null); + } + return this; + } + + /** + * Assert the specified values have been received. Values storage should be enabled to + * use this method. + * @param expectedValues the values to assert + * @see #configureValuesStorage(boolean) + * @return this + */ + public final TestSubscriber assertContainValues(Set expectedValues) { + if (!valuesStorage) { + throw new IllegalStateException( + "Using assertNoValues() requires enabling values storage"); + } + if (expectedValues.size() > values.size()) { + throw new AssertionError("Actual contains fewer elements" + values, null); + } + + Iterator expected = expectedValues.iterator(); + + for (; ; ) { + boolean n2 = expected.hasNext(); + if (n2) { + T t2 = expected.next(); + if (!values.contains(t2)) { + throw new AssertionError("The element is not contained in the " + + "received resuls" + + " = " + valueAndClass(t2), null); + } + } + else{ + break; + } + } + return this; + } + + /** + * Assert an error signal has been received. + * @return this + */ + public final TestSubscriber assertError() { + assertNotComplete(); + int s = errors.size(); + if (s == 0) { + throw new AssertionError("No error", null); + } + if (s > 1) { + throw new AssertionError("Multiple errors: " + s, null); + } + return this; + } + + /** + * Assert an error signal has been received. + * @param clazz The class of the exception contained in the error signal + * @return this + */ + public final TestSubscriber assertError(Class clazz) { + assertNotComplete(); + int s = errors.size(); + if (s == 0) { + throw new AssertionError("No error", null); + } + if (s == 1) { + Throwable e = errors.get(0); + if (!clazz.isInstance(e)) { + throw new AssertionError("Error class incompatible: expected = " + + clazz + ", actual = " + e, null); + } + } + if (s > 1) { + throw new AssertionError("Multiple errors: " + s, null); + } + return this; + } + + public final TestSubscriber assertErrorMessage(String message) { + assertNotComplete(); + int s = errors.size(); + if (s == 0) { + assertionError("No error", null); + } + if (s == 1) { + if (!Objects.equals(message, + errors.get(0) + .getMessage())) { + assertionError("Error class incompatible: expected = \"" + message + + "\", actual = \"" + errors.get(0).getMessage() + "\"", null); + } + } + if (s > 1) { + assertionError("Multiple errors: " + s, null); + } + + return this; + } + + /** + * Assert an error signal has been received. + * @param expectation A method that can verify the exception contained in the error signal + * and throw an exception (like an {@link AssertionError}) if the exception is not valid. + * @return this + */ + public final TestSubscriber assertErrorWith(Consumer expectation) { + assertNotComplete(); + int s = errors.size(); + if (s == 0) { + throw new AssertionError("No error", null); + } + if (s == 1) { + expectation.accept(errors.get(0)); + } + if (s > 1) { + throw new AssertionError("Multiple errors: " + s, null); + } + return this; + } + + /** + * Assert that the upstream was a Fuseable source. + * + * @return this + */ + public final TestSubscriber assertFuseableSource() { + if (qs == null) { + throw new AssertionError("Upstream was not Fuseable"); + } + return this; + } + + /** + * Assert that the fusion mode was granted. + * + * @return this + */ + public final TestSubscriber assertFusionEnabled() { + if (establishedFusionMode != Fuseable.SYNC && establishedFusionMode != Fuseable.ASYNC) { + throw new AssertionError("Fusion was not enabled"); + } + return this; + } + + public final TestSubscriber assertFusionMode(int expectedMode) { + if (establishedFusionMode != expectedMode) { + throw new AssertionError("Wrong fusion mode: expected: " + fusionModeName( + expectedMode) + ", actual: " + fusionModeName(establishedFusionMode)); + } + return this; + } + + /** + * Assert that the fusion mode was granted. + * + * @return this + */ + public final TestSubscriber assertFusionRejected() { + if (establishedFusionMode != Fuseable.NONE) { + throw new AssertionError("Fusion was granted"); + } + return this; + } + + /** + * Assert no error signal has been received. + * @return this + */ + public final TestSubscriber assertNoError() { + int s = errors.size(); + if (s == 1) { + Throwable e = errors.get(0); + String valueAndClass = e == null ? null : e + " (" + e.getClass().getSimpleName() + ")"; + throw new AssertionError("Error present: " + valueAndClass, null); + } + if (s > 1) { + throw new AssertionError("Multiple errors: " + s, null); + } + return this; + } + + /** + * Assert no values have been received. + * + * @return this + */ + public final TestSubscriber assertNoValues() { + if (valueCount != 0) { + throw new AssertionError("No values expected but received: [length = " + values.size() + "] " + values, + null); + } + return this; + } + + /** + * Assert that the upstream was not a Fuseable source. + * @return this + */ + public final TestSubscriber assertNonFuseableSource() { + if (qs != null) { + throw new AssertionError("Upstream was Fuseable"); + } + return this; + } + + /** + * Assert no complete successfully signal has been received. + * @return this + */ + public final TestSubscriber assertNotComplete() { + int c = completionCount; + if (c == 1) { + throw new AssertionError("Completed", null); + } + if (c > 1) { + throw new AssertionError("Multiple completions: " + c, null); + } + return this; + } + + /** + * Assert no subscription occurred. + * + * @return this + */ + public final TestSubscriber assertNotSubscribed() { + int s = subscriptionCount; + + if (s == 1) { + throw new AssertionError("OnSubscribe called once", null); + } + if (s > 1) { + throw new AssertionError("OnSubscribe called multiple times: " + s, null); + } + + return this; + } + + /** + * Assert no complete successfully or error signal has been received. + * @return this + */ + public final TestSubscriber assertNotTerminated() { + if (cdl.getCount() == 0) { + throw new AssertionError("Terminated", null); + } + return this; + } + + /** + * Assert subscription occurred (once). + * @return this + */ + public final TestSubscriber assertSubscribed() { + int s = subscriptionCount; + + if (s == 0) { + throw new AssertionError("OnSubscribe not called", null); + } + if (s > 1) { + throw new AssertionError("OnSubscribe called multiple times: " + s, null); + } + + return this; + } + + /** + * Assert either complete successfully or error signal has been received. + * @return this + */ + public final TestSubscriber assertTerminated() { + if (cdl.getCount() != 0) { + throw new AssertionError("Not terminated", null); + } + return this; + } + + /** + * Assert {@code n} values has been received. + * + * @param n the expected value count + * + * @return this + */ + public final TestSubscriber assertValueCount(long n) { + if (valueCount != n) { + throw new AssertionError("Different value count: expected = " + n + ", actual = " + valueCount, + null); + } + return this; + } + + /** + * Assert the specified values have been received in the same order read by the + * passed {@link Iterable}. Values storage + * should be enabled to + * use this method. + * @param expectedSequence the values to assert + * @see #configureValuesStorage(boolean) + * @return this + */ + public final TestSubscriber assertValueSequence(Iterable expectedSequence) { + if (!valuesStorage) { + throw new IllegalStateException("Using assertNoValues() requires enabling values storage"); + } + Iterator actual = values.iterator(); + Iterator expected = expectedSequence.iterator(); + int i = 0; + for (; ; ) { + boolean n1 = actual.hasNext(); + boolean n2 = expected.hasNext(); + if (n1 && n2) { + T t1 = actual.next(); + T t2 = expected.next(); + if (!Objects.equals(t1, t2)) { + throw new AssertionError("The element with index " + i + " does not match: expected = " + valueAndClass(t2) + ", actual = " + + valueAndClass( + t1), null); + } + i++; + } else if (n1 && !n2) { + throw new AssertionError("Actual contains more elements" + values, null); + } else if (!n1 && n2) { + throw new AssertionError("Actual contains fewer elements: " + values, null); + } else { + break; + } + } + return this; + } + + /** + * Assert the specified values have been received in the declared order. Values + * storage should be enabled to use this method. + * + * @param expectedValues the values to assert + * + * @return this + * + * @see #configureValuesStorage(boolean) + */ + @SafeVarargs + public final TestSubscriber assertValues(T... expectedValues) { + return assertValueSequence(Arrays.asList(expectedValues)); + } + + /** + * Assert the specified values have been received in the declared order. Values + * storage should be enabled to use this method. + * + * @param expectations One or more methods that can verify the values and throw a + * exception (like an {@link AssertionError}) if the value is not valid. + * + * @return this + * + * @see #configureValuesStorage(boolean) + */ + @SafeVarargs + public final TestSubscriber assertValuesWith(Consumer... expectations) { + if (!valuesStorage) { + throw new IllegalStateException( + "Using assertNoValues() requires enabling values storage"); + } + final int expectedValueCount = expectations.length; + if (expectedValueCount != values.size()) { + throw new AssertionError("Different value count: expected = " + expectedValueCount + ", actual = " + valueCount, null); + } + for (int i = 0; i < expectedValueCount; i++) { + Consumer consumer = expectations[i]; + T actualValue = values.get(i); + consumer.accept(actualValue); + } + return this; + } + +// ============================================================================================================== +// Await methods +// ============================================================================================================== + + /** + * Blocking method that waits until a complete successfully or error signal is received. + * @return this + */ + public final TestSubscriber await() { + if (cdl.getCount() == 0) { + return this; + } + try { + cdl.await(); + } catch (InterruptedException ex) { + throw new AssertionError("Wait interrupted", ex); + } + return this; + } + + /** + * Blocking method that waits until a complete successfully or error signal is received + * or until a timeout occurs. + * @param timeout The timeout value + * @return this + */ + public final TestSubscriber await(Duration timeout) { + if (cdl.getCount() == 0) { + return this; + } + try { + if (!cdl.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + throw new AssertionError("No complete or error signal before timeout"); + } + return this; + } + catch (InterruptedException ex) { + throw new AssertionError("Wait interrupted", ex); + } + } + + /** + * Blocking method that waits until {@code n} next values have been received. + * + * @param n the value count to assert + * + * @return this + */ + public final TestSubscriber awaitAndAssertNextValueCount(final long n) { + await(valuesTimeout, () -> { + if(valuesStorage){ + return String.format("%d out of %d next values received within %d, " + + "values : %s", + valueCount - nextValueAssertedCount, + n, + valuesTimeout.toMillis(), + values.toString() + ); + } + return String.format("%d out of %d next values received within %d", + valueCount - nextValueAssertedCount, + n, + valuesTimeout.toMillis()); + }, () -> valueCount >= (nextValueAssertedCount + n)); + nextValueAssertedCount += n; + return this; + } + + /** + * Blocking method that waits until {@code n} next values have been received (n is the + * number of values provided) to assert them. + * + * @param values the values to assert + * + * @return this + */ + @SafeVarargs + @SuppressWarnings("unchecked") + public final TestSubscriber awaitAndAssertNextValues(T... values) { + final int expectedNum = values.length; + final List> expectations = new ArrayList<>(); + for (int i = 0; i < expectedNum; i++) { + final T expectedValue = values[i]; + expectations.add(actualValue -> { + if (!actualValue.equals(expectedValue)) { + throw new AssertionError(String.format( + "Expected Next signal: %s, but got: %s", + expectedValue, + actualValue)); + } + }); + } + awaitAndAssertNextValuesWith(expectations.toArray((Consumer[]) new Consumer[0])); + return this; + } + + /** + * Blocking method that waits until {@code n} next values have been received + * (n is the number of expectations provided) to assert them. + * @param expectations One or more methods that can verify the values and throw a + * exception (like an {@link AssertionError}) if the value is not valid. + * @return this + */ + @SafeVarargs + public final TestSubscriber awaitAndAssertNextValuesWith(Consumer... expectations) { + valuesStorage = true; + final int expectedValueCount = expectations.length; + await(valuesTimeout, () -> { + if(valuesStorage){ + return String.format("%d out of %d next values received within %d, " + + "values : %s", + valueCount - nextValueAssertedCount, + expectedValueCount, + valuesTimeout.toMillis(), + values.toString() + ); + } + return String.format("%d out of %d next values received within %d ms", + valueCount - nextValueAssertedCount, + expectedValueCount, + valuesTimeout.toMillis()); + }, () -> valueCount >= (nextValueAssertedCount + expectedValueCount)); + List nextValuesSnapshot; + List empty = new ArrayList<>(); + for(;;){ + nextValuesSnapshot = values; + if(NEXT_VALUES.compareAndSet(this, values, empty)){ + break; + } + } + if (nextValuesSnapshot.size() < expectedValueCount) { + throw new AssertionError(String.format("Expected %d number of signals but received %d", + expectedValueCount, + nextValuesSnapshot.size())); + } + for (int i = 0; i < expectedValueCount; i++) { + Consumer consumer = expectations[i]; + T actualValue = nextValuesSnapshot.get(i); + consumer.accept(actualValue); + } + nextValueAssertedCount += expectedValueCount; + return this; + } + +// ============================================================================================================== +// Overrides +// ============================================================================================================== + + @Override + public void cancel() { + Subscription a = s; + if (a != Operators.cancelledSubscription()) { + a = S.getAndSet(this, Operators.cancelledSubscription()); + if (a != null && a != Operators.cancelledSubscription()) { + a.cancel(); + } + } + } + + @Override + public final boolean isCancelled() { + return s == Operators.cancelledSubscription(); + } + + @Override + public final boolean isStarted() { + return s != null; + } + + @Override + public final boolean isTerminated() { + return isCancelled(); + } + + @Override + public void onComplete() { + completionCount++; + cdl.countDown(); + } + + @Override + public void onError(Throwable t) { + errors.add(t); + cdl.countDown(); + } + + @Override + public void onNext(T t) { + if (establishedFusionMode == Fuseable.ASYNC) { + for (; ; ) { + t = qs.poll(); + if (t == null) { + break; + } + valueCount++; + if (valuesStorage) { + List nextValuesSnapshot; + for (; ; ) { + nextValuesSnapshot = values; + nextValuesSnapshot.add(t); + if (NEXT_VALUES.compareAndSet(this, + nextValuesSnapshot, + nextValuesSnapshot)) { + break; + } + } + } + } + } + else { + valueCount++; + if (valuesStorage) { + List nextValuesSnapshot; + for (; ; ) { + nextValuesSnapshot = values; + nextValuesSnapshot.add(t); + if (NEXT_VALUES.compareAndSet(this, + nextValuesSnapshot, + nextValuesSnapshot)) { + break; + } + } + } + } + } + + @Override + @SuppressWarnings("unchecked") + public void onSubscribe(Subscription s) { + subscriptionCount++; + int requestMode = requestedFusionMode; + if (requestMode >= 0) { + if (!setWithoutRequesting(s)) { + if (!isCancelled()) { + errors.add(new IllegalStateException("Subscription already set: " + + subscriptionCount)); + } + } else { + if (s instanceof Fuseable.QueueSubscription) { + this.qs = (Fuseable.QueueSubscription)s; + + int m = qs.requestFusion(requestMode); + establishedFusionMode = m; + + if (m == Fuseable.SYNC) { + for (;;) { + T v = qs.poll(); + if (v == null) { + onComplete(); + break; + } + + onNext(v); + } + } + else { + requestDeferred(); + } + } + else { + requestDeferred(); + } + } + } else { + if (!set(s)) { + if (!isCancelled()) { + errors.add(new IllegalStateException("Subscription already set: " + + subscriptionCount)); + } + } + } + } + + @Override + public void request(long n) { + if (Operators.validate(n)) { + if (establishedFusionMode != Fuseable.SYNC) { + normalRequest(n); + } + } + } + + @Override + public final long requestedFromDownstream() { + return requested; + } + + /** + * Setup what fusion mode should be requested from the incomining + * Subscription if it happens to be QueueSubscription + * @param requestMode the mode to request, see Fuseable constants + * @return this + */ + public final TestSubscriber requestedFusionMode(int requestMode) { + this.requestedFusionMode = requestMode; + return this; + } + + @Override + public Subscription upstream() { + return s; + } + + +// ============================================================================================================== +// Non public methods +// ============================================================================================================== + + protected final void normalRequest(long n) { + Subscription a = s; + if (a != null) { + a.request(n); + } else { + Operators.addAndGet(REQUESTED, this, n); + + a = s; + + if (a != null) { + long r = REQUESTED.getAndSet(this, 0L); + + if (r != 0L) { + a.request(r); + } + } + } + } + + /** + * Requests the deferred amount if not zero. + */ + protected final void requestDeferred() { + long r = REQUESTED.getAndSet(this, 0L); + + if (r != 0L) { + s.request(r); + } + } + + /** + * Atomically sets the single subscription and requests the missed amount from it. + * + * @param s + * @return false if this arbiter is cancelled or there was a subscription already set + */ + protected final boolean set(Subscription s) { + Objects.requireNonNull(s, "s"); + Subscription a = this.s; + if (a == Operators.cancelledSubscription()) { + s.cancel(); + return false; + } + if (a != null) { + s.cancel(); + Operators.reportSubscriptionSet(); + return false; + } + + if (S.compareAndSet(this, null, s)) { + + long r = REQUESTED.getAndSet(this, 0L); + + if (r != 0L) { + s.request(r); + } + + return true; + } + + a = this.s; + + if (a != Operators.cancelledSubscription()) { + s.cancel(); + return false; + } + + Operators.reportSubscriptionSet(); + return false; + } + + /** + * Sets the Subscription once but does not request anything. + * @param s the Subscription to set + * @return true if successful, false if the current subscription is not null + */ + protected final boolean setWithoutRequesting(Subscription s) { + Objects.requireNonNull(s, "s"); + for (;;) { + Subscription a = this.s; + if (a == Operators.cancelledSubscription()) { + s.cancel(); + return false; + } + if (a != null) { + s.cancel(); + Operators.reportSubscriptionSet(); + return false; + } + + if (S.compareAndSet(this, null, s)) { + return true; + } + } + } + + /** + * Prepares and throws an AssertionError exception based on the message, cause, the + * active state and the potential errors so far. + * + * @param message the message + * @param cause the optional Throwable cause + * + * @throws AssertionError as expected + */ + protected final void assertionError(String message, Throwable cause) { + StringBuilder b = new StringBuilder(); + + if (cdl.getCount() != 0) { + b.append("(active) "); + } + b.append(message); + + List err = errors; + if (!err.isEmpty()) { + b.append(" (+ ") + .append(err.size()) + .append(" errors)"); + } + AssertionError e = new AssertionError(b.toString(), cause); + + for (Throwable t : err) { + e.addSuppressed(t); + } + + throw e; + } + + protected final String fusionModeName(int mode) { + switch (mode) { + case -1: + return "Disabled"; + case Fuseable.NONE: + return "None"; + case Fuseable.SYNC: + return "Sync"; + case Fuseable.ASYNC: + return "Async"; + default: + return "Unknown(" + mode + ")"; + } + } + + protected final String valueAndClass(Object o) { + if (o == null) { + return null; + } + return o + " (" + o.getClass().getSimpleName() + ")"; + } + +}