Skip to content

Add Query by Example feature #2418 #2422

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2021-2022 the original author or authors.
* Copyright 2021-2023 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.
Expand Down Expand Up @@ -39,6 +39,7 @@
* query.
*
* @author Peter-Josef Meisch
* @author Ezequiel Antúnez Camacho
* @since 4.4
*/
class CriteriaQueryProcessor {
Expand Down Expand Up @@ -329,6 +330,13 @@ private static Query.Builder queryFor(Criteria.CriteriaEntry entry, Field field,
throw new CriteriaQueryException("value for " + fieldName + " is not an Iterable");
}
break;
case REGEXP:
queryBuilder //
.regexp(rb -> rb //
.field(fieldName) //
.value(value.toString()) //
.boost(boost)); //
break;
default:
throw new CriteriaQueryException("Could not build query for " + entry);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2013-2022 the original author or authors.
* Copyright 2013-2023 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.
Expand Down Expand Up @@ -44,6 +44,7 @@
* @author Rasmus Faber-Espensen
* @author James Bodkin
* @author Peter-Josef Meisch
* @author Ezequiel Antúnez Camacho
* @deprecated since 5.0
*/
@Deprecated
Expand Down Expand Up @@ -248,6 +249,9 @@ private QueryBuilder queryFor(Criteria.CriteriaEntry entry, Field field) {
}
}
break;
case REGEXP:
query = regexpQuery(fieldName, value.toString());
break;
}
return query;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2013-2022 the original author or authors.
* Copyright 2013-2023 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.
Expand Down Expand Up @@ -50,6 +50,7 @@
* @author Mohsin Husen
* @author Franck Marchand
* @author Peter-Josef Meisch
* @author Ezequiel Antúnez Camacho
*/
public class Criteria {

Expand Down Expand Up @@ -611,6 +612,21 @@ public Criteria notEmpty() {
return this;
}

/**
* Add a {@link OperationKey#REGEXP} entry to the {@link #queryCriteriaEntries}.
*
* @param value the regexp value to match
* @return this object
* @since 5.1
*/
public Criteria regexp(String value) {

Assert.notNull(value, "value must not be null");

queryCriteriaEntries.add(new CriteriaEntry(OperationKey.REGEXP, value));
return this;
}

// endregion

// region criteria entries - filter
Expand Down Expand Up @@ -954,7 +970,11 @@ public enum OperationKey { //
/**
* @since 4.3
*/
NOT_EMPTY;
NOT_EMPTY, //
/**
* @since 5.1
*/
REGEXP;

/**
* @return true if this key does not have an associated value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2013-2022 the original author or authors.
* Copyright 2013-2023 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.
Expand All @@ -15,29 +15,33 @@
*/
package org.springframework.data.elasticsearch.repository.support;

import static org.springframework.data.querydsl.QuerydslUtils.*;

import java.lang.reflect.Method;
import java.util.Optional;

import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.data.elasticsearch.repository.query.ElasticsearchPartQuery;
import org.springframework.data.elasticsearch.repository.query.ElasticsearchQueryMethod;
import org.springframework.data.elasticsearch.repository.query.ElasticsearchStringQuery;
import org.springframework.data.elasticsearch.repository.support.querybyexample.QueryByExampleElasticsearchExecutor;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryInformation;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.RepositoryComposition;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;
import org.springframework.data.repository.core.support.RepositoryFragment;
import org.springframework.data.repository.query.QueryByExampleExecutor;
import org.springframework.data.repository.query.QueryLookupStrategy;
import org.springframework.data.repository.query.QueryLookupStrategy.Key;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

import java.lang.reflect.Method;
import java.util.Optional;

import static org.springframework.data.querydsl.QuerydslUtils.QUERY_DSL_PRESENT;

/**
* Factory to create {@link ElasticsearchRepository}
*
Expand All @@ -49,6 +53,7 @@
* @author Christoph Strobl
* @author Sascha Woo
* @author Peter-Josef Meisch
* @author Ezequiel Antúnez Camacho
*/
public class ElasticsearchRepositoryFactory extends RepositoryFactorySupport {

Expand Down Expand Up @@ -122,4 +127,17 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata,
protected RepositoryMetadata getRepositoryMetadata(Class<?> repositoryInterface) {
return new ElasticsearchRepositoryMetadata(repositoryInterface);
}

@Override
protected RepositoryComposition.RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) {
RepositoryComposition.RepositoryFragments fragments = RepositoryComposition.RepositoryFragments.empty();

if (QueryByExampleExecutor.class.isAssignableFrom(metadata.getRepositoryInterface())) {
fragments = fragments.append(RepositoryFragment.implemented(QueryByExampleExecutor.class,
instantiateClass(QueryByExampleElasticsearchExecutor.class, elasticsearchOperations)));
}

return fragments;
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2019-2022 the original author or authors.
* Copyright 2019-2023 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.
Expand All @@ -25,15 +25,19 @@
import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchQueryMethod;
import org.springframework.data.elasticsearch.repository.query.ReactiveElasticsearchStringQuery;
import org.springframework.data.elasticsearch.repository.query.ReactivePartTreeElasticsearchQuery;
import org.springframework.data.elasticsearch.repository.support.querybyexample.ReactiveQueryByExampleElasticsearchExecutor;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.repository.core.NamedQueries;
import org.springframework.data.repository.core.RepositoryInformation;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.ReactiveRepositoryFactorySupport;
import org.springframework.data.repository.core.support.RepositoryComposition;
import org.springframework.data.repository.core.support.RepositoryFragment;
import org.springframework.data.repository.query.QueryLookupStrategy;
import org.springframework.data.repository.query.QueryLookupStrategy.Key;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
Expand All @@ -45,6 +49,7 @@
*
* @author Christoph Strobl
* @author Ivan Greene
* @author Ezequiel Antúnez Camacho
* @since 3.2
*/
public class ReactiveElasticsearchRepositoryFactory extends ReactiveRepositoryFactorySupport {
Expand Down Expand Up @@ -168,4 +173,16 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata,
}
}
}

@Override
protected RepositoryComposition.RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) {
RepositoryComposition.RepositoryFragments fragments = RepositoryComposition.RepositoryFragments.empty();

if (ReactiveQueryByExampleExecutor.class.isAssignableFrom(metadata.getRepositoryInterface())) {
fragments = fragments.append(RepositoryFragment.implemented(ReactiveQueryByExampleExecutor.class,
instantiateClass(ReactiveQueryByExampleElasticsearchExecutor.class, operations)));
}

return fragments;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright 2023 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.elasticsearch.repository.support.querybyexample;

import java.util.Map;
import java.util.Optional;

import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity;
import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty;
import org.springframework.data.elasticsearch.core.query.Criteria;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.support.ExampleMatcherAccessor;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;

/**
* Maps a {@link Example} to a {@link org.springframework.data.elasticsearch.core.query.Criteria}
*
* @author Ezequiel Antúnez Camacho
* @since 5.1
*/
class ExampleCriteriaMapper {

private final MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext;

/**
* Builds a {@link ExampleCriteriaMapper}
*
* @param mappingContext mappingContext to use
*/
ExampleCriteriaMapper(
MappingContext<? extends ElasticsearchPersistentEntity<?>, ElasticsearchPersistentProperty> mappingContext) {
this.mappingContext = mappingContext;
}

<S> Criteria criteria(Example<S> example) {
return buildCriteria(example);
}

private <S> Criteria buildCriteria(Example<S> example) {
final ExampleMatcherAccessor matcherAccessor = new ExampleMatcherAccessor(example.getMatcher());

return applyPropertySpecs(new Criteria(), "", example.getProbe(),
mappingContext.getRequiredPersistentEntity(example.getProbeType()), matcherAccessor,
example.getMatcher().getMatchMode());
}

private Criteria applyPropertySpecs(Criteria criteria, String path, @Nullable Object probe,
ElasticsearchPersistentEntity<?> persistentEntity, ExampleMatcherAccessor exampleSpecAccessor,
ExampleMatcher.MatchMode matchMode) {

if (probe == null) {
return criteria;
}

PersistentPropertyAccessor<?> propertyAccessor = persistentEntity.getPropertyAccessor(probe);

for (ElasticsearchPersistentProperty property : persistentEntity) {
final String propertyName = property.getName();
String propertyPath = StringUtils.hasText(path) ? (path + "." + propertyName) : propertyName;
if (exampleSpecAccessor.isIgnoredPath(propertyPath) || property.isCollectionLike()
|| property.isVersionProperty()) {
continue;
}

Object propertyValue = propertyAccessor.getProperty(property);
if (property.isMap() && propertyValue != null) {
for (Map.Entry<String, Object> entry : ((Map<String, Object>) propertyValue).entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
criteria = applyPropertySpec(propertyPath + "." + key, value, exampleSpecAccessor, property, matchMode,
criteria);
}
continue;
}

criteria = applyPropertySpec(propertyPath, propertyValue, exampleSpecAccessor, property, matchMode, criteria);
}
return criteria;
}

private Criteria applyPropertySpec(String path, Object propertyValue, ExampleMatcherAccessor exampleSpecAccessor,
ElasticsearchPersistentProperty property, ExampleMatcher.MatchMode matchMode, Criteria criteria) {

if (exampleSpecAccessor.isIgnoreCaseForPath(path)) {
throw new InvalidDataAccessApiUsageException(
"Current implementation of Query-by-Example supports only case-sensitive matching.");
}

final Object transformedValue = exampleSpecAccessor.getValueTransformerForPath(path)
.apply(Optional.ofNullable(propertyValue)).orElse(null);

if (transformedValue == null) {
criteria = tryToAppendMustNotSentence(criteria, path, exampleSpecAccessor);
} else {
if (property.isEntity()) {
return applyPropertySpecs(criteria, path, transformedValue,
mappingContext.getRequiredPersistentEntity(property), exampleSpecAccessor, matchMode);
} else {
return applyStringMatcher(applyMatchMode(criteria, path, matchMode), transformedValue,
exampleSpecAccessor.getStringMatcherForPath(path));
}
}
return criteria;
}

private Criteria tryToAppendMustNotSentence(Criteria criteria, String path,
ExampleMatcherAccessor exampleSpecAccessor) {
if (ExampleMatcher.NullHandler.INCLUDE.equals(exampleSpecAccessor.getNullHandler())
|| exampleSpecAccessor.hasPropertySpecifier(path)) {
return criteria.and(path).not().exists();
}
return criteria;
}

private Criteria applyMatchMode(Criteria criteria, String path, ExampleMatcher.MatchMode matchMode) {
if (matchMode == ExampleMatcher.MatchMode.ALL) {
return criteria.and(path);
} else {
return criteria.or(path);
}
}

private Criteria applyStringMatcher(Criteria criteria, Object value, ExampleMatcher.StringMatcher stringMatcher) {
return switch (stringMatcher) {
case DEFAULT -> criteria.is(value);
case EXACT -> criteria.matchesAll(value);
case STARTING -> criteria.startsWith(validateString(value));
case ENDING -> criteria.endsWith(validateString(value));
case CONTAINING -> criteria.contains(validateString(value));
case REGEX -> criteria.regexp(validateString(value));
};
}

private String validateString(Object value) {
if (value instanceof String) {
return value.toString();
}
throw new IllegalArgumentException("This operation requires a String but got " + value.getClass());
}

}
Loading