Explore returning Search Results.

Closes: #3868
This commit is contained in:
Mark Paluch
2025-04-11 14:44:56 +02:00
parent e6a008cf08
commit 17a59905a7
39 changed files with 1959 additions and 251 deletions

22
pom.xml
View File

@@ -38,6 +38,7 @@
<jsqlparser>5.2</jsqlparser>
<mysql-connector-java>9.2.0</mysql-connector-java>
<postgresql>42.7.5</postgresql>
<oracle>23.7.0.25.01</oracle>
<springdata.commons>4.0.0-SNAPSHOT</springdata.commons>
<vavr>0.10.3</vavr>
@@ -56,6 +57,14 @@
<profiles>
<profile>
<id>jmh</id>
<dependencies>
<dependency>
<groupId>com.github.mp911de.microbenchmark-runner</groupId>
<artifactId>microbenchmark-runner-junit5</artifactId>
<version>0.5.0.RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>jitpack</id>
@@ -112,6 +121,19 @@
</includes>
</configuration>
</execution>
<execution>
<id>oracle-test</id>
<phase>test</phase>
<goals>
<goal>test</goal>
</goals>
<configuration>
<includes>
<include>**/Oracle*IntegrationTests.java
</include>
</includes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>

View File

@@ -88,6 +88,12 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
@@ -161,6 +167,28 @@
<scope>test</scope>
</dependency>
<!-- Oracle testing support -->
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc17</artifactId>
<version>${oracle}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ucp17</artifactId>
<version>${oracle}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>oracle-free</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
@@ -183,6 +211,13 @@
</exclusions>
</dependency>
<dependency>
<groupId>${hibernate.groupId}.orm</groupId>
<artifactId>hibernate-vector</artifactId>
<version>${hibernate}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>${hibernate.groupId}.orm</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
@@ -318,6 +353,7 @@
<exclude>**/EclipseLink*</exclude>
<exclude>**/MySql*</exclude>
<exclude>**/Postgres*</exclude>
<exclude>**/Oracle*</exclude>
</excludes>
<argLine>
-Xmx4G

View File

@@ -224,7 +224,8 @@ class QueriesFactory {
ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT,
templates);
JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, returnedType, metadataProvider, templates, metamodel);
JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, false, returnedType, metadataProvider, templates,
metamodel);
return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(),
partTree.getResultLimit(), partTree.isDelete(), partTree.isExistsProjection());

View File

