22
pom.xml
22
pom.xml
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() + "]";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -305,6 +305,10 @@ abstract class QueryParameterSetterFactory {
|
||||
return super.create(binding, query);
|
||||
}
|
||||
|
||||
if (binding instanceof ParameterMetadataProvider.ScoreParameterBinding) {
|
||||
return super.create(binding, query);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -221,7 +221,7 @@ class AbstractJpaQueryTests {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected JpaQueryExecution getExecution() {
|
||||
protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) {
|
||||
return execution;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
16
spring-data-jpa/src/test/resources/scripts/oracle-vector.sql
Normal file
16
spring-data-jpa/src/test/resources/scripts/oracle-vector.sql
Normal 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);;
|
||||
7
spring-data-jpa/src/test/resources/scripts/pgvector.sql
Normal file
7
spring-data-jpa/src/test/resources/scripts/pgvector.sql
Normal 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);
|
||||
@@ -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[]
|
||||
|
||||
@@ -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[]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, …
|
||||
}
|
||||
----
|
||||
====
|
||||
@@ -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));
|
||||
----
|
||||
====
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user