From 6bc688184caded6529e8c687fc4bc0d38864a8e0 Mon Sep 17 00:00:00 2001 From: Youssef Aouichaoui Date: Tue, 9 Apr 2024 18:39:02 +0200 Subject: [PATCH 1/4] Support has_child and has_parent queries. Signed-off-by: Youssef Aouichaoui --- .../client/elc/CriteriaQueryProcessor.java | 79 +++++++ .../elasticsearch/client/elc/TypeUtils.java | 22 ++ .../elasticsearch/core/query/Criteria.java | 33 ++- .../core/query/HasChildQuery.java | 213 ++++++++++++++++++ .../core/query/HasParentQuery.java | 146 ++++++++++++ .../core/query/InnerHitsQuery.java | 114 ++++++++++ .../core/ElasticsearchIntegrationTests.java | 167 ++++++++++++++ 7 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java create mode 100644 src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java create mode 100644 src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java index c4934ddc0..50693cd8f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java @@ -16,12 +16,14 @@ package org.springframework.data.elasticsearch.client.elc; import static org.springframework.data.elasticsearch.client.elc.Queries.*; +import static org.springframework.data.elasticsearch.client.elc.TypeUtils.scoreMode; import static org.springframework.util.StringUtils.*; import co.elastic.clients.elasticsearch._types.FieldValue; import co.elastic.clients.elasticsearch._types.query_dsl.ChildScoreMode; import co.elastic.clients.elasticsearch._types.query_dsl.Operator; import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import co.elastic.clients.elasticsearch.core.search.InnerHits; import co.elastic.clients.json.JsonData; import java.util.ArrayList; @@ -30,7 +32,12 @@ import org.springframework.data.elasticsearch.annotations.FieldType; import org.springframework.data.elasticsearch.core.query.Criteria; +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; import org.springframework.data.elasticsearch.core.query.Field; +import org.springframework.data.elasticsearch.core.query.HasChildQuery; +import org.springframework.data.elasticsearch.core.query.HasParentQuery; +import org.springframework.data.elasticsearch.core.query.InnerHitsQuery; +import org.springframework.data.elasticsearch.core.query.StringQuery; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -343,6 +350,34 @@ private static Query.Builder queryFor(Criteria.CriteriaEntry entry, Field field, .value(value.toString()) // .boost(boost)); // break; + case HAS_CHILD: + if (value instanceof HasChildQuery query) { + queryBuilder.hasChild(hcb -> hcb + .type(query.getType()) + .query(getQuery(query.getQuery())) + .innerHits(getInnerHits(query.getInnerHitsQuery())) + .ignoreUnmapped(query.getIgnoreUnmapped()) + .minChildren(query.getMinChildren()) + .maxChildren(query.getMaxChildren()) + .scoreMode(scoreMode(query.getScoreMode())) + ); + } else { + throw new CriteriaQueryException("value for " + fieldName + " is not a has_child query"); + } + break; + case HAS_PARENT: + if (value instanceof HasParentQuery query) { + queryBuilder.hasParent(hpb -> hpb + .parentType(query.getParentType()) + .query(getQuery(query.getQuery())) + .innerHits(getInnerHits(query.getInnerHitsQuery())) + .ignoreUnmapped(query.getIgnoreUnmapped()) + .score(query.getScore()) + ); + } else { + throw new CriteriaQueryException("value for " + fieldName + " is not a has_parent query"); + } + break; default: throw new CriteriaQueryException("Could not build query for " + entry); } @@ -397,4 +432,48 @@ public static String escape(String s) { return sb.toString(); } + /** + * Convert a spring-data-elasticsearch {@literal query} to an Elasticsearch {@literal query}. + *

+ * The reason it was copied from {@link RequestConverter#getQuery(org.springframework.data.elasticsearch.core.query.Query, Class)} is because of a circular dependency. + * + * @param query spring-data-elasticsearch {@literal query}. + * @return an Elasticsearch {@literal query}. + */ + private static Query getQuery(org.springframework.data.elasticsearch.core.query.Query query) { + Query esQuery = null; + + if (query instanceof CriteriaQuery) { + esQuery = createQuery(((CriteriaQuery) query).getCriteria()); + } else if (query instanceof StringQuery) { + esQuery = Queries.wrapperQueryAsQuery(((StringQuery) query).getSource()); + } else if (query instanceof NativeQuery nativeQuery) { + if (nativeQuery.getQuery() != null) { + esQuery = nativeQuery.getQuery(); + } else if (nativeQuery.getSpringDataQuery() != null) { + esQuery = getQuery(nativeQuery.getSpringDataQuery()); + } + } else { + throw new IllegalArgumentException("unhandled Query implementation " + query.getClass().getName()); + } + + Assert.notNull(query, "query must not be null."); + return esQuery; + } + + /** + * Convert a spring-data-elasticsearch {@literal inner_hits} to an Elasticsearch {@literal inner_hits} query. + * + * @param query spring-data-elasticsearch {@literal inner_hits}. + * @return an Elasticsearch {@literal inner_hits} query. + */ + @Nullable + private static InnerHits getInnerHits(@Nullable InnerHitsQuery query) { + if (query == null) { + return null; + } + + return InnerHits.of(iqb -> iqb.from(query.getFrom()).size(query.getSize()).name(query.getName())); + } + } diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java index ac32da42c..62bb4eefd 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java @@ -18,6 +18,7 @@ import co.elastic.clients.elasticsearch._types.*; import co.elastic.clients.elasticsearch._types.mapping.FieldType; import co.elastic.clients.elasticsearch._types.mapping.TypeMapping; +import co.elastic.clients.elasticsearch._types.query_dsl.ChildScoreMode; import co.elastic.clients.elasticsearch._types.query_dsl.Operator; import co.elastic.clients.elasticsearch.core.search.BoundaryScanner; import co.elastic.clients.elasticsearch.core.search.HighlighterEncoder; @@ -41,6 +42,7 @@ import org.springframework.data.elasticsearch.core.RefreshPolicy; import org.springframework.data.elasticsearch.core.document.Document; import org.springframework.data.elasticsearch.core.query.GeoDistanceOrder; +import org.springframework.data.elasticsearch.core.query.HasChildQuery; import org.springframework.data.elasticsearch.core.query.IndexQuery; import org.springframework.data.elasticsearch.core.query.IndicesOptions; import org.springframework.data.elasticsearch.core.query.Order; @@ -527,4 +529,24 @@ static Operator operator(@Nullable OperatorType operator) { static Conflicts conflicts(@Nullable ConflictsType conflicts) { return conflicts != null ? Conflicts.valueOf(conflicts.name()) : null; } + + /** + * Convert a spring-data-elasticsearch {@literal scoreMode} to an Elasticsearch {@literal scoreMode}. + * + * @param scoreMode spring-data-elasticsearch {@literal scoreMode}. + * @return an Elasticsearch {@literal scoreMode}. + */ + static ChildScoreMode scoreMode(@Nullable HasChildQuery.ScoreMode scoreMode) { + if (scoreMode == null) { + return ChildScoreMode.None; + } + + return switch (scoreMode) { + case Avg -> ChildScoreMode.Avg; + case Max -> ChildScoreMode.Max; + case Min -> ChildScoreMode.Min; + case Sum -> ChildScoreMode.Sum; + default -> ChildScoreMode.None; + }; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java index 527379799..fe986a492 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java @@ -816,6 +816,32 @@ public Criteria contains(GeoJson geoShape) { filterCriteriaEntries.add(new CriteriaEntry(OperationKey.GEO_CONTAINS, geoShape)); return this; } + + /** + * Adds a new filter CriteriaEntry for HAS_CHILD. + * + * @param query the has_child query. + * @return the current Criteria. + */ + public Criteria hasChild(HasChildQuery query) { + Assert.notNull(query, "has_child query must not be null."); + + queryCriteriaEntries.add(new CriteriaEntry(OperationKey.HAS_CHILD, query)); + return this; + } + + /** + * Adds a new filter CriteriaEntry for HAS_PARENT. + * + * @param query the has_parent query. + * @return the current Criteria. + */ + public Criteria hasParent(HasParentQuery query) { + Assert.notNull(query, "has_parent query must not be null."); + + queryCriteriaEntries.add(new CriteriaEntry(OperationKey.HAS_PARENT, query)); + return this; + } // endregion // region helper functions @@ -977,7 +1003,12 @@ public enum OperationKey { // /** * @since 5.1 */ - REGEXP; + REGEXP, + /** + * @since 5.3 + */ + HAS_CHILD, + HAS_PARENT; /** * @return true if this key does not have an associated value diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java new file mode 100644 index 000000000..73fcc6a9a --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java @@ -0,0 +1,213 @@ +/* + * Copyright 2024 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.core.query; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Defines a has_child request. + * + * @author Aouichaoui Youssef + * @see docs + * @since 5.3 + */ +public class HasChildQuery { + /** + * Name of the child relationship mapped for the join field. + */ + private final String type; + + /** + * Query that specifies the documents to run on child documents of the {@link #type} field. + */ + private final Query query; + + /** + * Indicates whether to ignore an unmapped {@link #type} and not return any documents instead of an error. + * Default, this is set to {@code false}. + */ + @Nullable + private final Boolean ignoreUnmapped; + + /** + * The Maximum number of child documents that match the {@link #query} allowed for a returned parent document. + * If the parent document exceeds this limit, it is excluded from the search results. + */ + @Nullable + private final Integer maxChildren; + + /** + * Minimum number of child documents that match the query required to match the {@link #query} for a returned parent document. + * If the parent document does not meet this limit, it is excluded from the search results. + */ + @Nullable + private final Integer minChildren; + + /** + * Indicates how scores for matching child documents affect the root parent document’s relevance score. + */ + @Nullable + private final ScoreMode scoreMode; + + /** + * Obtaining nested objects and documents that have a parent-child relationship. + */ + @Nullable + private final InnerHitsQuery innerHitsQuery; + + public static Builder builder(String type) { + return new Builder(type); + } + + private HasChildQuery(Builder builder) { + this.type = builder.type; + this.query = builder.query; + this.innerHitsQuery = builder.innerHitsQuery; + + this.ignoreUnmapped = builder.ignoreUnmapped; + + this.maxChildren = builder.maxChildren; + this.minChildren = builder.minChildren; + + this.scoreMode = builder.scoreMode; + } + + public String getType() { + return type; + } + + public Query getQuery() { + return query; + } + + @Nullable + public Boolean getIgnoreUnmapped() { + return ignoreUnmapped; + } + + @Nullable + public Integer getMaxChildren() { + return maxChildren; + } + + @Nullable + public Integer getMinChildren() { + return minChildren; + } + + @Nullable + public ScoreMode getScoreMode() { + return scoreMode; + } + + @Nullable + public InnerHitsQuery getInnerHitsQuery() { + return innerHitsQuery; + } + + public enum ScoreMode { + Default, Avg, Max, Min, Sum + } + + public static final class Builder { + private final String type; + private Query query; + + @Nullable + private Boolean ignoreUnmapped; + + @Nullable + private Integer maxChildren; + @Nullable + private Integer minChildren; + + @Nullable + private ScoreMode scoreMode; + + @Nullable + private InnerHitsQuery innerHitsQuery; + + private Builder(String type) { + Assert.notNull(type, "type must not be null"); + + this.type = type; + } + + /** + * Query that specifies the documents to run on child documents of the {@link #type} field. + */ + public Builder withQuery(Query query) { + this.query = query; + + return this; + } + + /** + * Indicates whether to ignore an unmapped {@link #type} and not return any documents instead of an error. + * Default, this is set to {@code false}. + */ + public Builder withIgnoreUnmapped(@Nullable Boolean ignoreUnmapped) { + this.ignoreUnmapped = ignoreUnmapped; + + return this; + } + + /** + * The Maximum number of child documents that match the {@link #query} allowed for a returned parent document. + * If the parent document exceeds this limit, it is excluded from the search results. + */ + public Builder withMaxChildren(@Nullable Integer maxChildren) { + this.maxChildren = maxChildren; + + return this; + } + + /** + * Minimum number of child documents that match the query required to match the {@link #query} for a returned parent document. + * If the parent document does not meet this limit, it is excluded from the search results. + */ + public Builder withMinChildren(@Nullable Integer minChildren) { + this.minChildren = minChildren; + + return this; + } + + /** + * Indicates how scores for matching child documents affect the root parent document’s relevance score. + */ + public Builder withScoreMode(@Nullable ScoreMode scoreMode) { + this.scoreMode = scoreMode; + + return this; + } + + /** + * Obtaining nested objects and documents that have a parent-child relationship. + */ + public Builder withInnerHitsQuery(@Nullable InnerHitsQuery innerHitsQuery) { + this.innerHitsQuery = innerHitsQuery; + + return this; + } + + public HasChildQuery build() { + Assert.notNull(query, "query must not be null."); + + return new HasChildQuery(this); + } + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java new file mode 100644 index 000000000..bc83441b6 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java @@ -0,0 +1,146 @@ +/* + * Copyright 2024 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.core.query; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Defines a has_parent request. + * + * @author Aouichaoui Youssef + * @see docs + * @since 5.3 + */ +public class HasParentQuery { + /** + * Name of the parent relationship mapped for the join field. + */ + private final String parentType; + + /** + * Query that specifies the documents to run on parent documents of the {@link #parentType} field. + */ + private final Query query; + + /** + * Indicates whether the relevance score of a matching parent document is aggregated into its child documents. + * Default, this is set to {@code false}. + */ + @Nullable + private final Boolean score; + + /** + * Indicates whether to ignore an unmapped {@link #parentType} and not return any documents instead of an error. + * Default, this is set to {@code false}. + */ + @Nullable + private final Boolean ignoreUnmapped; + + /** + * Obtaining nested objects and documents that have a parent-child relationship. + */ + @Nullable + private final InnerHitsQuery innerHitsQuery; + + public static Builder builder(String parentType) { + return new Builder(parentType); + } + + private HasParentQuery(Builder builder) { + this.parentType = builder.parentType; + this.query = builder.query; + this.innerHitsQuery = builder.innerHitsQuery; + + this.score = builder.score; + this.ignoreUnmapped = builder.ignoreUnmapped; + } + + public String getParentType() { + return parentType; + } + + public Query getQuery() { + return query; + } + + @Nullable + public Boolean getScore() { + return score; + } + + @Nullable + public Boolean getIgnoreUnmapped() { + return ignoreUnmapped; + } + + @Nullable + public InnerHitsQuery getInnerHitsQuery() { + return innerHitsQuery; + } + + public static class Builder { + private final String parentType; + private Query query; + + @Nullable + private Boolean score; + @Nullable + private Boolean ignoreUnmapped; + + @Nullable + private InnerHitsQuery innerHitsQuery; + + private Builder(String parentType) { + Assert.notNull(parentType, "parent_type must not be null."); + + this.parentType = parentType; + } + + public Builder withQuery(Query query) { + this.query = query; + + return this; + } + + public Builder withScore(@Nullable Boolean score) { + this.score = score; + + return this; + } + + public Builder withIgnoreUnmapped(@Nullable Boolean ignoreUnmapped) { + this.ignoreUnmapped = ignoreUnmapped; + + return this; + } + + /** + * Obtaining nested objects and documents that have a parent-child relationship. + */ + public Builder withInnerHitsQuery(@Nullable InnerHitsQuery innerHitsQuery) { + this.innerHitsQuery = innerHitsQuery; + + return this; + } + + public HasParentQuery build() { + Assert.notNull(query, "query must not be null."); + + return new HasParentQuery(this); + } + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java new file mode 100644 index 000000000..c973d4bc0 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java @@ -0,0 +1,114 @@ +/* + * Copyright 2024 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.core.query; + +import org.springframework.lang.Nullable; + +/** + * Defines an inner_hits request. + * + * @author Aouichaoui Youssef + * @see docs + * @since 5.3 + */ +public class InnerHitsQuery { + /** + * The name to be used for the particular inner hit definition in the response. + */ + @Nullable + private final String name; + + /** + * The maximum number of hits to return. + */ + @Nullable + private final Integer size; + + /** + * The offset from where the first hit to fetch. + */ + @Nullable + private final Integer from; + + public static Builder builder() { + return new Builder(); + } + + private InnerHitsQuery(Builder builder) { + this.name = builder.name; + + this.from = builder.from; + this.size = builder.size; + } + + @Nullable + public String getName() { + return name; + } + + @Nullable + public Integer getSize() { + return size; + } + + @Nullable + public Integer getFrom() { + return from; + } + + public static final class Builder { + @Nullable + private String name; + @Nullable + private Integer size; + @Nullable + private Integer from; + + private Builder() { + } + + /** + * The name to be used for the particular inner hit definition in the response. + */ + public Builder withName(@Nullable String name) { + this.name = name; + + return this; + } + + /** + * The maximum number of hits to return. + */ + public Builder withSize(@Nullable Integer size) { + this.size = size; + + return this; + } + + /** + * The offset from where the first hit to fetch. + */ + public Builder withFrom(@Nullable Integer from) { + this.from = from; + + return this; + } + + public InnerHitsQuery build() { + return new InnerHitsQuery(this); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java index 90584b0d0..ea39da1d2 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java @@ -49,6 +49,7 @@ import org.springframework.data.elasticsearch.annotations.*; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.ScriptedField; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.geo.GeoPoint; import org.springframework.data.elasticsearch.core.index.AliasAction; @@ -3827,6 +3828,68 @@ public void shouldDeleteDocumentForGivenQueryAndUnavailableIndex() { assertThat(result.getDeleted()).isEqualTo(0); } + @Test + public void shouldGetOnlyDocumentsThatHasChild() { + // Given + String indexName = indexNameProvider.indexName() + "-join"; + operations.indexOps(RootEntity.class).createWithMapping(); + + RootEntity parentEntity = RootEntity.builder() + .withId(nextIdAsString()) + .withParent(new RootEntity.Parent()) + .build(); + IndexQuery indexQuery = new IndexQueryBuilder().withId(parentEntity.id).withObject(parentEntity).build(); + operations.index(indexQuery, IndexCoordinates.of(indexName)); + + RootEntity childEntity = RootEntity.builder() + .withId(nextIdAsString()) + .withChild(new RootEntity.Child()) + .withRelation(new JoinField<>("child", parentEntity.id)) + .build(); + indexQuery = new IndexQueryBuilder().withId(childEntity.id).withObject(childEntity).build(); + operations.index(indexQuery, IndexCoordinates.of(indexName)); + + HasChildQuery childQuery = HasChildQuery.builder("child").withQuery(operations.matchAllQuery()).build(); + Query query = CriteriaQuery.builder(Criteria.where("child").hasChild(childQuery)).build(); + + // When + SearchHits hits = operations.search(query, RootEntity.class); + + // Then + assertThat(hits.getTotalHits()).isEqualTo(1); + } + + @Test + public void shouldGetOnlyDocumentsThatHasParent() { + // Given + String indexName = indexNameProvider.indexName() + "-join"; + operations.indexOps(RootEntity.class).createWithMapping(); + + RootEntity parentEntity = RootEntity.builder() + .withId(nextIdAsString()) + .withParent(new RootEntity.Parent()) + .build(); + IndexQuery indexQuery = new IndexQueryBuilder().withId(parentEntity.id).withObject(parentEntity).build(); + operations.index(indexQuery, IndexCoordinates.of(indexName)); + + RootEntity childEntity = RootEntity.builder() + .withId(nextIdAsString()) + .withChild(new RootEntity.Child()) + .withRelation(new JoinField<>("child", parentEntity.id)) + .build(); + indexQuery = new IndexQueryBuilder().withId(childEntity.id).withObject(childEntity).build(); + operations.index(indexQuery, IndexCoordinates.of(indexName)); + + HasParentQuery childQuery = HasParentQuery.builder("parent").withQuery(operations.matchAllQuery()).build(); + Query query = CriteriaQuery.builder(Criteria.where("parent").hasParent(childQuery)).build(); + + // When + SearchHits hits = operations.search(query, RootEntity.class); + + // Then + assertThat(hits.getTotalHits()).isEqualTo(1); + } + // region entities @Document(indexName = "#{@indexNameProvider.indexName()}") @Setting(shards = 1, replicas = 0, refreshInterval = "-1") @@ -4948,5 +5011,109 @@ public void setIndexedIndexName(@Nullable String indexedIndexName) { this.indexedIndexName = indexedIndexName; } } + + @Document(indexName = "#{@indexNameProvider.indexName()}-join") + private static class RootEntity { + @Id + private String id; + + @Field(type = FieldType.Object) + private Child child; + + @Field(type = FieldType.Object) + private Parent parent; + + @JoinTypeRelations(relations = { + @JoinTypeRelation(parent = "parent", children = {"child"}) + }) + private JoinField relation = new JoinField<>("parent"); + + private static final class Child {} + private static final class Parent {} + + public static Builder builder() { + return new Builder(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Child getChild() { + return child; + } + + public void setChild(@Nullable Child child) { + this.child = child; + } + + public Parent getParent() { + return parent; + } + + public void setParent(@Nullable Parent parent) { + this.parent = parent; + } + + public JoinField getRelation() { + if (relation == null) { + relation = new JoinField<>("parent"); + } + + return relation; + } + + public void setRelation(JoinField relation) { + this.relation = relation; + } + + public static final class Builder { + @Nullable private String id; + + @Nullable private Parent parent; + @Nullable private Child child; + private JoinField relation = new JoinField<>("parent"); + + private Builder() {} + + public Builder withId(@Nullable String id) { + this.id = id; + + return this; + } + + public Builder withParent(@Nullable Parent parent) { + this.parent = parent; + + return this; + } + + public Builder withChild(@Nullable Child child) { + this.child = child; + + return this; + } + + public Builder withRelation(JoinField relation) { + this.relation = relation; + + return this; + } + + public RootEntity build() { + RootEntity root = new RootEntity(); + root.setId(id); + root.setParent(parent); + root.setChild(child); + root.setRelation(relation); + + return root; + } + } + } // endregion } From d1a933fe97bf85a950e5523c9a7b7a3f97575cba Mon Sep 17 00:00:00 2001 From: Youssef Aouichaoui Date: Tue, 9 Apr 2024 18:44:07 +0200 Subject: [PATCH 2/4] Polishing. Signed-off-by: Youssef Aouichaoui --- .../core/query/HasChildQuery.java | 30 +++++++------------ .../core/query/HasParentQuery.java | 18 ++++------- .../core/query/InnerHitsQuery.java | 18 ++++------- 3 files changed, 22 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java index 73fcc6a9a..df92e8329 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java @@ -40,34 +40,29 @@ public class HasChildQuery { * Indicates whether to ignore an unmapped {@link #type} and not return any documents instead of an error. * Default, this is set to {@code false}. */ - @Nullable - private final Boolean ignoreUnmapped; + @Nullable private final Boolean ignoreUnmapped; /** * The Maximum number of child documents that match the {@link #query} allowed for a returned parent document. * If the parent document exceeds this limit, it is excluded from the search results. */ - @Nullable - private final Integer maxChildren; + @Nullable private final Integer maxChildren; /** * Minimum number of child documents that match the query required to match the {@link #query} for a returned parent document. * If the parent document does not meet this limit, it is excluded from the search results. */ - @Nullable - private final Integer minChildren; + @Nullable private final Integer minChildren; /** * Indicates how scores for matching child documents affect the root parent document’s relevance score. */ - @Nullable - private final ScoreMode scoreMode; + @Nullable private final ScoreMode scoreMode; /** * Obtaining nested objects and documents that have a parent-child relationship. */ - @Nullable - private final InnerHitsQuery innerHitsQuery; + @Nullable private final InnerHitsQuery innerHitsQuery; public static Builder builder(String type) { return new Builder(type); @@ -127,19 +122,14 @@ public static final class Builder { private final String type; private Query query; - @Nullable - private Boolean ignoreUnmapped; + @Nullable private Boolean ignoreUnmapped; - @Nullable - private Integer maxChildren; - @Nullable - private Integer minChildren; + @Nullable private Integer maxChildren; + @Nullable private Integer minChildren; - @Nullable - private ScoreMode scoreMode; + @Nullable private ScoreMode scoreMode; - @Nullable - private InnerHitsQuery innerHitsQuery; + @Nullable private InnerHitsQuery innerHitsQuery; private Builder(String type) { Assert.notNull(type, "type must not be null"); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java index bc83441b6..6428c0d63 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java @@ -40,21 +40,18 @@ public class HasParentQuery { * Indicates whether the relevance score of a matching parent document is aggregated into its child documents. * Default, this is set to {@code false}. */ - @Nullable - private final Boolean score; + @Nullable private final Boolean score; /** * Indicates whether to ignore an unmapped {@link #parentType} and not return any documents instead of an error. * Default, this is set to {@code false}. */ - @Nullable - private final Boolean ignoreUnmapped; + @Nullable private final Boolean ignoreUnmapped; /** * Obtaining nested objects and documents that have a parent-child relationship. */ - @Nullable - private final InnerHitsQuery innerHitsQuery; + @Nullable private final InnerHitsQuery innerHitsQuery; public static Builder builder(String parentType) { return new Builder(parentType); @@ -96,13 +93,10 @@ public static class Builder { private final String parentType; private Query query; - @Nullable - private Boolean score; - @Nullable - private Boolean ignoreUnmapped; + @Nullable private Boolean score; + @Nullable private Boolean ignoreUnmapped; - @Nullable - private InnerHitsQuery innerHitsQuery; + @Nullable private InnerHitsQuery innerHitsQuery; private Builder(String parentType) { Assert.notNull(parentType, "parent_type must not be null."); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java index c973d4bc0..877a80f29 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java @@ -28,20 +28,17 @@ public class InnerHitsQuery { /** * The name to be used for the particular inner hit definition in the response. */ - @Nullable - private final String name; + @Nullable private final String name; /** * The maximum number of hits to return. */ - @Nullable - private final Integer size; + @Nullable private final Integer size; /** * The offset from where the first hit to fetch. */ - @Nullable - private final Integer from; + @Nullable private final Integer from; public static Builder builder() { return new Builder(); @@ -70,12 +67,9 @@ public Integer getFrom() { } public static final class Builder { - @Nullable - private String name; - @Nullable - private Integer size; - @Nullable - private Integer from; + @Nullable private String name; + @Nullable private Integer size; + @Nullable private Integer from; private Builder() { } From 1011c98ea62200e1f8f59d20a5b71dd3ecdd84a1 Mon Sep 17 00:00:00 2001 From: Youssef Aouichaoui Date: Mon, 15 Apr 2024 12:33:06 +0200 Subject: [PATCH 3/4] Create a common class for processing queries. Signed-off-by: Youssef Aouichaoui --- .../client/elc/AbstractQueryProcessor.java | 72 +++++++++++++++++++ .../client/elc/CriteriaQueryProcessor.java | 35 +-------- .../client/elc/RequestConverter.java | 28 +------- .../core/ElasticsearchIntegrationTests.java | 1 - 4 files changed, 77 insertions(+), 59 deletions(-) create mode 100644 src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java new file mode 100644 index 000000000..5c96765c3 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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.client.elc; + +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.core.query.StringQuery; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import java.util.function.Consumer; + +/** + * An abstract class that serves as a base for query processors. + * It provides a common interface and basic functionality for query processing. + * + * @author Aouichaoui Youssef + * @since 5.3 + */ +public abstract class AbstractQueryProcessor { + + /** + * Convert a spring-data-elasticsearch {@literal query} to an Elasticsearch {@literal query}. + * + * @param query spring-data-elasticsearch {@literal query}. + * @param queryConverter correct mapped field names and the values to the converted values. + * @return an Elasticsearch {@literal query}. + */ + @Nullable + static co.elastic.clients.elasticsearch._types.query_dsl.Query getEsQuery(@Nullable Query query, + @Nullable Consumer queryConverter) { + if (query == null) { + return null; + } + + if (queryConverter != null) { + queryConverter.accept(query); + } + + co.elastic.clients.elasticsearch._types.query_dsl.Query esQuery = null; + + if (query instanceof CriteriaQuery criteriaQuery) { + esQuery = CriteriaQueryProcessor.createQuery(criteriaQuery.getCriteria()); + } else if (query instanceof StringQuery stringQuery) { + esQuery = Queries.wrapperQueryAsQuery(stringQuery.getSource()); + } else if (query instanceof NativeQuery nativeQuery) { + if (nativeQuery.getQuery() != null) { + esQuery = nativeQuery.getQuery(); + } else if (nativeQuery.getSpringDataQuery() != null) { + esQuery = getEsQuery(nativeQuery.getSpringDataQuery(), queryConverter); + } + } else { + throw new IllegalArgumentException("unhandled Query implementation " + query.getClass().getName()); + } + + Assert.notNull(esQuery, "query must not be null."); + return esQuery; + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java index 50693cd8f..c0876b78c 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryProcessor.java @@ -49,7 +49,7 @@ * @author Ezequiel Antúnez Camacho * @since 4.4 */ -class CriteriaQueryProcessor { +class CriteriaQueryProcessor extends AbstractQueryProcessor { /** * creates a query from the criteria @@ -354,7 +354,7 @@ private static Query.Builder queryFor(Criteria.CriteriaEntry entry, Field field, if (value instanceof HasChildQuery query) { queryBuilder.hasChild(hcb -> hcb .type(query.getType()) - .query(getQuery(query.getQuery())) + .query(getEsQuery(query.getQuery(), null)) .innerHits(getInnerHits(query.getInnerHitsQuery())) .ignoreUnmapped(query.getIgnoreUnmapped()) .minChildren(query.getMinChildren()) @@ -369,7 +369,7 @@ private static Query.Builder queryFor(Criteria.CriteriaEntry entry, Field field, if (value instanceof HasParentQuery query) { queryBuilder.hasParent(hpb -> hpb .parentType(query.getParentType()) - .query(getQuery(query.getQuery())) + .query(getEsQuery(query.getQuery(), null)) .innerHits(getInnerHits(query.getInnerHitsQuery())) .ignoreUnmapped(query.getIgnoreUnmapped()) .score(query.getScore()) @@ -432,35 +432,6 @@ public static String escape(String s) { return sb.toString(); } - /** - * Convert a spring-data-elasticsearch {@literal query} to an Elasticsearch {@literal query}. - *

- * The reason it was copied from {@link RequestConverter#getQuery(org.springframework.data.elasticsearch.core.query.Query, Class)} is because of a circular dependency. - * - * @param query spring-data-elasticsearch {@literal query}. - * @return an Elasticsearch {@literal query}. - */ - private static Query getQuery(org.springframework.data.elasticsearch.core.query.Query query) { - Query esQuery = null; - - if (query instanceof CriteriaQuery) { - esQuery = createQuery(((CriteriaQuery) query).getCriteria()); - } else if (query instanceof StringQuery) { - esQuery = Queries.wrapperQueryAsQuery(((StringQuery) query).getSource()); - } else if (query instanceof NativeQuery nativeQuery) { - if (nativeQuery.getQuery() != null) { - esQuery = nativeQuery.getQuery(); - } else if (nativeQuery.getSpringDataQuery() != null) { - esQuery = getQuery(nativeQuery.getSpringDataQuery()); - } - } else { - throw new IllegalArgumentException("unhandled Query implementation " + query.getClass().getName()); - } - - Assert.notNull(query, "query must not be null."); - return esQuery; - } - /** * Convert a spring-data-elasticsearch {@literal inner_hits} to an Elasticsearch {@literal inner_hits} query. * diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java index 8e7851f15..50efe0823 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java @@ -112,7 +112,7 @@ * @since 4.4 */ @SuppressWarnings("ClassCanBeRecord") -class RequestConverter { +class RequestConverter extends AbstractQueryProcessor { private static final Log LOGGER = LogFactory.getLog(RequestConverter.class); @@ -1755,31 +1755,7 @@ private void prepareNativeSearch(NativeQuery query, MultisearchBody.Builder buil @Nullable co.elastic.clients.elasticsearch._types.query_dsl.Query getQuery(@Nullable Query query, @Nullable Class clazz) { - - if (query == null) { - return null; - } - - elasticsearchConverter.updateQuery(query, clazz); - - co.elastic.clients.elasticsearch._types.query_dsl.Query esQuery = null; - - if (query instanceof CriteriaQuery) { - esQuery = CriteriaQueryProcessor.createQuery(((CriteriaQuery) query).getCriteria()); - } else if (query instanceof StringQuery) { - esQuery = Queries.wrapperQueryAsQuery(((StringQuery) query).getSource()); - } else if (query instanceof NativeQuery nativeQuery) { - - if (nativeQuery.getQuery() != null) { - esQuery = nativeQuery.getQuery(); - } else if (nativeQuery.getSpringDataQuery() != null) { - esQuery = getQuery(nativeQuery.getSpringDataQuery(), clazz); - } - } else { - throw new IllegalArgumentException("unhandled Query implementation " + query.getClass().getName()); - } - - return esQuery; + return getEsQuery(query, (q) -> elasticsearchConverter.updateQuery(q, clazz)); } private void addFilter(Query query, SearchRequest.Builder builder) { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java index ea39da1d2..6d4fad5ec 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java @@ -49,7 +49,6 @@ import org.springframework.data.elasticsearch.annotations.*; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.ScriptedField; -import org.springframework.data.elasticsearch.client.elc.NativeQuery; import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.geo.GeoPoint; import org.springframework.data.elasticsearch.core.index.AliasAction; From 08e18e8aa859a48f6bd308a32af345510b1bf993 Mon Sep 17 00:00:00 2001 From: Youssef Aouichaoui Date: Tue, 16 Apr 2024 09:55:47 +0200 Subject: [PATCH 4/4] Remove assertion. Signed-off-by: Youssef Aouichaoui --- .../data/elasticsearch/client/elc/AbstractQueryProcessor.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java index 5c96765c3..feaf51618 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java @@ -66,7 +66,6 @@ static co.elastic.clients.elasticsearch._types.query_dsl.Query getEsQuery(@Nulla throw new IllegalArgumentException("unhandled Query implementation " + query.getClass().getName()); } - Assert.notNull(esQuery, "query must not be null."); return esQuery; } }