diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 12a704faf..e0879b19a 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -33,6 +33,8 @@ include::reference/elasticsearch-auditing.adoc[] include::{spring-data-commons-docs}/entity-callbacks.adoc[] include::reference/elasticsearch-entity-callbacks.adoc[leveloffset=+1] +include::reference/elasticsearch-join-types.adoc[] +include::reference/elasticsearch-routing.adoc[] include::reference/elasticsearch-misc.adoc[] :leveloffset: -1 diff --git a/src/main/asciidoc/reference/elasticsearch-join-types.adoc b/src/main/asciidoc/reference/elasticsearch-join-types.adoc new file mode 100644 index 000000000..d588ee278 --- /dev/null +++ b/src/main/asciidoc/reference/elasticsearch-join-types.adoc @@ -0,0 +1,229 @@ +[[elasticsearch.jointype]] += Join-Type implementation + +Spring Data Elasticsearch supports the https://www.elastic.co/guide/en/elasticsearch/reference/current/parent-join.html[Join data type] for creating the corresponding index mappings and for storing the relevant information. + +== Setting up the data + +For an entity to be used in a parent child join relationship, it must have a property of type `JoinField` which must be annotated. +Let's assume a `Statement` entity where a statement may be a _question_, an _answer_, a _comment_ or a _vote_ (a _Builder_ is also shown in this example, it's not necessary, but later used in the sample code): + +==== +[source,java] +---- +@Document(indexName = "statements") +@Routing("routing") <.> +public class Statement { + @Id + private String id; + + @Field(type = FieldType.Text) + private String text; + + @Field(type = FieldType.Keyword) + private String routing; + + @JoinTypeRelations( + relations = + { + @JoinTypeRelation(parent = "question", children = {"answer", "comment"}), <.> + @JoinTypeRelation(parent = "answer", children = "vote") <.> + } + ) + private JoinField relation; <.> + + private Statement() { + } + + public static StatementBuilder builder() { + return new StatementBuilder(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRouting() { + return routing; + } + + public void setRouting(Routing routing) { + this.routing = routing; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public JoinField getRelation() { + return relation; + } + + public void setRelation(JoinField relation) { + this.relation = relation; + } + + public static final class StatementBuilder { + private String id; + private String text; + private String routing; + private JoinField relation; + + private StatementBuilder() { + } + + public StatementBuilder withId(String id) { + this.id = id; + return this; + } + + public StatementBuilder withRouting(String routing) { + this.routing = routing; + return this; + } + + public StatementBuilder withText(String text) { + this.text = text; + return this; + } + + public StatementBuilder withRelation(JoinField relation) { + this.relation = relation; + return this; + } + + public Statement build() { + Statement statement = new Statement(); + statement.setId(id); + statement.setRouting(routing); + statement.setText(text); + statement.setRelation(relation); + return statement; + } + } +} +---- +<.> for routing related info see <> +<.> a question can have answers and comments +<.> an answer can have votes +<.> the `JoinField` property is used to combine the name (_question_, _answer_, _comment_ or _vote_) of the relation with the parent id. +The generic type must be the same as the `@Id` annotated property. +==== + +Spring Data Elasticsearch will build the following mapping for this class: + +==== +[source,json] +---- +{ + "statements": { + "mappings": { + "properties": { + "_class": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "routing": { + "type": "keyword" + }, + "relation": { + "type": "join", + "eager_global_ordinals": true, + "relations": { + "question": [ + "answer", + "comment" + ], + "answer": "vote" + } + }, + "text": { + "type": "text" + } + } + } + } +} +---- +==== + +== Storing data + +Given a repository for this class the following code inserts a question, two answers, a comment and a vote: + +==== +[source,java] +---- +void init() { + repository.deleteAll(); + + Statement savedWeather = repository.save( + Statement.builder() + .withText("How is the weather?") + .withRelation(new JoinField<>("question")) <1> + .build()); + + Statement sunnyAnswer = repository.save( + Statement.builder() + .withText("sunny") + .withRelation(new JoinField<>("answer", savedWeather.getId())) <2> + .build()); + + repository.save( + Statement.builder() + .withText("rainy") + .withRelation(new JoinField<>("answer", savedWeather.getId())) <3> + .build()); + + repository.save( + Statement.builder() + .withText("I don't like the rain") + .withRelation(new JoinField<>("comment", savedWeather.getId())) <4> + .build()); + + repository.save( + Statement.builder() + .withText("+1 for the sun") + ,withRouting(savedWeather.getId()) + .withRelation(new JoinField<>("vote", sunnyAnswer.getId())) <5> + .build()); +} +---- +<1> create a question statement +<2> the first answer to the question +<3> the second answer +<4> a comment to the question +<5> a vote for the first answer, this needs to have the routing set to the weather document, see <>. +==== + +== Retrieving data + +Currently native search queries must be used to query the data, so there is no support from standard repository methods. <> can be used instead. + +The following code shows as an example how to retrieve all entries that have a _vote_ (which must be _answers_, because only answers can have a vote) using an `ElasticsearchOperations` instance: + +==== +[source,java] +---- +SearchHits hasVotes() { + NativeSearchQuery query = new NativeSearchQueryBuilder() + .withQuery(hasChildQuery("vote", matchAllQuery(), ScoreMode.None)) + .build(); + + return operations.search(query, Statement.class); +} +---- +==== diff --git a/src/main/asciidoc/reference/elasticsearch-misc.adoc b/src/main/asciidoc/reference/elasticsearch-misc.adoc index 15b25ef85..b92cf1adf 100644 --- a/src/main/asciidoc/reference/elasticsearch-misc.adoc +++ b/src/main/asciidoc/reference/elasticsearch-misc.adoc @@ -1,7 +1,8 @@ [[elasticsearch.misc]] = Miscellaneous Elasticsearch Operation Support -This chapter covers additional support for Elasticsearch operations that cannot be directly accessed via the repository interface. It is recommended to add those operations as custom implementation as described in <> . +This chapter covers additional support for Elasticsearch operations that cannot be directly accessed via the repository interface. +It is recommended to add those operations as custom implementation as described in <> . [[elasticsearch.misc.filter]] == Filter Builder @@ -27,7 +28,8 @@ Page sampleEntities = operations.searchForPage(searchQuery, Sample [[elasticsearch.scroll]] == Using Scroll For Big Result Set -Elasticsearch has a scroll API for getting big result set in chunks. This is internally used by Spring Data Elasticsearch to provide the implementations of the ` SearchHitsIterator SearchOperations.searchForStream(Query query, Class clazz, IndexCoordinates index)` method. +Elasticsearch has a scroll API for getting big result set in chunks. +This is internally used by Spring Data Elasticsearch to provide the implementations of the ` SearchHitsIterator SearchOperations.searchForStream(Query query, Class clazz, IndexCoordinates index)` method. [source,java] ---- @@ -76,7 +78,8 @@ while (scroll.hasSearchHits()) { template.searchScrollClear(scrollId); ---- -To use the Scroll API with repository methods, the return type must defined as `Stream` in the Elasticsearch Repository. The implementation of the method will then use the scroll methods from the ElasticsearchTemplate. +To use the Scroll API with repository methods, the return type must defined as `Stream` in the Elasticsearch Repository. +The implementation of the method will then use the scroll methods from the ElasticsearchTemplate. [source,java] ---- @@ -98,209 +101,3 @@ If the class to be retrieved has a `GeoPoint` property named _location_, the fol ---- Sort.by(new GeoDistanceOrder("location", new GeoPoint(48.137154, 11.5761247))) ---- - -[[elasticsearch.misc.jointype]] -== Join-Type implementation - -Spring Data Elasticsearch supports the https://www.elastic.co/guide/en/elasticsearch/reference/current/parent-join.html[Join data type] for creating the corresponding index mappings and for storing the relevant information. - -=== Setting up the data - -For an entity to be used in a parent child join relationship, it must have a property of type `JoinField` which must be annotated. -Let's assume a `Statement` entity where a statement may be a _question_, an _answer_, a _comment_ or a _vote_ (a _Builder_ is also shown in this example, it's not necessary, but later used in the sample code): - -==== -[source,java] ----- -@Document(indexName = "statements") -public class Statement { - @Id - private String id; - - @Field(type = FieldType.Text) - private String text; - - @JoinTypeRelations( - relations = - { - @JoinTypeRelation(parent = "question", children = {"answer", "comment"}), <1> - @JoinTypeRelation(parent = "answer", children = "vote") <2> - } - ) - private JoinField relation; <3> - - private Statement() { - } - - public static StatementBuilder builder() { - return new StatementBuilder(); - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getText() { - return text; - } - - public void setText(String text) { - this.text = text; - } - - public JoinField getRelation() { - return relation; - } - - public void setRelation(JoinField relation) { - this.relation = relation; - } - - public static final class StatementBuilder { - private String id; - private String text; - private JoinField relation; - - private StatementBuilder() { - } - - public StatementBuilder withId(String id) { - this.id = id; - return this; - } - - public StatementBuilder withText(String text) { - this.text = text; - return this; - } - - public StatementBuilder withRelation(JoinField relation) { - this.relation = relation; - return this; - } - - public Statement build() { - Statement statement = new Statement(); - statement.setId(id); - statement.setText(text); - statement.setRelation(relation); - return statement; - } - } -} ----- -<1> a question can have answers and comments -<2> an answer can have votes -<3> the `JoinField` property is used to combine the name (_question_, _answer_, _comment_ or _vote_) of the relation with the parent id. The generic type must be the same as the `@Id` annotated property. -==== - -Spring Data Elasticsearch will build the following mapping for this class: - -==== -[source,json] ----- -{ - "statements": { - "mappings": { - "properties": { - "_class": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "relation": { - "type": "join", - "eager_global_ordinals": true, - "relations": { - "question": [ - "answer", - "comment" - ], - "answer": "vote" - } - }, - "text": { - "type": "text" - } - } - } - } -} ----- -==== - -=== Storing data - -Given a repository for this class the following code inserts a question, two answers, a comment and a vote: - -==== -[source,java] ----- -void init() { - repository.deleteAll(); - - Statement savedWeather = repository.save( - Statement.builder() - .withText("How is the weather?") - .withRelation(new JoinField<>("question")) <1> - .build()); - - Statement sunnyAnswer = repository.save( - Statement.builder() - .withText("sunny") - .withRelation(new JoinField<>("answer", savedWeather.getId())) <2> - .build()); - - repository.save( - Statement.builder() - .withText("rainy") - .withRelation(new JoinField<>("answer", savedWeather.getId())) <3> - .build()); - - repository.save( - Statement.builder() - .withText("I don't like the rain") - .withRelation(new JoinField<>("comment", savedWeather.getId())) <4> - .build()); - - repository.save( - Statement.builder() - .withText("+1 for the sun") - .withRelation(new JoinField<>("vote", sunnyAnswer.getId())) <5> - .build()); -} ----- -<1> create a question statement -<2> the first answer to the question -<3> the second answer -<4> a comment to the question -<5> a vote for the first answer -==== - -=== Retrieving data - -Currently native search queries must be used to query the data, so there is no support from standard repository methods. <> can be used instead. - -The following code shows as an example how to retrieve all entries that have a _vote_ (which must be _answers_, because only answers can have a vote) using an `ElasticsearchOperations` instance: - -==== -[source,java] ----- -SearchHits hasVotes() { - NativeSearchQuery query = new NativeSearchQueryBuilder() - .withQuery(hasChildQuery("vote", matchAllQuery(), ScoreMode.None)) - .build(); - - return operations.search(query, Statement.class); -} ----- -==== - diff --git a/src/main/asciidoc/reference/elasticsearch-new.adoc b/src/main/asciidoc/reference/elasticsearch-new.adoc index 288779bad..5d160500f 100644 --- a/src/main/asciidoc/reference/elasticsearch-new.adoc +++ b/src/main/asciidoc/reference/elasticsearch-new.adoc @@ -5,6 +5,7 @@ == New in Spring Data Elasticsearch 4.2 * Upgrade to Elasticsearch 7.10.0. +* Support for custom routing values [[new-features.4-1-0]] == New in Spring Data Elasticsearch 4.1 diff --git a/src/main/asciidoc/reference/elasticsearch-routing.adoc b/src/main/asciidoc/reference/elasticsearch-routing.adoc new file mode 100644 index 000000000..8407a614d --- /dev/null +++ b/src/main/asciidoc/reference/elasticsearch-routing.adoc @@ -0,0 +1,106 @@ + +[[elasticsearch.routing]] += Routing values + +When Elasticsearch stores a document in an index that has multiple shards, it determines the shard to you use based on the _id_ of the document. +Sometimes it is necessary to predefine that multiple documents should be indexed on the same shard (join-types, faster search for related data). +For this Elasticsearch offers the possibility to define a routing, which is the value that should be used to calculate the shard from instead of the _id_. + +Spring Data Elasticsearch supports routing definitions on storing and retrieving data in the following ways: + +== Routing on join-types + +When using join-types (see <>), Spring Data Elasticsearch will automatically use the `parent` property of the entity's `JoinField` property as the value for the routing. + +This is correct for all the use-cases where the parent-child relationship has just one level. +If it is deeper, like a child-parent-grandparent relationship - like in the above example from _vote_ -> _answer_ -> _question_ - then the routing needs to explicitly specified by using the techniques described in the next section (the _vote_ needs the _question.id_ as routing value). + +== Custom routing values + +To define a custom routing for an entity, Spring Data Elasticsearch provides a `@Routing` annotation (reusing the `Statement` class from above): + +==== +[source,java] +---- +@Document(indexName = "statements") +@Routing("routing") <.> +public class Statement { + @Id + private String id; + + @Field(type = FieldType.Text) + private String text; + + @JoinTypeRelations( + relations = + { + @JoinTypeRelation(parent = "question", children = {"answer", "comment"}), + @JoinTypeRelation(parent = "answer", children = "vote") + } + ) + private JoinField relation; + + @Nullable + @Field(type = FieldType.Keyword) + private String routing; <.> + + // getter/setter... +} +---- +<.> This defines _"routing"_ as routing specification +<.> a property with the name _routing_ +==== + +If the `routing` specification of the annotation is a plain string and not a SpEL expression, it is interpreted as the name of a property of the entity, in the example it's the _routing_ property. +The value of this property will then be used as the routing value for all requests that use the entity. + +It is also possible to us a SpEL expression in the `@Document` annotation like this: + +==== +[source,java] +---- +@Document(indexName = "statements") +@Routing("@myBean.getRouting(#entity)") +public class Statement{ + // all the needed stuff +} +---- +==== + +In this case the user needs to provide a bean with the name _myBean_ that has a method `String getRouting(Object)`. To reference the entity _"#entity"_ must be used in the SpEL expression, and the return value must be `null` or the routing value as a String. + +If plain property's names and SpEL expressions are not enough to customize the routing definitions, it is possible to define provide an implementation of the `RoutingResolver` interface. This can then be set on the `ElasticOperations` instance: + +==== +[source,java] +---- +RoutingResolver resolver = ...; + +ElasticsearchOperations customOperations= operations.withRouting(resolver); + +---- +==== + +The `withRouting()` functions return a copy of the original `ElasticsearchOperations` instance with the customized routing set. + + +When a routing has been defined on an entity when it is stored in Elasticsearch, the same value must be provided when doing a _get_ or _delete_ operation. For methods that do not use an entity - like `get(ID)` or `delete(ID)` - the `ElasticsearchOperations.withRouting(RoutingResolver)` method can be used like this: + +==== +[source,java] +---- +String id = "someId"; +String routing = "theRoutingValue"; + +// get an entity +Statement s = operations + .withRouting(RoutingResolver.just(routing)) <.> + .get(id, Statement.class); + +// delete an entity +operations.withRouting(RoutingResolver.just(routing)).delete(id); + +---- +<.> `RoutingResolver.just(s)` returns a resolver that will just return the given String. +==== + diff --git a/src/main/java/org/springframework/data/elasticsearch/annotations/Routing.java b/src/main/java/org/springframework/data/elasticsearch/annotations/Routing.java new file mode 100644 index 000000000..bbae27740 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/annotations/Routing.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.data.annotation.Persistent; + +/** + * Annotation to enable custom routing values for an entity. + * + * @author Peter-Josef Meisch + * @since 4.2 + */ +@Persistent +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface Routing { + + /** + * defines how the routing is determined. Can be either the name of a property or a SpEL expression. See the reference + * documentation for examples how to use this annotation. + */ + String value(); +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java index 44ad248eb..fd7c4f2be 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/AbstractElasticsearchTemplate.java @@ -22,6 +22,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -61,9 +62,12 @@ import org.springframework.data.elasticsearch.core.query.Query; import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.elasticsearch.core.query.UpdateQuery; +import org.springframework.data.elasticsearch.core.routing.DefaultRoutingResolver; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.data.elasticsearch.support.VersionInfo; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.callback.EntityCallbacks; +import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.util.CloseableIterator; import org.springframework.data.util.Streamable; import org.springframework.lang.NonNull; @@ -85,6 +89,7 @@ public abstract class AbstractElasticsearchTemplate implements ElasticsearchOper @Nullable private EntityOperations entityOperations; @Nullable private EntityCallbacks entityCallbacks; @Nullable private RefreshPolicy refreshPolicy; + @Nullable protected RoutingResolver routingResolver; // region Initialization protected void initialize(ElasticsearchConverter elasticsearchConverter) { @@ -92,12 +97,32 @@ protected void initialize(ElasticsearchConverter elasticsearchConverter) { Assert.notNull(elasticsearchConverter, "elasticsearchConverter must not be null."); this.elasticsearchConverter = elasticsearchConverter; - this.entityOperations = new EntityOperations(this.elasticsearchConverter.getMappingContext()); - requestFactory = new RequestFactory(elasticsearchConverter); + MappingContext, ElasticsearchPersistentProperty> mappingContext = this.elasticsearchConverter.getMappingContext(); + this.entityOperations = new EntityOperations(mappingContext); + this.routingResolver = new DefaultRoutingResolver((SimpleElasticsearchMappingContext) mappingContext); + requestFactory = new RequestFactory(elasticsearchConverter); VersionInfo.logVersions(getClusterVersion()); } + /** + * @return copy of this instance. + */ + private AbstractElasticsearchTemplate copy() { + + AbstractElasticsearchTemplate copy = doCopy(); + + if (entityCallbacks != null) { + copy.setEntityCallbacks(entityCallbacks); + } + + copy.setRoutingResolver(routingResolver); + + return copy; + } + + protected abstract AbstractElasticsearchTemplate doCopy(); + protected ElasticsearchConverter createElasticsearchConverter() { MappingElasticsearchConverter mappingElasticsearchConverter = new MappingElasticsearchConverter( new SimpleElasticsearchMappingContext()); @@ -278,6 +303,19 @@ public String delete(Object entity, IndexCoordinates index) { return this.delete(getEntityId(entity), index); } + @Override + public String delete(String id, IndexCoordinates index) { + return doDelete(id, routingResolver.getRouting(), index); + } + + @Override + @Deprecated + final public String delete(String id, @Nullable String routing, IndexCoordinates index) { + return doDelete(id, routing, index); + } + + protected abstract String doDelete(String id, @Nullable String routing, IndexCoordinates index); + @Override public List bulkIndex(List queries, Class clazz) { return bulkIndex(queries, getIndexCoordinatesFor(clazz)); @@ -621,7 +659,8 @@ ElasticsearchPersistentEntity getRequiredPersistentEntity(Class clazz) { @Nullable private String getEntityId(Object entity) { - Object id = entityOperations.forEntity(entity, elasticsearchConverter.getConversionService()).getId(); + Object id = entityOperations.forEntity(entity, elasticsearchConverter.getConversionService(), routingResolver) + .getId(); if (id != null) { return stringIdRepresentation(id); @@ -632,13 +671,15 @@ private String getEntityId(Object entity) { @Nullable public String getEntityRouting(Object entity) { - return entityOperations.forEntity(entity, elasticsearchConverter.getConversionService()).getRouting(); + return entityOperations.forEntity(entity, elasticsearchConverter.getConversionService(), routingResolver) + .getRouting(); } @Nullable private Long getEntityVersion(Object entity) { - Number version = entityOperations.forEntity(entity, elasticsearchConverter.getConversionService()).getVersion(); + Number version = entityOperations.forEntity(entity, elasticsearchConverter.getConversionService(), routingResolver) + .getVersion(); if (version != null && Long.class.isAssignableFrom(version.getClass())) { return ((Long) version); @@ -651,7 +692,7 @@ private Long getEntityVersion(Object entity) { private SeqNoPrimaryTerm getEntitySeqNoPrimaryTerm(Object entity) { EntityOperations.AdaptibleEntity adaptibleEntity = entityOperations.forEntity(entity, - elasticsearchConverter.getConversionService()); + elasticsearchConverter.getConversionService(), routingResolver); return adaptibleEntity.hasSeqNoPrimaryTerm() ? adaptibleEntity.getSeqNoPrimaryTerm() : null; } @@ -868,4 +909,24 @@ public SearchScrollHits doWith(SearchDocumentResponse response) { } } // endregion + + // region routing + private void setRoutingResolver(RoutingResolver routingResolver) { + + Assert.notNull(routingResolver, "routingResolver must not be null"); + + this.routingResolver = routingResolver; + } + + @Override + public ElasticsearchOperations withRouting(RoutingResolver routingResolver) { + + Assert.notNull(routingResolver, "routingResolver must not be null"); + + AbstractElasticsearchTemplate copy = copy(); + copy.setRoutingResolver(routingResolver); + return copy; + } + + // endregion } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java index d67a2352d..7211e95fc 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/DocumentOperations.java @@ -25,6 +25,7 @@ import org.springframework.data.elasticsearch.core.query.Query; import org.springframework.data.elasticsearch.core.query.UpdateQuery; import org.springframework.data.elasticsearch.core.query.UpdateResponse; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.lang.Nullable; /** @@ -227,9 +228,7 @@ default void bulkUpdate(List queries, IndexCoordinates index) { * @param index the index from which to delete * @return documentId of the document deleted */ - default String delete(String id, IndexCoordinates index) { - return delete(id, null, index); - } + String delete(String id, IndexCoordinates index); /** * Delete the one object with provided id. @@ -239,7 +238,10 @@ default String delete(String id, IndexCoordinates index) { * @param index the index from which to delete * @return documentId of the document deleted * @since 4.1 + * @deprecated since 4.2, use {@link ElasticsearchOperations#withRouting(RoutingResolver)} and + * {@link #delete(String, IndexCoordinates)} */ + @Deprecated String delete(String id, @Nullable String routing, IndexCoordinates index); /** diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchOperations.java index 674a4babd..841a659fc 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchOperations.java @@ -24,6 +24,7 @@ import org.springframework.data.elasticsearch.core.document.Document; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.query.AliasQuery; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.lang.Nullable; /** @@ -416,4 +417,16 @@ default String stringIdRepresentation(@Nullable Object id) { return Objects.toString(id, null); } // endregion + + //region routing + /** + * Returns a copy of this instance with the same configuration, but that uses a different {@link RoutingResolver} to + * obtain routing information. + * + * @param routingResolver the {@link RoutingResolver} value, must not be {@literal null}. + * @return DocumentOperations instance + * @since 4.2 + */ + ElasticsearchOperations withRouting(RoutingResolver routingResolver); + //endregion } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java index 4694ac3b2..187ae5251 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java @@ -93,7 +93,7 @@ public class ElasticsearchRestTemplate extends AbstractElasticsearchTemplate { private static final Logger LOGGER = LoggerFactory.getLogger(ElasticsearchRestTemplate.class); private final RestHighLevelClient client; - private final ElasticsearchExceptionTranslator exceptionTranslator; + private final ElasticsearchExceptionTranslator exceptionTranslator = new ElasticsearchExceptionTranslator(); // region Initialization public ElasticsearchRestTemplate(RestHighLevelClient client) { @@ -101,7 +101,6 @@ public ElasticsearchRestTemplate(RestHighLevelClient client) { Assert.notNull(client, "Client must not be null!"); this.client = client; - this.exceptionTranslator = new ElasticsearchExceptionTranslator(); initialize(createElasticsearchConverter()); } @@ -111,11 +110,15 @@ public ElasticsearchRestTemplate(RestHighLevelClient client, ElasticsearchConver Assert.notNull(client, "Client must not be null!"); this.client = client; - this.exceptionTranslator = new ElasticsearchExceptionTranslator(); initialize(elasticsearchConverter); } + @Override + protected AbstractElasticsearchTemplate doCopy() { + return new ElasticsearchRestTemplate(client, elasticsearchConverter); + } + // endregion // region IndexOperations @@ -155,7 +158,7 @@ public String doIndex(IndexQuery query, IndexCoordinates index) { @Override @Nullable public T get(String id, Class clazz, IndexCoordinates index) { - GetRequest request = requestFactory.getRequest(id, index); + GetRequest request = requestFactory.getRequest(id,routingResolver.getRouting(), index); GetResponse response = execute(client -> client.get(request, RequestOptions.DEFAULT)); DocumentCallback callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index); @@ -177,7 +180,7 @@ public List multiGet(Query query, Class clazz, IndexCoordinates index) @Override protected boolean doExists(String id, IndexCoordinates index) { - GetRequest request = requestFactory.getRequest(id, index); + GetRequest request = requestFactory.getRequest(id, routingResolver.getRouting(),index); request.fetchSourceContext(FetchSourceContext.DO_NOT_FETCH_SOURCE); return execute(client -> client.get(request, RequestOptions.DEFAULT).isExists()); } @@ -192,7 +195,7 @@ public void bulkUpdate(List queries, BulkOptions bulkOptions, Index } @Override - public String delete(String id, @Nullable String routing, IndexCoordinates index) { + protected String doDelete(String id, @Nullable String routing, IndexCoordinates index) { Assert.notNull(id, "id must not be null"); Assert.notNull(index, "index must not be null"); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java index 28122f85a..836b3b008 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java @@ -112,6 +112,14 @@ private void initialize(Client client, ElasticsearchConverter elasticsearchConve this.client = client; initialize(elasticsearchConverter); } + + @Override + protected AbstractElasticsearchTemplate doCopy() { + ElasticsearchTemplate elasticsearchTemplate = new ElasticsearchTemplate(client, elasticsearchConverter); + elasticsearchTemplate.setSearchTimeout(searchTimeout); + return elasticsearchTemplate; + } + // endregion // region IndexOperations @@ -170,7 +178,8 @@ public String doIndex(IndexQuery query, IndexCoordinates index) { @Override @Nullable public T get(String id, Class clazz, IndexCoordinates index) { - GetRequestBuilder getRequestBuilder = requestFactory.getRequestBuilder(client, id, index); + + GetRequestBuilder getRequestBuilder = requestFactory.getRequestBuilder(client, id, routingResolver.getRouting(), index); GetResponse response = getRequestBuilder.execute().actionGet(); DocumentCallback callback = new ReadDocumentCallback<>(elasticsearchConverter, clazz, index); @@ -192,7 +201,8 @@ public List multiGet(Query query, Class clazz, IndexCoordinates index) @Override protected boolean doExists(String id, IndexCoordinates index) { - GetRequestBuilder getRequestBuilder = requestFactory.getRequestBuilder(client, id, index); + + GetRequestBuilder getRequestBuilder = requestFactory.getRequestBuilder(client, id, routingResolver.getRouting(), index); getRequestBuilder.setFetchSource(false); return getRequestBuilder.execute().actionGet().isExists(); } @@ -207,7 +217,7 @@ public void bulkUpdate(List queries, BulkOptions bulkOptions, Index } @Override - public String delete(String id, @Nullable String routing, IndexCoordinates index) { + protected String doDelete(String id, @Nullable String routing, IndexCoordinates index) { Assert.notNull(id, "id must not be null"); Assert.notNull(index, "index must not be null"); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java index 28bac30b4..1e3a4b7ec 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/EntityOperations.java @@ -23,6 +23,7 @@ import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.data.mapping.IdentifierAccessor; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.MappingContext; @@ -72,14 +73,15 @@ Entity forEntity(T entity) { } /** - * Creates a new {@link AdaptibleEntity} for the given bean and {@link ConversionService}. + * Creates a new {@link AdaptibleEntity} for the given bean and {@link ConversionService} and {@link RoutingResolver}. * * @param entity must not be {@literal null}. * @param conversionService must not be {@literal null}. - * @return + * @param routingResolver the {@link RoutingResolver}, must not be {@literal null} + * @return the {@link AdaptibleEntity} */ @SuppressWarnings({ "unchecked", "rawtypes" }) - AdaptibleEntity forEntity(T entity, ConversionService conversionService) { + AdaptibleEntity forEntity(T entity, ConversionService conversionService, RoutingResolver routingResolver) { Assert.notNull(entity, "Bean must not be null!"); Assert.notNull(conversionService, "ConversionService must not be null!"); @@ -88,7 +90,7 @@ AdaptibleEntity forEntity(T entity, ConversionService conversionService) return new SimpleMappedEntity((Map) entity); } - return AdaptibleMappedEntity.of(entity, context, conversionService); + return AdaptibleMappedEntity.of(entity, context, conversionService, routingResolver); } /** @@ -304,7 +306,7 @@ interface AdaptibleEntity extends Entity { /** * returns the routing for the entity if it is available - * + * * @return routing if available * @since 4.1 */ @@ -464,7 +466,7 @@ private static class SimpleMappedEntity> extends M super(map); } - /* + /* * (non-Javadoc) * @see org.springframework.data.elasticsearch.core.EntityOperations.UnmappedEntity#getId() */ @@ -546,32 +548,37 @@ public ElasticsearchPersistentEntity getPersistentEntity() { */ private static class AdaptibleMappedEntity extends MappedEntity implements AdaptibleEntity { + private final T bean; private final ElasticsearchPersistentEntity entity; private final ConvertingPropertyAccessor propertyAccessor; private final IdentifierAccessor identifierAccessor; private final ConversionService conversionService; + private final RoutingResolver routingResolver; - private AdaptibleMappedEntity(ElasticsearchPersistentEntity entity, IdentifierAccessor identifierAccessor, - ConvertingPropertyAccessor propertyAccessor, ConversionService conversionService) { + private AdaptibleMappedEntity(T bean, ElasticsearchPersistentEntity entity, + IdentifierAccessor identifierAccessor, ConvertingPropertyAccessor propertyAccessor, + ConversionService conversionService, RoutingResolver routingResolver) { super(entity, identifierAccessor, propertyAccessor); + this.bean = bean; this.entity = entity; this.propertyAccessor = propertyAccessor; this.identifierAccessor = identifierAccessor; this.conversionService = conversionService; + this.routingResolver = routingResolver; } static AdaptibleEntity of(T bean, MappingContext, ElasticsearchPersistentProperty> context, - ConversionService conversionService) { + ConversionService conversionService, RoutingResolver routingResolver) { ElasticsearchPersistentEntity entity = context.getRequiredPersistentEntity(bean.getClass()); IdentifierAccessor identifierAccessor = entity.getIdentifierAccessor(bean); PersistentPropertyAccessor propertyAccessor = entity.getPropertyAccessor(bean); - return new AdaptibleMappedEntity<>(entity, identifierAccessor, - new ConvertingPropertyAccessor<>(propertyAccessor, conversionService), conversionService); + return new AdaptibleMappedEntity<>(bean, entity, identifierAccessor, + new ConvertingPropertyAccessor<>(propertyAccessor, conversionService), conversionService, routingResolver); } @Override @@ -616,7 +623,7 @@ public T populateIdIfNecessary(@Nullable Object id) { public Number getVersion() { ElasticsearchPersistentProperty versionProperty = entity.getVersionProperty(); - return versionProperty != null ? propertyAccessor.getProperty(versionProperty, Number.class) : null; + return versionProperty != null ? propertyAccessor.getProperty(versionProperty, Number.class) : null; } @Override @@ -661,6 +668,12 @@ public T incrementVersion() { @Override public String getRouting() { + String routing = routingResolver.getRouting(bean); + + if (routing != null) { + return routing; + } + ElasticsearchPersistentProperty joinFieldProperty = entity.getJoinFieldProperty(); if (joinFieldProperty != null) { @@ -673,6 +686,7 @@ public String getRouting() { return null; } + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveDocumentOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveDocumentOperations.java index 69be8c83c..251d18c29 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveDocumentOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveDocumentOperations.java @@ -301,7 +301,7 @@ default Mono findById(String id, Class entityType, IndexCoordinates in * @param entityType must not be {@literal null}. * @param index the target index, must not be {@literal null} * @return a {@link Mono} emitting the {@literal id} of the removed document. - * @deprecated since 4.0, use {@link #delete(String, Class)} or {@link #deleteById(String, IndexCoordinates)} + * @deprecated since 4.0, use {@link #delete(String, Class)} or {@link #delete(String, IndexCoordinates)} */ @Deprecated default Mono delete(String id, Class entityType, IndexCoordinates index) { diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchOperations.java b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchOperations.java index d29c18561..37862b212 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchOperations.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchOperations.java @@ -20,6 +20,7 @@ import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.lang.Nullable; /** @@ -88,6 +89,17 @@ public interface ReactiveElasticsearchOperations extends ReactiveDocumentOperati */ ReactiveIndexOperations indexOps(Class clazz); + //region routing + /** + * Returns a copy of this instance with the same configuration, but that uses a different {@link RoutingResolver} to + * obtain routing information. + * + * @param routingResolver the {@link RoutingResolver} value, must not be {@literal null}. + * @return DocumentOperations instance + */ + ReactiveElasticsearchOperations withRouting(RoutingResolver routingResolver); + //endregion + /** * Callback interface to be used with {@link #execute(ClientCallback)} for operating directly on * {@link ReactiveElasticsearchClient}. diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java index a2f590bbe..ba993ac13 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java @@ -75,6 +75,8 @@ import org.springframework.data.elasticsearch.core.query.SeqNoPrimaryTerm; import org.springframework.data.elasticsearch.core.query.UpdateQuery; import org.springframework.data.elasticsearch.core.query.UpdateResponse; +import org.springframework.data.elasticsearch.core.routing.DefaultRoutingResolver; +import org.springframework.data.elasticsearch.core.routing.RoutingResolver; import org.springframework.data.elasticsearch.support.VersionInfo; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.callback.ReactiveEntityCallbacks; @@ -104,7 +106,7 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera private final ReactiveElasticsearchClient client; private final ElasticsearchConverter converter; - private final MappingContext, ElasticsearchPersistentProperty> mappingContext; + private final SimpleElasticsearchMappingContext mappingContext; private final ElasticsearchExceptionTranslator exceptionTranslator; private final EntityOperations operations; protected RequestFactory requestFactory; @@ -114,18 +116,23 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera private @Nullable ReactiveEntityCallbacks entityCallbacks; + private RoutingResolver routingResolver; + // region Initialization public ReactiveElasticsearchTemplate(ReactiveElasticsearchClient client) { this(client, new MappingElasticsearchConverter(new SimpleElasticsearchMappingContext())); } public ReactiveElasticsearchTemplate(ReactiveElasticsearchClient client, ElasticsearchConverter converter) { + Assert.notNull(client, "client must not be null"); Assert.notNull(converter, "converter must not be null"); this.client = client; this.converter = converter; - this.mappingContext = converter.getMappingContext(); + + this.mappingContext = (SimpleElasticsearchMappingContext) converter.getMappingContext(); + this.routingResolver = new DefaultRoutingResolver(this.mappingContext); this.exceptionTranslator = new ElasticsearchExceptionTranslator(); this.operations = new EntityOperations(this.mappingContext); @@ -134,6 +141,16 @@ public ReactiveElasticsearchTemplate(ReactiveElasticsearchClient client, Elastic logVersions(); } + private ReactiveElasticsearchTemplate copy() { + + ReactiveElasticsearchTemplate copy = new ReactiveElasticsearchTemplate(client, converter); + copy.setRefreshPolicy(refreshPolicy); + copy.setIndicesOptions(indicesOptions); + copy.setEntityCallbacks(entityCallbacks); + copy.setRoutingResolver(routingResolver); + return copy; + } + private void logVersions() { getClusterVersion() // .doOnSuccess(VersionInfo::logVersions) // @@ -254,7 +271,8 @@ public Flux saveAll(Mono> entitiesPubli } private T updateIndexedObject(T entity, IndexedObjectInformation indexedObjectInformation) { - AdaptibleEntity adaptibleEntity = operations.forEntity(entity, converter.getConversionService()); + AdaptibleEntity adaptibleEntity = operations.forEntity(entity, converter.getConversionService(), + routingResolver); adaptibleEntity.populateIdIfNecessary(indexedObjectInformation.getId()); ElasticsearchPersistentEntity persistentEntity = getRequiredPersistentEntity(entity.getClass()); @@ -372,7 +390,7 @@ public Mono exists(String id, Class entityType, IndexCoordinates ind } private Mono doExists(String id, IndexCoordinates index) { - return Mono.defer(() -> doExists(requestFactory.getRequest(id, index))); + return Mono.defer(() -> doExists(requestFactory.getRequest(id, routingResolver.getRouting(), index))); } /** @@ -395,7 +413,7 @@ private Mono> doIndex(T entity, IndexCoordinates in } private IndexQuery getIndexQuery(Object value) { - AdaptibleEntity entity = operations.forEntity(value, converter.getConversionService()); + AdaptibleEntity entity = operations.forEntity(value, converter.getConversionService(), routingResolver); Object id = entity.getId(); IndexQuery query = new IndexQuery(); @@ -448,7 +466,7 @@ public Mono get(String id, Class entityType, IndexCoordinates index) { } private Mono doGet(String id, IndexCoordinates index) { - return Mono.defer(() -> doGet(requestFactory.getRequest(id, index))); + return Mono.defer(() -> doGet(requestFactory.getRequest(id, routingResolver.getRouting(), index))); } /** @@ -470,7 +488,8 @@ protected Mono doGet(GetRequest request) { @Override public Mono delete(Object entity, IndexCoordinates index) { - AdaptibleEntity elasticsearchEntity = operations.forEntity(entity, converter.getConversionService()); + AdaptibleEntity elasticsearchEntity = operations.forEntity(entity, converter.getConversionService(), + routingResolver); if (elasticsearchEntity.getId() == null) { return Mono.error(new IllegalArgumentException("entity must have an id")); @@ -503,7 +522,7 @@ public Mono delete(String id, IndexCoordinates index) { Assert.notNull(id, "id must not be null"); Assert.notNull(index, "index must not be null"); - return doDeleteById(id, null, index); + return doDeleteById(id, routingResolver.getRouting(), index); } private Mono doDeleteById(String id, @Nullable String routing, IndexCoordinates index) { @@ -976,6 +995,25 @@ protected Mono maybeCallAfterConvert(T entity, Document document, IndexCo } // endregion + // region routing + private void setRoutingResolver(RoutingResolver routingResolver) { + + Assert.notNull(routingResolver, "routingResolver must not be null"); + + this.routingResolver = routingResolver; + } + + @Override + public ReactiveElasticsearchOperations withRouting(RoutingResolver routingResolver) { + + Assert.notNull(routingResolver, "routingResolver must not be null"); + + ReactiveElasticsearchTemplate copy = copy(); + copy.setRoutingResolver(routingResolver); + return copy; + } + // endregion + protected interface DocumentCallback { @NonNull diff --git a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java index 68a715a74..50ebb5478 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java @@ -833,12 +833,17 @@ public DeleteByQueryRequestBuilder deleteByQueryRequestBuilder(Client client, Qu // endregion // region get - public GetRequest getRequest(String id, IndexCoordinates index) { - return new GetRequest(index.getIndexName(), id); + public GetRequest getRequest(String id, @Nullable String routing, IndexCoordinates index) { + GetRequest getRequest = new GetRequest(index.getIndexName(), id); + getRequest.routing(routing); + return getRequest; } - public GetRequestBuilder getRequestBuilder(Client client, String id, IndexCoordinates index) { - return client.prepareGet(index.getIndexName(), null, id); + public GetRequestBuilder getRequestBuilder(Client client, String id, @Nullable String routing, + IndexCoordinates index) { + GetRequestBuilder getRequestBuilder = client.prepareGet(index.getIndexName(), null, id); + getRequestBuilder.setRouting(routing); + return getRequestBuilder; } public MultiGetRequest multiGetRequest(Query query, Class clazz, IndexCoordinates index) { diff --git a/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java b/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java index 17aca5356..119f19be3 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchHit.java @@ -44,17 +44,19 @@ public class SearchHit { private final Map> highlightFields = new LinkedHashMap<>(); private final Map> innerHits = new LinkedHashMap<>(); @Nullable private final NestedMetaData nestedMetaData; + @Nullable private String routing; - public SearchHit(@Nullable String index, @Nullable String id, float score, @Nullable Object[] sortValues, - @Nullable Map> highlightFields, T content) { - this(index, id, score, sortValues, highlightFields, null, null, content); + public SearchHit(@Nullable String index, @Nullable String id, @Nullable String routing, float score, + @Nullable Object[] sortValues, @Nullable Map> highlightFields, T content) { + this(index, id, routing, score, sortValues, highlightFields, null, null, content); } - public SearchHit(@Nullable String index, @Nullable String id, float score, @Nullable Object[] sortValues, - @Nullable Map> highlightFields, @Nullable Map> innerHits, - @Nullable NestedMetaData nestedMetaData, T content) { + public SearchHit(@Nullable String index, @Nullable String id, @Nullable String routing, float score, + @Nullable Object[] sortValues, @Nullable Map> highlightFields, + @Nullable Map> innerHits, @Nullable NestedMetaData nestedMetaData, T content) { this.index = index; this.id = id; + this.routing = routing; this.score = score; this.sortValues = (sortValues != null) ? Arrays.asList(sortValues) : new ArrayList<>(); @@ -165,4 +167,13 @@ public String toString() { return "SearchHit{" + "id='" + id + '\'' + ", score=" + score + ", sortValues=" + sortValues + ", content=" + content + ", highlightFields=" + highlightFields + '}'; } + + /** + * @return the routing for this SearchHit, may be {@literal null}. + * @since 4.2 + */ + @Nullable + public String getRouting() { + return routing; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java b/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java index 9d8a29203..43ff9e97f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchHitMapping.java @@ -107,6 +107,7 @@ SearchHit mapHit(SearchDocument searchDocument, T content) { return new SearchHit(searchDocument.getIndex(), // searchDocument.hasId() ? searchDocument.getId() : null, // + searchDocument.getRouting(), // searchDocument.getScore(), // searchDocument.getSortValues(), // getHighlightsAndRemapFieldNames(searchDocument), // @@ -189,6 +190,7 @@ private SearchHits mapInnerDocuments(SearchHits searchHits, C Object targetObject = converter.read(targetType, searchDocument); convertedSearchHits.add(new SearchHit(searchDocument.getIndex(), // searchDocument.getId(), // + searchDocument.getRouting(), // searchDocument.getScore(), // searchDocument.getSortValues(), // searchDocument.getHighlightFields(), // diff --git a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java index b0ce686ad..b305a1ac5 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/document/SearchDocument.java @@ -89,4 +89,13 @@ default Map getInnerHits() { default NestedMetaData getNestedMetaData() { return null; } + + /** + * @return the routing value for the document + * @since 4.2 + */ + @Nullable + default String getRouting() { + return getFieldValue("_routing"); + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java index 1441972f1..d581a0e11 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java @@ -171,9 +171,18 @@ default ElasticsearchPersistentProperty getRequiredSeqNoPrimaryTermProperty() { /** * returns the default settings for an index. - * + * * @return settings as {@link Document} * @since 4.1 */ Document getDefaultSettings(); + + /** + * Resolves the routing for a bean. + * + * @param bean the bean to resolve the routing for + * @return routing value, may be {@literal null} + */ + @Nullable + String resolveRouting(T bean); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java index 431374d77..fb5feb0cc 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java @@ -24,7 +24,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.elasticsearch.annotations.Parent; +import org.springframework.data.elasticsearch.annotations.Routing; import org.springframework.data.elasticsearch.annotations.Setting; import org.springframework.data.elasticsearch.core.document.Document; import org.springframework.data.elasticsearch.core.join.JoinField; @@ -32,11 +34,11 @@ import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.BasicPersistentEntity; import org.springframework.data.mapping.model.PersistentPropertyAccessorFactory; -import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExpressionDependencies; import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; import org.springframework.expression.EvaluationContext; +import org.springframework.expression.EvaluationException; import org.springframework.expression.Expression; import org.springframework.expression.ParserContext; import org.springframework.expression.common.LiteralExpression; @@ -78,6 +80,8 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit private @Nullable VersionType versionType; private boolean createIndexAndMapping; private final Map fieldNamePropertyCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap routingExpressions = new ConcurrentHashMap<>(); + private @Nullable String routing; private final ConcurrentHashMap indexNameExpressions = new ConcurrentHashMap<>(); private final Lazy indexNameEvaluationContext = Lazy.of(this::getIndexNameEvaluationContext); @@ -102,12 +106,21 @@ public SimpleElasticsearchPersistentEntity(TypeInformation typeInformation) { this.indexStoreType = document.indexStoreType(); this.versionType = document.versionType(); this.createIndexAndMapping = document.createIndex(); + + Setting setting = AnnotatedElementUtils.getMergedAnnotation(clazz, Setting.class); + + if (setting != null) { + this.settingPath = setting.settingPath(); + } } - Setting setting = AnnotatedElementUtils.getMergedAnnotation(clazz, Setting.class); + Routing routingAnnotation = AnnotatedElementUtils.findMergedAnnotation(clazz, Routing.class); - if (setting != null) { - this.settingPath = setting.settingPath(); + if (routingAnnotation != null) { + + Assert.hasText(routingAnnotation.value(), "@Routing annotation must contain a non-empty value"); + + this.routing = routingAnnotation.value(); } } @@ -188,6 +201,8 @@ public ElasticsearchPersistentProperty getScoreProperty() { return scoreProperty; } + // endregion + @Override public void addPersistentProperty(ElasticsearchPersistentProperty property) { super.addPersistentProperty(property); @@ -351,14 +366,15 @@ private String resolve(String name) { Expression expression = getExpressionForIndexName(name); - String resolvedName = expression != null ? expression.getValue(indexNameEvaluationContext.get(), String.class) : null; + String resolvedName = expression != null ? expression.getValue(indexNameEvaluationContext.get(), String.class) + : null; return resolvedName != null ? resolvedName : name; } /** * returns an {@link Expression} for #name if name contains a {@link ParserContext#TEMPLATE_EXPRESSION} otherwise * returns {@literal null}. - * + * * @param name the name to get the expression for * @return Expression may be null */ @@ -373,7 +389,7 @@ private Expression getExpressionForIndexName(String name) { /** * build the {@link EvaluationContext} considering {@link ExpressionDependencies} from the name returned by * {@link #getIndexName()}. - * + * * @return EvaluationContext */ private EvaluationContext getIndexNameEvaluationContext() { @@ -385,6 +401,36 @@ private EvaluationContext getIndexNameEvaluationContext() { return getEvaluationContext(null, expressionDependencies); } + @Override + @Nullable + public String resolveRouting(T bean) { + + if (routing == null) { + return null; + } + + ElasticsearchPersistentProperty persistentProperty = getPersistentProperty(routing); + + if (persistentProperty != null) { + Object propertyValue = getPropertyAccessor(bean).getProperty(persistentProperty); + + return propertyValue != null ? propertyValue.toString() : null; + } + + try { + Expression expression = routingExpressions.computeIfAbsent(routing, PARSER::parseExpression); + ExpressionDependencies expressionDependencies = ExpressionDependencies.discover(expression); + + EvaluationContext context = getEvaluationContext(null, expressionDependencies); + context.setVariable("entity", bean); + + return expression.getValue(context, String.class); + } catch (EvaluationException e) { + throw new InvalidDataAccessApiUsageException( + "Could not resolve expression: " + routing + " for object of class " + bean.getClass().getCanonicalName(), e); + } + } + // endregion @Override diff --git a/src/main/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolver.java b/src/main/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolver.java new file mode 100644 index 000000000..388aa82a0 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolver.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 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.routing; + +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.lang.Nullable; + +/** + * Default implementation of the {@link RoutingResolver} interface. Returns {@literal null} for the non-bean method and + * delegates to the corresponding persistent entity for the bean-method. + * + * @author Peter-Josef Meisch + * @since 4.2 + */ +public class DefaultRoutingResolver implements RoutingResolver { + + private final MappingContext mappingContext; + + public DefaultRoutingResolver( + MappingContext mappingContext) { + this.mappingContext = mappingContext; + } + + @Override + public String getRouting() { + return null; + } + + @Override + @Nullable + public String getRouting(T bean) { + + ElasticsearchPersistentEntity persistentEntity = (ElasticsearchPersistentEntity) mappingContext + .getPersistentEntity(bean.getClass()); + + if (persistentEntity != null) { + return persistentEntity.resolveRouting(bean); + } + + return null; + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/routing/RoutingResolver.java b/src/main/java/org/springframework/data/elasticsearch/core/routing/RoutingResolver.java new file mode 100644 index 000000000..06044bcfd --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/routing/RoutingResolver.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020 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.routing; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * @author Peter-Josef Meisch + * @since 4.2 + */ +public interface RoutingResolver { + + /** + * returns the routing when no entity is available. + * + * @return the routing value + */ + @Nullable + String getRouting(); + + /** + * Returns the routing for a bean. + * + * @param bean the bean to get the routing for + * @return the routing value + */ + @Nullable + String getRouting(T bean); + + /** + * Returns a {@link RoutingResolver that always retuns a fixed value} + * + * @param value the value to return + * @return the fixed-value {@link RoutingResolver} + */ + static RoutingResolver just(String value) { + + Assert.notNull(value, "value must not be null"); + + return new RoutingResolver() { + @Override + public String getRouting() { + return value; + } + + @Override + public String getRouting(Object bean) { + return value; + } + }; + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/routing/package-info.java b/src/main/java/org/springframework/data/elasticsearch/core/routing/package-info.java new file mode 100644 index 000000000..fee0db988 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/routing/package-info.java @@ -0,0 +1,6 @@ +/** + * classes/interfaces for specification and implementation of Elasticsearch routing. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package org.springframework.data.elasticsearch.core.routing; diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplateTests.java index fb085f0e0..8e8929e94 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplateTests.java @@ -58,7 +58,6 @@ * @author Don Wellington * @author Peter-Josef Meisch */ -@SpringIntegrationTest @ContextConfiguration(classes = { ElasticsearchRestTemplateConfiguration.class }) @DisplayName("ElasticsearchRestTemplate") public class ElasticsearchRestTemplateTests extends ElasticsearchTemplateTests { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java index 974ff724d..b2c4955d7 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java @@ -91,6 +91,7 @@ import org.springframework.data.elasticsearch.core.join.JoinField; import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; import org.springframework.data.elasticsearch.core.query.*; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.data.util.StreamUtils; import org.springframework.lang.Nullable; @@ -119,6 +120,7 @@ * @author Roman Puchkovskiy * @author Subhobrata Dey */ +@SpringIntegrationTest public abstract class ElasticsearchTemplateTests { protected static final String INDEX_NAME_JOIN_SAMPLE_ENTITY = "test-index-sample-join-template"; @@ -130,6 +132,7 @@ public abstract class ElasticsearchTemplateTests { @Autowired protected ElasticsearchOperations operations; protected IndexOperations indexOperations; + @BeforeEach public void before() { indexOperations = operations.indexOps(SampleEntity.class); @@ -3351,7 +3354,8 @@ void shouldSupportCRUDOpsForEntityWithJoinFields() throws Exception { shouldDeleteEntityWithJoinFields(qId2, aId2); } - void shouldSaveEntityWithJoinFields(String qId1, String qId2, String aId1, String aId2) throws Exception { + // #1218 + private void shouldSaveEntityWithJoinFields(String qId1, String qId2, String aId1, String aId2) throws Exception { SampleJoinEntity sampleQuestionEntity1 = new SampleJoinEntity(); sampleQuestionEntity1.setUuid(qId1); sampleQuestionEntity1.setText("This is a question"); @@ -3391,18 +3395,18 @@ void shouldSaveEntityWithJoinFields(String qId1, String qId2, String aId1, Strin new NativeSearchQueryBuilder().withQuery(new ParentIdQueryBuilder("answer", qId1)).build(), SampleJoinEntity.class); - List hitIds = hits.getSearchHits().stream().map(new Function, String>() { - @Override - public String apply(SearchHit sampleJoinEntitySearchHit) { - return sampleJoinEntitySearchHit.getId(); - } - }).collect(Collectors.toList()); + List hitIds = hits.getSearchHits().stream() + .map(sampleJoinEntitySearchHit -> sampleJoinEntitySearchHit.getId()).collect(Collectors.toList()); assertThat(hitIds.size()).isEqualTo(2); assertThat(hitIds.containsAll(Arrays.asList(aId1, aId2))).isTrue(); + + hits.forEach(searchHit -> { + assertThat(searchHit.getRouting()).isEqualTo(qId1); + }); } - void shouldUpdateEntityWithJoinFields(String qId1, String qId2, String aId1, String aId2) throws Exception { + private void shouldUpdateEntityWithJoinFields(String qId1, String qId2, String aId1, String aId2) throws Exception { org.springframework.data.elasticsearch.core.document.Document document = org.springframework.data.elasticsearch.core.document.Document .create(); document.put("myJoinField", toDocument(new JoinField<>("answer", qId2))); @@ -3444,7 +3448,7 @@ public String apply(SearchHit sampleJoinEntitySearchHit) { assertThat(hitIds.get(0)).isEqualTo(aId1); } - void shouldDeleteEntityWithJoinFields(String qId2, String aId2) throws Exception { + private void shouldDeleteEntityWithJoinFields(String qId2, String aId2) throws Exception { Query query = new NativeSearchQueryBuilder().withQuery(new ParentIdQueryBuilder("answer", qId2)).withRoute(qId2) .build(); operations.delete(query, SampleJoinEntity.class, IndexCoordinates.of(INDEX_NAME_JOIN_SAMPLE_ENTITY)); @@ -3822,4 +3826,5 @@ static class SampleJoinEntity { @JoinTypeRelation(parent = "question", children = { "answer" }) }) private JoinField myJoinField; @Field(type = Text) private String text; } + } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTransportTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTransportTemplateTests.java index 02dac2443..1636105ab 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTransportTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTransportTemplateTests.java @@ -48,14 +48,12 @@ import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.data.elasticsearch.core.query.UpdateQuery; import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; -import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; import org.springframework.test.context.ContextConfiguration; /** * @author Peter-Josef Meisch * @author Sascha Woo */ -@SpringIntegrationTest @ContextConfiguration(classes = { ElasticsearchTemplateConfiguration.class }) @DisplayName("ElasticsearchTransportTemplate") public class ElasticsearchTransportTemplateTests extends ElasticsearchTemplateTests { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/EntityOperationsTest.java b/src/test/java/org/springframework/data/elasticsearch/core/EntityOperationsTest.java new file mode 100644 index 000000000..90e8b9a25 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/EntityOperationsTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020 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; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Arrays; +import java.util.HashSet; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Routing; +import org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter; +import org.springframework.data.elasticsearch.core.join.JoinField; +import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; +import org.springframework.data.elasticsearch.core.routing.DefaultRoutingResolver; +import org.springframework.lang.Nullable; + +/** + * @author Peter-Josef Meisch + */ +class EntityOperationsTest { + + @Nullable private static ConversionService conversionService; + @Nullable private static EntityOperations entityOperations; + @Nullable private static SimpleElasticsearchMappingContext mappingContext; + + @BeforeAll + static void setUpAll() { + mappingContext = new SimpleElasticsearchMappingContext(); + mappingContext.setInitialEntitySet(new HashSet<>(Arrays.asList(EntityWithRouting.class))); + mappingContext.afterPropertiesSet(); + entityOperations = new EntityOperations(mappingContext); + + MappingElasticsearchConverter converter = new MappingElasticsearchConverter(mappingContext, + new GenericConversionService()); + converter.afterPropertiesSet(); + + conversionService = converter.getConversionService(); + } + + @Test // #1218 + @DisplayName("should return routing from DefaultRoutingAccessor") + void shouldReturnRoutingFromDefaultRoutingAccessor() { + + EntityWithRouting entity = EntityWithRouting.builder().id("42").routing("theRoute").build(); + EntityOperations.AdaptibleEntity adaptibleEntity = entityOperations.forEntity(entity, + conversionService, new DefaultRoutingResolver(mappingContext)); + + String routing = adaptibleEntity.getRouting(); + + assertThat(routing).isEqualTo("theRoute"); + } + + @Test // #1218 + @DisplayName("should return routing from JoinField when routing value is null") + void shouldReturnRoutingFromJoinFieldWhenRoutingValueIsNull() { + + EntityWithRoutingAndJoinField entity = EntityWithRoutingAndJoinField.builder().id("42") + .joinField(new JoinField<>("foo", "foo-routing")).build(); + + EntityOperations.AdaptibleEntity adaptibleEntity = entityOperations.forEntity(entity, + conversionService, new DefaultRoutingResolver(mappingContext)); + + String routing = adaptibleEntity.getRouting(); + + assertThat(routing).isEqualTo("foo-routing"); + } + + @Test // #1218 + @DisplayName("should return routing from routing when JoinField is set") + void shouldReturnRoutingFromRoutingWhenJoinFieldIsSet() { + EntityWithRoutingAndJoinField entity = EntityWithRoutingAndJoinField.builder().id("42").routing("theRoute") + .joinField(new JoinField<>("foo", "foo-routing")).build(); + + EntityOperations.AdaptibleEntity adaptibleEntity = entityOperations.forEntity(entity, + conversionService, new DefaultRoutingResolver(mappingContext)); + + String routing = adaptibleEntity.getRouting(); + + assertThat(routing).isEqualTo("theRoute"); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Document(indexName = "entity-operations-test") + @Routing("routing") + static class EntityWithRouting { + @Id private String id; + private String routing; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Document(indexName = "entity-operations-test") + @Routing("routing") + static class EntityWithRoutingAndJoinField { + @Id private String id; + private String routing; + private JoinField joinField; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/SearchHitSupportTest.java b/src/test/java/org/springframework/data/elasticsearch/core/SearchHitSupportTest.java index 653f58616..a985b2cc4 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/SearchHitSupportTest.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/SearchHitSupportTest.java @@ -59,11 +59,11 @@ void unwrapsSearchHitsIteratorToCloseableIteratorOfEntities() { void shouldReturnTheSameListInstanceInSearchHitsAndGetContent() { List> hits = new ArrayList<>(); - hits.add(new SearchHit<>(null, null, 0, null, null, "one")); - hits.add(new SearchHit<>(null, null, 0, null, null, "two")); - hits.add(new SearchHit<>(null, null, 0, null, null, "three")); - hits.add(new SearchHit<>(null, null, 0, null, null, "four")); - hits.add(new SearchHit<>(null, null, 0, null, null, "five")); + hits.add(new SearchHit<>(null, null, null, 0, null, null, "one")); + hits.add(new SearchHit<>(null, null, null, 0, null, null, "two")); + hits.add(new SearchHit<>(null, null, null, 0, null, null, "three")); + hits.add(new SearchHit<>(null, null, null, 0, null, null, "four")); + hits.add(new SearchHit<>(null, null, null, 0, null, null, "five")); SearchHits originalSearchHits = new SearchHitsImpl<>(hits.size(), TotalHitsRelation.EQUAL_TO, 0, "scroll", hits, null); @@ -112,7 +112,7 @@ public boolean hasNext() { @Override public SearchHit next() { String nextString = iterator.next(); - return new SearchHit<>("index", "id", 1.0f, new Object[0], emptyMap(), nextString); + return new SearchHit<>("index", "id", null, 1.0f, new Object[0], emptyMap(), nextString); } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/StreamQueriesTest.java b/src/test/java/org/springframework/data/elasticsearch/core/StreamQueriesTest.java index fc4bebc31..2045fb499 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/StreamQueriesTest.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/StreamQueriesTest.java @@ -38,7 +38,7 @@ public void shouldCallClearScrollOnIteratorClose() { // given List> hits = new ArrayList<>(); - hits.add(new SearchHit(null, null, 0, null, null, "one")); + hits.add(getOneSearchHit()); SearchScrollHits searchHits = newSearchScrollHits(hits, "1234"); @@ -61,12 +61,16 @@ public void shouldCallClearScrollOnIteratorClose() { } + private SearchHit getOneSearchHit() { + return new SearchHit(null, null, null, 0, null, null, "one"); + } + @Test // DATAES-766 public void shouldReturnTotalHits() { // given List> hits = new ArrayList<>(); - hits.add(new SearchHit(null, null, 0, null, null, "one")); + hits.add(getOneSearchHit()); SearchScrollHits searchHits = newSearchScrollHits(hits, "1234"); @@ -85,12 +89,9 @@ public void shouldReturnTotalHits() { @Test // DATAES-817 void shouldClearAllScrollIds() { - SearchScrollHits searchHits1 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-1"); - SearchScrollHits searchHits2 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); - SearchScrollHits searchHits3 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); + SearchScrollHits searchHits1 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-1"); + SearchScrollHits searchHits2 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); + SearchScrollHits searchHits3 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); SearchScrollHits searchHits4 = newSearchScrollHits(Collections.emptyList(), "s-3"); Iterator> searchScrollHitsIterator = Arrays @@ -114,12 +115,9 @@ void shouldClearAllScrollIds() { @Test // DATAES-831 void shouldReturnAllForRequestedSizeOf0() { - SearchScrollHits searchHits1 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-1"); - SearchScrollHits searchHits2 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); - SearchScrollHits searchHits3 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); + SearchScrollHits searchHits1 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-1"); + SearchScrollHits searchHits2 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); + SearchScrollHits searchHits3 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); SearchScrollHits searchHits4 = newSearchScrollHits(Collections.emptyList(), "s-3"); Iterator> searchScrollHitsIterator = Arrays @@ -139,12 +137,9 @@ void shouldReturnAllForRequestedSizeOf0() { @Test // DATAES-831 void shouldOnlyReturnRequestedCount() { - SearchScrollHits searchHits1 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-1"); - SearchScrollHits searchHits2 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); - SearchScrollHits searchHits3 = newSearchScrollHits( - Collections.singletonList(new SearchHit(null, null, 0, null, null, "one")), "s-2"); + SearchScrollHits searchHits1 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-1"); + SearchScrollHits searchHits2 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); + SearchScrollHits searchHits3 = newSearchScrollHits(Collections.singletonList(getOneSearchHit()), "s-2"); SearchScrollHits searchHits4 = newSearchScrollHits(Collections.emptyList(), "s-3"); Iterator> searchScrollHitsIterator = Arrays diff --git a/src/test/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolverUnitTest.java b/src/test/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolverUnitTest.java new file mode 100644 index 000000000..8d5a712c9 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/routing/DefaultRoutingResolverUnitTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2020 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.routing; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Routing; +import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; +import org.springframework.lang.Nullable; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * @author Peter-Josef Meisch + */ +@SpringJUnitConfig({ DefaultRoutingResolverUnitTest.Config.class }) +class DefaultRoutingResolverUnitTest { + + @Autowired private ApplicationContext applicationContext; + private SimpleElasticsearchMappingContext mappingContext; + + @Nullable private RoutingResolver routingResolver; + + @Configuration + static class Config { + @Bean + SpelRouting spelRouting() { + return new SpelRouting(); + } + } + + @BeforeEach + void setUp() { + mappingContext = new SimpleElasticsearchMappingContext(); + mappingContext.setApplicationContext(applicationContext); + + routingResolver = new DefaultRoutingResolver(mappingContext); + } + + @Test // #1218 + @DisplayName("should throw an exception on unknown property") + void shouldThrowAnExceptionOnUnknownProperty() { + + InvalidRoutingEntity entity = new InvalidRoutingEntity("42", "route 66"); + + assertThatThrownBy(() -> routingResolver.getRouting(entity)).isInstanceOf(InvalidDataAccessApiUsageException.class); + } + + @Test // #1218 + @DisplayName("should return the routing from the entity") + void shouldReturnTheRoutingFromTheEntity() { + + ValidRoutingEntity entity = new ValidRoutingEntity("42", "route 66"); + + String routing = routingResolver.getRouting(entity); + + assertThat(routing).isEqualTo("route 66"); + } + + @Test // #1218 + @DisplayName("should return routing from SpEL expression") + void shouldReturnRoutingFromSpElExpression() { + + ValidSpelRoutingEntity entity = new ValidSpelRoutingEntity("42", "route 42"); + + String routing = routingResolver.getRouting(entity); + + assertThat(routing).isEqualTo("route 42"); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Document(indexName = "routing-resolver-test") + @Routing("theRouting") + static class ValidRoutingEntity { + @Id private String id; + private String theRouting; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Document(indexName = "routing-resolver-test") + @Routing(value = "@spelRouting.getRouting(#entity)") + static class ValidSpelRoutingEntity { + @Id private String id; + private String theRouting; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Document(indexName = "routing-resolver-test") + @Routing("unknownProperty") + static class InvalidRoutingEntity { + @Id private String id; + private String theRouting; + } + + static class SpelRouting { + + @Nullable + public String getRouting(Object o) { + + if (o instanceof ValidSpelRoutingEntity) { + return ((ValidSpelRoutingEntity) o).getTheRouting(); + } + + return null; + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTests.java b/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTests.java new file mode 100644 index 000000000..f70f9ef8d --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2020 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.routing; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.function.Function; + +import org.elasticsearch.cluster.routing.Murmur3HashFunction; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Routing; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchRestTemplateConfiguration; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@SuppressWarnings("ConstantConditions") +@SpringIntegrationTest +@ContextConfiguration(classes = { ElasticsearchRestTemplateConfiguration.class }) +public class ElasticsearchOperationsRoutingTests { + + private static final String INDEX = "routing-test"; + private static final String ID_1 = "id1"; + private static final String ID_2 = "id2"; + private static final String ID_3 = "id3"; + + @Autowired ElasticsearchOperations operations; + @Nullable private IndexOperations indexOps; + + @BeforeAll + static void beforeAll() { + // check that the used id values go to different shards of the index which is configured to have 5 shards. + // Elasticsearch uses the following function: + Function calcShard = routing -> Math.floorMod(Murmur3HashFunction.hash(routing), 5); + + Integer shard1 = calcShard.apply(ID_1); + Integer shard2 = calcShard.apply(ID_2); + Integer shard3 = calcShard.apply(ID_3); + + assertThat(shard1).isNotEqualTo(shard2); + assertThat(shard1).isNotEqualTo(shard3); + assertThat(shard2).isNotEqualTo(shard3); + } + + @BeforeEach + void setUp() { + indexOps = operations.indexOps(RoutingEntity.class); + indexOps.delete(); + indexOps.create(); + indexOps.putMapping(); + } + + @Test // #1218 + @DisplayName("should store data with different routing and be able to get it") + void shouldStoreDataWithDifferentRoutingAndBeAbleToGetIt() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity); + indexOps.refresh(); + + RoutingEntity savedEntity = operations.withRouting(RoutingResolver.just(ID_2)).get(entity.id, RoutingEntity.class); + + assertThat(savedEntity).isEqualTo(entity); + } + + @Test // #1218 + @DisplayName("should store data with different routing and be able to delete it") + void shouldStoreDataWithDifferentRoutingAndBeAbleToDeleteIt() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity); + indexOps.refresh(); + + String deletedId = operations.withRouting(RoutingResolver.just(ID_2)).delete(entity.id, IndexCoordinates.of(INDEX)); + + assertThat(deletedId).isEqualTo(entity.getId()); + } + + @Test // #1218 + @DisplayName("should store data with different routing and get the routing in the search result") + void shouldStoreDataWithDifferentRoutingAndGetTheRoutingInTheSearchResult() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity); + indexOps.refresh(); + + SearchHits searchHits = operations.search(Query.findAll(), RoutingEntity.class); + + assertThat(searchHits.getSearchHits()).hasSize(1); + assertThat(searchHits.getSearchHit(0).getRouting()).isEqualTo(ID_2); + } + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Document(indexName = INDEX, shards = 5) + @Routing("routing") + static class RoutingEntity { + @Id private String id; + private String routing; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTransportTests.java b/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTransportTests.java new file mode 100644 index 000000000..2f6df3950 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/routing/ElasticsearchOperationsRoutingTransportTests.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020 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.routing; + +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@ContextConfiguration(classes = { ElasticsearchTemplateConfiguration.class }) +public class ElasticsearchOperationsRoutingTransportTests extends ElasticsearchOperationsRoutingTests {} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/routing/ReactiveElasticsearchOperationsRoutingTests.java b/src/test/java/org/springframework/data/elasticsearch/core/routing/ReactiveElasticsearchOperationsRoutingTests.java new file mode 100644 index 000000000..774f31a88 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/routing/ReactiveElasticsearchOperationsRoutingTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2020 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.routing; + +import static org.assertj.core.api.Assertions.*; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.function.Function; + +import org.elasticsearch.cluster.routing.Murmur3HashFunction; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Routing; +import org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations; +import org.springframework.data.elasticsearch.core.ReactiveIndexOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.junit.jupiter.ReactiveElasticsearchRestTemplateConfiguration; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.lang.Nullable; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@SpringIntegrationTest +@ContextConfiguration(classes = { ReactiveElasticsearchRestTemplateConfiguration.class }) +public class ReactiveElasticsearchOperationsRoutingTests { + + private static final String INDEX = "routing-test"; + private static final String ID_1 = "id1"; + private static final String ID_2 = "id2"; + private static final String ID_3 = "id3"; + + @Autowired ReactiveElasticsearchOperations operations; + @Nullable private ReactiveIndexOperations indexOps; + + @BeforeAll + static void beforeAll() { + // check that the used id values go to different shards of the index which is configured to have 5 shards. + // Elasticsearch uses the following function: + Function calcShard = routing -> Math.floorMod(Murmur3HashFunction.hash(routing), 5); + + Integer shard1 = calcShard.apply(ID_1); + Integer shard2 = calcShard.apply(ID_2); + Integer shard3 = calcShard.apply(ID_3); + + assertThat(shard1).isNotEqualTo(shard2); + assertThat(shard1).isNotEqualTo(shard3); + assertThat(shard2).isNotEqualTo(shard3); + } + + @BeforeEach + void setUp() { + indexOps = operations.indexOps(RoutingEntity.class); + indexOps.delete().then(indexOps.create()).then(indexOps.putMapping()).block(); + } + + @Test // #1218 + @DisplayName("should store data with different routing and be able to get it") + void shouldStoreDataWithDifferentRoutingAndBeAbleToGetIt() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity).then(indexOps.refresh()).block(); + + RoutingEntity savedEntity = operations.withRouting(RoutingResolver.just(ID_2)).get(entity.id, RoutingEntity.class) + .block(); + + assertThat(savedEntity).isEqualTo(entity); + } + + @Test // #1218 + @DisplayName("should store data with different routing and be able to delete it") + void shouldStoreDataWithDifferentRoutingAndBeAbleToDeleteIt() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity).then(indexOps.refresh()).block(); + + String deletedId = operations.withRouting(RoutingResolver.just(ID_2)).delete(entity.id, IndexCoordinates.of(INDEX)) + .block(); + + assertThat(deletedId).isEqualTo(entity.getId()); + } + + @Test // #1218 + @DisplayName("should store data with different routing and get the routing in the search result") + void shouldStoreDataWithDifferentRoutingAndGetTheRoutingInTheSearchResult() { + + RoutingEntity entity = RoutingEntity.builder().id(ID_1).routing(ID_2).build(); + operations.save(entity).then(indexOps.refresh()).block(); + + List> searchHits = operations.search(Query.findAll(), RoutingEntity.class).collectList() + .block(); + + assertThat(searchHits).hasSize(1); + assertThat(searchHits.get(0).getRouting()).isEqualTo(ID_2); + } + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Document(indexName = INDEX, shards = 5) + @Routing("routing") + static class RoutingEntity { + @Id private String id; + private String routing; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/junit/jupiter/ReactiveElasticsearchRestTemplateConfiguration.java b/src/test/java/org/springframework/data/elasticsearch/junit/jupiter/ReactiveElasticsearchRestTemplateConfiguration.java index 17c4d9433..d6f242bbe 100644 --- a/src/test/java/org/springframework/data/elasticsearch/junit/jupiter/ReactiveElasticsearchRestTemplateConfiguration.java +++ b/src/test/java/org/springframework/data/elasticsearch/junit/jupiter/ReactiveElasticsearchRestTemplateConfiguration.java @@ -43,6 +43,11 @@ public ReactiveElasticsearchClient reactiveElasticsearchClient() { ClientConfiguration.TerminalClientConfigurationBuilder configurationBuilder = ClientConfiguration.builder() // .connectedTo(elasticsearchHostPort); + String proxy = System.getenv("DATAES_ELASTICSEARCH_PROXY"); + + if (proxy != null) { + configurationBuilder = configurationBuilder.withProxy(proxy); + } if (clusterConnectionInfo.isUseSsl()) { configurationBuilder = ((ClientConfiguration.MaybeSecureClientConfigurationBuilder) configurationBuilder) .usingSsl();