@@ -101,7 +101,7 @@ public abstract class AbstractJpaQuery implements RepositoryQuery {
return new StreamExecution();
} else if (method.isProcedureQuery()) {
return new ProcedureExecution(method.isCollectionQuery());
} else if (method.isCollectionQuery()) {
} else if (method.isCollectionQuery() || method.isSearchQuery()) {
return new CollectionExecution();
} else if (method.isSliceQuery()) {
return new SlicedExecution();
@@ -149,7 +149,9 @@ public abstract class AbstractJpaQuery implements RepositoryQuery {
@Override
public @Nullable Object execute(Object[] parameters) {
return doExecute(getExecution(), parameters);
JpaParametersParameterAccessor accessor = obtainParameterAccessor(parameters);
return doExecute(getExecution(accessor), accessor);
}
/**
@@ -157,9 +159,8 @@ public abstract class AbstractJpaQuery implements RepositoryQuery {
* @param values
* @return
*/
private @Nullable Object doExecute(JpaQueryExecution execution, Object[] values) {
private @Nullable Object doExecute(JpaQueryExecution execution, JpaParametersParameterAccessor accessor) {
JpaParametersParameterAccessor accessor = obtainParameterAccessor(values);
Object result = execution.execute(this, accessor);
ResultProcessor withDynamicProjection = method.getResultProcessor().withDynamicProjection(accessor);
@@ -176,10 +177,17 @@ public abstract class AbstractJpaQuery implements RepositoryQuery {
return new JpaParametersParameterAccessor(method.getParameters(), values);
}
protected JpaQueryExecution getExecution() {
protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) {
JpaQueryExecution execution = this.execution.getNullable();
if (method.isSearchQuery()) {
ReturnedType returnedType = method.getResultProcessor().withDynamicProjection(accessor).getReturnedType();
return new JpaQueryExecution.SearchResultExecution(execution == null ? new SingleEntityExecution() : execution,
returnedType, accessor.getScoringFunction(), accessor.normalizeSimilarity());
}
if (execution != null) {
return execution;
}

View File

@@ -48,7 +48,7 @@ public class JpaCountQueryCreator extends JpaQueryCreator {
public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider,
JpqlQueryTemplates templates, EntityManager em) {
super(tree, returnedType, provider, templates, em);
super(tree, returnedType, provider, templates, em.getMetamodel());
this.distinct = tree.isDistinct();
this.returnedType = returnedType;

View File

@@ -23,6 +23,8 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.KeysetScrollPosition;
@@ -49,7 +51,7 @@ class JpaKeysetScrollQueryCreator extends JpaQueryCreator {
JpqlQueryTemplates templates, JpaEntityInformation<?, ?> entityInformation, KeysetScrollPosition scrollPosition,
EntityManager em) {
super(tree, type, provider, templates, em);
super(tree, type, provider, templates, em.getMetamodel());
this.entityInformation = entityInformation;
this.scrollPosition = scrollPosition;

View File

@@ -15,8 +15,16 @@
*/
package org.springframework.data.jpa.repository.query;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.Similarity;
import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
@@ -68,4 +76,54 @@ public class JpaParametersParameterAccessor extends ParametersParameterAccessor
return parameterValue;
}
/**
* Returns the {@link ScoringFunction}.
*
* @return
*/
public ScoringFunction getScoringFunction() {
return doWithScore(Score::getFunction, Score.class::isInstance, ScoringFunction::unspecified);
}
/**
* Returns whether to normalize similarities (i.e. translate the database-specific score into {@link Similarity}).
*
* @return
*/
public boolean normalizeSimilarity() {
return doWithScore(it -> true, Similarity.class::isInstance, () -> false);
}
/**
* Returns the {@link ScoringFunction}.
*
* @return
*/
public <T> T doWithScore(Function<Score, T> function, Predicate<Score> scoreFilter, Supplier<T> defaultValue) {
Score score = getScore();
if (score != null && scoreFilter.test(score)) {
return function.apply(score);
}
JpaParameters parameters = getParameters();
if (parameters.hasScoreRangeParameter()) {
Range<Score> range = getScoreRange();
if (range != null && range.getLowerBound().isBounded()
&& scoreFilter.test(range.getLowerBound().getValue().get())) {
return function.apply(range.getUpperBound().getValue().get());
}
if (range != null && range.getUpperBound().isBounded()
&& scoreFilter.test(range.getUpperBound().getValue().get())) {
return function.apply(range.getUpperBound().getValue().get());
}
}
return defaultValue.get();
}
}

View File

@@ -28,14 +28,21 @@ import jakarta.persistence.metamodel.Metamodel;
import jakarta.persistence.metamodel.SingularAttribute;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.springframework.dao.InvalidDataAccessApiUsageException;
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.VectorScoringFunctions;
import org.springframework.data.jpa.domain.JpaSort;
import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder;
import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding;
@@ -63,8 +70,21 @@ import org.springframework.util.Assert;
* @author Christoph Strobl
* @author Jinmyeong Kim
*/
public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuilder.Predicate> implements JpqlQueryCreator {
public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuilder.Predicate>
implements JpqlQueryCreator {
private static final Map<ScoringFunction, DistanceFunction> DISTANCE_FUNCTIONS = Map.of(VectorScoringFunctions.COSINE,
new DistanceFunction("cosine_distance", Sort.Direction.ASC), //
VectorScoringFunctions.EUCLIDEAN, new DistanceFunction("euclidean_distance", Sort.Direction.ASC), //
VectorScoringFunctions.TAXICAB, new DistanceFunction("taxicab_distance", Sort.Direction.ASC), //
VectorScoringFunctions.HAMMING, new DistanceFunction("hamming_distance", Sort.Direction.ASC), //
VectorScoringFunctions.DOT_PRODUCT, new DistanceFunction("negative_inner_product", Sort.Direction.ASC));
record DistanceFunction(String distanceFunction, Sort.Direction direction) {
}
private final boolean searchQuery;
private final ReturnedType returnedType;
private final ParameterMetadataProvider provider;
private final JpqlQueryTemplates templates;
@@ -73,6 +93,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
private final EntityType<?> entityType;
private final JpqlQueryBuilder.Entity entity;
private final Metamodel metamodel;
private final SimilarityNormalizer similarityNormalizer;
private final boolean useNamedParameters;
/**
@@ -80,20 +101,26 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
*
* @param tree must not be {@literal null}.
* @param type must not be {@literal null}.
* @param templates must not be {@literal null}.
* @param provider must not be {@literal null}.
* @param templates must not be {@literal null}.
* @param em must not be {@literal null}.
*/
public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider,
JpqlQueryTemplates templates, EntityManager em) {
this(tree, type, provider, templates, em.getMetamodel());
this(tree, false, type, provider, templates, em.getMetamodel());
}
public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider,
JpqlQueryTemplates templates, Metamodel metamodel) {
JpqlQueryTemplates templates, Metamodel metamodel) {
this(tree, false, type, provider, templates, metamodel);
}
public JpaQueryCreator(PartTree tree, boolean searchQuery, ReturnedType type, ParameterMetadataProvider provider,
JpqlQueryTemplates templates, Metamodel metamodel) {
super(tree);
this.searchQuery = searchQuery;
this.tree = tree;
this.returnedType = type;
this.provider = provider;
@@ -119,6 +146,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
this.entityType = metamodel.entity(type.getDomainType());
this.entity = JpqlQueryBuilder.entity(returnedType.getDomainType());
this.metamodel = metamodel;
this.similarityNormalizer = provider.getSimilarityNormalizer();
}
Bindable<?> getFrom() {
@@ -198,28 +226,41 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
return select;
}
for (Sort.Order order : sort) {
if (sort.isSorted()) {
JpqlQueryBuilder.Expression expression;
QueryUtils.checkSortExpression(order);
for (Sort.Order order : sort) {
try {
expression = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
PropertyPath.from(order.getProperty(), entityType.getJavaType()));
} catch (PropertyReferenceException e) {
JpqlQueryBuilder.Expression expression;
QueryUtils.checkSortExpression(order);
if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) {
expression = JpqlQueryBuilder.expression(order.getProperty());
} else {
throw e;
try {
expression = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
PropertyPath.from(order.getProperty(), entityType.getJavaType()));
} catch (PropertyReferenceException e) {
if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) {
expression = JpqlQueryBuilder.expression(order.getProperty());
} else {
throw e;
}
}
if (order.isIgnoreCase()) {
expression = JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expression);
}
select.orderBy(JpqlQueryBuilder.orderBy(expression, order));
}
} else {
if (searchQuery) {
DistanceFunction distanceFunction = DISTANCE_FUNCTIONS.get(provider.getScoringFunction());
if (distanceFunction != null) {
select
.orderBy(JpqlQueryBuilder.orderBy(JpqlQueryBuilder.expression("distance"), distanceFunction.direction()));
}
}
if (order.isIgnoreCase()) {
expression = JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expression);
}
select.orderBy(JpqlQueryBuilder.orderBy(expression, order));
}
return select;
@@ -248,17 +289,46 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
requiredSelection = getRequiredSelection(sort, returnedType);
}
List<JpqlQueryBuilder.PathExpression> paths = new ArrayList<>(requiredSelection.size());
List<JpqlQueryBuilder.Expression> paths = new ArrayList<>(requiredSelection.size());
for (String selection : requiredSelection) {
paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
PropertyPath.from(selection, returnedType.getDomainType()), true));
}
JpqlQueryBuilder.Expression distance = null;
if (searchQuery) {
distance = getDistanceExpression();
}
if (useTupleQuery()) {
if (searchQuery) {
paths.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance"));
}
return selectStep.select(paths);
} else {
return selectStep.instantiate(returnedType.getReturnedType(), paths);
JpqlQueryBuilder.ConstructorExpression expression = new JpqlQueryBuilder.ConstructorExpression(
returnedType.getReturnedType().getName(), new JpqlQueryBuilder.Multiselect(entity, paths));
List<JpqlQueryBuilder.Expression> selection = new ArrayList<>(2);
selection.add(expression);
if (searchQuery) {
selection.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance"));
}
return selectStep.select(selection);
}
}
if (searchQuery) {
JpqlQueryBuilder.Expression distance = getDistanceExpression();
if (distance != null) {
return selectStep.select(new JpqlQueryBuilder.Multiselect(entity,
Arrays.asList(new JpqlQueryBuilder.EntitySelection(entity), distance.as("distance"))));
}
}
@@ -287,6 +357,34 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
}
}
@org.springframework.lang.Nullable
private JpqlQueryBuilder.Expression getDistanceExpression() {
DistanceFunction distanceFunction = DISTANCE_FUNCTIONS.get(provider.getScoringFunction());
if (distanceFunction != null) {
JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
getVectorPath(), true);
return JpqlQueryBuilder.function(distanceFunction.distanceFunction(), pas,
placeholder(provider.getVectorBinding()));
}
return null;
}
PropertyPath getVectorPath() {
for (PartTree.OrPart parts : tree) {
for (Part part : parts) {
if (part.getType() == NEAR || part.getType() == WITHIN) {
return part.getProperty();
}
}
}
throw new IllegalStateException("No vector path found");
}
Collection<String> getRequiredSelection(Sort sort, ReturnedType returnedType) {
return returnedType.getInputProperties();
}
@@ -307,7 +405,7 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
* @return
*/
private JpqlQueryBuilder.Predicate toPredicate(Part part) {
return new PredicateBuilder(part).build();
return new PredicateBuilder(part, similarityNormalizer).build();
}
/**
@@ -315,21 +413,23 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
*
* @author Phil Webb
* @author Oliver Gierke
* @author Mark Paluch
*/
private class PredicateBuilder {
private final Part part;
private final SimilarityNormalizer normalizer;
/**
* Creates a new {@link PredicateBuilder} for the given {@link Part}.
*
* @param part must not be {@literal null}.
* @param normalizer must not be {@literal null}.
*/
public PredicateBuilder(Part part) {
Assert.notNull(part, "Part must not be null");
public PredicateBuilder(Part part, SimilarityNormalizer normalizer) {
this.part = part;
this.normalizer = normalizer;
}
/**
@@ -387,11 +487,10 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
PartTreeParameterBinding parameter = provider.next(part, String.class);
JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty(),
placeholder(parameter));
// Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter());
String escapeChar = Character.toString(escape.getEscapeCharacter());
return
type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING)
return type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING)
? whereIgnoreCase.notLike(parameterExpression, escapeChar)
: whereIgnoreCase.like(parameterExpression, escapeChar);
case TRUE:
@@ -418,12 +517,99 @@ public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuild
where = JpqlQueryBuilder.where(entity, property);
return type.equals(IS_NOT_EMPTY) ? where.isNotEmpty() : where.isEmpty();
case WITHIN:
case NEAR:
PartTreeParameterBinding vector = provider.next(part);
PartTreeParameterBinding within = provider.next(part);
if (within.getValue() instanceof Range<?> r) {
Range<Score> range = (Range<Score>) r;
if (range.getUpperBound().isBounded() || range.getUpperBound().isBounded()) {
Range.Bound<Score> lower = range.getLowerBound();
Range.Bound<Score> upper = range.getUpperBound();
String distanceFunction = getDistanceFunction(provider.getScoringFunction());
JpqlQueryBuilder.Expression distance = JpqlQueryBuilder.function(distanceFunction, pas,
placeholder(vector));
JpqlQueryBuilder.Predicate lowerPredicate = null;
JpqlQueryBuilder.Predicate upperPredicate = null;
// Score is a distance function, you typically want less when you specify a lower boundary,
// therefore lower and upper predicates are inverted.
if (lower.isBounded()) {
JpqlQueryBuilder.Expression distanceValue = placeholder(provider.lower(within, normalizer));
lowerPredicate = getUpperPredicate(lower.isInclusive(), distance, distanceValue);
}
if (upper.isBounded()) {
JpqlQueryBuilder.Expression distanceValue = placeholder(provider.upper(within, normalizer));
upperPredicate = getLowerPredicate(upper.isInclusive(), distance, distanceValue);
}
if (lowerPredicate != null && upperPredicate != null) {
return lowerPredicate.and(upperPredicate);
} else if (lowerPredicate != null) {
return lowerPredicate;
} else if (upperPredicate != null) {
return upperPredicate;
}
}
}
if (within.getValue() instanceof Score score) {
String distanceFunction = getDistanceFunction(score.getFunction());
JpqlQueryBuilder.Expression distanceValue = placeholder(provider.normalize(within, normalizer));
JpqlQueryBuilder.Expression distance = JpqlQueryBuilder.function(distanceFunction, pas,
placeholder(vector));
return getUpperPredicate(true, distance, distanceValue);
}
throw new InvalidDataAccessApiUsageException(
"Near/Within keywords must be used with a Score or Range<Score> type");
default:
throw new IllegalArgumentException("Unsupported keyword " + type);
}
}
private JpqlQueryBuilder.Predicate getLowerPredicate(boolean inclusive, JpqlQueryBuilder.Expression lhs,
JpqlQueryBuilder.Expression distance) {
return doLower(inclusive, lhs, distance);
}
private JpqlQueryBuilder.Predicate getUpperPredicate(boolean inclusive, JpqlQueryBuilder.Expression lhs,
JpqlQueryBuilder.Expression distance) {
return doUpper(inclusive, lhs, distance);
}
private static JpqlQueryBuilder.Predicate doLower(boolean inclusive, JpqlQueryBuilder.Expression lhs,
JpqlQueryBuilder.Expression distance) {
return inclusive ? JpqlQueryBuilder.where(lhs).gte(distance) : JpqlQueryBuilder.where(lhs).gt(distance);
}
private static JpqlQueryBuilder.Predicate doUpper(boolean inclusive, JpqlQueryBuilder.Expression lhs,
JpqlQueryBuilder.Expression distance) {
return inclusive ? JpqlQueryBuilder.where(lhs).lte(distance) : JpqlQueryBuilder.where(lhs).lt(distance);
}
private static String getDistanceFunction(ScoringFunction scoringFunction) {
DistanceFunction distanceFunction = JpaQueryCreator.DISTANCE_FUNCTIONS.get(scoringFunction);
if (distanceFunction == null) {
throw new IllegalArgumentException(
"Unsupported ScoringFunction: %s. Make sure to declare a supported ScoringFunction when creating Score/Similarity instances."
.formatted(scoringFunction.getName()));
}
return distanceFunction.distanceFunction();
}
/**
* Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part}
* requires ignoring case.

View File

@@ -18,8 +18,10 @@ package org.springframework.data.jpa.repository.query;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.StoredProcedureQuery;
import jakarta.persistence.Tuple;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -32,12 +34,18 @@ import org.springframework.core.convert.support.ConfigurableConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.ScrollPosition;
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.domain.Sort;
import org.springframework.data.jpa.provider.PersistenceProvider;
import org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor;
import org.springframework.data.repository.query.ReturnedType;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.data.util.CloseableIterator;
import org.springframework.data.util.StreamUtils;
@@ -123,6 +131,80 @@ public abstract class JpaQueryExecution {
}
}
static class SearchResultExecution extends JpaQueryExecution {
private final JpaQueryExecution delegate;
private final ReturnedType returnedType;
private final ScoringFunction function;
private final boolean normalizeSimilarity;
private final SimilarityNormalizer normalizer;
SearchResultExecution(JpaQueryExecution delegate, ReturnedType returnedType, ScoringFunction function,
boolean normalizeSimilarity) {
this.delegate = delegate;
this.returnedType = returnedType;
this.function = function;
this.normalizeSimilarity = normalizeSimilarity;
this.normalizer = normalizeSimilarity ? SimilarityNormalizer.get(function) : SimilarityNormalizer.IDENTITY;
}
@Override
protected @Nullable Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
Object result = delegate.execute(query, accessor);
if (result instanceof Tuple || result instanceof Object[]) {
return map(result);
}
if (result instanceof Collection<?> c) {
List<SearchResult<Object>> objects = new ArrayList<>(c.size());
for (Object o : c) {
objects.add(o instanceof Tuple || o instanceof Object[] ? map(o) : new SearchResult<>(o, 0));
}
return new SearchResults<>(objects);
}
return result;
}
private @Nullable SearchResult<Object> map(Object result) {
if (result instanceof Tuple t) {
Object value = returnedType.needsCustomConstruction() ? t : t.get(0);
try {
return new SearchResult<>(value, getScore(t.get("distance", Number.class).doubleValue()));
} catch (RuntimeException e) {
return new SearchResult<>(value, getScore(0));
}
}
if (result instanceof Object[] objects) {
Object value = returnedType.needsCustomConstruction() ? objects : objects[0];
try {
return new SearchResult<>(value, getScore(((Number) (objects[objects.length - 1])).doubleValue()));
} catch (RuntimeException e) {
return new SearchResult<>(value, getScore(0));
}
}
return null;
}
private Score getScore(double score) {
return normalizeSimilarity ? Similarity.raw(normalizer.getSimilarity(score), function)
: Score.of(score, function);
}
}
/**
* Executes the query to return a {@link org.springframework.data.domain.Window} of entities.
*

View File

@@ -28,9 +28,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import org.springframework.data.domain.Sort;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Sort;
import org.springframework.data.mapping.PropertyPath;
import org.springframework.data.util.Predicates;
import org.springframework.lang.CheckReturnValue;
@@ -124,15 +124,20 @@ public final class JpqlQueryBuilder {
}
@Override
public Select instantiate(String resultType, Collection<JpqlQueryBuilder.PathExpression> paths) {
public Select instantiate(String resultType, Collection<? extends JpqlQueryBuilder.Expression> paths) {
return new Select(postProcess(new ConstructorExpression(resultType, new Multiselect(from, paths))), from);
}
@Override
public Select select(Collection<JpqlQueryBuilder.PathExpression> paths) {
public Select select(Collection<? extends JpqlQueryBuilder.Expression> paths) {
return new Select(postProcess(new Multiselect(from, paths)), from);
}
@Override
public Select select(Selection selection) {
return new Select(postProcess(selection), from);
}
Selection postProcess(Selection selection) {
return distinct ? new DistinctSelection(selection) : selection;
}
@@ -239,6 +244,17 @@ public final class JpqlQueryBuilder {
return new ParameterExpression(placeholder);
}
/**
* Create a new ordering expression.
*
* @param sortExpression
* @return
* @since 4.0
*/
public static Expression orderBy(Expression sortExpression) {
return new OrderExpression(sortExpression, null, Sort.NullHandling.NATIVE);
}
/**
* Create a new ordering expression.
*
@@ -247,7 +263,19 @@ public final class JpqlQueryBuilder {
* @return
*/
public static Expression orderBy(Expression sortExpression, Sort.Order order) {
return new OrderExpression(sortExpression, order);
return new OrderExpression(sortExpression, order.getDirection(), order.getNullHandling());
}
/**
* Create a new ordering expression.
*
* @param sortExpression
* @param direction
* @return
* @since 4.0
*/
public static Expression orderBy(Expression sortExpression, Sort.Direction direction) {
return new OrderExpression(sortExpression, direction, Sort.NullHandling.NATIVE);
}
/**
@@ -431,7 +459,7 @@ public final class JpqlQueryBuilder {
* @return
*/
@CheckReturnValue
default Select instantiate(Class<?> resultType, Collection<JpqlQueryBuilder.PathExpression> paths) {
default Select instantiate(Class<?> resultType, Collection<? extends JpqlQueryBuilder.Expression> paths) {
return instantiate(resultType.getName(), paths);
}
@@ -440,10 +468,10 @@ public final class JpqlQueryBuilder {
*
* @param resultType
* @param paths
* @return
* @returninstanti
*/
@CheckReturnValue
Select instantiate(String resultType, Collection<JpqlQueryBuilder.PathExpression> paths);
Select instantiate(String resultType, Collection<? extends JpqlQueryBuilder.Expression> paths);
/**
* Specify a multi-select.
@@ -452,7 +480,7 @@ public final class JpqlQueryBuilder {
* @return
*/
@CheckReturnValue
Select select(Collection<JpqlQueryBuilder.PathExpression> paths);
Select select(Collection<? extends JpqlQueryBuilder.Expression> paths);
/**
* Select a single attribute.
@@ -465,9 +493,18 @@ public final class JpqlQueryBuilder {
return select(List.of(path));
}
/**
* Select a single attribute.
*
* @param selection
* @return
*/
@CheckReturnValue
Select select(Selection selection);
}
interface Selection {
public interface Selection {
String render(RenderContext context);
}
@@ -530,7 +567,7 @@ public final class JpqlQueryBuilder {
*
* @param source
*/
record EntitySelection(Entity source) implements Selection {
record EntitySelection(Entity source) implements Selection, Expression {
@Override
public String render(RenderContext context) {
@@ -568,7 +605,7 @@ public final class JpqlQueryBuilder {
* @param resultType
* @param multiselect
*/
record ConstructorExpression(String resultType, Multiselect multiselect) implements Selection {
record ConstructorExpression(String resultType, Multiselect multiselect) implements Selection, Expression {
@Override
public String render(RenderContext context) {
@@ -588,22 +625,22 @@ public final class JpqlQueryBuilder {
* @param source
* @param paths
*/
record Multiselect(Origin source, Collection<JpqlQueryBuilder.PathExpression> paths) implements Selection {
record Multiselect(Origin source, Collection<? extends JpqlQueryBuilder.Expression> paths) implements Selection {
@Override
public String render(RenderContext context) {
StringBuilder builder = new StringBuilder();
for (PathExpression path : paths) {
for (Expression path : paths) {
if (!builder.isEmpty()) {
builder.append(", ");
}
builder.append(path.render(context));
if (!context.isConstructorContext()) {
builder.append(" ").append(path.getPropertyPath().getSegment());
if (!context.isConstructorContext() && path instanceof AliasedExpression ae) {
builder.append(" ").append(ae.getAlias());
}
}
@@ -677,6 +714,47 @@ public final class JpqlQueryBuilder {
* @return
*/
String render(RenderContext context);
default AliasedExpression as(String alias) {
if (this instanceof DefaultAliasedExpression de) {
return new DefaultAliasedExpression(de.delegate, alias);
}
return new DefaultAliasedExpression(this, alias);
}
}
/**
* Aliased expression.
*
* @since 4.0
*/
public interface AliasedExpression extends Expression {
/**
* @return the expression alias.
*/
String getAlias();
}
record DefaultAliasedExpression(Expression delegate, String alias) implements AliasedExpression {
@Override
public String render(RenderContext context) {
return delegate.render(context);
}
@Override
public String getAlias() {
return alias();
}
@Override
public String toString() {
return render(RenderContext.EMPTY);
}
}
/**
@@ -812,7 +890,8 @@ public final class JpqlQueryBuilder {
}
}
record OrderExpression(Expression sortExpression, Sort.Order order) implements Expression {
record OrderExpression(Expression sortExpression, @org.springframework.lang.Nullable Sort.Direction direction,
Sort.NullHandling nullHandling) implements Expression {
@Override
public String render(RenderContext context) {
@@ -820,14 +899,17 @@ public final class JpqlQueryBuilder {
StringBuilder builder = new StringBuilder();
builder.append(sortExpression.render(context));
builder.append(" ");
builder.append(order.isDescending() ? TOKEN_DESC : TOKEN_ASC);
if (direction != null) {
if (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) {
builder.append(" NULLS FIRST");
} else if (order.getNullHandling() == Sort.NullHandling.NULLS_LAST) {
builder.append(" NULLS LAST");
builder.append(" ");
builder.append(direction.isDescending() ? TOKEN_DESC : TOKEN_ASC);
if (nullHandling == Sort.NullHandling.NULLS_FIRST) {
builder.append(" NULLS FIRST");
} else if (nullHandling == Sort.NullHandling.NULLS_LAST) {
builder.append(" NULLS LAST");
}
}
return builder.toString();
@@ -1395,7 +1477,8 @@ public final class JpqlQueryBuilder {
* @param origin
* @param onTheJoin whether the path should target the join itself instead of matching {@link PropertyPath}.
*/
record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) implements PathExpression {
record PathAndOrigin(PropertyPath path, Origin origin,
boolean onTheJoin) implements PathExpression, AliasedExpression {
@Override
public PropertyPath getPropertyPath() {
@@ -1411,6 +1494,11 @@ public final class JpqlQueryBuilder {
return context.getAlias(origin());
}
}
@Override
public String getAlias() {
return path().getSegment();
}
}
/**

View File

@@ -23,10 +23,13 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.Vector;
import org.springframework.data.expression.ValueExpression;
import org.springframework.data.jpa.provider.PersistenceProvider;
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
@@ -160,6 +163,15 @@ public class ParameterBinding {
* @param valueToBind value to prepare
*/
public @Nullable Object prepare(@Nullable Object valueToBind) {
if (valueToBind instanceof Score score) {
return score.getValue();
}
if (valueToBind instanceof Vector v) {
return v.getType() == Float.TYPE ? v.toFloatArray() : v.toDoubleArray();
}
return valueToBind;
}
@@ -216,6 +228,7 @@ public class ParameterBinding {
private final Type type;
private final boolean ignoreCase;
private final boolean noWildcards;
private final @Nullable Object value;
public PartTreeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, Class<?> parameterType,
Part part, @Nullable Object value, JpqlQueryTemplates templates, EscapeCharacter escape) {
@@ -225,7 +238,7 @@ public class ParameterBinding {
this.parameterType = parameterType;
this.templates = templates;
this.escape = escape;
this.value = value;
this.type = value == null
&& (Type.SIMPLE_PROPERTY.equals(part.getType()) || Type.NEGATING_SIMPLE_PROPERTY.equals(part.getType()))
? Type.IS_NULL
@@ -241,9 +254,14 @@ public class ParameterBinding {
return Type.IS_NULL.equals(type);
}
public @Nullable Object getValue() {
return value;
}
@Override
public @Nullable Object prepare(@Nullable Object value) {
value = super.prepare(value);
if (value == null || parameterType == null) {
return value;
}
@@ -306,6 +324,9 @@ public class ParameterBinding {
return Collections.singleton(value);
}
public String lower() {
return null;
}
}
/**
@@ -389,7 +410,7 @@ public class ParameterBinding {
@Override
public @Nullable Object prepare(@Nullable Object value) {
Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(value);
Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(super.prepare(value));
if (unwrapped == null) {
return null;
}
@@ -544,6 +565,26 @@ public class ParameterBinding {
default int getPosition() {
throw new IllegalStateException("No position associated");
}
/**
* Map the name of the binding to a new name using the given {@link Function} if the binding has a name. If the
* binding is not associated with a name, then the binding is returned unchanged.
*
* @param nameMapper must not be {@literal null}.
* @return the transformed {@link BindingIdentifier} if the binding has a name, otherwise the binding itself.
* @since 4.0
*/
BindingIdentifier mapName(Function<? super String, ? extends String> nameMapper);
/**
* Associate a position with the binding.
*
* @param position
* @return the new binding identifier with the position.
* @since 4.0
*/
BindingIdentifier withPosition(int position);
}
private record Named(String name) implements BindingIdentifier {
@@ -562,6 +603,16 @@ public class ParameterBinding {
public String toString() {
return name();
}
@Override
public BindingIdentifier mapName(Function<? super String, ? extends String> nameMapper) {
return new Named(nameMapper.apply(name()));
}
@Override
public BindingIdentifier withPosition(int position) {
return new NamedAndIndexed(name, position);
}
}
private record Indexed(int position) implements BindingIdentifier {
@@ -576,6 +627,16 @@ public class ParameterBinding {
return position();
}
@Override
public BindingIdentifier mapName(Function<? super String, ? extends String> nameMapper) {
return this;
}
@Override
public BindingIdentifier withPosition(int position) {
return new Indexed(position);
}
@Override
public String toString() {
return "[" + position() + "]";
@@ -604,6 +665,16 @@ public class ParameterBinding {
return position();
}
@Override
public BindingIdentifier mapName(Function<? super String, ? extends String> nameMapper) {
return new NamedAndIndexed(nameMapper.apply(name), position);
}
@Override
public BindingIdentifier withPosition(int position) {
return new NamedAndIndexed(name, position);
}
@Override
public String toString() {
return "[" + name() + ", " + position() + "]";

View File

@@ -20,33 +20,29 @@ import static org.springframework.data.jpa.repository.query.ParameterBinding.*;
import jakarta.persistence.criteria.CriteriaBuilder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.Vector;
import org.springframework.data.jpa.provider.PersistenceProvider;
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
import org.springframework.data.repository.query.Parameter;
import org.springframework.data.repository.query.Parameters;
import org.springframework.data.repository.query.ParametersParameterAccessor;
import org.springframework.data.repository.query.parser.Part;
import org.springframework.data.repository.query.parser.Part.IgnoreCaseType;
import org.springframework.data.repository.query.parser.Part.Type;
import org.springframework.expression.Expression;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
/**
* Helper class to allow easy creation of {@link ParameterMetadata}s.
* Helper class to allow easy creation of {@link PartTreeParameterBinding}s.
*
* @author Oliver Gierke
* @author Thomas Darimont
@@ -60,14 +56,19 @@ import org.springframework.util.ObjectUtils;
*/
public class ParameterMetadataProvider {
static final Object PLACEHOLDER = new Object();
private final Iterator<? extends Parameter> parameters;
private final @Nullable JpaParametersParameterAccessor accessor;
private final List<ParameterBinding> bindings;
private final Set<String> syntheticParameterNames = new LinkedHashSet<>();
private @Nullable ParameterBinding vector;
private final @Nullable Iterator<Object> bindableParameterValues;
private final EscapeCharacter escape;
private final JpqlQueryTemplates templates;
private final JpaParameters jpaParameters;
private int position;
private int bindMarker;
/**
* Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} and
@@ -77,9 +78,9 @@ public class ParameterMetadataProvider {
* @param escape must not be {@literal null}.
* @param templates must not be {@literal null}.
*/
public ParameterMetadataProvider(JpaParametersParameterAccessor accessor,
EscapeCharacter escape, JpqlQueryTemplates templates) {
this(accessor.iterator(), accessor.getParameters(), escape, templates);
public ParameterMetadataProvider(JpaParametersParameterAccessor accessor, EscapeCharacter escape,
JpqlQueryTemplates templates) {
this(accessor.iterator(), accessor, accessor.getParameters(), escape, templates);
}
/**
@@ -90,9 +91,8 @@ public class ParameterMetadataProvider {
* @param escape must not be {@literal null}.
* @param templates must not be {@literal null}.
*/
public ParameterMetadataProvider(JpaParameters parameters, EscapeCharacter escape,
JpqlQueryTemplates templates) {
this(null, parameters, escape, templates);
public ParameterMetadataProvider(JpaParameters parameters, EscapeCharacter escape, JpqlQueryTemplates templates) {
this(null, null, parameters, escape, templates);
}
/**
@@ -104,14 +104,15 @@ public class ParameterMetadataProvider {
* @param escape must not be {@literal null}.
* @param templates must not be {@literal null}.
*/
private ParameterMetadataProvider(@Nullable Iterator<Object> bindableParameterValues, JpaParameters parameters,
EscapeCharacter escape, JpqlQueryTemplates templates) {
private ParameterMetadataProvider(@Nullable Iterator<Object> bindableParameterValues,
@Nullable JpaParametersParameterAccessor accessor, JpaParameters parameters, EscapeCharacter escape,
JpqlQueryTemplates templates) {
Assert.notNull(parameters, "Parameters must not be null");
Assert.notNull(escape, "EscapeCharacter must not be null");
Assert.notNull(templates, "JpqlQueryTemplates must not be null");
this.jpaParameters = parameters;
this.accessor = accessor;
this.parameters = parameters.getBindableParameters().iterator();
this.bindings = new ArrayList<>();
this.bindableParameterValues = bindableParameterValues;
@@ -119,6 +120,10 @@ public class ParameterMetadataProvider {
this.templates = templates;
}
public JpaParameters getParameters() {
return this.jpaParameters;
}
/**
* Returns all {@link ParameterBinding}s built.
*
@@ -128,11 +133,23 @@ public class ParameterMetadataProvider {
return bindings;
}
/**
* @return the {@link SimilarityNormalizer}.
*/
SimilarityNormalizer getSimilarityNormalizer() {
if (accessor != null && accessor.normalizeSimilarity()) {
return SimilarityNormalizer.get(accessor.getScoringFunction());
}
return SimilarityNormalizer.IDENTITY;
}
/**
* Builds a new {@link PartTreeParameterBinding} for given {@link Part} and the next {@link Parameter}.
*/
@SuppressWarnings("unchecked")
public <T> PartTreeParameterBinding next(Part part) {
PartTreeParameterBinding next(Part part) {
Assert.isTrue(parameters.hasNext(), () -> String.format("No parameter available for part %s", part));
@@ -144,12 +161,11 @@ public class ParameterMetadataProvider {
* Builds a new {@link PartTreeParameterBinding} of the given {@link Part} and type. Forwards the underlying
* {@link Parameters} as well.
*
* @param <T> is the type parameter of the returned {@link ParameterMetadata}.
* @param <T> is the type parameter of the returned {@link PartTreeParameterBinding}.
* @param type must not be {@literal null}.
* @return ParameterMetadata for the next parameter.
*/
@SuppressWarnings("unchecked")
public <T> PartTreeParameterBinding next(Part part, Class<T> type) {
<T> PartTreeParameterBinding next(Part part, Class<T> type) {
Parameter parameter = parameters.next();
Class<?> typeToUse = ClassUtils.isAssignable(type, parameter.getType()) ? parameter.getType() : type;
@@ -159,11 +175,11 @@ public class ParameterMetadataProvider {
/**
* Builds a new {@link PartTreeParameterBinding} for the given type and name.
*
* @param <T> type parameter for the returned {@link ParameterMetadata}.
* @param <T> type parameter for the returned {@link PartTreeParameterBinding}.
* @param part must not be {@literal null}.
* @param type must not be {@literal null}.
* @param parameter providing the name for the returned {@link ParameterMetadata}.
* @return a new {@link ParameterMetadata} for the given type and name.
* @param parameter providing the name for the returned {@link PartTreeParameterBinding}.
* @return a new {@link PartTreeParameterBinding} for the given type and name.
*/
private <T> PartTreeParameterBinding next(Part part, Class<T> type, Parameter parameter) {
@@ -175,23 +191,63 @@ public class ParameterMetadataProvider {
@SuppressWarnings("unchecked")
Class<T> reifiedType = Expression.class.equals(type) ? (Class<T>) Object.class : type;
Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next();
Object value = bindableParameterValues == null ? PLACEHOLDER : bindableParameterValues.next();
int currentPosition = ++position;
int currentBindMarker = ++bindMarker;
BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition))
BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentBindMarker))
.orElseGet(() -> BindingIdentifier.of(currentBindMarker));
BindingIdentifier origin = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition))
.orElseGet(() -> BindingIdentifier.of(currentPosition));
/* identifier refers to bindable parameters, not _all_ parameters index */
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier);
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, methodParameter, reifiedType,
part, value, templates, escape);
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(origin);
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier,
methodParameter, reifiedType, part, value, templates, escape);
// PartTreeParameterBinding is more expressive than a potential ParameterBinding for Vector.
bindings.add(binding);
if (Vector.class.isAssignableFrom(parameter.getType())) {
this.vector = binding;
}
return binding;
}
ScoringFunction getScoringFunction() {
if (accessor != null) {
return accessor.getScoringFunction();
}
return ScoringFunction.unspecified();
}
ParameterBinding getVectorBinding() {
if (!getParameters().hasVectorParameter()) {
throw new IllegalStateException("Vector parameter not available");
}
if (this.vector != null) {
return this.vector;
}
int vectorIndex = getParameters().getVectorIndex();
BindingIdentifier bindingIdentifier = BindingIdentifier.of(vectorIndex + 1);
/* identifier refers to bindable parameters, not _all_ parameters index */
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier);
ParameterBinding parameterBinding = new ParameterBinding(bindingIdentifier, methodParameter);
this.bindings.add(parameterBinding);
return parameterBinding;
}
EscapeCharacter getEscape() {
return escape;
}
@@ -204,9 +260,9 @@ public class ParameterMetadataProvider {
* @param source
* @return a new {@link ParameterBinding} for the given value and source.
*/
public ParameterBinding nextSynthetic(String nameHint, Object value, Object source) {
ParameterBinding nextSynthetic(String nameHint, Object value, Object source) {
int currentPosition = ++position;
int currentPosition = ++bindMarker;
String bindingName = nameHint;
if (!syntheticParameterNames.add(bindingName)) {
@@ -219,126 +275,124 @@ public class ParameterMetadataProvider {
ParameterOrigin.synthetic(value, source));
}
public JpaParameters getParameters() {
return this.jpaParameters;
RangeParameterBinding lower(PartTreeParameterBinding within, SimilarityNormalizer normalizer) {
int bindMarker = within.getRequiredPosition();
if (!bindings.remove(within)) {
bindMarker = ++this.bindMarker;
}
BindingIdentifier identifier = within.getIdentifier();
RangeParameterBinding rangeBinding = new RangeParameterBinding(
identifier.mapName(name -> name + "_lower").withPosition(bindMarker), within.getOrigin(), true, normalizer);
bindings.add(rangeBinding);
return rangeBinding;
}
/**
* @author Oliver Gierke
* @author Thomas Darimont
* @author Andrey Kovalev
*/
public static class ParameterMetadata {
RangeParameterBinding upper(PartTreeParameterBinding within, SimilarityNormalizer normalizer) {
static final Object PLACEHOLDER = new Object();
int bindMarker = within.getRequiredPosition();
private final Class<?> parameterType;
private final Type type;
private final int position;
private final JpqlQueryTemplates templates;
private final EscapeCharacter escape;
private final boolean ignoreCase;
private final boolean noWildcards;
/**
* Creates a new {@link ParameterMetadata}.
*/
public ParameterMetadata(Class<?> parameterType, Part part, @Nullable Object value, EscapeCharacter escape,
int position, JpqlQueryTemplates templates) {
this.parameterType = parameterType;
this.position = position;
this.templates = templates;
this.type = value == null
&& (Type.SIMPLE_PROPERTY.equals(part.getType()) || Type.NEGATING_SIMPLE_PROPERTY.equals(part.getType()))
? Type.IS_NULL
: part.getType();
this.ignoreCase = IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase());
this.noWildcards = part.getProperty().getLeafProperty().isCollection();
this.escape = escape;
if (!bindings.remove(within)) {
bindMarker = ++this.bindMarker;
}
public int getPosition() {
return position;
}
public Class<?> getParameterType() {
return parameterType;
}
/**
* Returns whether the parameter shall be considered an {@literal IS NULL} parameter.
*/
public boolean isIsNullParameter() {
return Type.IS_NULL.equals(type);
}
/**
* Prepares the object before it's actually bound to the {@link jakarta.persistence.Query;}.
*
* @param value can be {@literal null}.
*/
public @Nullable Object prepare(@Nullable Object value) {
if (value == null || parameterType == null) {
return value;
}
if (String.class.equals(parameterType) && !noWildcards) {
return switch (type) {
case STARTING_WITH -> String.format("%s%%", escape.escape(value.toString()));
case ENDING_WITH -> String.format("%%%s", escape.escape(value.toString()));
case CONTAINING, NOT_CONTAINING -> String.format("%%%s%%", escape.escape(value.toString()));
default -> value;
};
}
return Collection.class.isAssignableFrom(parameterType) //
? potentiallyIgnoreCase(ignoreCase, toCollection(value)) //
: value;
}
/**
* Returns the given argument as {@link Collection} which means it will return it as is if it's a
* {@link Collections}, turn an array into an {@link ArrayList} or simply wrap any other value into a single element
* {@link Collections}.
*
* @param value the value to be converted to a {@link Collection}.
* @return the object itself as a {@link Collection} or a {@link Collection} constructed from the value.
*/
private static @Nullable Collection<?> toCollection(@Nullable Object value) {
if (value == null) {
return null;
}
if (value instanceof Collection<?> collection) {
return collection.isEmpty() ? null : collection;
}
if (ObjectUtils.isArray(value)) {
List<Object> collection = Arrays.asList(ObjectUtils.toObjectArray(value));
return collection.isEmpty() ? null : collection;
}
return Collections.singleton(value);
}
@SuppressWarnings("unchecked")
private @Nullable Collection<?> potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection<?> collection) {
if (!ignoreCase || CollectionUtils.isEmpty(collection)) {
return collection;
}
return ((Collection<String>) collection).stream() //
.map(it -> it == null //
? null //
: templates.ignoreCase(it)) //
.collect(Collectors.toList());
}
BindingIdentifier identifier = within.getIdentifier();
RangeParameterBinding rangeBinding = new RangeParameterBinding(
identifier.mapName(name -> name + "_upper").withPosition(bindMarker), within.getOrigin(), false, normalizer);
bindings.add(rangeBinding);
return rangeBinding;
}
ScoreParameterBinding normalize(PartTreeParameterBinding within, SimilarityNormalizer normalizer) {
bindings.remove(within);
ScoreParameterBinding rangeBinding = new ScoreParameterBinding(within.getIdentifier(), within.getOrigin(),
normalizer);
bindings.add(rangeBinding);
return rangeBinding;
}
static class ScoreParameterBinding extends ParameterBinding {
private final SimilarityNormalizer normalizer;
/**
* Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin.
*
* @param identifier of the parameter, must not be {@literal null}.
* @param origin the origin of the parameter (expression or method argument)
*/
ScoreParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, SimilarityNormalizer normalizer) {
super(identifier, origin);
this.normalizer = normalizer;
}
@Override
public @Nullable Object prepare(@Nullable Object valueToBind) {
if (valueToBind instanceof Score score) {
return normalizer.getScore(score.getValue());
}
return super.prepare(valueToBind);
}
@Override
public boolean isCompatibleWith(ParameterBinding binding) {
if (super.isCompatibleWith(binding) && binding instanceof ScoreParameterBinding other) {
return normalizer == other.normalizer;
}
return false;
}
}
static class RangeParameterBinding extends ScoreParameterBinding {
private final boolean lower;
/**
* Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin.
*
* @param identifier of the parameter, must not be {@literal null}.
* @param origin the origin of the parameter (expression or method argument)
*/
RangeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, boolean lower,
SimilarityNormalizer normalizer) {
super(identifier, origin, normalizer);
this.lower = lower;
}
@Override
public @Nullable Object prepare(@Nullable Object valueToBind) {
if (valueToBind instanceof Range<?> r) {
if (lower) {
return super.prepare(r.getLowerBound().getValue().orElse(null));
} else {
return super.prepare(r.getUpperBound().getValue().orElse(null));
}
}
return super.prepare(valueToBind);
}
@Override
public boolean isCompatibleWith(ParameterBinding binding) {
if (super.isCompatibleWith(binding) && binding instanceof RangeParameterBinding other) {
return lower == other.lower;
}
return false;
}
}
}

View File

@@ -129,7 +129,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery {
}
@Override
protected JpaQueryExecution getExecution() {
protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) {
if (this.getQueryMethod().isScrollQuery()) {
return new ScrollExecution(this.tree.getSort(), new ScrollDelegate<>(entityInformation));
@@ -139,7 +139,7 @@ public class PartTreeJpaQuery extends AbstractJpaQuery {
return new ExistsExecution();
}
return super.getExecution();
return super.getExecution(accessor);
}
private static void validate(PartTree tree, JpaParameters parameters, String methodName) {
@@ -301,13 +301,16 @@ public class PartTreeJpaQuery extends AbstractJpaQuery {
entityManager);
}
JpqlQueryCreator creator = new CacheableJpqlQueryCreator(sort,
new JpaQueryCreator(tree, returnedType, provider, templates, em));
if (accessor.getParameters().hasDynamicProjection()) {
return creator;
JpaParameters parameters = getQueryMethod().getParameters();
if (accessor.getParameters().hasDynamicProjection() || getQueryMethod().isSearchQuery()
|| parameters.hasScoreRangeParameter() || parameters.hasScoreParameter()) {
return new JpaQueryCreator(tree, getQueryMethod().isSearchQuery(), returnedType, provider, templates,
em.getMetamodel());
}
JpqlQueryCreator creator = new CacheableJpqlQueryCreator(sort, new JpaQueryCreator(tree,
getQueryMethod().isSearchQuery(), returnedType, provider, templates, em.getMetamodel()));
cache.put(sort, accessor, creator);
return creator;

View File

@@ -305,6 +305,10 @@ abstract class QueryParameterSetterFactory {
return super.create(binding, query);
}
if (binding instanceof ParameterMetadataProvider.ScoreParameterBinding) {
return super.create(binding, query);
}
return null;
}
}

View File

@@ -0,0 +1,125 @@
/*
* Copyright 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.jpa.repository.query;
import java.util.HashMap;
import java.util.Map;
import java.util.function.DoubleUnaryOperator;
import org.springframework.data.domain.ScoringFunction;
import org.springframework.data.domain.VectorScoringFunctions;
/**
* Normalizes the score returned by a database to a similarity value and vice versa.
*
* @author Mark Paluch
* @since 4.0
* @see org.springframework.data.domain.Similarity
*/
public class SimilarityNormalizer {
/**
* Identity normalizer for {@link ScoringFunction#unspecified()} scoring function without altering the score.
*/
public static final SimilarityNormalizer IDENTITY = new SimilarityNormalizer(ScoringFunction.unspecified(),
DoubleUnaryOperator.identity(), DoubleUnaryOperator.identity());
/**
* Normalizer for Euclidean scores using {@code euclidean_distance(…)} as the scoring function.
*/
public static final SimilarityNormalizer EUCLIDEAN = new SimilarityNormalizer(VectorScoringFunctions.EUCLIDEAN,
it -> 1 / (1.0 + Math.pow(it, 2)), it -> it == 0 ? Float.MAX_VALUE : Math.sqrt((1 / it) - 1));
/**
* Normalizer for Cosine scores using {@code cosine_distance(…)} as the scoring function.
*/
public static final SimilarityNormalizer COSINE = new SimilarityNormalizer(VectorScoringFunctions.COSINE,
it -> (1.0 + (1 - it)) / 2.0, it -> 1 - ((it * 2) - 1));
/**
* Normalizer for Negative Inner Product (Dot) scores using {@code negative_inner_product(…)} as the scoring function.
*/
public static final SimilarityNormalizer DOT_PRODUCT = new SimilarityNormalizer(VectorScoringFunctions.DOT_PRODUCT,
it -> (1 - it) / 2, it -> 1 - (it * 2));
private static final Map<ScoringFunction, SimilarityNormalizer> NORMALIZERS = new HashMap<>();
static {
NORMALIZERS.put(EUCLIDEAN.scoringFunction, EUCLIDEAN);
NORMALIZERS.put(COSINE.scoringFunction, COSINE);
NORMALIZERS.put(DOT_PRODUCT.scoringFunction, DOT_PRODUCT);
}
private final ScoringFunction scoringFunction;
private final DoubleUnaryOperator similarity;
private final DoubleUnaryOperator score;
/**
* Constructor for {@link SimilarityNormalizer} using the given {@link DoubleUnaryOperator} for similarity and score
* computation.
*
* @param similarity compute the similarity from the underlying score returned by a database result.
* @param score compute the score value from a given {@link org.springframework.data.domain.Similarity} to compare
* against database results.
*/
SimilarityNormalizer(ScoringFunction scoringFunction, DoubleUnaryOperator similarity, DoubleUnaryOperator score) {
this.scoringFunction = scoringFunction;
this.score = score;
this.similarity = similarity;
}
/**
* Lookup a {@link SimilarityNormalizer} for a given {@link ScoringFunction}.
*
* @param scoringFunction the scoring function to translate.
* @return the {@link SimilarityNormalizer} for the given {@link ScoringFunction}.
* @throws IllegalArgumentException if the {@link ScoringFunction} is not associated with a
* {@link SimilarityNormalizer}.
*/
public static SimilarityNormalizer get(ScoringFunction scoringFunction) {
SimilarityNormalizer normalizer = NORMALIZERS.get(scoringFunction);
if (normalizer == null) {
throw new IllegalArgumentException("No SimilarityNormalizer found for " + scoringFunction.getName());
}
return normalizer;
}
/**
* @param score score value as returned by the database.
* @return the {@link org.springframework.data.domain.Similarity} value.
*/
public double getSimilarity(double score) {
return similarity.applyAsDouble(score);
}
/**
* @param similarity similarity value as requested by the query mechanism.
* @return database score value.
*/
public double getScore(double similarity) {
return score.applyAsDouble(similarity);
}
@Override
public String toString() {
return "%s Normalizer: Similarity[0 to 1] -> Score[%f to %f]".formatted(scoringFunction.getName(), getScore(0),
getScore(1));
}
}

View File

@@ -0,0 +1,342 @@
/*
* Copyright 2015-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.jpa.repository;
import static org.assertj.core.api.Assertions.*;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import org.hibernate.annotations.Array;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Range;
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.Vector;
import org.springframework.data.domain.VectorScoringFunctions;
import org.springframework.test.annotation.Rollback;
import org.springframework.transaction.annotation.Transactional;
/**
* Testcase to verify Vector Search work with Hibernate.
*
* @author Mark Paluch
*/
@Transactional
@Rollback(value = false)
abstract class AbstractVectorIntegrationTests {
Vector VECTOR = Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f);
@Autowired VectorSearchRepository repository;
@BeforeEach
void setUp() {
WithVector w1 = new WithVector("de", "one", new float[] { 0.1001f, 0.22345f, 0.33456f, 0.44567f, 0.55678f });
WithVector w2 = new WithVector("de", "two", new float[] { 0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f });
WithVector w3 = new WithVector("en", "three", new float[] { 0.9001f, 0.82345f, 0.73456f, 0.64567f, 0.55678f });
WithVector w4 = new WithVector("de", "four", new float[] { 0.9001f, 0.92345f, 0.93456f, 0.94567f, 0.95678f });
repository.deleteAllInBatch();
repository.saveAllAndFlush(Arrays.asList(w1, w2, w3, w4));
}
@ParameterizedTest
@MethodSource("scoringFunctions")
void shouldApplyVectorSearchWithDistance(VectorScoringFunctions functions) {
SearchResults<WithVector> results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR,
Similarity.of(0, functions));
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry)
.containsOnly("de", "de");
assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription)
.containsExactlyInAnyOrder("two", "one", "four");
}
static Set<VectorScoringFunctions> scoringFunctions() {
return EnumSet.of(VectorScoringFunctions.COSINE, VectorScoringFunctions.DOT_PRODUCT,
VectorScoringFunctions.EUCLIDEAN);
}
@Test
void shouldNormalizeEuclideanSimilarity() {
SearchResults<WithVector> results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR,
Similarity.of(0.99, VectorScoringFunctions.EUCLIDEAN));
assertThat(results).hasSize(1);
SearchResult<WithVector> two = results.getContent().get(0);
assertThat(two.getContent().getDescription()).isEqualTo("two");
assertThat(two.getScore()).isInstanceOf(Similarity.class);
assertThat(two.getScore().getValue()).isGreaterThan(0.99);
}
@Test
void shouldNormalizeCosineSimilarity() {
SearchResults<WithVector> results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR,
Similarity.of(0.999, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(1);
SearchResult<WithVector> two = results.getContent().get(0);
assertThat(two.getContent().getDescription()).isEqualTo("two");
assertThat(two.getScore()).isInstanceOf(Similarity.class);
assertThat(two.getScore().getValue()).isGreaterThan(0.99);
}
@Test
void shouldRunStringQuery() {
List<WithVector> results = repository.findAnnotatedByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(2, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(WithVector::getCountry).containsOnly("de", "de", "de");
assertThat(results).extracting(WithVector::getDescription).containsSequence("two", "one", "four");
}
@Test
void shouldRunStringQueryWithDistance() {
SearchResults<WithVector> results = repository.searchAnnotatedByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(2, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry)
.containsOnly("de", "de", "de");
assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription)
.containsSequence("two", "one", "four");
SearchResult<WithVector> result = results.getContent().get(0);
assertThat(result.getScore().getValue()).isGreaterThanOrEqualTo(0);
assertThat(result.getScore().getFunction()).isEqualTo(VectorScoringFunctions.COSINE);
}
@Test
void shouldRunStringQueryWithFloatDistance() {
SearchResults<WithVector> results = repository.searchAnnotatedByCountryAndEmbeddingWithin("de", VECTOR, 2);
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry)
.containsOnly("de", "de", "de");
assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription)
.containsSequence("two", "one", "four");
SearchResult<WithVector> result = results.getContent().get(0);
assertThat(result.getScore().getValue()).isGreaterThanOrEqualTo(0);
assertThat(result.getScore().getFunction()).isEqualTo(ScoringFunction.unspecified());
}
@Test
void shouldApplyVectorSearchWithRange() {
SearchResults<WithVector> results = repository.searchAllByCountryAndEmbeddingWithin("de", VECTOR,
Similarity.between(0, 1, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry)
.containsOnly("de", "de", "de");
assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription)
.containsSequence("two", "one", "four");
}
@Test
void shouldApplyVectorSearchAndReturnList() {
List<WithVector> results = repository.findAllByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(10, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(WithVector::getCountry).containsOnly("de", "de", "de");
assertThat(results).extracting(WithVector::getDescription).containsSequence("one", "two", "four");
}
@Test
void shouldProjectVectorSearchAsInterface() {
SearchResults<WithDescription> results = repository.searchInterfaceProjectionByCountryAndEmbeddingWithin("de",
VECTOR, Score.of(10, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithDescription::getDescription)
.containsSequence("two", "one", "four");
}
@Test
void shouldProjectVectorSearchAsDto() {
SearchResults<DescriptionDto> results = repository.searchDtoByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(10, VectorScoringFunctions.COSINE));
assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(DescriptionDto::getDescription)
.containsSequence("two", "one", "four");
}
@Test
void shouldProjectVectorSearchDynamically() {
SearchResults<DescriptionDto> dtos = repository.searchDynamicByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(10, VectorScoringFunctions.COSINE), DescriptionDto.class);
assertThat(dtos).hasSize(3).extracting(SearchResult::getContent).extracting(DescriptionDto::getDescription)
.containsSequence("two", "one", "four");
SearchResults<WithDescription> proxies = repository.searchDynamicByCountryAndEmbeddingWithin("de", VECTOR,
Score.of(10, VectorScoringFunctions.COSINE), WithDescription.class);
assertThat(proxies).hasSize(3).extracting(SearchResult::getContent).extracting(WithDescription::getDescription)
.containsSequence("two", "one", "four");
}
@Entity
@Table(name = "with_vector")
public static class WithVector {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) //
private Integer id;
private String country;
private String description;
@Column(name = "the_embedding")
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 5) private float[] embedding;
public WithVector() {}
public WithVector(String country, String description, float[] embedding) {
this.country = country;
this.description = description;
this.embedding = embedding;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getCountry() {
return country;
}
public void setCountry(String country) {
this.country = country;
}
public String getDescription() {
return description;
}
public float[] getEmbedding() {
return embedding;
}
public void setEmbedding(float[] embedding) {
this.embedding = embedding;
}
@Override
public String toString() {
return "WithVector{" + "country='" + country + '\'' + ", description='" + description + '\'' + '}';
}
}
interface WithDescription {
String getDescription();
}
static class DescriptionDto {
private final String description;
public DescriptionDto(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
interface VectorSearchRepository extends JpaRepository<WithVector, Integer> {
List<WithVector> findAllByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance);
@Query("""
SELECT w FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w
WHERE w.country = ?1
AND cosine_distance(w.embedding, :embedding) <= :distance
ORDER BY cosine_distance(w.embedding, :embedding) asc""")
List<WithVector> findAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance);
@Query("""
SELECT w, cosine_distance(w.embedding, :embedding) as distance FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w
WHERE w.country = ?1
AND cosine_distance(w.embedding, :embedding) <= :distance
ORDER BY distance asc""")
SearchResults<WithVector> searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding,
Score distance);
@Query("""
SELECT w, cosine_distance(w.embedding, :embedding) as distance FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w
WHERE w.country = ?1
AND cosine_distance(w.embedding, :embedding) <= :distance
ORDER BY distance asc""")
SearchResults<WithVector> searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding,
float distance);
SearchResults<WithVector> searchAllByCountryAndEmbeddingWithin(String country, Vector embedding,
Range<Similarity> distance);
SearchResults<WithVector> searchTop5ByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance);
SearchResults<WithDescription> searchInterfaceProjectionByCountryAndEmbeddingWithin(String country,
Vector embedding, Score distance);
SearchResults<DescriptionDto> searchDtoByCountryAndEmbeddingWithin(String country, Vector embedding,
Score distance);
<T> SearchResults<T> searchDynamicByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance,
Class<T> projection);
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2015-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.jpa.repository;
import java.net.URL;
import java.util.List;
import org.hibernate.dialect.OracleDialect;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.testcontainers.oracle.OracleContainer;
import org.testcontainers.utility.MountableFile;
/**
* Testcase to verify Vector Search work with Oracle.
*
* @author Mark Paluch
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = OracleVectorIntegrationTests.Config.class)
class OracleVectorIntegrationTests extends AbstractVectorIntegrationTests {
@EnableJpaRepositories(considerNestedRepositories = true,
includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = VectorSearchRepository.class))
@EnableTransactionManagement
static class Config extends TestcontainerConfigSupport {
public Config() {
super(OracleDialect.class, new ClassPathResource("scripts/oracle-vector.sql"));
}
@Override
protected String getSchemaAction() {
return "none";
}
@Override
protected PersistenceManagedTypes getManagedTypes() {
return new PersistenceManagedTypes() {
@Override
public List<String> getManagedClassNames() {
return List.of(WithVector.class.getName());
}
@Override
public List<String> getManagedPackages() {
return List.of();
}
@Override
public @Nullable URL getPersistenceUnitRootUrl() {
return null;
}
};
}
@SuppressWarnings("resource")
@Bean(initMethod = "start", destroyMethod = "start")
public OracleContainer container() {
return new OracleContainer("gvenzl/oracle-free:23-slim") //
.withReuse(true)
.withCopyFileToContainer(MountableFile.forClasspathResource("/scripts/oracle-vector-initialize.sql"),
"/container-entrypoint-initdb.d/initialize.sql");
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2015-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.jpa.repository;
import java.net.URL;
import java.util.List;
import org.hibernate.dialect.PostgreSQLDialect;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.testcontainers.containers.PostgreSQLContainer;
/**
* Testcase to verify Vector Search work with Postgres (PGvector).
*
* @author Mark Paluch
*/
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = PgVectorIntegrationTests.Config.class)
class PgVectorIntegrationTests extends AbstractVectorIntegrationTests {
@EnableJpaRepositories(considerNestedRepositories = true,
includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = VectorSearchRepository.class))
@EnableTransactionManagement
static class Config extends TestcontainerConfigSupport {
public Config() {
super(PostgreSQLDialect.class, new ClassPathResource("scripts/pgvector.sql"));
}
@Override
protected String getSchemaAction() {
return "none";
}
@Override
protected PersistenceManagedTypes getManagedTypes() {
return new PersistenceManagedTypes() {
@Override
public List<String> getManagedClassNames() {
return List.of(WithVector.class.getName());
}
@Override
public List<String> getManagedPackages() {
return List.of();
}
@Override
public @Nullable URL getPersistenceUnitRootUrl() {
return null;
}
};
}
@SuppressWarnings("resource")
@Bean(initMethod = "start", destroyMethod = "start")
public PostgreSQLContainer<?> container() {
return new PostgreSQLContainer<>("pgvector/pgvector:pg17") //
.withUsername("postgres").withReuse(true);
}
}
}

View File

@@ -23,10 +23,12 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.NamedStoredProcedureQuery;
import java.net.URL;
import java.util.List;
import java.util.Objects;
import org.hibernate.dialect.MySQLDialect;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,6 +40,8 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.jpa.repository.query.Procedure;
import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -223,12 +227,33 @@ class MySqlStoredProcedureIntegrationTests {
basePackageClasses = Config.class, //
includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = EmployeeRepositoryWithNoCursor.class))
@EnableTransactionManagement
static class Config extends StoredProcedureConfigSupport {
static class Config extends TestcontainerConfigSupport {
public Config() {
super(MySQLDialect.class, new ClassPathResource("scripts/mysql-stored-procedures.sql"));
}
@Override
protected PersistenceManagedTypes getManagedTypes() {
return new PersistenceManagedTypes() {
@Override
public List<String> getManagedClassNames() {
return List.of(Employee.class.getName());
}
@Override
public List<String> getManagedPackages() {
return List.of();
}
@Override
public @Nullable URL getPersistenceUnitRootUrl() {
return null;
}
};
}
@SuppressWarnings("resource")
@Bean(initMethod = "start", destroyMethod = "stop")
public MySQLContainer<?> container() {

View File

@@ -26,11 +26,13 @@ import jakarta.persistence.ParameterMode;
import jakarta.persistence.StoredProcedureParameter;
import java.math.BigDecimal;
import java.net.URL;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.hibernate.dialect.PostgreSQLDialect;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,7 +44,9 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.jpa.repository.query.Procedure;
import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport;
import org.springframework.data.jpa.util.DisabledOnHibernate;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -292,12 +296,33 @@ class PostgresStoredProcedureIntegrationTests {
@EnableJpaRepositories(considerNestedRepositories = true,
includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = EmployeeRepositoryWithRefCursor.class))
@EnableTransactionManagement
static class Config extends StoredProcedureConfigSupport {
static class Config extends TestcontainerConfigSupport {
public Config() {
super(PostgreSQLDialect.class, new ClassPathResource("scripts/postgres-stored-procedures.sql"));
}
@Override
protected PersistenceManagedTypes getManagedTypes() {
return new PersistenceManagedTypes() {
@Override
public List<String> getManagedClassNames() {
return List.of(Employee.class.getName());
}
@Override
public List<String> getManagedPackages() {
return List.of();
}
@Override
public @Nullable URL getPersistenceUnitRootUrl() {
return null;
}
};
}
@SuppressWarnings("resource")
@Bean(initMethod = "start", destroyMethod = "stop")
public PostgreSQLContainer<?> container() {

View File

@@ -20,10 +20,13 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.net.URL;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import org.hibernate.dialect.PostgreSQLDialect;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -36,7 +39,9 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Temporal;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.jpa.repository.query.Procedure;
import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport;
import org.springframework.data.jpa.util.DisabledOnHibernate;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@@ -128,12 +133,33 @@ class PostgresStoredProcedureNullHandlingIntegrationTests {
@EnableJpaRepositories(considerNestedRepositories = true,
includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = TestModelRepository.class))
@EnableTransactionManagement
static class Config extends StoredProcedureConfigSupport {
static class Config extends TestcontainerConfigSupport {
public Config() {
super(PostgreSQLDialect.class, new ClassPathResource("scripts/postgres-nullable-stored-procedures.sql"));
}
@Override
protected PersistenceManagedTypes getManagedTypes() {
return new PersistenceManagedTypes() {
@Override
public List<String> getManagedClassNames() {
return List.of(TestModel.class.getName());
}
@Override
public List<String> getManagedPackages() {
return List.of();
}
@Override
public @Nullable URL getPersistenceUnitRootUrl() {
return null;
}
};
}
@SuppressWarnings("resource")
@Bean(initMethod = "start", destroyMethod = "stop")
public PostgreSQLContainer<?> container() {

View File

@@ -221,7 +221,7 @@ class AbstractJpaQueryTests {
}
@Override
protected JpaQueryExecution getExecution() {
protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) {
return execution;
}

View File

@@ -40,8 +40,11 @@ import org.junit.jupiter.params.provider.FieldSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Vector;
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
import org.springframework.data.jpa.util.TestMetaModel;
import org.springframework.data.projection.ProjectionFactory;
@@ -743,7 +746,8 @@ class JpaQueryCreatorTests {
ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(parameterAccessor,
EscapeCharacter.DEFAULT, templates);
return new JpaQueryCreator(tree, returnedType, parameterMetadataProvider, templates, entityManager);
return new JpaQueryCreator(tree, false, returnedType, parameterMetadataProvider, templates,
entityManager.getMetamodel());
}
@SuppressWarnings({ "rawtypes", "unchecked" })
@@ -979,6 +983,21 @@ class JpaQueryCreatorTests {
public ParameterAccessor bindableParameters() {
return new ParameterAccessor() {
@Override
public @Nullable Vector getVector() {
return null;
}
@Override
public @Nullable Score getScore() {
return null;
}
@Override
public @Nullable Range<Score> getScoreRange() {
return null;
}
@Override
public @Nullable ScrollPosition getScrollPosition() {
return null;

View File

@@ -33,6 +33,7 @@ import org.junit.jupiter.api.Test;
* Unit tests for {@link JpqlQueryBuilder}.
*
* @author Christoph Strobl
* @author Mark Paluch
*/
class JpqlQueryBuilderUnitTests {
@@ -77,6 +78,15 @@ class JpqlQueryBuilderUnitTests {
assertThat(expression.render(RenderContext.EMPTY)).isEqualTo("CONCAT(person.lastName, , , person.firstName))");
}
@Test // GH-
void aliasedExpression() {
// aliasing is contextual and happens during selection rendering. E.g. constructor expressions don't use aliases.
Expression expression = expression("CONCAT(person.lastName, , , person.firstName)").as("concatted");
assertThat(expression.render(RenderContext.EMPTY))
.isEqualTo("CONCAT(person.lastName, , , person.firstName)");
}
@Test // GH-3588
void xxx() {

View File

@@ -26,6 +26,10 @@ import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.Similarity;
import org.springframework.data.domain.Vector;
import org.springframework.data.jpa.domain.sample.User;
import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
import org.springframework.data.repository.query.Param;
@@ -41,6 +45,7 @@ import org.springframework.test.util.ReflectionTestUtils;
*
* @author Oliver Gierke
* @author Jens Schauder
* @author Mark Paluch
* @soundtrack Elephants Crossing - We are (Irrelephant)
*/
@ExtendWith(SpringExtension.class)
@@ -78,6 +83,52 @@ class ParameterMetadataProviderIntegrationTests {
assertThat(binding.prepare(1)).isEqualTo(1);
}
@Test // GH-
void appliesScoreValuePreparation() throws Exception {
ParameterMetadataProvider provider = createProvider(
Sample.class.getMethod("findByVectorWithin", Vector.class, Score.class));
ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class));
ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class));
ParameterMetadataProvider.ScoreParameterBinding binding = provider.normalize(score, SimilarityNormalizer.EUCLIDEAN);
assertThat(binding.prepare(Score.of(1))).isEqualTo(0.0);
assertThat(binding.prepare(Score.of(0.5))).isEqualTo(1.0);
assertThat(provider.getBindings()).hasSize(2).contains(binding).doesNotContain(score);
}
@Test // GH-
void appliesLowerRangeValuePreparation() throws Exception {
ParameterMetadataProvider provider = createProvider(
Sample.class.getMethod("findByVectorWithin", Vector.class, Range.class));
ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class));
ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class));
ParameterMetadataProvider.ScoreParameterBinding lower = provider.lower(score, SimilarityNormalizer.EUCLIDEAN);
Range<Similarity> range = Similarity.between(0.5, 1);
assertThat(lower.prepare(range)).isEqualTo(1.0);
assertThat(provider.getBindings()).hasSize(2).contains(lower).doesNotContain(score);
}
@Test // GH-
void appliesRangeValuePreparation() throws Exception {
ParameterMetadataProvider provider = createProvider(
Sample.class.getMethod("findByVectorWithin", Vector.class, Range.class));
ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class));
ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class));
ParameterMetadataProvider.ScoreParameterBinding lower = provider.lower(score, SimilarityNormalizer.EUCLIDEAN);
ParameterMetadataProvider.ScoreParameterBinding upper = provider.upper(score, SimilarityNormalizer.EUCLIDEAN);
Range<Similarity> range = Similarity.between(0.5, 1);
assertThat(lower.prepare(range)).isEqualTo(1.0);
assertThat(upper.prepare(range)).isEqualTo(0.0);
assertThat(provider.getBindings()).hasSize(3).contains(lower, upper).doesNotContain(score);
}
private ParameterMetadataProvider createProvider(Method method) {
JpaParameters parameters = new JpaParameters(ParametersSource.of(method));
@@ -102,5 +153,13 @@ class ParameterMetadataProviderIntegrationTests {
User findByLastname(String lastname);
User findByAgeContaining(@Param("age") Integer age);
User findByVectorWithin(Vector vector, Score score);
User findByVectorWithin(Vector vector, Range<Score> score);
}
static class WithVector {
Vector vector;
}
}

View File

@@ -61,25 +61,4 @@ class ParameterMetadataProviderUnitTests {
.withMessageContaining("parameter");
}
@Test // GH-3137
void returnAugmentedValueForStringExpressions() {
when(part.getProperty().getLeafProperty().isCollection()).thenReturn(false);
when(part.getProperty().getType()).thenReturn((Class) String.class);
assertThat(createParameterMetadata(Part.Type.STARTING_WITH).prepare("starting with")).isEqualTo("starting with%");
assertThat(createParameterMetadata(Part.Type.ENDING_WITH).prepare("ending with")).isEqualTo("%ending with");
assertThat(createParameterMetadata(Part.Type.CONTAINING).prepare("containing")).isEqualTo("%containing%");
assertThat(createParameterMetadata(Part.Type.NOT_CONTAINING).prepare("not containing"))
.isEqualTo("%not containing%");
assertThat(createParameterMetadata(Part.Type.LIKE).prepare("%like%")).isEqualTo("%like%");
assertThat(createParameterMetadata(Part.Type.IS_NULL).prepare(null)).isEqualTo(null);
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private ParameterMetadataProvider.ParameterMetadata createParameterMetadata(Part.Type partType) {
when(part.getType()).thenReturn(partType);
return new ParameterMetadataProvider.ParameterMetadata(part.getProperty().getType(), part, null, EscapeCharacter.DEFAULT, 1, JpqlQueryTemplates.LOWER);
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 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.jpa.repository.query;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link SimilarityNormalizer}.
*
* @author Mark Paluch
*/
class SimilarityNormalizerUnitTests {
@Test
void normalizesEuclidean() {
assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(0)).isCloseTo(1.0, offset(0.01));
assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(0.223606791085977)).isCloseTo(0.9523810148239136,
offset(0.01));
assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(1.1618950141221271)).isCloseTo(0.42553189396858215,
offset(0.01));
assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(1.0)).isCloseTo(0.0, offset(0.01));
assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(0.9523810148239136)).isCloseTo(0.223606791085977, offset(0.01));
assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(0.42553189396858215)).isCloseTo(1.1618950141221271,
offset(0.01));
}
@Test
void normalizesCosine() {
assertThat(SimilarityNormalizer.COSINE.getSimilarity(0)).isCloseTo(1.0, offset(0.01));
assertThat(SimilarityNormalizer.COSINE.getSimilarity(0.004470301418728173)).isCloseTo(0.9977648258209229,
offset(0.01));
assertThat(SimilarityNormalizer.COSINE.getSimilarity(0.05568200370295473)).isCloseTo(0.9721590280532837,
offset(0.01));
assertThat(SimilarityNormalizer.COSINE.getScore(1.0)).isCloseTo(0.0, offset(0.01));
assertThat(SimilarityNormalizer.COSINE.getScore(0.9977648258209229)).isCloseTo(0.004470301418728173, offset(0.01));
assertThat(SimilarityNormalizer.COSINE.getScore(0.9721590280532837)).isCloseTo(0.05568200370295473, offset(0.01));
}
@Test
void normalizesNegativeInnerProduct() {
assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-0.8465620279312134)).isCloseTo(0.9232810139656067,
offset(0.01));
assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-1.0626180171966553)).isCloseTo(1.0313090085983276,
offset(0.01));
assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-2.0293400287628174)).isCloseTo(1.5146700143814087,
offset(0.01));
assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(0.9232810139656067)).isCloseTo(-0.8465620279312134,
offset(0.01));
assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(1.0313090085983276)).isCloseTo(-1.0626180171966553,
offset(0.01));
assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(1.5146700143814087)).isCloseTo(-2.0293400287628174,
offset(0.01));
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2024-2025 the original author or authors.
* Copyright 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.
@@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.jpa.repository.procedures;
package org.springframework.data.jpa.repository.support;
import jakarta.persistence.EntityManagerFactory;
import java.util.Collection;
import java.util.Collections;
import java.util.Properties;
import javax.sql.DataSource;
@@ -29,6 +31,8 @@ import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.persistenceunit.ManagedClassNameFilter;
import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
@@ -39,12 +43,12 @@ import org.testcontainers.containers.JdbcDatabaseContainer;
*
* @author Mark Paluch
*/
class StoredProcedureConfigSupport {
public class TestcontainerConfigSupport {
private final Class<?> dialect;
private final Resource initScript;
StoredProcedureConfigSupport(Class<?> dialect, Resource initScript) {
protected TestcontainerConfigSupport(Class<?> dialect, Resource initScript) {
this.dialect = dialect;
this.initScript = initScript;
}
@@ -67,16 +71,36 @@ class StoredProcedureConfigSupport {
factoryBean.setDataSource(dataSource);
factoryBean.setPersistenceUnitRootLocation("simple-persistence");
factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
factoryBean.setPackagesToScan(this.getClass().getPackage().getName());
factoryBean.setManagedTypes(getManagedTypes());
factoryBean.setPackagesToScan(getPackagesToScan().toArray(new String[0]));
factoryBean.setManagedClassNameFilter(getManagedClassNameFilter());
Properties properties = new Properties();
properties.setProperty("hibernate.hbm2ddl.auto", "create");
properties.setProperty("hibernate.hbm2ddl.auto", getSchemaAction());
properties.setProperty("hibernate.dialect", dialect.getCanonicalName());
factoryBean.setJpaProperties(properties);
return factoryBean;
}
protected String getSchemaAction() {
return "create";
}
protected PersistenceManagedTypes getManagedTypes() {
return null;
}
protected Collection<String> getPackagesToScan() {
return Collections.emptyList();
}
protected ManagedClassNameFilter getManagedClassNameFilter() {
return className -> true;
}
@Bean
PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);

View File

@@ -0,0 +1,11 @@
-- Exit on any errors
WHENEVER SQLERROR EXIT SQL.SQLCODE
-- Configure the size of the Vector Pool to 1 GiB.
ALTER SYSTEM SET vector_memory_size = 1G SCOPE=SPFILE;
SHUTDOWN
ABORT;
STARTUP;
exit;

View File

@@ -0,0 +1,16 @@
DROP TABLE IF EXISTS with_vector;;
CREATE TABLE IF NOT EXISTS with_vector
(
id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY,
country varchar2(10),
description varchar2(10),
the_embedding vector(5, FLOAT32) annotations(Distance 'COSINE', IndexType 'IVF')
);;
create
vector index if not exists vector_index_1 on with_vector (the_embedding)
organization neighbor partitions
distance COSINE
with target accuracy 95
parameters (type IVF, neighbor partitions 10);;

View File

@@ -0,0 +1,7 @@
CREATE EXTENSION IF NOT EXISTS vector;
DROP TABLE IF EXISTS with_vector;
CREATE TABLE IF NOT EXISTS with_vector (id bigserial PRIMARY KEY,country varchar(10), description varchar(10),the_embedding vector(5));
CREATE INDEX ON with_vector USING hnsw (the_embedding vector_l2_ops);

View File

@@ -14,6 +14,7 @@
** xref:jpa/stored-procedures.adoc[]
** xref:jpa/specifications.adoc[]
** xref:repositories/query-by-example.adoc[]
** xref:repositories/vector-search.adoc[]
** xref:jpa/transactions.adoc[]
** xref:jpa/locking.adoc[]
** xref:auditing.adoc[]

View File

@@ -0,0 +1,8 @@
:vector-search-intro-include: data-jpa::partial$vector-search-intro-include.adoc
:vector-search-model-include: data-jpa::partial$vector-search-model-include.adoc
:vector-search-repository-include: data-jpa::partial$vector-search-repository-include.adoc
:vector-search-scoring-include: data-jpa::partial$vector-search-scoring-include.adoc
:vector-search-method-derived-include: data-jpa::partial$vector-search-method-derived-include.adoc
:vector-search-method-annotated-include: data-jpa::partial$vector-search-method-annotated-include.adoc
include::{commons}@data-commons::page$repositories/vector-search.adoc[]

View File

@@ -0,0 +1,32 @@
To use Hibernate Vector Search, you need to add the following dependencies to your project.
The following example shows how to set up dependencies in Maven and Gradle:
[tabs]
======
Maven::
+
[source,xml,indent=0,subs="verbatim,quotes",role="primary"]
----
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-vector</artifactId>
<version>${hibernate.version}</version>
</dependency>
</dependencies>
----
Gradle::
+
====
[source,groovy,indent=0,subs="verbatim,quotes",role="secondary"]
----
dependencies {
implementation 'org.hibernate.orm:hibernate-vector:${hibernateVersion}'
}
----
====
======
NOTE: While you can use `Vector` as type for queries, you cannot use it in your domain model as Hibernate requires float or double arrays as vector types.

View File

@@ -0,0 +1,28 @@
Annotated search methods must define the entire JPQL query to run a Vector Search.
.Using `@Query` Search Methods
====
[source,java]
----
interface CommentRepository extends Repository<Comment, String> {
@Query("""
SELECT c, cosine_distance(c.embedding, :embedding) as distance FROM Comment c
WHERE c.country = ?1
AND cosine_distance(c.embedding, :embedding) <= :distance
ORDER BY distance asc""")
SearchResults<WithVector> searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding,
Score distance);
@Query("""
SELECT c FROM Comment c
WHERE c.country = ?1
AND cosine_distance(c.embedding, :embedding) <= :distance
ORDER BY cosine_distance(c.embedding, :embedding) asc""")
List<WithVector> findAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance);
}
----
====
Vector Search methods are not required to include a score or distance in their projection.
When using annotated search methods returning `SearchResults`, the execution mechanism assumes that if a second projection column is present that this one holds the score value.

View File

@@ -0,0 +1,16 @@
.Using `Near` and `Within` Keywords in Repository Search Methods
====
[source,java]
----
interface CommentRepository extends Repository<Comment, String> {
SearchResults<Comment> searchByEmbeddingNear(Vector vector, Score score);
SearchResults<Comment> searchByEmbeddingWithin(Vector vector, Range<Similarity> range);
SearchResults<Comment> searchByCountryAndEmbeddingWithin(String country, Vector vector, Range<Similarity> range);
}
----
====
Derived search methods can declare predicates on domain model attributes and Vector parameters.

View File

@@ -0,0 +1,18 @@
====
[source,java]
----
class Comment {
@Id String id;
String country;
String comment;
@Column(name = "the_embedding")
@JdbcTypeCode(SqlTypes.VECTOR)
@Array(length = 5)
Vector embedding;
// getters, setters, …
}
----
====

View File

@@ -0,0 +1,21 @@
.Using `SearchResult<T>` in a Repository Search Method
====
[source,java]
----
interface CommentRepository extends Repository<Comment, String> {
SearchResults<Comment> searchByCountryAndEmbeddingNear(String country, Vector vector, Score distance,
Limit limit);
@Query("""
SELECT c, cosine_distance(c.embedding, :embedding) as distance FROM Comment c
WHERE c.country = ?1
AND cosine_distance(c.embedding, :embedding) <= :distance
ORDER BY distance asc""")
SearchResults<WithVector> searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding,
Score distance);
}
SearchResults<Comment> results = repository.searchByCountryAndEmbeddingNear("en", Vector.of(…), Score.of(0.9), Limit.of(10));
----
====

View File

@@ -0,0 +1,38 @@
Hibernate translates distance function calls to native database functions for PGvector and Oracle.
Their result is typically a distance.
When using `Similarity` instead of `Score`, Spring Data normalizes distance scores into a similarity score between 0 and 1. The higher the score, the more similar the two vectors are.
// END
.Using `Score` and `Similarity` in a Repository Search Methods
====
[source,java]
----
interface CommentRepository extends Repository<Comment, String> {
SearchResults<Comment> searchByEmbeddingNear(Vector vector, ScoringFunction function);
SearchResults<Comment> searchByEmbeddingNear(Vector vector, Score score);
SearchResults<Comment> searchByEmbeddingNear(Vector vector, Similarity similarity);
SearchResults<Comment> searchByEmbeddingNear(Vector vector, Range<Similarity> range);
}
repository.searchByEmbeddingNear(Vector.of(…), ScoringFunction.cosine()); <1>
repository.searchByEmbeddingNear(Vector.of(…), Score.of(0.9, ScoringFunction.cosine())); <2>
repository.searchByEmbeddingNear(Vector.of(…), Similarity.of(0.9, ScoringFunction.cosine())); <3>
repository.searchByEmbeddingNear(Vector.of(…), Similarity.between(0.5, 1, ScoringFunction.euclidean()));<4>
----
<1> Run a search and return results that are similar to the given `Vector` applying Cosine scoring.
<2> Run a search and return results with a score of `0.9` or smaller using the Cosine distance.
<3> Run a search and normalize the score into a similarity value.
Return results with a similarity of `0.9` or greater using Cosine scoring.
<4> Run a search and normalize the score into a similarity value.
Return results with a similarity of between `0.5` and `1.0` or greater using Euclidean scoring.
====
NOTE: JPA requires a `ScoringFunction` to be provided when creating `Score` or `Similarity` instances to select a scoring function.