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..feaf51618 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/AbstractQueryProcessor.java @@ -0,0 +1,71 @@ +/* + * 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()); + } + + 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 c4934ddc0..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 @@ -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; @@ -42,7 +49,7 @@ * @author Ezequiel Antúnez Camacho * @since 4.4 */ -class CriteriaQueryProcessor { +class CriteriaQueryProcessor extends AbstractQueryProcessor { /** * creates a query from the criteria @@ -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(getEsQuery(query.getQuery(), null)) + .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(getEsQuery(query.getQuery(), null)) + .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,19 @@ public static String escape(String s) { return sb.toString(); } + /** + * 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/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/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..df92e8329 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasChildQuery.java @@ -0,0 +1,203 @@ +/* + * 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..6428c0d63 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/HasParentQuery.java @@ -0,0 +1,140 @@ +/* + * 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..877a80f29 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/InnerHitsQuery.java @@ -0,0 +1,108 @@ +/* + * 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..6d4fad5ec 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java @@ -3827,6 +3827,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 +5010,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 }