diff --git a/src/main/asciidoc/reference/elasticsearch-misc.adoc b/src/main/asciidoc/reference/elasticsearch-misc.adoc index df0235ecf..eb2899d7c 100644 --- a/src/main/asciidoc/reference/elasticsearch-misc.adoc +++ b/src/main/asciidoc/reference/elasticsearch-misc.adoc @@ -183,3 +183,81 @@ 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.runtime-fields]] +== Runtime Fields + +From version 7.12 on Elasticsearch has added the feature of runtime fields (https://www.elastic.co/guide/en/elasticsearch/reference/7.12/runtime.html). +Spring Data Elasticsearch supports this in two ways: + +=== Runtime field definitions in the index mappings + +The first way to define runtime fields is by adding the definitions to the index mappings (see https://www.elastic.co/guide/en/elasticsearch/reference/7.12/runtime-mapping-fields.html). +To use this approach in Spring Data Elasticsearch the user must provide a JSON file that contains the corresponding definition, for example: + +.runtime-fields.json +==== +[source,json] +---- +{ + "day_of_week": { + "type": "keyword", + "script": { + "source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))" + } + } +} +---- +==== + +The path to this JSON file, which must be present on the classpath, must then be set in the `@Mapping` annotation of the entity: + +==== +[source,java] +---- +@Document(indexName = "runtime-fields") +@Mapping(runtimeFieldsPath = "/runtime-fields.json") +public class RuntimeFieldEntity { + // properties, getter, setter,... +} + +---- +==== + +=== Runtime fields definitions set on a Query + +The second way to define runtime fields is by adding the definitions to a search query (see https://www.elastic.co/guide/en/elasticsearch/reference/7.12/runtime-search-request.html). +The following code example shows how to do this with Spring Data Elasticsearch : + +The entity used is a simple object that has a `price` property: + +==== +[source,java] +---- +@Document(indexName = "some_index_name") +public class SomethingToBuy { + + private @Id @Nullable String id; + @Nullable @Field(type = FieldType.Text) private String description; + @Nullable @Field(type = FieldType.Double) private Double price; + + // getter and setter +} + +---- +==== + +The following query uses a runtime field that calculates a `priceWithTax` value by adding 19% to the price and uses this value in the search query to find all entities where `priceWithTax` is higher or equal than a given value: + +==== +[source,java] +---- +RuntimeField runtimeField = new RuntimeField("priceWithTax", "double", "emit(doc['price'].value * 1.19)"); +Query query = new CriteriaQuery(new Criteria("priceWithTax").greaterThanEqual(16.5)); +query.addRuntimeField(runtimeField); + +SearchHits searchHits = operations.search(query, SomethingToBuy.class); +---- +==== + +This works with every implementation of the `Query` interface. 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 d906b47dd..18a2f6206 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/RequestFactory.java @@ -1020,7 +1020,17 @@ private SearchRequest prepareSearchRequest(Query query, @Nullable Class clazz request.requestCache(query.getRequestCache()); } + if (!query.getRuntimeFields().isEmpty()) { + + Map runtimeMappings = new HashMap<>(); + query.getRuntimeFields().forEach(runtimeField -> { + runtimeMappings.put(runtimeField.getName(), runtimeField.getMapping()); + }); + sourceBuilder.runtimeMappings(runtimeMappings); + } + request.source(sourceBuilder); + return request; } @@ -1112,6 +1122,15 @@ private SearchRequestBuilder prepareSearchRequestBuilder(Query query, Client cli searchRequestBuilder.setRequestCache(query.getRequestCache()); } + if (!query.getRuntimeFields().isEmpty()) { + + Map runtimeMappings = new HashMap<>(); + query.getRuntimeFields().forEach(runtimeField -> { + runtimeMappings.put(runtimeField.getName(), runtimeField.getMapping()); + }); + searchRequestBuilder.setRuntimeMappings(runtimeMappings); + } + return searchRequestBuilder; } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/RuntimeField.java b/src/main/java/org/springframework/data/elasticsearch/core/RuntimeField.java new file mode 100644 index 000000000..0b52cd8fe --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/RuntimeField.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021 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 java.util.HashMap; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * Defines a runtime field to be added to a Query + * + * @author Peter-Josef Meisch + * @since 4.3 + */ +public class RuntimeField { + + private final String name; + private final String type; + private final String script; + + public RuntimeField(String name, String type, String script) { + + Assert.notNull(name, "name must not be null"); + Assert.notNull(type, "type must not be null"); + Assert.notNull(script, "script must not be null"); + + this.name = name; + this.type = type; + this.script = script; + } + + public String getName() { + return name; + } + + /** + * @return the mapping as a Map like it is needed for the Elasticsearch client + */ + public Map getMapping() { + + Map map = new HashMap<>(); + map.put("type", type); + map.put("script", script); + return map; + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java index 20282b268..daa0bd3d5 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java @@ -28,6 +28,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.core.RuntimeField; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -67,6 +68,7 @@ public class BaseQuery implements Query { protected List rescorerQueries = new ArrayList<>(); @Nullable protected Boolean requestCache; private List idsWithRouting = Collections.emptyList(); + private final List runtimeFields = new ArrayList<>(); @Override @Nullable @@ -374,4 +376,16 @@ public Boolean getRequestCache() { return this.requestCache; } + @Override + public void addRuntimeField(RuntimeField runtimeField) { + + Assert.notNull(runtimeField, "runtimeField must not be null"); + + this.runtimeFields.add(runtimeField); + } + + @Override + public List getRuntimeFields() { + return runtimeFields; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java index 47277ce6b..853958870 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java @@ -24,6 +24,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.core.RuntimeField; import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -392,6 +393,20 @@ default List getRescorerQueries() { @Nullable Boolean getRequestCache(); + /** + * Adds a runtime field to the query. + * + * @param runtimeField the runtime field definition, must not be {@literal null} + * @since 4.3 + */ + void addRuntimeField(RuntimeField runtimeField); + + /** + * @return the runtime fields for this query. May be empty but not null + * @since 4.3 + */ + List getRuntimeFields(); + /** * @since 4.3 */ diff --git a/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsIntegrationTests.java new file mode 100644 index 000000000..95fda78b1 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsIntegrationTests.java @@ -0,0 +1,120 @@ +/* + * Copyright 2021 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +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.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.Criteria; +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.Query; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; +import org.springframework.lang.Nullable; + +/** + * @author Peter-Josef Meisch + */ +@SpringIntegrationTest +public abstract class RuntimeFieldsIntegrationTests { + + @Autowired private ElasticsearchOperations operations; + @Autowired protected IndexNameProvider indexNameProvider; + private IndexOperations indexOperations; + + @BeforeEach + void setUp() { + + indexNameProvider.increment(); + indexOperations = operations.indexOps(SomethingToBuy.class); + indexOperations.createWithMapping(); + } + + @Test + @Order(java.lang.Integer.MAX_VALUE) + void cleanup() { + operations.indexOps(IndexCoordinates.of("*")).delete(); + } + + @Test // #1971 + @DisplayName("should use runtime-field from query in search") + void shouldUseRuntimeFieldFromQueryInSearch() { + + insert("1", "item 1", 13.5); + insert("2", "item 2", 15); + Query query = new CriteriaQuery(new Criteria("priceWithTax").greaterThanEqual(16.5)); + RuntimeField runtimeField = new RuntimeField("priceWithTax", "double", "emit(doc['price'].value * 1.19)"); + query.addRuntimeField(runtimeField); + + SearchHits searchHits = operations.search(query, SomethingToBuy.class); + + assertThat(searchHits.getTotalHits()).isEqualTo(1); + assertThat(searchHits.getSearchHit(0).getId()).isEqualTo("2"); + } + + private void insert(String id, String description, double price) { + SomethingToBuy entity = new SomethingToBuy(); + entity.setId(id); + entity.setDescription(description); + entity.setPrice(price); + operations.save(entity); + } + + @Document(indexName = "#{@indexNameProvider.indexName()}") + private static class SomethingToBuy { + private @Id @Nullable String id; + + @Nullable @Field(type = FieldType.Text) private String description; + + @Nullable @Field(type = FieldType.Double) private Double price; + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + @Nullable + public String getDescription() { + return description; + } + + public void setDescription(@Nullable String description) { + this.description = description; + } + + @Nullable + public Double getPrice() { + return price; + } + + public void setPrice(@Nullable Double price) { + this.price = price; + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsRestTemplateIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsRestTemplateIntegrationTests.java new file mode 100644 index 000000000..1a49d2bd8 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsRestTemplateIntegrationTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2021 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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchRestTemplateConfiguration; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@ContextConfiguration(classes = { RuntimeFieldsRestTemplateIntegrationTests.Config.class }) +public class RuntimeFieldsRestTemplateIntegrationTests extends RuntimeFieldsIntegrationTests { + + @Configuration + @Import({ ElasticsearchRestTemplateConfiguration.class }) + static class Config { + @Bean + IndexNameProvider indexNameProvider() { + return new IndexNameProvider("runtime-fields-rest-template"); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsTransportTemplateIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsTransportTemplateIntegrationTests.java new file mode 100644 index 000000000..b4a49de73 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/RuntimeFieldsTransportTemplateIntegrationTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021 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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Peter-Josef Meisch + */ +@ContextConfiguration(classes = { RuntimeFieldsTransportTemplateIntegrationTests.Config.class }) +public class RuntimeFieldsTransportTemplateIntegrationTests extends RuntimeFieldsIntegrationTests { + + @Configuration + @Import({ ElasticsearchTemplateConfiguration.class }) + static class Config { + @Bean + IndexNameProvider indexNameProvider() { + return new IndexNameProvider("runtime-fields-transport-template"); + } + } + +}