Explore returning Search Results.

Closes #1573
This commit is contained in:
Mark Paluch
2025-04-16 10:03:08 +02:00
parent 1b0a4da628
commit 0e1e594d13
26 changed files with 892 additions and 144 deletions

View File

@@ -20,6 +20,7 @@ import java.util.List;
import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
import org.springframework.dao.DataAccessException;
import org.springframework.data.cassandra.core.convert.CassandraConverter;
import org.springframework.data.cassandra.core.cql.CqlOperations;
@@ -92,7 +93,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
/**
* The table name used for the specified class by this template.
*
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @return the {@link CqlIdentifier}
*/
CqlIdentifier getTableName(Class<?> entityClass);
@@ -105,7 +106,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Execute a {@code SELECT} query and convert the resulting items to a {@link List} of entities.
*
* @param cql must not be {@literal null}.
* @param entityClass The entity type 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 executing the query.
*/
@@ -129,7 +130,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* 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}.
* @param entityClass the entity type must not be {@literal null}.
* @return the converted object or {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
@@ -154,7 +155,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Execute a {@code SELECT} query and convert the resulting items to a {@link List} of entities.
*
* @param statement must not be {@literal null}.
* @param entityClass The entity type 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 executing the query.
*/
@@ -165,7 +166,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* query translates the effective {@link Statement#getPageSize()} to the page size.
*
* @param statement the CQL statement, must not be {@literal null}.
* @param entityClass The entity type 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 executing the query.
* @since 2.0
@@ -190,7 +191,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* 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}.
* @param entityClass the entity type must not be {@literal null}.
* @return the converted object or {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
@@ -204,7 +205,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Execute a {@code SELECT} query and convert the resulting items to a {@link List} of entities.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type 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 executing the query.
* @since 2.0
@@ -215,7 +216,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Execute a {@code SELECT} query with paging and convert the result set to a {@link Slice} of entities.
*
* @param query the query object used to create a CQL statement, must not be {@literal null}.
* @param entityClass The entity type 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 executing the query.
* @since 2.0
@@ -241,7 +242,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Execute a {@code SELECT} query and convert the resulting item to an entity.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @return the converted object or {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
* @since 2.0
@@ -253,7 +254,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
*
* @param query must not be {@literal null}.
* @param update must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
boolean update(Query query, Update update, Class<?> entityClass) throws DataAccessException;
@@ -262,7 +263,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* Remove entities (rows)/columns from the table by {@link Query}.
*
* @param query must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
boolean delete(Query query, Class<?> entityClass) throws DataAccessException;
@@ -322,7 +323,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* @param id the Id value. For single primary keys it's the plain value. For composite primary keys either the
* {@link org.springframework.data.cassandra.core.mapping.PrimaryKeyClass} or
* {@link org.springframework.data.cassandra.core.mapping.MapId}. Must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @return the converted object or {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
@@ -407,7 +408,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
* @param id the Id value. For single primary keys it's the plain value. For composite primary keys either the
* {@link org.springframework.data.cassandra.core.mapping.PrimaryKeyClass} or
* {@link org.springframework.data.cassandra.core.mapping.MapId}. Must not be {@literal null}.
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
boolean deleteById(Object id, Class<?> entityClass) throws DataAccessException;
@@ -415,7 +416,7 @@ public interface CassandraOperations extends FluentCassandraOperations {
/**
* Execute a {@code TRUNCATE} query to remove all entities of a given class.
*
* @param entityClass The entity type must not be {@literal null}.
* @param entityClass the entity type must not be {@literal null}.
* @throws DataAccessException if there is any problem executing the query.
*/
void truncate(Class<?> entityClass) throws DataAccessException;

View File

@@ -354,7 +354,8 @@ public class CassandraTemplate implements CassandraOperations, ApplicationEventP
Assert.notNull(statement, "Statement must not be null");
Assert.notNull(entityClass, "Entity type must not be null");
return doSelect(statement, entityClass, getTableName(entityClass), entityClass, QueryResultConverter.entity());
return doSelect(statement, entityClass, EntityQueryUtils.getTableName(statement), entityClass,
QueryResultConverter.entity());
}
<T, R> List<R> doSelect(Statement<?> statement, Class<?> entityClass, CqlIdentifier tableName, Class<T> returnType,

View File

@@ -767,12 +767,11 @@ public class StatementFactory {
.stream().map(param -> {
if (param instanceof ColumnSelector s) {
return com.datastax.oss.driver.api.querybuilder.select.Selector.column(s.getExpression());
return com.datastax.oss.driver.api.querybuilder.select.Selector.column(s.getIdentifier());
}
if (param instanceof CqlIdentifier i) {
return com.datastax.oss.driver.api.querybuilder.select.Selector.column(i.toString());
return com.datastax.oss.driver.api.querybuilder.select.Selector.column(i);
}
return new SimpleSelector(param.toString());

View File

@@ -73,11 +73,9 @@ public abstract class CassandraConverters {
converters.add(VectorToFloatArrayConverter.INSTANCE);
converters.add(VectorToDoubleArrayConverter.INSTANCE);
converters.add(VectorToFloatListConverter.INSTANCE);
converters.add(FloatArrayToVectorConverter.INSTANCE);
converters.add(DoubleArrayToVectorConverter.INSTANCE);
converters.add(NumberListToVectorConverter.INSTANCE);
converters.add(VectorToCqlVectorConverter.INSTANCE);
converters.add(CqlVectorToVectorConverter.INSTANCE);
@@ -307,17 +305,6 @@ public abstract class CassandraConverters {
}
}
@ReadingConverter
public enum NumberListToVectorConverter implements Converter<List<Number>, CqlVector<Number>> {
INSTANCE;
@Override
public CqlVector<Number> convert(List<Number> source) {
return CqlVector.newInstance(source);
}
}
@ReadingConverter
public enum VectorToFloatArrayConverter implements Converter<CqlVector<Number>, float[]> {
@@ -348,18 +335,4 @@ public abstract class CassandraConverters {
}
}
@ReadingConverter
public enum VectorToFloatListConverter implements Converter<CqlVector<Number>, List<Float>> {
INSTANCE;
@Override
public List<Float> convert(CqlVector<Number> source) {
List<Float> values = new ArrayList<>(source.size());
for (int i = 0; i < source.size(); i++) {
values.add(source.get(i).floatValue());
}
return values;
}
}
}

View File

@@ -199,7 +199,7 @@ public class QueryMapper {
Field field = createPropertyField(entity, column);
columns.getSelector(column).ifPresent(selector -> {
columns.getSelector(column).forEach(selector -> {
List<CqlIdentifier> mappedColumnNames = getCqlIdentifier(column, field);
@@ -301,8 +301,12 @@ public class QueryMapper {
Field field = createPropertyField(entity, column);
field.getProperty().ifPresent(seen::add);
columns.getSelector(column).filter(selector -> selector instanceof ColumnSelector)
.ifPresent(columnSelector -> columnNames.addAll(getCqlIdentifier(column, field)));
columns.getSelector(column).forEach(columnSelector -> {
if (columnSelector instanceof ColumnSelector) {
columnNames.addAll(getCqlIdentifier(column, field));
}
});
}
if (columns.isEmpty()) {

View File

@@ -169,7 +169,7 @@ public abstract class ColumnName {
@Override
public String toCql() {
return this.cqlIdentifier.toString();
return this.cqlIdentifier.asInternal();
}
@Override

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.cassandra.core.query;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
@@ -26,10 +27,11 @@ import java.util.Map.Entry;
import java.util.Optional;
import java.util.function.Function;
import org.jspecify.annotations.Nullable;
import org.springframework.data.cassandra.core.convert.CassandraVector;
import org.springframework.data.cassandra.core.mapping.SimilarityFunction;
import org.springframework.data.domain.Vector;
import org.jspecify.annotations.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
@@ -51,9 +53,9 @@ import com.datastax.oss.driver.api.core.data.CqlVector;
*/
public class Columns implements Iterable<ColumnName> {
private final Map<ColumnName, Selector> columns;
private final Map<ColumnName, List<Selector>> columns;
private Columns(Map<ColumnName, Selector> columns) {
private Columns(Map<ColumnName, List<Selector>> columns) {
this.columns = Collections.unmodifiableMap(columns);
}
@@ -76,10 +78,10 @@ public class Columns implements Iterable<ColumnName> {
Assert.notNull(columnNames, "Column names must not be null");
Map<ColumnName, Selector> columns = new LinkedHashMap<>(columnNames.length, 1);
Map<ColumnName, List<Selector>> columns = new LinkedHashMap<>(columnNames.length, 1);
for (String columnName : columnNames) {
columns.put(ColumnName.from(columnName), ColumnSelector.from(columnName));
columns.put(ColumnName.from(columnName), new ArrayList<>(List.of(ColumnSelector.from(columnName))));
}
return new Columns(columns);
@@ -95,10 +97,10 @@ public class Columns implements Iterable<ColumnName> {
Assert.notNull(columnNames, "Column names must not be null");
Map<ColumnName, Selector> columns = new LinkedHashMap<>(columnNames.length, 1);
Map<ColumnName, List<Selector>> columns = new LinkedHashMap<>(columnNames.length, 1);
for (CqlIdentifier cqlId : columnNames) {
columns.put(ColumnName.from(cqlId), ColumnSelector.from(cqlId));
columns.put(ColumnName.from(cqlId), new ArrayList<>(List.of(ColumnSelector.from(cqlId))));
}
return new Columns(columns);
@@ -209,8 +211,8 @@ public class Columns implements Iterable<ColumnName> {
*/
private Columns select(ColumnName columnName, Selector selector) {
Map<ColumnName, Selector> result = new LinkedHashMap<>(this.columns);
result.put(columnName, selector);
Map<ColumnName, List<Selector>> result = new LinkedHashMap<>(this.columns);
result.computeIfAbsent(columnName, it -> new ArrayList<>()).add(selector);
return new Columns(result);
}
@@ -231,9 +233,10 @@ public class Columns implements Iterable<ColumnName> {
*/
public Columns and(Columns columns) {
Map<ColumnName, Selector> result = new LinkedHashMap<>(this.columns);
Map<ColumnName, List<Selector>> result = new LinkedHashMap<>(this.columns);
result.putAll(columns.columns);
columns.columns
.forEach((col, selectors) -> result.computeIfAbsent(col, columnName -> new ArrayList<>()).addAll(selectors));
return new Columns(result);
}
@@ -247,11 +250,12 @@ public class Columns implements Iterable<ColumnName> {
* @param columnName must not be {@literal null}.
* @return the {@link Optional} {@link Selector} for {@link ColumnName}.
*/
public Optional<Selector> getSelector(ColumnName columnName) {
public List<Selector> getSelector(ColumnName columnName) {
Assert.notNull(columnName, "ColumnName must not be null");
return Optional.ofNullable(this.columns.get(columnName));
List<Selector> selectors = this.columns.get(columnName);
return selectors == null ? List.of() : selectors;
}
@Override
@@ -280,7 +284,7 @@ public class Columns implements Iterable<ColumnName> {
@Override
public String toString() {
Iterator<Entry<ColumnName, Selector>> iterator = this.columns.entrySet().iterator();
Iterator<Entry<ColumnName, List<Selector>>> iterator = this.columns.entrySet().iterator();
StringBuilder builder = toString(iterator);
if (builder.isEmpty()) {
@@ -290,24 +294,26 @@ public class Columns implements Iterable<ColumnName> {
return builder.toString();
}
private StringBuilder toString(Iterator<Entry<ColumnName, Selector>> iterator) {
private StringBuilder toString(Iterator<Entry<ColumnName, List<Selector>>> iterator) {
StringBuilder builder = new StringBuilder();
boolean first = true;
while (iterator.hasNext()) {
Entry<ColumnName, Selector> entry = iterator.next();
Entry<ColumnName, List<Selector>> entry = iterator.next();
Selector expression = entry.getValue();
for (Selector selector : entry.getValue()) {
if (first) {
first = false;
} else {
builder.append(", ");
if (first) {
first = false;
} else {
builder.append(", ");
}
builder.append(selector);
}
builder.append(expression.toString());
}
return builder;
@@ -340,6 +346,9 @@ public class Columns implements Iterable<ColumnName> {
*/
Selector as(CqlIdentifier alias);
/**
* @return the expression that forms this selection.
*/
String getExpression();
Optional<CqlIdentifier> getAlias();
@@ -410,6 +419,10 @@ public class Columns implements Iterable<ColumnName> {
return alias;
}
public CqlIdentifier getIdentifier() {
return columnName.getCqlIdentifier().orElseGet(() -> CqlIdentifier.fromCql(columnName.toCql()));
}
@Override
public String getExpression() {
return columnName.toCql();
@@ -662,8 +675,9 @@ public class Columns implements Iterable<ColumnName> {
@Override
public Selector using(SimilarityFunction similarityFunction) {
return FunctionCall.from("similarity_" + similarityFunction.name().toLowerCase(Locale.ROOT), columnName,
vector).as(columnName);
return FunctionCall
.from("similarity_" + similarityFunction.name().toLowerCase(Locale.ROOT), columnName, vector)
.as(columnName);
}
};
}

View File

@@ -57,7 +57,7 @@ abstract class SerializationUtils {
CriteriaDefinition.Predicate predicate = criteria.getPredicate();
return String.format("%s %s", criteria.getColumnName(),
predicate.getOperator().toCql(serializeToCqlSafely(predicate.getValue())));
predicate.getOperator().toCql(predicate != null ? serializeToCqlSafely(predicate.getValue()) : ""));
}
/**

View File

@@ -16,17 +16,10 @@
package org.springframework.data.cassandra.repository.query;
import org.jspecify.annotations.Nullable;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.cassandra.core.CassandraOperations;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.CollectionExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.ExistsExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.ResultProcessingConverter;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.ResultProcessingExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.ResultSetQuery;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.SingleEntityExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.SlicedExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.StreamExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.WindowExecution;
import org.springframework.data.cassandra.repository.query.CassandraQueryExecution.*;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
@@ -120,6 +113,8 @@ public abstract class AbstractCassandraQuery extends CassandraRepositoryQuerySup
return new SlicedExecution(getOperations(), parameterAccessor.getPageable());
} else if (getQueryMethod().isScrollQuery()) {
return new WindowExecution(getOperations(), parameterAccessor.getScrollPosition(), parameterAccessor.getLimit());
} else if (getQueryMethod().isSearchQuery()) {
return new SearchExecution(getOperations(), parameterAccessor);
} else if (getQueryMethod().isCollectionQuery()) {
return new CollectionExecution(getOperations());
} else if (getQueryMethod().isResultSetQuery()) {

View File

@@ -18,6 +18,7 @@ package org.springframework.data.cassandra.repository.query;
import reactor.core.publisher.Mono;
import org.reactivestreams.Publisher;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.cassandra.ReactiveResultSet;
import org.springframework.data.cassandra.core.CassandraOperations;
@@ -26,6 +27,7 @@ import org.springframework.data.cassandra.repository.query.ReactiveCassandraQuer
import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.ExistsExecution;
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.SearchExecution;
import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.SingleEntityExecution;
import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.SlicedExecution;
import org.springframework.data.cassandra.repository.query.ReactiveCassandraQueryExecution.WindowExecution;
@@ -126,6 +128,8 @@ public abstract class AbstractReactiveCassandraQuery extends CassandraRepository
} else if (getQueryMethod().isScrollQuery()) {
return new WindowExecution(getReactiveCassandraOperations(), parameterAccessor.getScrollPosition(),
parameterAccessor.getLimit());
} else if (getQueryMethod().isSearchQuery()) {
return new SearchExecution(getReactiveCassandraOperations(), parameterAccessor);
} else if (getQueryMethod().isCollectionQuery()) {
return new CollectionExecution(getReactiveCassandraOperations());
} else if (isCountQuery()) {

View File

@@ -20,9 +20,9 @@ import java.util.Collections;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Limit;
import org.springframework.data.mapping.model.ValueExpressionEvaluator;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.util.Assert;
/**
@@ -35,7 +35,7 @@ class BindingContext {
private final CassandraParameters parameters;
private final ParameterAccessor parameterAccessor;
private final CassandraParameterAccessor parameterAccessor;
private final List<ParameterBinding> bindings;
@@ -44,7 +44,7 @@ class BindingContext {
/**
* Create new {@link BindingContext}.
*/
BindingContext(CassandraParameters parameters, ParameterAccessor parameterAccessor,
BindingContext(CassandraParameters parameters, CassandraParameterAccessor parameterAccessor,
List<ParameterBinding> bindings, ValueExpressionEvaluator evaluator) {
this.parameters = parameters;
@@ -75,8 +75,9 @@ class BindingContext {
List<Object> parameters = new ArrayList<>(bindings.size());
for (ParameterBinding binding : bindings) {
Object parameterValueForBinding = getParameterValueForBinding(binding);
parameters.add(parameterValueForBinding);
parameters.add(binding.prepareValue(parameterValueForBinding));
}
return parameters;
@@ -95,20 +96,20 @@ class BindingContext {
}
return binding.isNamed()
? parameterAccessor.getBindableValue(getParameterIndex(parameters, binding.getRequiredParameterName()))
? parameterAccessor.getValue(getParameterIndex(parameters, binding.getRequiredParameterName()))
: parameterAccessor.getBindableValue(binding.getParameterIndex());
}
private int getParameterIndex(CassandraParameters parameters, String parameterName) {
return parameters.stream() //
.filter(cassandraParameter -> cassandraParameter //
.getName().filter(s -> s.equals(parameterName)) //
.isPresent()) //
.mapToInt(Parameter::getIndex) //
.findFirst() //
.orElseThrow(() -> new IllegalArgumentException(
String.format("Invalid parameter name; Cannot resolve parameter [%s]", parameterName)));
for (CassandraParameters.CassandraParameter parameter : parameters) {
if (parameter.getName().filter(s -> s.equals(parameterName)).isPresent()) {
return parameter.getIndex();
}
}
throw new IllegalArgumentException(
String.format("Invalid parameter name; Cannot resolve parameter [%s]", parameterName));
}
/**
@@ -129,31 +130,31 @@ class BindingContext {
this.parameterName = parameterName;
}
static ParameterBinding expression(String expression, boolean quoted) {
public static ParameterBinding expression(String expression, boolean quoted) {
return new ParameterBinding(-1, expression, null);
}
static ParameterBinding indexed(int parameterIndex) {
public static ParameterBinding indexed(int parameterIndex) {
return new ParameterBinding(parameterIndex, null, null);
}
static ParameterBinding named(String name) {
public static ParameterBinding named(String name) {
return new ParameterBinding(-1, null, name);
}
boolean isNamed() {
public boolean isNamed() {
return (parameterName != null);
}
int getParameterIndex() {
public int getParameterIndex() {
return parameterIndex;
}
String getParameter() {
public String getParameter() {
return ("?" + (isExpression() ? "expr" : "") + parameterIndex);
}
String getRequiredExpression() {
public String getRequiredExpression() {
Assert.state(expression != null, "ParameterBinding is not an expression");
return expression;
@@ -169,5 +170,24 @@ class BindingContext {
return parameterName;
}
/**
* Prepare a value before binding it to the query.
*
* @param value
* @return
*/
public @Nullable Object prepareValue(@Nullable Object value) {
if (value == null) {
return value;
}
if (value instanceof Limit limit) {
return limit.max();
}
return value;
}
}
}

View File

@@ -16,9 +16,11 @@
package org.springframework.data.cassandra.repository.query;
import org.jspecify.annotations.Nullable;
import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraType;
import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.repository.query.ParameterAccessor;
import com.datastax.oss.driver.api.core.type.DataType;
@@ -65,6 +67,17 @@ public interface CassandraParameterAccessor extends ParameterAccessor {
*/
Class<?> getParameterType(int index);
/**
* Get the value of the parameter at the given index. In contrast to {@link #getBindableValue(int)}, this method has
* access to all parameters.
*
* @param parameterIndex
* @return
* @since 5.0
*/
@Nullable
Object getValue(int parameterIndex);
/**
* Returns the raw parameter values of the underlying query method.
*
@@ -76,6 +89,16 @@ public interface CassandraParameterAccessor extends ParameterAccessor {
@Override
CassandraScrollPosition getScrollPosition();
/**
* Returns the {@link ScoringFunction} from a {@link org.springframework.data.domain.Score} or
* {@link org.springframework.data.domain.Range} of scores if such a parameter is declared.
*
* @return the scoring function or {@literal null} if none is provided.
* @since 5.0
*/
@Nullable
ScoringFunction getScoringFunction();
/**
* Returns the {@link QueryOptions} associated with the associated Repository query method.
*
@@ -85,4 +108,5 @@ public interface CassandraParameterAccessor extends ParameterAccessor {
@Nullable
QueryOptions getQueryOptions();
}

View File

@@ -29,6 +29,9 @@ import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraType;
import org.springframework.data.cassandra.repository.query.CassandraParameters.CassandraParameter;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.ParametersSource;
@@ -46,6 +49,7 @@ import org.springframework.data.util.TypeInformation;
public class CassandraParameters extends Parameters<CassandraParameters, CassandraParameter> {
private final @Nullable Integer queryOptionsIndex;
private final @Nullable Integer scoringFunctionIndex;
/**
* Create a new {@link CassandraParameters} instance from the given {@link Method}.
@@ -58,18 +62,23 @@ public class CassandraParameters extends Parameters<CassandraParameters, Cassand
this.queryOptionsIndex = Arrays.asList(parametersSource.getMethod().getParameterTypes())
.indexOf(QueryOptions.class);
this.scoringFunctionIndex = Arrays.asList(parametersSource.getMethod().getParameterTypes())
.indexOf(ScoringFunction.class);
}
private CassandraParameters(List<CassandraParameter> originals, @Nullable Integer queryOptionsIndex) {
private CassandraParameters(List<CassandraParameter> originals, @Nullable Integer queryOptionsIndex,
@Nullable Integer scoringFunctionIndex) {
super(originals);
this.queryOptionsIndex = queryOptionsIndex;
this.scoringFunctionIndex = scoringFunctionIndex;
}
@Override
protected CassandraParameters createFrom(List<CassandraParameter> parameters) {
return new CassandraParameters(parameters, queryOptionsIndex);
return new CassandraParameters(parameters, queryOptionsIndex, scoringFunctionIndex);
}
/**
@@ -82,6 +91,16 @@ public class CassandraParameters extends Parameters<CassandraParameters, Cassand
return (queryOptionsIndex != null ? queryOptionsIndex : -1);
}
/**
* Returns the index of the {@link ScoringFunction} parameter to be applied to queries.
*
* @return
* @since 5.0
*/
public int getScoringFunctionIndex() {
return (scoringFunctionIndex != null ? scoringFunctionIndex : -1);
}
/**
* Custom {@link Parameter} implementation adding {@link CassandraType} support.
*
@@ -91,6 +110,8 @@ public class CassandraParameters extends Parameters<CassandraParameters, Cassand
private final @Nullable CassandraType cassandraType;
private final Class<?> parameterType;
private final boolean isScoreRange;
private final boolean isScoringFunction;
CassandraParameter(MethodParameter parameter, TypeInformation<?> domainType) {
@@ -104,12 +125,17 @@ public class CassandraParameters extends Parameters<CassandraParameters, Cassand
this.cassandraType = null;
}
parameterType = potentiallyUnwrapParameterType(parameter);
this.parameterType = potentiallyUnwrapParameterType(parameter);
ResolvableType type = ResolvableType.forMethodParameter(parameter);
this.isScoreRange = Range.class.isAssignableFrom(getType()) && type.getGeneric(0).isAssignableFrom(Score.class);
this.isScoringFunction = ScoringFunction.class.isAssignableFrom(getType());
}
@Override
public boolean isSpecialParameter() {
return super.isSpecialParameter() || QueryOptions.class.isAssignableFrom(getType());
return super.isSpecialParameter() || isScoreRange || isScoringFunction || Score.class.isAssignableFrom(getType())
|| QueryOptions.class.isAssignableFrom(getType());
}
/**

View File

@@ -16,11 +16,15 @@
package org.springframework.data.cassandra.repository.query;
import org.jspecify.annotations.Nullable;
import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraSimpleTypeHolder;
import org.springframework.data.cassandra.core.mapping.CassandraType;
import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.repository.query.ParameterAccessor;
import org.springframework.data.repository.query.ParametersParameterAccessor;
@@ -79,6 +83,11 @@ public class CassandraParametersParameterAccessor extends ParametersParameterAcc
return super.getValues();
}
@Override
public @Nullable Object getValue(int parameterIndex) {
return super.getValue(parameterIndex);
}
@Override
public CassandraScrollPosition getScrollPosition() {
@@ -95,6 +104,36 @@ public class CassandraParametersParameterAccessor extends ParametersParameterAcc
"Unsupported scroll position " + scrollPosition + ". Only CassandraScrollPosition supported.");
}
@Override
public @Nullable ScoringFunction getScoringFunction() {
Score score = getScore();
if (score != null) {
return score.getFunction();
}
Range<Score> range = getScoreRange();
if (range != null) {
if (range.getLowerBound().isBounded()) {
return range.getLowerBound().getValue().get().getFunction();
}
if (range.getUpperBound().isBounded()) {
return range.getUpperBound().getValue().get().getFunction();
}
}
int scoringFunctionIndex = getParameters().getScoringFunctionIndex();
if (scoringFunctionIndex != -1) {
return (ScoringFunction) getValue(scoringFunctionIndex);
}
return null;
}
@Override
public Limit getLimit() {

View File

@@ -32,9 +32,12 @@ import org.springframework.data.cassandra.core.query.Criteria;
import org.springframework.data.cassandra.core.query.CriteriaDefinition;
import org.springframework.data.cassandra.core.query.Filter;
import org.springframework.data.cassandra.core.query.Query;
import org.springframework.data.cassandra.core.query.VectorSort;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.repository.query.parser.AbstractQueryCreator;
import org.springframework.data.repository.query.parser.Part;
@@ -56,6 +59,8 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
private final MappingContext<?, CassandraPersistentProperty> mappingContext;
private final QueryBuilder queryBuilder = new QueryBuilder();
private final CassandraParameterAccessor parameterAccessor;
private final PartTree tree;
/**
* Create a new {@link CassandraQueryCreator} from the given {@link PartTree}, {@link ConvertingParameterAccessor} and
@@ -72,6 +77,8 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
Assert.notNull(mappingContext, "CassandraMappingContext must not be null");
this.tree = tree;
this.parameterAccessor = parameterAccessor;
this.mappingContext = mappingContext;
}
@@ -102,9 +109,6 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
.getPersistentPropertyPath(part.getProperty());
CassandraPersistentProperty property = path.getLeafProperty();
Assert.state(property != null && path.toDotPath() != null, "Leaf property must not be null");
Object filterOrCriteria = from(part, property, Criteria.where(path.toDotPath()), iterator);
if (filterOrCriteria instanceof CriteriaDefinition) {
@@ -115,10 +119,12 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
}
@Override
protected Filter and(Part part, Filter base, Iterator<Object> iterator) {
protected Filter and(Part part, @Nullable Filter base, Iterator<Object> iterator) {
for (CriteriaDefinition criterion : base) {
getQueryBuilder().and(criterion);
if (base != null) {
for (CriteriaDefinition criterion : base) {
getQueryBuilder().and(criterion);
}
}
return create(part, iterator);
@@ -139,7 +145,8 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
}
}
Query query = getQueryBuilder().create(sort);
Query query = sort.isUnsorted() && parameterAccessor.getVector() != null ? getQueryBuilder().create(getVectorSort())
: getQueryBuilder().create(sort);
if (LOG.isDebugEnabled()) {
LOG.debug(String.format("Created query [%s]", query));
@@ -148,10 +155,29 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
return query;
}
private Sort getVectorSort() {
return VectorSort.ann(getVectorProperty().toDotPath(), parameterAccessor.getVector());
}
PropertyPath getVectorProperty() {
for (PartTree.OrPart parts : tree) {
for (Part part : parts) {
if (part.getType() == Type.NEAR || part.getType() == Type.WITHIN) {
return part.getProperty();
}
}
}
throw new IllegalArgumentException("No Near/Within property found");
}
/**
* Returns a {@link Filter} or {@link CriteriaDefinition} object representing the criterion for a {@link Part}.
*/
private Object from(Part part, CassandraPersistentProperty property, Criteria where, Iterator<Object> parameters) {
private @Nullable Object from(Part part, CassandraPersistentProperty property, Criteria where,
Iterator<Object> parameters) {
Type type = part.getType();
@@ -182,10 +208,24 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
return where.is(false);
case SIMPLE_PROPERTY:
return where.is(parameters.next());
case NEAR:
case WITHIN:
Object next = parameters.next();
if (!(next instanceof Vector)) {
throw new IllegalArgumentException("Expected a Vector for Near/Within keyword but got [%s]"
.formatted(next == null ? "null" : next.getClass()));
}
return null;
default:
throw new InvalidDataAccessApiUsageException(
String.format("Unsupported keyword [%s] in part [%s]", type, part));
}
}
/**
@@ -280,7 +320,7 @@ class CassandraQueryCreator extends AbstractQueryCreator<Query, Filter> {
*/
static class QueryBuilder {
private List<CriteriaDefinition> criterias = new ArrayList<>();
private final List<CriteriaDefinition> criterias = new ArrayList<>();
CriteriaDefinition and(CriteriaDefinition clause) {
criterias.add(clause);

View File

@@ -18,6 +18,7 @@ package org.springframework.data.cassandra.repository.query;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.springframework.core.convert.converter.Converter;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.data.cassandra.core.CassandraOperations;
@@ -28,6 +29,11 @@ import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.convert.DtoInstantiatingConverter;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.data.mapping.context.MappingContext;
@@ -180,8 +186,9 @@ interface CassandraQueryExecution {
ScoringFunction function = accessor.getScoringFunction();
List<SearchResult<?>> results = operations.select(statement, type, (o, row) -> {
List<SearchResult<Object>> results = operations.query(statement).as(type).map((row, reader) -> {
Object o = reader.get();
if (row.getColumnDefinitions().contains("__score__")) {
return new SearchResult<>(o, getScore(row, "__score__", function));
}
@@ -189,17 +196,20 @@ interface CassandraQueryExecution {
if (row.getColumnDefinitions().contains("score")) {
return new SearchResult<>(o, getScore(row, "score", function));
}
return new SearchResult<>(o, 0);
});
return new SearchResults(results);
return new SearchResult<>(o, Similarity.of(0));
}).all();
return new SearchResults<>(results);
}
private Score getScore(Row row, String columnName, ScoringFunction function) {
private Score getScore(Row row, String columnName, @Nullable ScoringFunction function) {
Object object = row.getObject(columnName);
return Score.of(((Number) object).doubleValue(), function);
return Similarity.raw(((Number) object).doubleValue(),
function == null ? ScoringFunction.unspecified() : function);
}
}
/**

View File

@@ -18,6 +18,7 @@ package org.springframework.data.cassandra.repository.query;
import java.util.Iterator;
import org.jspecify.annotations.Nullable;
import org.springframework.data.cassandra.core.convert.CassandraConverter;
import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraType;
@@ -25,7 +26,10 @@ import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import com.datastax.oss.driver.api.core.type.DataType;
@@ -54,6 +58,26 @@ class ConvertingParameterAccessor implements CassandraParameterAccessor {
return delegate.getScrollPosition();
}
@Override
public ScoringFunction getScoringFunction() {
return delegate.getScoringFunction();
}
@Override
public @Nullable Vector getVector() {
return delegate.getVector();
}
@Override
public @Nullable Score getScore() {
return delegate.getScore();
}
@Override
public @Nullable Range<Score> getScoreRange() {
return delegate.getScoreRange();
}
@Override
public Pageable getPageable() {
return this.delegate.getPageable();
@@ -113,6 +137,11 @@ class ConvertingParameterAccessor implements CassandraParameterAccessor {
return this.delegate.getValues();
}
@Override
public @Nullable Object getValue(int parameterIndex) {
return potentiallyConvert(parameterIndex, this.delegate.getValue(parameterIndex));
}
@Nullable
Object potentiallyConvert(int index, @Nullable Object bindableValue) {

View File

@@ -15,11 +15,16 @@
*/
package org.springframework.data.cassandra.repository.query;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.data.cassandra.core.StatementFactory;
import org.springframework.data.cassandra.core.cql.QueryExtractorDelegate;
import org.springframework.data.cassandra.core.cql.QueryOptions;
@@ -27,10 +32,15 @@ import org.springframework.data.cassandra.core.cql.QueryOptions.QueryOptionsBuil
import org.springframework.data.cassandra.core.cql.QueryOptionsUtil;
import org.springframework.data.cassandra.core.mapping.CassandraPersistentEntity;
import org.springframework.data.cassandra.core.mapping.CassandraPersistentProperty;
import org.springframework.data.cassandra.core.mapping.SimilarityFunction;
import org.springframework.data.cassandra.core.query.Columns;
import org.springframework.data.cassandra.core.query.Query;
import org.springframework.data.cassandra.repository.Query.Idempotency;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.Vector;
import org.springframework.data.domain.VectorScoringFunctions;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mapping.model.ValueExpressionEvaluator;
import org.springframework.data.repository.query.QueryCreationException;
@@ -50,6 +60,11 @@ import com.datastax.oss.driver.api.core.cql.Statement;
*/
class QueryStatementCreator {
private static final Map<ScoringFunction, SimilarityFunction> SIMILARITY_FUNCTIONS = Map.of(
VectorScoringFunctions.COSINE, SimilarityFunction.COSINE, //
VectorScoringFunctions.EUCLIDEAN, SimilarityFunction.EUCLIDEAN, //
VectorScoringFunctions.DOT_PRODUCT, SimilarityFunction.DOT_PRODUCT);
private static final Log LOG = LogFactory.getLog(QueryStatementCreator.class);
private final CassandraQueryMethod queryMethod;
@@ -81,9 +96,28 @@ class QueryStatementCreator {
ReturnedType returnedType = processor.withDynamicProjection(parameterAccessor).getReturnedType();
Columns columns = null;
if (returnedType.needsCustomConstruction()) {
columns = Columns.from(returnedType.getInputProperties().toArray(new String[0]));
} else if (queryMethod.isSearchQuery()) {
columns = getColumns(returnedType.getReturnedType());
}
if (columns != null) {
if (queryMethod.isSearchQuery()) {
CassandraQueryCreator queryCreator = new CassandraQueryCreator(tree, parameterAccessor, this.mappingContext);
PropertyPath vectorProperty = queryCreator.getVectorProperty();
Vector vector = parameterAccessor.getVector();
SimilarityFunction similarityFunction = getSimilarityFunction(parameterAccessor.getScoringFunction());
columns = columns.select(vectorProperty.toDotPath(),
selectorBuilder -> selectorBuilder.similarity(vector).using(similarityFunction).as("\"__score__\""));
}
Columns columns = Columns.from(returnedType.getInputProperties().toArray(new String[0]));
query = query.columns(columns);
}
@@ -99,6 +133,34 @@ class QueryStatementCreator {
return doWithQuery(parameterAccessor, tree, function);
}
private Columns getColumns(Class<?> returnedType) {
CassandraPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(returnedType);
List<String> names = new ArrayList<>();
for (CassandraPersistentProperty property : entity) {
names.add(property.getName());
}
return Columns.from(names.toArray(new String[0]));
}
private SimilarityFunction getSimilarityFunction(@Nullable ScoringFunction function) {
if (function == null) {
throw new IllegalStateException(
"Cannot determine ScoringFunction. No ScoringFunction, Score/Similarity or bounded Score Range parameters provided.");
}
SimilarityFunction similarityFunction = SIMILARITY_FUNCTIONS.get(function);
if (similarityFunction == null) {
throw new IllegalArgumentException(
"Cannot determine SimilarityFunction from ScoreFunction '%s'".formatted(function));
}
return similarityFunction;
}
/**
* Create a {@literal COUNT} {@link Statement} from a {@link PartTree} and apply query options.
*

View File

@@ -20,7 +20,9 @@ import reactor.core.publisher.Mono;
import java.util.List;
import org.jspecify.annotations.Nullable;
import org.reactivestreams.Publisher;
import org.springframework.core.convert.converter.Converter;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.data.cassandra.core.ReactiveCassandraOperations;
@@ -31,6 +33,10 @@ import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.convert.DtoInstantiatingConverter;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.data.mapping.context.MappingContext;
@@ -152,6 +158,45 @@ interface ReactiveCassandraQueryExecution {
}
final class SearchExecution implements ReactiveCassandraQueryExecution {
private final ReactiveCassandraOperations operations;
private final CassandraParameterAccessor accessor;
public SearchExecution(ReactiveCassandraOperations operations, CassandraParameterAccessor accessor) {
this.operations = operations;
this.accessor = accessor;
}
@Override
public Publisher<? extends Object> execute(Statement<?> statement, Class<?> type) {
ScoringFunction function = accessor.getScoringFunction();
return operations.query(statement).as(type).map((row, reader) -> {
Object entity = reader.get();
if (row.getColumnDefinitions().contains("__score__")) {
return new SearchResult<>(entity, getScore(row, "__score__", function));
}
if (row.getColumnDefinitions().contains("score")) {
return new SearchResult<>(entity, getScore(row, "score", function));
}
return new SearchResult<>(entity, 0);
}).all();
}
private Score getScore(Row row, String columnName, @Nullable ScoringFunction function) {
Object object = row.getObject(columnName);
return Similarity.raw(((Number) object).doubleValue(),
function == null ? ScoringFunction.unspecified() : function);
}
}
/**
* {@link ReactiveCassandraQueryExecution} to return a single entity.
*

View File

@@ -921,16 +921,14 @@ class StatementFactoryUnitTests {
withVector.id = "foo";
withVector.vector = CqlVector.newInstance(1.2f, 1.3f);
withVector.array = new float[] { 2.2f, 2.3f };
withVector.list = Arrays.asList(3.2f, 3.3f);
SimpleStatement statement = statementFactory.update(withVector, WriteOptions.empty())
.build(ParameterHandling.BY_NAME);
assertThat(statement.getQuery()).isEqualTo("UPDATE withvector SET vector=:p0, array=:p1, list=:p2 WHERE id=:p3");
assertThat(statement.getQuery()).isEqualTo("UPDATE withvector SET vector=:p0, array=:p1 WHERE id=:p2");
assertThat(statement.getNamedValues().get(CqlIdentifier.fromCql("p0"))).isInstanceOf(CqlVector.class).hasToString("[1.2, 1.3]");
assertThat(statement.getNamedValues().get(CqlIdentifier.fromCql("p1"))).isInstanceOf(CqlVector.class).hasToString("[2.2, 2.3]");
assertThat(statement.getNamedValues().get(CqlIdentifier.fromCql("p2"))).isInstanceOf(CqlVector.class).hasToString("[3.2, 3.3]");
}
@Test // GH-1504
@@ -990,6 +988,5 @@ class StatementFactoryUnitTests {
@VectorType(dimensions = 12) CqlVector<Number> vector;
@VectorType(dimensions = 12) float[] array;
@VectorType(dimensions = 12) List<Float> list;
}
}

View File

@@ -21,7 +21,6 @@ import static org.springframework.data.domain.Sort.Order.*;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Currency;
@@ -65,6 +64,7 @@ import org.springframework.data.convert.ValueConverter;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.domain.Vector;
import org.springframework.lang.Nullable;
import com.datastax.oss.driver.api.core.CqlIdentifier;
@@ -368,6 +368,31 @@ public class QueryMapperUnitTests {
assertThat(mappedObject).contains(CqlIdentifier.fromCql("first_name"));
}
@Test //
void shouldMapMultipleColumnNames() {
Columns columnNames = Columns.from("array").select("array",
selectorBuilder -> selectorBuilder.similarity(Vector.of(1, 2)).cosine().as("score"));
List<CqlIdentifier> mappedObject = queryMapper.getMappedColumnNames(columnNames,
mappingContext.getRequiredPersistentEntity(WithVector.class));
assertThat(mappedObject).contains(CqlIdentifier.fromCql("array"));
}
@Test //
void shouldMapMultipleSelectorsNames() {
Columns columnNames = Columns.from("array").select("array",
selectorBuilder -> selectorBuilder.similarity(Vector.of(1, 2)).cosine().as("score"));
List<Selector> mappedObject = queryMapper.getMappedSelectors(columnNames,
mappingContext.getRequiredPersistentEntity(WithVector.class));
assertThat(mappedObject).extracting(Selector::toString).contains("array",
"similarity_cosine(array, [1.0, 2.0]) AS score");
}
@Test // DATACASS-523
@SuppressWarnings("all")
void shouldMapTuple() {
@@ -440,7 +465,7 @@ public class QueryMapperUnitTests {
assertThat(mappedObject.iterator().next().getPredicate().getValue()).isEqualTo(42L);
}
@Test //
@Test // GH-1504
void shouldConvertVectorValues() {
Filter filter = Filter.from(Criteria.where("array").is(new float[] { 1.1f, 2.2f }));
@@ -452,23 +477,13 @@ public class QueryMapperUnitTests {
assertThat(mappedObject.iterator().next().getPredicate().getValue()).isEqualTo(new float[] { 1.1f, 2.2f });
}
@Test // GH-1504
void shouldConvertVectorValuesFromList() {
Filter filter = Filter.from(Criteria.where("list").is(Arrays.asList(1.1f, 2.2f)));
Filter mappedObject = this.queryMapper.getMappedObject(filter,
this.mappingContext.getRequiredPersistentEntity(WithVector.class));
assertThat(mappedObject.iterator().next().getColumnName()).isEqualTo(ColumnName.from("list"));
assertThat(mappedObject.iterator().next().getPredicate().getValue()).isEqualTo(CqlVector.newInstance(1.1f, 2.2f));
}
@Test // GH-1504
void shouldConvertVectorSelectorFunction() {
Columns columns = Columns.empty();
Columns.FunctionCall similarity = Columns.FunctionCall.from("similarity_cosine", CqlIdentifier.fromCql("array"), Arrays.asList(1.1f, 2.2f));
Columns.FunctionCall similarity = Columns.FunctionCall.from("similarity_cosine", CqlIdentifier.fromCql("array"),
CqlVector.newInstance(1.1f, 2.2f));
Query query = Query.empty().columns(columns.select("array", similarity));

View File

@@ -0,0 +1,202 @@
/*
* Copyright 2016-2025 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
*
* https://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 reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.cassandra.config.SchemaAction;
import org.springframework.data.cassandra.core.mapping.SaiIndexed;
import org.springframework.data.cassandra.core.mapping.Table;
import org.springframework.data.cassandra.core.mapping.VectorType;
import org.springframework.data.cassandra.repository.config.EnableReactiveCassandraRepositories;
import org.springframework.data.cassandra.repository.support.AbstractSpringDataEmbeddedCassandraIntegrationTest;
import org.springframework.data.cassandra.repository.support.IntegrationTestConfig;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Vector;
import org.springframework.data.domain.VectorScoringFunctions;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/**
* Integration tests for Vector Search using reactive repositories.
*
* @author Mark Paluch
*/
@SpringJUnitConfig
class ReactiveVectorSearchIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest {
Vector VECTOR = Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f);
@Configuration
@EnableReactiveCassandraRepositories(basePackageClasses = ReactiveVectorSearchRepository.class,
considerNestedRepositories = true,
includeFilters = @ComponentScan.Filter(classes = ReactiveVectorSearchRepository.class,
type = FilterType.ASSIGNABLE_TYPE))
public static class Config extends IntegrationTestConfig {
@Override
protected Set<Class<?>> getInitialEntitySet() {
return Collections.singleton(WithVectorFields.class);
}
@Override
public SchemaAction getSchemaAction() {
return SchemaAction.RECREATE_DROP_UNUSED;
}
}
@Autowired ReactiveVectorSearchRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll().as(StepVerifier::create).verifyComplete();
WithVectorFields w1 = new WithVectorFields("de", "one", Vector.of(0.1001f, 0.22345f, 0.33456f, 0.44567f, 0.55678f));
WithVectorFields w2 = new WithVectorFields("de", "two", Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f));
WithVectorFields w3 = new WithVectorFields("en", "three",
Vector.of(0.9001f, 0.82345f, 0.73456f, 0.64567f, 0.55678f));
WithVectorFields w4 = new WithVectorFields("de", "four",
Vector.of(0.9001f, 0.92345f, 0.93456f, 0.94567f, 0.95678f));
repository.saveAll(List.of(w1, w2, w3, w4)).as(StepVerifier::create).expectNextCount(4).verifyComplete();
}
@Test // GH-
void shouldConsiderScoringFunction() {
Vector vector = Vector.of(0.9f, 0.54f, 0.12f, 0.1f, 0.95f);
List<SearchResult<WithVectorFields>> results = repository
.searchByEmbeddingNear(vector, VectorScoringFunctions.COSINE, Limit.of(100)).collectList().block();
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0d, offset(0.1d));
}
results = repository.searchByEmbeddingNear(VECTOR, VectorScoringFunctions.EUCLIDEAN, Limit.of(100)).collectList()
.block();
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0.3d, offset(0.1d));
}
}
@Test // GH-
void shouldRunAnnotatedSearchByVector() {
List<SearchResult<WithVectorFields>> results = repository.searchAnnotatedByEmbeddingNear(VECTOR, Limit.of(100))
.collectList().block();
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0d, offset(0.1d));
}
}
@Test // GH-
void shouldFindByVector() {
List<WithVectorFields> result = repository.findByEmbeddingNear(VECTOR, Limit.of(100)).collectList().block();
assertThat(result).hasSize(4);
}
interface ReactiveVectorSearchRepository extends ReactiveCrudRepository<WithVectorFields, UUID> {
Flux<SearchResult<WithVectorFields>> searchByEmbeddingNear(Vector embedding, ScoringFunction function, Limit limit);
Flux<WithVectorFields> findByEmbeddingNear(Vector embedding, Limit limit);
@Query("SELECT id,description,country,similarity_cosine(embedding,:embedding) AS score FROM withvectorfields ORDER BY embedding ANN OF :embedding LIMIT :limit")
Flux<SearchResult<WithVectorFields>> searchAnnotatedByEmbeddingNear(Vector embedding, Limit limit);
}
@Table
static class WithVectorFields {
@Id String id;
String country;
String description;
@VectorType(dimensions = 5)
@SaiIndexed Vector embedding;
@PersistenceCreator
public WithVectorFields(String id, String country, String description, Vector embedding) {
this.id = id;
this.country = country;
this.description = description;
this.embedding = embedding;
}
public WithVectorFields(String country, String description, Vector embedding) {
this.id = UUID.randomUUID().toString();
this.country = country;
this.description = description;
this.embedding = embedding;
}
public String getId() {
return id;
}
public String getCountry() {
return country;
}
public String getDescription() {
return description;
}
public Vector getEmbedding() {
return embedding;
}
@Override
public String toString() {
return "WithVectorFields{" + "id='" + id + '\'' + ", country='" + country + '\'' + ", description='" + description
+ '\'' + '}';
}
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2016-2025 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
*
* https://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.Collections;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.PersistenceCreator;
import org.springframework.data.cassandra.config.SchemaAction;
import org.springframework.data.cassandra.core.mapping.SaiIndexed;
import org.springframework.data.cassandra.core.mapping.Table;
import org.springframework.data.cassandra.core.mapping.VectorType;
import org.springframework.data.cassandra.repository.config.EnableCassandraRepositories;
import org.springframework.data.cassandra.repository.support.AbstractSpringDataEmbeddedCassandraIntegrationTest;
import org.springframework.data.cassandra.repository.support.IntegrationTestConfig;
import org.springframework.data.domain.Limit;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.SearchResult;
import org.springframework.data.domain.SearchResults;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Vector;
import org.springframework.data.domain.VectorScoringFunctions;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.QueryCreationException;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/**
* Integration tests for Vector Search using repositories.
*
* @author Mark Paluch
*/
@SpringJUnitConfig
class VectorSearchIntegrationTests extends AbstractSpringDataEmbeddedCassandraIntegrationTest {
Vector VECTOR = Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f);
@Configuration
@EnableCassandraRepositories(basePackageClasses = VectorSearchRepository.class, considerNestedRepositories = true,
includeFilters = @ComponentScan.Filter(classes = VectorSearchRepository.class, type = FilterType.ASSIGNABLE_TYPE))
public static class Config extends IntegrationTestConfig {
@Override
protected Set<Class<?>> getInitialEntitySet() {
return Collections.singleton(WithVectorFields.class);
}
@Override
public SchemaAction getSchemaAction() {
return SchemaAction.RECREATE_DROP_UNUSED;
}
}
@Autowired VectorSearchRepository repository;
@BeforeEach
void setUp() {
repository.deleteAll();
WithVectorFields w1 = new WithVectorFields("de", "one", Vector.of(0.1001f, 0.22345f, 0.33456f, 0.44567f, 0.55678f));
WithVectorFields w2 = new WithVectorFields("de", "two", Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f));
WithVectorFields w3 = new WithVectorFields("en", "three",
Vector.of(0.9001f, 0.82345f, 0.73456f, 0.64567f, 0.55678f));
WithVectorFields w4 = new WithVectorFields("de", "four",
Vector.of(0.9001f, 0.92345f, 0.93456f, 0.94567f, 0.95678f));
repository.saveAll(List.of(w1, w2, w3, w4));
}
@Test // GH-
void searchWithoutScoringFunctionShouldFail() {
assertThatExceptionOfType(QueryCreationException.class)
.isThrownBy(() -> repository.searchByEmbeddingNear(VECTOR, Limit.of(100)));
}
@Test // GH-
void shouldConsiderScoringFunction() {
SearchResults<WithVectorFields> results = repository.searchByEmbeddingNear(VECTOR,
ScoringFunction.dotProduct(), Limit.of(100));
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0d, offset(0.1d));
}
results = repository.searchByEmbeddingNear(VECTOR, VectorScoringFunctions.EUCLIDEAN, Limit.of(100));
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0.3d, offset(0.1d));
}
}
@Test // GH-
void shouldRunAnnotatedSearchByVector() {
SearchResults<WithVectorFields> results = repository.searchAnnotatedByEmbeddingNear(VECTOR, Limit.of(100));
assertThat(results).hasSize(4);
for (SearchResult<WithVectorFields> result : results) {
assertThat(result.getScore()).isInstanceOf(Similarity.class);
assertThat(result.getScore().getValue()).isNotCloseTo(0d, offset(0.1d));
}
}
@Test // GH-
void shouldFindByVector() {
List<WithVectorFields> result = repository.findByEmbeddingNear(VECTOR, Limit.of(100));
assertThat(result).hasSize(4);
}
interface VectorSearchRepository extends CrudRepository<WithVectorFields, UUID> {
SearchResults<WithVectorFields> searchByEmbeddingNear(Vector embedding, ScoringFunction function, Limit limit);
SearchResults<WithVectorFields> searchByEmbeddingNear(Vector embedding, Limit limit);
List<WithVectorFields> findByEmbeddingNear(Vector embedding, Limit limit);
@Query("SELECT id,description,country,similarity_cosine(embedding,:embedding) AS score FROM withvectorfields ORDER BY embedding ANN OF :embedding LIMIT :limit")
SearchResults<WithVectorFields> searchAnnotatedByEmbeddingNear(Vector embedding, Limit limit);
}
@Table
static class WithVectorFields {
@Id String id;
String country;
String description;
@VectorType(dimensions = 5)
@SaiIndexed Vector embedding;
@PersistenceCreator
public WithVectorFields(String id, String country, String description, Vector embedding) {
this.id = id;
this.country = country;
this.description = description;
this.embedding = embedding;
}
public WithVectorFields(String country, String description, Vector embedding) {
this.id = UUID.randomUUID().toString();
this.country = country;
this.description = description;
this.embedding = embedding;
}
public String getId() {
return id;
}
public String getCountry() {
return country;
}
public String getDescription() {
return description;
}
public Vector getEmbedding() {
return embedding;
}
@Override
public String toString() {
return "WithVectorFields{" + "id='" + id + '\'' + ", country='" + country + '\'' + ", description='" + description
+ '\'' + '}';
}
}
}

View File

@@ -26,10 +26,11 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.cassandra.core.mapping.CassandraType;
import org.springframework.data.cassandra.domain.Person;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
import org.springframework.data.repository.query.ParametersSource;
/**
@@ -89,6 +90,15 @@ class CassandraParametersUnitTests {
assertThat(cassandraParameters.getParameter(0).getCassandraType().type()).isEqualTo(Name.BOOLEAN);
}
@Test // GH-
void considersScoringFunctionIndex() throws Exception {
Method method = PersonRepository.class.getMethod("findByObject", ScoringFunction.class);
CassandraParameters cassandraParameters = new CassandraParameters(ParametersSource.of(method));
assertThat(cassandraParameters.getScoringFunctionIndex()).isEqualTo(0);
}
interface PersonRepository extends Repository<Person, String> {
Person findByFirstname(String firstname);
@@ -97,6 +107,8 @@ class CassandraParametersUnitTests {
Person findByObject(Object firstname);
Person findByObject(ScoringFunction firstname);
Person findByAnnotatedObject(@CassandraType(type = Name.TIME) Object firstname);
Person findByComposedAnnotationObject(@ComposedCassandraTypeAnnotation Object firstname);

View File

@@ -24,7 +24,11 @@ import org.springframework.data.cassandra.core.cql.QueryOptions;
import org.springframework.data.cassandra.core.mapping.CassandraType;
import org.springframework.data.cassandra.core.query.CassandraScrollPosition;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.repository.query.ParameterAccessor;
import com.datastax.oss.driver.api.core.type.DataType;
@@ -77,6 +81,26 @@ class StubParameterAccessor implements CassandraParameterAccessor {
return null;
}
@Override
public ScoringFunction getScoringFunction() {
return null;
}
@Override
public @Nullable Vector getVector() {
return null;
}
@Override
public @Nullable Score getScore() {
return null;
}
@Override
public @Nullable Range<Score> getScoreRange() {
return null;
}
@Override
public Pageable getPageable() {
return null;
@@ -117,4 +141,9 @@ class StubParameterAccessor implements CassandraParameterAccessor {
public Object[] getValues() {
return new Object[0];
}
@Override
public @Nullable Object getValue(int parameterIndex) {
return null;
}
}

View File

@@ -290,7 +290,7 @@ class CassandraDelegate {
if (container == null) {
container = getCassandraDockerImageName().map(CassandraContainer::new)
.orElseGet(() -> new CassandraContainer("cassandra:5.0.3"));
.orElseGet(() -> new CassandraContainer("cassandra:5.0.3")).withReuse(true);
container.withEnv("MAX_HEAP_SIZE", "1500M");
container.withEnv("HEAP_NEWSIZE", "300M");