diff --git a/src/main/asciidoc/index.adoc b/src/main/asciidoc/index.adoc index 5b8f2704b..f63518ecf 100644 --- a/src/main/asciidoc/index.adoc +++ b/src/main/asciidoc/index.adoc @@ -29,6 +29,7 @@ include::{spring-data-commons-docs}/repositories.adoc[] :leveloffset: +1 include::reference/elasticsearch-clients.adoc[] +include::reference/elasticsearch-object-mapping.adoc[] include::reference/data-elasticsearch.adoc[] include::reference/reactive-elasticsearch-operations.adoc[] include::reference/reactive-elasticsearch-repositories.adoc[] diff --git a/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc b/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc new file mode 100644 index 000000000..e98e5471c --- /dev/null +++ b/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc @@ -0,0 +1,287 @@ +[[elasticsearch.mapping]] += Elasticsearch Object Mapping + +Spring Data Elasticsearch allows to choose between two mapping implementations abstracted via the `EntityMapper` interface: + +* <> +* <> + +[[elasticsearch.mapping.jackson2]] +== Jackson Object Mapping + +The Jackson2 based approach (used by default) utilizes a customized `ObjectMapper` instance with spring data specific modules. +Extensions to the actual mapping need to be customized via Jackson annotations like `@JsonInclude`. + +.Jackson2 Object Mapping Configuration +==== +[source,java] +---- +@Configuration +public class Config extends AbstractElasticsearchConfiguration { <1> + + @Override + public RestHighLevelClient elasticsearchClient() { + return RestClients.create(ClientConfiguration.create("localhost:9200")).rest() + } +} +---- +<1> `AbstractElasticsearchConfiguration` already defines a Jackson2 based `entityMapper` via `ElasticsearchConfigurationSupport`. +==== + +WARNING: `CustomConversions`, `@ReadingConverter` & `@WritingConverter` cannot be applied when using the Jackson based `EntityMapper`. + +[[elasticsearch.mapping.meta-model]] +== Meta Model Object Mapping + +The Metamodel based approach uses domain type information for reading/writing from/to Elasticsearch. +This allows to register `Converter` instances for specific domain type mapping. + +.Meta Model Object Mapping Configuration +==== +[source,java] +---- +@Configuration +public class Config extends AbstractElasticsearchConfiguration { + + @Override + public RestHighLevelClient elasticsearchClient() { + return RestClients.create(ClientConfiguration.create("localhost:9200")).rest() + } + + @Bean + @Override + public EntityMapper entityMapper() { <1> + + ElasticsearchEntityMapper entityMapper = new ElasticsearchEntityMapper(elasticsearchMappingContext(), + new DefaultConversionService()); <2> + entityMapper.setConversions(elasticsearchCustomConversions()); <3> + + return entityMapper; + } +} +---- +<1> Overwrite the default `EntityMapper` from `ElasticsearchConfigurationSupport` and expose it as bean. +<2> Use the provided `SimpleElasticsearchMappingContext` to avoid inconsistencies and provide a `GenericConversionService` +for `Converter` registration. +<3> Optionally set `CustomConversions` if applicable. +==== + +[[elasticsearch.mapping.meta-model.rules]] +=== Mapping Rules + +==== Type Hints + +Mapping uses _type hints_ embedded in the document sent to the server to allow generic type mapping. Those type hints are +represented as `_class` attributes within the document on the server and will be written for each aggregate root. + +.Type Hints +==== +[source,java] +---- +public class Person { <1> + + @Id String id; + String firstname; + String lastname; +} +---- +[source,json] +---- +{ + "_class" : "com.example.Person", <1> + "id" : "cb7bef", + "firstname" : "Sarah", + "lastname" : "Connor" +} +---- +<1> By default the domain types class name is used for the type hint. +==== + +Type hints can be configured to hold custom information. Use the `@TypeAlias` annotation to do so. + +NOTE: Make sure to add types with `@TypeAlias` to the initial entity set (`AbstractElasticsearchConfiguration#getInitialEntitySet`) +to already have entity information available when first reading data from the store. + +.Type Hints with Alias +==== +[source,java] +---- +@TypeAlias("human") <1> +public class Person { + + @Id String id; + // ... +} +---- +[source,json] +---- +{ + "_class" : "human", <1> + "id" : ... +} +---- +<1> The configured alias is used when writing the entity. +==== + +NOTE: Type hints will not be written for nested Objects unless the properties type is `Object`, an interface or the +actual value type does not match the properties declaration. + +==== Geospatial Types + +Geospatial types like `Point` & `GeoPoint` are converted into _lat/lon_ pairs. + +.Geospatial types +==== +[source,java] +---- +public class Address { + + String city, street; + Point location; +} +---- +[source,json] +---- +{ + "city" : "Los Angeles", + "street" : "2800 East Observatory Road", + "location" : { "lat" : 34.118347, "lon" : -118.3026284 } +} +---- +==== + +==== Collections + +For values inside Collections apply the same mapping rules as for aggregate roots when it comes to _type hints_ +and <>. + +.Collections +==== +[source,java] +---- +public class Person { + + // ... + + List friends; + +} +---- +[source,json] +---- +{ + // ... + + "friends" : [ { "firstname" : "Kyle", "lastname" : "Reese" } ] +} +---- +==== + +==== Maps + +For values inside Maps apply the same mapping rules as for aggregate roots when it comes to _type hints_ +and <>. +However the Map key needs to a String to be processed by Elasticsearch. + +.Collections +==== +[source,java] +---- +public class Person { + + // ... + + Map knownLocations; + +} +---- +[source,json] +---- +{ + // ... + + "knownLocations" : { + "arrivedAt" : { + "city" : "Los Angeles", + "street" : "2800 East Observatory Road", + "location" : { "lat" : 34.118347, "lon" : -118.3026284 } + } + } +} +---- +==== + +[[elasticsearch.mapping.meta-model.conversions]] +=== Custom Conversions + +Looking at the `Configuration` from the <> `ElasticsearchCustomConversions` +allows to register specific rules for mapping domain and simple types. + +.Meta Model Object Mapping Configuration +==== +[source,java] +---- +@Configuration +public class Config extends AbstractElasticsearchConfiguration { + + @Override + public RestHighLevelClient elasticsearchClient() { + return RestClients.create(ClientConfiguration.create("localhost:9200")).rest() + } + + @Bean + @Override + public EntityMapper entityMapper() { + + ElasticsearchEntityMapper entityMapper = new ElasticsearchEntityMapper(elasticsearchMappingContext(), + new DefaultConversionService()); + entityMapper.setConversions(elasticsearchCustomConversions()); <1> + + return entityMapper; + } + + @Bean + @Override + public ElasticsearchCustomConversions elasticsearchCustomConversions() { + return new ElasticsearchCustomConversions(Arrays.asList(new AddressToMap(), new MapToAddress())); <2> + } + + @WritingConverter <3> + static class AddressToMap implements Converter> { + + @Override + public Map convert(Address source) { + + LinkedHashMap target = new LinkedHashMap<>(); + target.put("ciudad", source.getCity()); + // ... + + return target; + } + } + + @ReadingConverter <4> + static class MapToAddress implements Converter, Address> { + + @Override + public Address convert(Map source) { + + // ... + return address; + } + } +} +---- +[source,json] +---- +{ + "ciudad" : "Los Angeles", + "calle" : "2800 East Observatory Road", + "localidad" : { "lat" : 34.118347, "lon" : -118.3026284 } +} +---- +<1> Register `ElasticsearchCustomConversions` with the `EntityMapper`. +<2> Add `Converter` implementations. +<3> Set up the `Converter` used for writing `DomainType` to Elasticsearch. +<4> Set up the `Converter` used for reading `DomainType` from search result. +==== diff --git a/src/main/java/org/springframework/data/elasticsearch/config/AbstractElasticsearchConfiguration.java b/src/main/java/org/springframework/data/elasticsearch/config/AbstractElasticsearchConfiguration.java index 8d8165568..4ee3129ea 100644 --- a/src/main/java/org/springframework/data/elasticsearch/config/AbstractElasticsearchConfiguration.java +++ b/src/main/java/org/springframework/data/elasticsearch/config/AbstractElasticsearchConfiguration.java @@ -43,6 +43,6 @@ public abstract class AbstractElasticsearchConfiguration extends ElasticsearchCo */ @Bean public ElasticsearchOperations elasticsearchOperations() { - return new ElasticsearchRestTemplate(elasticsearchClient(), elasticsearchConverter()); + return new ElasticsearchRestTemplate(elasticsearchClient(), elasticsearchConverter(), resultsMapper()); } } diff --git a/src/main/java/org/springframework/data/elasticsearch/config/AbstractReactiveElasticsearchConfiguration.java b/src/main/java/org/springframework/data/elasticsearch/config/AbstractReactiveElasticsearchConfiguration.java index 411cb740f..3bdfadccc 100644 --- a/src/main/java/org/springframework/data/elasticsearch/config/AbstractReactiveElasticsearchConfiguration.java +++ b/src/main/java/org/springframework/data/elasticsearch/config/AbstractReactiveElasticsearchConfiguration.java @@ -50,7 +50,7 @@ public abstract class AbstractReactiveElasticsearchConfiguration extends Elastic public ReactiveElasticsearchOperations reactiveElasticsearchTemplate() { ReactiveElasticsearchTemplate template = new ReactiveElasticsearchTemplate(reactiveElasticsearchClient(), - elasticsearchConverter()); + elasticsearchConverter(), resultsMapper()); template.setIndicesOptions(indicesOptions()); template.setRefreshPolicy(refreshPolicy()); diff --git a/src/main/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupport.java b/src/main/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupport.java index 79c587c7c..d223a838b 100644 --- a/src/main/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupport.java +++ b/src/main/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupport.java @@ -30,6 +30,10 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.data.annotation.Persistent; import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.core.DefaultEntityMapper; +import org.springframework.data.elasticsearch.core.DefaultResultMapper; +import org.springframework.data.elasticsearch.core.EntityMapper; +import org.springframework.data.elasticsearch.core.ResultsMapper; import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; import org.springframework.data.elasticsearch.core.convert.ElasticsearchCustomConversions; import org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter; @@ -62,18 +66,48 @@ public class ElasticsearchConfigurationSupport { SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext(); mappingContext.setInitialEntitySet(getInitialEntitySet()); - mappingContext.setSimpleTypeHolder(customConversions().getSimpleTypeHolder()); + mappingContext.setSimpleTypeHolder(elasticsearchCustomConversions().getSimpleTypeHolder()); return mappingContext; } + /** + * Returns the {@link EntityMapper} used for mapping source <> DomainType.
+ * Hint: you can use {@link org.springframework.data.elasticsearch.core.ElasticsearchEntityMapper} as + * an alternative to the {@link DefaultEntityMapper}. + * + *
{@code
+	 * ElasticsearchEntityMapper entityMapper = new ElasticsearchEntityMapper(elasticsearchMappingContext(),
+	 * 			new DefaultConversionService());
+	 * entityMapper.setConversions(elasticsearchCustomConversions());
+	 * }
+	 * 
+ * + * @return never {@literal null}. + */ + @Bean + public EntityMapper entityMapper() { + return new DefaultEntityMapper(elasticsearchMappingContext()); + } + + /** + * Returns the {@link ResultsMapper} to be used for search responses. + * + * @see #entityMapper() + * @return never {@literal null}. + */ + @Bean + public ResultsMapper resultsMapper() { + return new DefaultResultMapper(elasticsearchMappingContext(), entityMapper()); + } + /** * Register custom {@link Converter}s in a {@link ElasticsearchCustomConversions} object if required. * * @return never {@literal null}. */ @Bean - public ElasticsearchCustomConversions customConversions() { + public ElasticsearchCustomConversions elasticsearchCustomConversions() { return new ElasticsearchCustomConversions(Collections.emptyList()); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/AbstractResultMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/AbstractResultMapper.java index a83216381..7118d9eba 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/AbstractResultMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/AbstractResultMapper.java @@ -15,24 +15,49 @@ */ package org.springframework.data.elasticsearch.core; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.util.Assert; /** * @author Artur Konczak + * @author Christoph Strobl */ public abstract class AbstractResultMapper implements ResultsMapper { - private EntityMapper entityMapper; + private final EntityMapper entityMapper; + private final ProjectionFactory projectionFactory; public AbstractResultMapper(EntityMapper entityMapper) { + this(entityMapper, new SpelAwareProxyProjectionFactory()); + } + + /** + * + * @param entityMapper + * @param projectionFactory + * @since 3.2 + */ + public AbstractResultMapper(EntityMapper entityMapper, ProjectionFactory projectionFactory) { Assert.notNull(entityMapper, "EntityMapper must not be null!"); + Assert.notNull(projectionFactory, "ProjectionFactory must not be null!"); this.entityMapper = entityMapper; + this.projectionFactory = projectionFactory; } @Override public EntityMapper getEntityMapper() { return this.entityMapper; } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.ResultsMapper#getProjectionFactory() + */ + @Override + public ProjectionFactory getProjectionFactory() { + return projectionFactory; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/DefaultEntityMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/DefaultEntityMapper.java index 528bb7bba..069382180 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/DefaultEntityMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/DefaultEntityMapper.java @@ -17,12 +17,15 @@ package org.springframework.data.elasticsearch.core; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.springframework.data.annotation.ReadOnlyProperty; import org.springframework.data.elasticsearch.core.geo.CustomGeoModule; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; +import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.context.MappingContext; import org.springframework.util.Assert; @@ -40,6 +43,7 @@ import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; * @author Artur Konczak * @author Petar Tahchiev * @author Oliver Gierke + * @author Christoph Strobl */ public class DefaultEntityMapper implements EntityMapper { @@ -52,14 +56,14 @@ public class DefaultEntityMapper implements EntityMapper { */ public DefaultEntityMapper( MappingContext, ElasticsearchPersistentProperty> context) { - + Assert.notNull(context, "MappingContext must not be null!"); objectMapper = new ObjectMapper(); - + objectMapper.registerModule(new SpringDataElasticsearchModule(context)); objectMapper.registerModule(new CustomGeoModule()); - + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); } @@ -73,6 +77,20 @@ public class DefaultEntityMapper implements EntityMapper { return objectMapper.writeValueAsString(object); } + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.EntityMapper#mapObject(java.lang.Object) + */ + @Override + public Map mapObject(Object source) { + + try { + return objectMapper.readValue(mapToString(source), HashMap.class); + } catch (IOException e) { + throw new MappingException(e.getMessage(), e); + } + } + /* * (non-Javadoc) * @see org.springframework.data.elasticsearch.core.EntityMapper#mapToObject(java.lang.String, java.lang.Class) @@ -82,6 +100,20 @@ public class DefaultEntityMapper implements EntityMapper { return objectMapper.readValue(source, clazz); } + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.core.EntityMapper#readObject(java.util.Map, java.lang.Class) + */ + @Override + public T readObject (Map source, Class targetType) { + + try { + return mapToObject(mapToString(source), targetType); + } catch (IOException e) { + throw new MappingException(e.getMessage(), e); + } + } + /** * A simple Jackson module to register the {@link SpringDataSerializerModifier}. * @@ -101,7 +133,7 @@ public class DefaultEntityMapper implements EntityMapper { MappingContext, ElasticsearchPersistentProperty> context) { Assert.notNull(context, "MappingContext must not be null!"); - + setSerializerModifier(new SpringDataSerializerModifier(context)); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/DefaultResultMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/DefaultResultMapper.java index ac294b61a..718eb355f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/DefaultResultMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/DefaultResultMapper.java @@ -43,12 +43,13 @@ import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMa import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonGenerator; -import org.springframework.util.StringUtils; /** * @author Artur Konczak @@ -59,24 +60,20 @@ import org.springframework.util.StringUtils; * @author Mark Paluch * @author Ilkang Na * @author Sascha Woo + * @author Christoph Strobl */ public class DefaultResultMapper extends AbstractResultMapper { private final MappingContext, ElasticsearchPersistentProperty> mappingContext; - private final ConversionService conversionService = new DefaultConversionService(); public DefaultResultMapper() { this(new SimpleElasticsearchMappingContext()); } - public DefaultResultMapper(MappingContext, ElasticsearchPersistentProperty> mappingContext) { - - super(new DefaultEntityMapper(mappingContext)); - - Assert.notNull(mappingContext, "MappingContext must not be null!"); - - this.mappingContext = mappingContext; + public DefaultResultMapper( + MappingContext, ElasticsearchPersistentProperty> mappingContext) { + this(mappingContext, initEntityMapper(mappingContext)); } public DefaultResultMapper(EntityMapper entityMapper) { @@ -85,18 +82,23 @@ public class DefaultResultMapper extends AbstractResultMapper { public DefaultResultMapper( MappingContext, ElasticsearchPersistentProperty> mappingContext, - EntityMapper entityMapper) { - - super(entityMapper); - - Assert.notNull(mappingContext, "MappingContext must not be null!"); - + @Nullable EntityMapper entityMapper) { + + super(entityMapper != null ? entityMapper : initEntityMapper(mappingContext)); this.mappingContext = mappingContext; } + static EntityMapper initEntityMapper( + MappingContext, ElasticsearchPersistentProperty> mappingContext) { + + Assert.notNull(mappingContext, "MappingContext must not be null!"); + return new DefaultEntityMapper(mappingContext); + + } + @Override public AggregatedPage mapResults(SearchResponse response, Class clazz, Pageable pageable) { - + long totalHits = response.getHits().getTotalHits(); float maxScore = response.getHits().getMaxScore(); @@ -113,7 +115,7 @@ public class DefaultResultMapper extends AbstractResultMapper { setPersistentEntityId(result, hit.getId(), clazz); setPersistentEntityVersion(result, hit.getVersion(), clazz); setPersistentEntityScore(result, hit.getScore(), clazz); - + populateScriptFields(result, hit); results.add(result); } @@ -200,14 +202,14 @@ public class DefaultResultMapper extends AbstractResultMapper { } private void setPersistentEntityId(T result, String id, Class clazz) { - + if (clazz.isAnnotationPresent(Document.class)) { - + ElasticsearchPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(clazz); ElasticsearchPersistentProperty idProperty = persistentEntity.getIdProperty(); - PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>(persistentEntity.getPropertyAccessor(result), - conversionService); + PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>( + persistentEntity.getPropertyAccessor(result), conversionService); // Only deal with String because ES generated Ids are strings ! if (idProperty != null && idProperty.getType().isAssignableFrom(String.class)) { @@ -217,9 +219,9 @@ public class DefaultResultMapper extends AbstractResultMapper { } private void setPersistentEntityVersion(T result, long version, Class clazz) { - + if (clazz.isAnnotationPresent(Document.class)) { - + ElasticsearchPersistentEntity persistentEntity = mappingContext.getPersistentEntity(clazz); ElasticsearchPersistentProperty versionProperty = persistentEntity.getVersionProperty(); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapper.java new file mode 100644 index 000000000..0c076a25f --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapper.java @@ -0,0 +1,683 @@ +/* + * Copyright 2019 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 + * + * http://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.io.IOException; +import java.util.*; +import java.util.Map.Entry; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.EntityConverter; +import org.springframework.data.convert.EntityInstantiator; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.convert.EntityReader; +import org.springframework.data.convert.EntityWriter; +import org.springframework.data.elasticsearch.core.convert.ElasticsearchCustomConversions; +import org.springframework.data.elasticsearch.core.convert.ElasticsearchTypeMapper; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider; +import org.springframework.data.mapping.model.PropertyValueProvider; +import org.springframework.data.util.ClassTypeInformation; +import org.springframework.data.util.Streamable; +import org.springframework.data.util.TypeInformation; +import org.springframework.format.datetime.DateFormatterRegistrar; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Elasticsearch specific {@link EntityReader} & {@link EntityWriter} implementation based on domain type + * {@link ElasticsearchPersistentEntity metadata}. + * + * @author Christoph Strobl + * @since 3.2 + */ +public class ElasticsearchEntityMapper implements + EntityConverter, ElasticsearchPersistentProperty, Object, Map>, + EntityWriter>, EntityReader>, InitializingBean, + EntityMapper { + + private final MappingContext, ElasticsearchPersistentProperty> mappingContext; + private final GenericConversionService conversionService; + + private CustomConversions conversions = new ElasticsearchCustomConversions(Collections.emptyList()); + private EntityInstantiators instantiators = new EntityInstantiators(); + + private ElasticsearchTypeMapper typeMapper; + + public ElasticsearchEntityMapper( + MappingContext, ElasticsearchPersistentProperty> mappingContext, + @Nullable GenericConversionService conversionService) { + + this.mappingContext = mappingContext; + this.conversionService = conversionService != null ? conversionService : new DefaultConversionService(); + this.typeMapper = ElasticsearchTypeMapper.defaultTypeMapper(mappingContext); + } + + // --> READ + + @Override + public T readObject(Map source, Class targetType) { + return read(targetType, source); + } + + @Override + @Nullable + public R read(Class type, Map source) { + return doRead(source, ClassTypeInformation.from((Class) ClassUtils.getUserClass(type))); + } + + @Nullable + protected R doRead(Map source, TypeInformation typeHint) { + + if (source == null) { + return null; + } + + typeHint = (TypeInformation) typeMapper.readType(source, typeHint); + + if (conversions.hasCustomReadTarget(Map.class, typeHint.getType())) { + return conversionService.convert(source, typeHint.getType()); + } + + if (typeHint.isMap() || ClassTypeInformation.OBJECT.equals(typeHint)) { + return (R) source; + } + + ElasticsearchPersistentEntity entity = mappingContext.getRequiredPersistentEntity(typeHint); + return readEntity(entity, source); + } + + protected R readEntity(ElasticsearchPersistentEntity entity, Map source) { + + ElasticsearchPersistentEntity targetEntity = computeClosestEntity(entity, source); + + ElasticsearchPropertyValueProvider propertyValueProvider = new ElasticsearchPropertyValueProvider( + new MapValueAccessor(source)); + + EntityInstantiator instantiator = instantiators.getInstantiatorFor(targetEntity); + + R instance = (R) instantiator.createInstance(targetEntity, + new PersistentEntityParameterValueProvider<>(targetEntity, propertyValueProvider, null)); + + if (targetEntity.requiresPropertyPopulation()) { + return readProperties(targetEntity, instance, propertyValueProvider); + } + + return instance; + } + + protected R readProperties(ElasticsearchPersistentEntity entity, R instance, + ElasticsearchPropertyValueProvider valueProvider) { + + PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>(entity.getPropertyAccessor(instance), + conversionService); + + for (ElasticsearchPersistentProperty prop : entity) { + + if (entity.isConstructorArgument(prop) || prop.isScoreProperty()) { + continue; + } + + Object value = valueProvider.getPropertyValue(prop); + if (value != null) { + accessor.setProperty(prop, valueProvider.getPropertyValue(prop)); + } + } + + return accessor.getBean(); + } + + protected R readValue(@Nullable Object source, ElasticsearchPersistentProperty property, + TypeInformation targetType) { + + if (source == null) { + return null; + } + + Class rawType = targetType.getType(); + if (conversions.hasCustomReadTarget(source.getClass(), rawType)) { + return (R) conversionService.convert(source, rawType); + } else if (source instanceof List) { + return readCollectionValue((List) source, property, targetType); + } else if (source instanceof Map) { + return readMapValue((Map) source, property, targetType); + } else if (Enum.class.isAssignableFrom(rawType)) { + return (R) Enum.valueOf((Class) rawType, source.toString()); + } + + return (R) readSimpleValue(source, targetType); + } + + private R readMapValue(@Nullable Map source, ElasticsearchPersistentProperty property, + TypeInformation targetType) { + + TypeInformation information = typeMapper.readType(source); + if (property.isEntity() && !property.isMap() || information != null) { + + ElasticsearchPersistentEntity targetEntity = information != null + ? mappingContext.getRequiredPersistentEntity(information) + : mappingContext.getRequiredPersistentEntity(property); + return readEntity(targetEntity, source); + } + + Map target = new LinkedHashMap(); + for (Entry entry : source.entrySet()) { + + if (conversions.isSimpleType(entry.getValue().getClass())) { + target.put(entry.getKey(), + readSimpleValue(entry.getValue(), targetType.isMap() ? targetType.getComponentType() : targetType)); + } else { + + ElasticsearchPersistentEntity targetEntity = computeGenericValueTypeForRead(property, entry.getValue()); + + if (targetEntity.getTypeInformation().isMap()) { + + Map valueMap = (Map) entry.getValue(); + if (typeMapper.containsTypeInformation(valueMap)) { + target.put(entry.getKey(), readEntity(targetEntity, (Map) entry.getValue())); + } else { + target.put(entry.getKey(), readValue(valueMap, property, targetEntity.getTypeInformation())); + } + + } else if (targetEntity.getTypeInformation().isCollectionLike()) { + target.put(entry.getKey(), + readValue(entry.getValue(), property, targetEntity.getTypeInformation().getActualType())); + } else { + target.put(entry.getKey(), readEntity(targetEntity, (Map) entry.getValue())); + } + + } + } + + return (R) target; + } + + private R readCollectionValue(@Nullable List source, ElasticsearchPersistentProperty property, + TypeInformation targetType) { + + if (source == null) { + return null; + } + + List sourceList = source; + Collection target = createCollectionForValue(targetType, sourceList.size()); + + for (Object value : sourceList) { + + if (conversions.isSimpleType(value.getClass())) { + target.add( + readSimpleValue(value, targetType.getComponentType() != null ? targetType.getComponentType() : targetType)); + } else { + + if (value instanceof List) { + target.add(readValue(value, property, property.getTypeInformation().getActualType())); + } else { + target.add(readEntity(computeGenericValueTypeForRead(property, value), (Map) value)); + } + } + } + + return (R) target; + } + + protected Object readSimpleValue(@Nullable Object value, TypeInformation targetType) { + + if (value == null) { + return null; + } + + if (ClassTypeInformation.OBJECT.equals(targetType) + || (targetType != null && value.getClass().equals(targetType.getActualType()))) { + return value; + } + + if (conversionService.canConvert(value.getClass(), targetType.getType())) { + return conversionService.convert(value, targetType.getType()); + } + + throw new MappingException( + String.format("Unable to map %s of type %s to %s", value, value.getClass(), targetType.getType())); + } + + // --> WRITE + + @Override + public Map mapObject(Object source) { + + LinkedHashMap target = new LinkedHashMap<>(); + write(source, target); + return target; + } + + @Override + public void write(@Nullable Object source, Map sink) { + + if (source == null) { + return; + } + + if (source instanceof Map) { + + sink.putAll((Map) source); + return; + } + + Class entityType = ClassUtils.getUserClass(source.getClass()); + TypeInformation type = ClassTypeInformation.from(entityType); + + if (requiresTypeHint(type, source.getClass(), null)) { + typeMapper.writeType(source.getClass(), sink); + } + + doWrite(source, sink, type); + } + + protected void doWrite(@Nullable Object source, Map sink, + @Nullable TypeInformation typeHint) { + + if (source == null) { + return; + } + + Class entityType = source.getClass(); + Optional> customTarget = conversions.getCustomWriteTarget(entityType, Map.class); + + if (customTarget.isPresent()) { + + sink.putAll(conversionService.convert(source, Map.class)); + return; + } + + if (typeHint != null) { + + ElasticsearchPersistentEntity entity = typeHint.getType().equals(entityType) + ? mappingContext.getRequiredPersistentEntity(typeHint) + : mappingContext.getRequiredPersistentEntity(entityType); + + writeEntity(entity, source, sink, null); + return; + } + + // write Entity + ElasticsearchPersistentEntity entity = mappingContext.getRequiredPersistentEntity(entityType); + writeEntity(entity, source, sink, null); + } + + protected void writeEntity(ElasticsearchPersistentEntity entity, Object source, Map sink, + @Nullable TypeInformation containingStructure) { + + PersistentPropertyAccessor accessor = entity.getPropertyAccessor(source); + + if (requiresTypeHint(entity.getTypeInformation(), source.getClass(), containingStructure)) { + typeMapper.writeType(source.getClass(), sink); + } + writeProperties(entity, accessor, sink); + } + + protected void writeProperties(ElasticsearchPersistentEntity entity, PersistentPropertyAccessor accessor, + Map sink) { + + for (ElasticsearchPersistentProperty property : entity) { + + if (!property.isWritable()) { + continue; + } + + Object value = accessor.getProperty(property); + + if (value == null) { + continue; + } + + if (!conversions.isSimpleType(value.getClass())) { + writeProperty(property, value, sink); + } else { + sink.put(property.getFieldName(), getWriteSimpleValue(value)); + } + } + } + + protected void writeProperty(ElasticsearchPersistentProperty property, Object value, Map sink) { + + Optional> customWriteTarget = conversions.getCustomWriteTarget(value.getClass()); + + if (customWriteTarget.isPresent()) { + + Class writeTarget = customWriteTarget.get(); + sink.put(property.getFieldName(), conversionService.convert(value, writeTarget)); + return; + } + + TypeInformation typeHint = property.getTypeInformation(); + if (typeHint.equals(ClassTypeInformation.OBJECT)) { + + if (value instanceof List) { + typeHint = ClassTypeInformation.LIST; + } else if (value instanceof Map) { + typeHint = ClassTypeInformation.MAP; + } else if (value instanceof Set) { + typeHint = ClassTypeInformation.SET; + } else if (value instanceof Collection) { + typeHint = ClassTypeInformation.COLLECTION; + } + } + + sink.put(property.getFieldName(), getWriteComplexValue(property, typeHint, value)); + } + + protected Object getWriteSimpleValue(Object value) { + + Optional> customWriteTarget = conversions.getCustomWriteTarget(value.getClass()); + + if (!customWriteTarget.isPresent()) { + return Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value; + } + + return conversionService.convert(value, customWriteTarget.get()); + } + + protected Object getWriteComplexValue(ElasticsearchPersistentProperty property, TypeInformation typeHint, + Object value) { + + if (typeHint.isCollectionLike() || value instanceof Iterable) { + return writeCollectionValue(value, property, typeHint); + } + if (typeHint.isMap()) { + return writeMapValue((Map) value, property, typeHint); + } + + if (property.isEntity() || !conversions.isSimpleType(value.getClass())) { + return writeEntity(value, property, typeHint); + } + + return value; + } + + private Object writeEntity(Object value, ElasticsearchPersistentProperty property, TypeInformation typeHint) { + Map target = new LinkedHashMap<>(); + writeEntity(mappingContext.getRequiredPersistentEntity(value.getClass()), value, target, + property.getTypeInformation()); + return target; + } + + private Object writeMapValue(Map value, ElasticsearchPersistentProperty property, + TypeInformation typeHint) { + + Map target = new LinkedHashMap<>(); + Streamable> mapSource = Streamable.of(value.entrySet()); + + if (!typeHint.getActualType().getType().equals(Object.class) + && conversions.isSimpleType(typeHint.getMapValueType().getType())) { + mapSource.forEach(it -> target.put(it.getKey(), getWriteSimpleValue(it.getValue()))); + } else { + + mapSource.forEach(it -> { + + Object converted = null; + if (it.getValue() != null) { + + if (conversions.isSimpleType(it.getValue().getClass())) { + converted = getWriteSimpleValue(it.getValue()); + } else { + converted = getWriteComplexValue(property, ClassTypeInformation.from(it.getValue().getClass()), + it.getValue()); + } + } + + target.put(it.getKey(), converted); + + }); + } + + return target; + } + + private Object writeCollectionValue(Object value, ElasticsearchPersistentProperty property, + TypeInformation typeHint) { + + Streamable collectionSource = value instanceof Iterable ? Streamable.of((Iterable) value) + : Streamable.of(ObjectUtils.toObjectArray(value)); + + List target = new ArrayList<>(); + if (!typeHint.getActualType().getType().equals(Object.class) + && conversions.isSimpleType(typeHint.getActualType().getType())) { + + collectionSource.map(this::getWriteSimpleValue).forEach(target::add); + } else { + + Streamable.of((Iterable) value).map(it -> { + + if (it == null) { + return null; + } + + if (conversions.isSimpleType(it.getClass())) { + return getWriteSimpleValue(it); + } + + return getWriteComplexValue(property, ClassTypeInformation.from(it.getClass()), it); + }).forEach(target::add); + + } + return target; + } + + // --> GETTERS / SETTERS + + @Override + public MappingContext, ElasticsearchPersistentProperty> getMappingContext() { + return mappingContext; + } + + @Override + public ConversionService getConversionService() { + return conversionService; + } + + /** + * Set the {@link CustomConversions} to be applied during the mapping process.
+ * Conversions are registered after {@link #afterPropertiesSet() bean initialization}. + * + * @param conversions must not be {@literal null}. + */ + public void setConversions(CustomConversions conversions) { + this.conversions = conversions; + } + + /** + * Set the {@link ElasticsearchTypeMapper} to use for reading / writing type hints. + * + * @param typeMapper must not be {@literal null}. + */ + public void setTypeMapper(ElasticsearchTypeMapper typeMapper) { + this.typeMapper = typeMapper; + } + + /* + * (non-Javadoc) + * @see org.springframework.beans.factory.InitializingBean#afterPropertiesSet() + */ + @Override + public void afterPropertiesSet() { + + DateFormatterRegistrar.addDateConverters(conversionService); + conversions.registerConvertersIn(conversionService); + } + + // --> LEGACY + + @Override + public String mapToString(Object source) throws IOException { + + Map sink = new LinkedHashMap<>(); + write(source, sink); + + return new ObjectMapper().writeValueAsString(sink); + } + + @Override + public T mapToObject(String source, Class clazz) throws IOException { + return read(clazz, new ObjectMapper().readerFor(HashMap.class).readValue(source)); + } + + // --> PRIVATE HELPERS + + private boolean requiresTypeHint(TypeInformation type, Class actualType, + @Nullable TypeInformation containingStructure) { + + if (containingStructure != null) { + + if (containingStructure.isCollectionLike()) { + if (containingStructure.getActualType().equals(type) && type.getType().equals(actualType)) { + return false; + } + } + if (containingStructure.isMap()) { + if (containingStructure.getMapValueType().equals(type) && type.getType().equals(actualType)) { + return false; + } + } + if (containingStructure.equals(type) && type.getType().equals(actualType)) { + return false; + } + } + + return !conversions.isSimpleType(type.getType()) && !type.isCollectionLike() + && !conversions.hasCustomWriteTarget(type.getType()); + } + + /** + * Compute the type to use by checking the given entity against the store type; + * + * @param entity + * @param source + * @return + */ + private ElasticsearchPersistentEntity computeClosestEntity(ElasticsearchPersistentEntity entity, + Map source) { + + TypeInformation typeToUse = typeMapper.readType(source); + + if (typeToUse == null) { + return entity; + } + + if (!entity.getTypeInformation().getType().isInterface() && !entity.getTypeInformation().isCollectionLike() + && !entity.getTypeInformation().isMap() + && !ClassUtils.isAssignableValue(entity.getType(), typeToUse.getType())) { + return entity; + } + + return mappingContext.getRequiredPersistentEntity(typeToUse); + } + + private ElasticsearchPersistentEntity computeGenericValueTypeForRead(ElasticsearchPersistentProperty property, + Object value) { + + return property.getTypeInformation().getActualType().equals(ClassTypeInformation.OBJECT) + ? mappingContext.getRequiredPersistentEntity(value.getClass()) + : mappingContext.getRequiredPersistentEntity(property.getTypeInformation().getActualType()); + } + + private Collection createCollectionForValue(TypeInformation collectionTypeInformation, int size) { + + Class collectionType = collectionTypeInformation.isSubTypeOf(Collection.class) // + ? collectionTypeInformation.getType() // + : List.class; + + TypeInformation componentType = collectionTypeInformation.getComponentType() != null // + ? collectionTypeInformation.getComponentType() // + : ClassTypeInformation.OBJECT; + + return collectionTypeInformation.getType().isArray() // + ? new ArrayList<>(size) // + : CollectionFactory.createCollection(collectionType, componentType.getType(), size); + + } + + // --> OHTER STUFF + + class ElasticsearchPropertyValueProvider implements PropertyValueProvider { + + final MapValueAccessor mapValueAccessor; + + public ElasticsearchPropertyValueProvider(MapValueAccessor mapValueAccessor) { + this.mapValueAccessor = mapValueAccessor; + } + + @Override + public T getPropertyValue(ElasticsearchPersistentProperty property) { + return (T) readValue(mapValueAccessor.get(property), property, property.getTypeInformation()); + } + + } + + static class MapValueAccessor { + + final Map target; + + MapValueAccessor(Map target) { + this.target = target; + } + + public Object get(ElasticsearchPersistentProperty property) { + + String fieldName = property.getFieldName(); + + if (!fieldName.contains(".")) { + return target.get(fieldName); + } + + Iterator parts = Arrays.asList(fieldName.split("\\.")).iterator(); + Map source = target; + Object result = null; + + while (source != null && parts.hasNext()) { + + result = source.get(parts.next()); + + if (parts.hasNext()) { + source = getAsMap(result); + } + } + + return result; + } + + private Map getAsMap(Object result) { + + if (result instanceof Map) { + return (Map) result; + } + + throw new IllegalArgumentException(String.format("%s is no Map.", result)); + } + } + +} 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 2fe14a500..e32e88740 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchRestTemplate.java @@ -15,17 +15,25 @@ */ package org.springframework.data.elasticsearch.core; -import static org.elasticsearch.client.Requests.refreshRequest; -import static org.elasticsearch.index.query.QueryBuilders.moreLikeThisQuery; -import static org.elasticsearch.index.query.QueryBuilders.wrapperQuery; -import static org.springframework.data.elasticsearch.core.MappingBuilder.buildMapping; +import static org.elasticsearch.client.Requests.*; +import static org.elasticsearch.index.query.QueryBuilders.*; +import static org.springframework.data.elasticsearch.core.MappingBuilder.*; import static org.springframework.util.CollectionUtils.isEmpty; -import static org.springframework.util.StringUtils.hasText; +import static org.springframework.util.StringUtils.*; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; import org.apache.http.util.EntityUtils; import org.elasticsearch.action.ActionFuture; @@ -105,11 +113,11 @@ import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMa import org.springframework.data.elasticsearch.core.query.*; import org.springframework.data.util.CloseableIterator; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.util.StringUtils; /** * ElasticsearchRestTemplate @@ -132,6 +140,7 @@ import org.springframework.util.StringUtils; * @author Zetang Zeng * @author Peter Nowak * @author Ivan Greene + * @author Christoph Strobl */ public class ElasticsearchRestTemplate implements ElasticsearchOperations, EsClient, ApplicationContextAware { @@ -345,7 +354,8 @@ public class ElasticsearchRestTemplate return queryForPage(queries, clazz, resultsMapper); } - private List> doMultiSearch(List queries, Class clazz, MultiSearchRequest request, SearchResultMapper resultsMapper) { + private List> doMultiSearch(List queries, Class clazz, MultiSearchRequest request, + SearchResultMapper resultsMapper) { MultiSearchResponse.Item[] items = getMultiSearchResult(request); List> res = new ArrayList<>(queries.size()); int c = 0; @@ -355,7 +365,8 @@ public class ElasticsearchRestTemplate return res; } - private List> doMultiSearch(List queries, List> classes, MultiSearchRequest request, SearchResultMapper resultsMapper) { + private List> doMultiSearch(List queries, List> classes, MultiSearchRequest request, + SearchResultMapper resultsMapper) { MultiSearchResponse.Item[] items = getMultiSearchResult(request); List> res = new ArrayList<>(queries.size()); int c = 0; @@ -726,8 +737,7 @@ public class ElasticsearchRestTemplate private UpdateRequest prepareUpdate(UpdateQuery query) { String indexName = hasText(query.getIndexName()) ? query.getIndexName() : getPersistentEntityFor(query.getClazz()).getIndexName(); - String type = hasText(query.getType()) ? query.getType() - : getPersistentEntityFor(query.getClazz()).getIndexType(); + String type = hasText(query.getType()) ? query.getType() : getPersistentEntityFor(query.getClazz()).getIndexType(); Assert.notNull(indexName, "No index defined for Query"); Assert.notNull(type, "No type define for Query"); Assert.notNull(query.getId(), "No Id define for Query"); @@ -880,6 +890,11 @@ public class ElasticsearchRestTemplate } return new AggregatedPageImpl(Collections.EMPTY_LIST, response.getScrollId()); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }; Page scrolledResult = startScroll(scrollTimeInMillis, searchQuery, String.class, onlyIdResultMapper); @@ -1132,7 +1147,7 @@ public class ElasticsearchRestTemplate if (highlightBuilder == null) { highlightBuilder = new HighlightBuilder(); } - if(searchQuery.getHighlightFields() != null) { + if (searchQuery.getHighlightFields() != null) { for (HighlightBuilder.Field highlightField : searchQuery.getHighlightFields()) { highlightBuilder.field(highlightField); } @@ -1326,7 +1341,8 @@ public class ElasticsearchRestTemplate String indexName = StringUtils.isEmpty(query.getIndexName()) ? retrieveIndexNameFromPersistentEntity(query.getObject().getClass())[0] : query.getIndexName(); - String type = StringUtils.isEmpty(query.getType()) ? retrieveTypeFromPersistentEntity(query.getObject().getClass())[0] + String type = StringUtils.isEmpty(query.getType()) + ? retrieveTypeFromPersistentEntity(query.getObject().getClass())[0] : query.getType(); IndexRequest indexRequest = 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 65c4274a4..461f2326a 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java @@ -99,21 +99,7 @@ import org.springframework.data.elasticsearch.core.facet.FacetRequest; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; -import org.springframework.data.elasticsearch.core.query.AliasQuery; -import org.springframework.data.elasticsearch.core.query.CriteriaQuery; -import org.springframework.data.elasticsearch.core.query.DeleteQuery; -import org.springframework.data.elasticsearch.core.query.FetchSourceFilter; -import org.springframework.data.elasticsearch.core.query.GetQuery; -import org.springframework.data.elasticsearch.core.query.IndexBoost; -import org.springframework.data.elasticsearch.core.query.IndexQuery; -import org.springframework.data.elasticsearch.core.query.MoreLikeThisQuery; -import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; -import org.springframework.data.elasticsearch.core.query.Query; -import org.springframework.data.elasticsearch.core.query.ScriptField; -import org.springframework.data.elasticsearch.core.query.SearchQuery; -import org.springframework.data.elasticsearch.core.query.SourceFilter; -import org.springframework.data.elasticsearch.core.query.StringQuery; -import org.springframework.data.elasticsearch.core.query.UpdateQuery; +import org.springframework.data.elasticsearch.core.query.*; import org.springframework.data.util.CloseableIterator; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -138,6 +124,7 @@ import org.springframework.util.StringUtils; * @author Jean-Baptiste Nizet * @author Zetang Zeng * @author Ivan Greene + * @author Christoph Strobl */ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient, ApplicationContextAware { @@ -223,8 +210,8 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< ElasticsearchPersistentProperty property = persistentEntity.getRequiredIdProperty(); - xContentBuilder = buildMapping(clazz, persistentEntity.getIndexType(), - property.getFieldName(), persistentEntity.getParentType()); + xContentBuilder = buildMapping(clazz, persistentEntity.getIndexType(), property.getFieldName(), + persistentEntity.getParentType()); } catch (Exception e) { throw new ElasticsearchException("Failed to build mapping for " + clazz.getSimpleName(), e); } @@ -332,7 +319,8 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< return doMultiSearch(queries, clazz, request, mapper); } - private List> doMultiSearch(List queries, Class clazz, MultiSearchRequest request, SearchResultMapper resultsMapper) { + private List> doMultiSearch(List queries, Class clazz, MultiSearchRequest request, + SearchResultMapper resultsMapper) { MultiSearchResponse.Item[] items = getMultiSearchResult(request); List> res = new ArrayList<>(queries.size()); int c = 0; @@ -342,7 +330,8 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< return res; } - private List> doMultiSearch(List queries, List> classes, MultiSearchRequest request, SearchResultMapper resultsMapper) { + private List> doMultiSearch(List queries, List> classes, MultiSearchRequest request, + SearchResultMapper resultsMapper) { MultiSearchResponse.Item[] items = getMultiSearchResult(request); List> res = new ArrayList<>(queries.size()); int c = 0; @@ -446,7 +435,8 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< @Override public CloseableIterator stream(CriteriaQuery query, Class clazz) { final long scrollTimeInMillis = TimeValue.timeValueMinutes(1).millis(); - return doStream(scrollTimeInMillis, (ScrolledPage) startScroll(scrollTimeInMillis, query, clazz), clazz, resultsMapper); + return doStream(scrollTimeInMillis, (ScrolledPage) startScroll(scrollTimeInMillis, query, clazz), clazz, + resultsMapper); } @Override @@ -457,10 +447,12 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< @Override public CloseableIterator stream(SearchQuery query, final Class clazz, final SearchResultMapper mapper) { final long scrollTimeInMillis = TimeValue.timeValueMinutes(1).millis(); - return doStream(scrollTimeInMillis, (ScrolledPage) startScroll(scrollTimeInMillis, query, clazz, mapper), clazz, mapper); + return doStream(scrollTimeInMillis, (ScrolledPage) startScroll(scrollTimeInMillis, query, clazz, mapper), clazz, + mapper); } - private CloseableIterator doStream(final long scrollTimeInMillis, final ScrolledPage page, final Class clazz, final SearchResultMapper mapper) { + private CloseableIterator doStream(final long scrollTimeInMillis, final ScrolledPage page, + final Class clazz, final SearchResultMapper mapper) { return new CloseableIterator() { /** As we couldn't retrieve single result with scroll, store current hits. */ @@ -776,6 +768,11 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< } return new AggregatedPageImpl(Collections.EMPTY_LIST, response.getScrollId()); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }; Page scrolledResult = startScroll(scrollTimeInMillis, searchQuery, String.class, onlyIdResultMapper); @@ -784,8 +781,9 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< do { ids.addAll(scrolledResult.getContent()); - scrolledResult = continueScroll(((ScrolledPage)scrolledResult).getScrollId(), scrollTimeInMillis, String.class, onlyIdResultMapper); - } while(scrolledResult.getContent().size() != 0); + scrolledResult = continueScroll(((ScrolledPage) scrolledResult).getScrollId(), scrollTimeInMillis, + String.class, onlyIdResultMapper); + } while (scrolledResult.getContent().size() != 0); for (String id : ids) { bulkRequestBuilder.add(client.prepareDelete(indexName, typeName, id)); @@ -821,12 +819,10 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< private SearchRequestBuilder prepareScroll(Query query, long scrollTimeInMillis) { SearchRequestBuilder requestBuilder = client.prepareSearch(toArray(query.getIndices())) - .setTypes(toArray(query.getTypes())) - .setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)) - .setFrom(0) + .setTypes(toArray(query.getTypes())).setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)).setFrom(0) .setVersion(true); - if(query.getPageable().isPaged()){ + if (query.getPageable().isPaged()) { requestBuilder.setSize(query.getPageable().getPageSize()); } @@ -880,25 +876,28 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< return resultsMapper.mapResults(response, clazz, null); } - public Page startScroll(long scrollTimeInMillis, SearchQuery searchQuery, Class clazz, SearchResultMapper mapper) { + public Page startScroll(long scrollTimeInMillis, SearchQuery searchQuery, Class clazz, + SearchResultMapper mapper) { SearchResponse response = doScroll(prepareScroll(searchQuery, scrollTimeInMillis, clazz), searchQuery); return mapper.mapResults(response, clazz, null); } - public Page startScroll(long scrollTimeInMillis, CriteriaQuery criteriaQuery, Class clazz, SearchResultMapper mapper) { + public Page startScroll(long scrollTimeInMillis, CriteriaQuery criteriaQuery, Class clazz, + SearchResultMapper mapper) { SearchResponse response = doScroll(prepareScroll(criteriaQuery, scrollTimeInMillis, clazz), criteriaQuery); return mapper.mapResults(response, clazz, null); } public Page continueScroll(@Nullable String scrollId, long scrollTimeInMillis, Class clazz) { - SearchResponse response = getSearchResponse(client.prepareSearchScroll(scrollId) - .setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)).execute()); + SearchResponse response = getSearchResponse( + client.prepareSearchScroll(scrollId).setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)).execute()); return resultsMapper.mapResults(response, clazz, Pageable.unpaged()); } - public Page continueScroll(@Nullable String scrollId, long scrollTimeInMillis, Class clazz, SearchResultMapper mapper) { - SearchResponse response = getSearchResponse(client.prepareSearchScroll(scrollId) - .setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)).execute()); + public Page continueScroll(@Nullable String scrollId, long scrollTimeInMillis, Class clazz, + SearchResultMapper mapper) { + SearchResponse response = getSearchResponse( + client.prepareSearchScroll(scrollId).setScroll(TimeValue.timeValueMillis(scrollTimeInMillis)).execute()); return mapper.mapResults(response, clazz, Pageable.unpaged()); } @@ -911,15 +910,16 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< public Page moreLikeThis(MoreLikeThisQuery query, Class clazz) { ElasticsearchPersistentEntity persistentEntity = getPersistentEntityFor(clazz); - String indexName = !StringUtils.isEmpty(query.getIndexName()) ? query.getIndexName() : persistentEntity.getIndexName(); + String indexName = !StringUtils.isEmpty(query.getIndexName()) ? query.getIndexName() + : persistentEntity.getIndexName(); String type = !StringUtils.isEmpty(query.getType()) ? query.getType() : persistentEntity.getIndexType(); Assert.notNull(indexName, "No 'indexName' defined for MoreLikeThisQuery"); Assert.notNull(type, "No 'type' defined for MoreLikeThisQuery"); Assert.notNull(query.getId(), "No document id defined for MoreLikeThisQuery"); - - MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = moreLikeThisQuery(toArray(new MoreLikeThisQueryBuilder.Item(indexName, type, query.getId()))); + MoreLikeThisQueryBuilder moreLikeThisQueryBuilder = moreLikeThisQuery( + toArray(new MoreLikeThisQueryBuilder.Item(indexName, type, query.getId()))); if (query.getMinTermFreq() != null) { moreLikeThisQueryBuilder.minTermFreq(query.getMinTermFreq()); @@ -978,7 +978,7 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< if (highlightBuilder == null) { highlightBuilder = new HighlightBuilder(); } - if(searchQuery.getHighlightFields() != null) { + if (searchQuery.getHighlightFields() != null) { for (HighlightBuilder.Field highlightField : searchQuery.getHighlightFields()) { highlightBuilder.field(highlightField); } @@ -1075,9 +1075,9 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< @Override public Map getSetting(String indexName) { Assert.notNull(indexName, "No index defined for getSettings"); - Settings settings = client.admin().indices().getSettings(new GetSettingsRequest()).actionGet() - .getIndexToSettings().get(indexName); - return settings.keySet().stream().collect(Collectors.toMap((key)->key, (key)->settings.get(key))); + Settings settings = client.admin().indices().getSettings(new GetSettingsRequest()).actionGet().getIndexToSettings() + .get(indexName); + return settings.keySet().stream().collect(Collectors.toMap((key) -> key, (key) -> settings.get(key))); } private SearchRequestBuilder prepareSearch(Query query, Class clazz) { @@ -1091,9 +1091,7 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< int startRecord = 0; SearchRequestBuilder searchRequestBuilder = client.prepareSearch(toArray(query.getIndices())) - .setSearchType(query.getSearchType()) - .setTypes(toArray(query.getTypes())) - .setVersion(true) + .setSearchType(query.getSearchType()).setTypes(toArray(query.getTypes())).setVersion(true) .setTrackScores(query.getTrackScores()); if (query.getSourceFilter() != null) { @@ -1152,7 +1150,8 @@ public class ElasticsearchTemplate implements ElasticsearchOperations, EsClient< String indexName = StringUtils.isEmpty(query.getIndexName()) ? retrieveIndexNameFromPersistentEntity(query.getObject().getClass())[0] : query.getIndexName(); - String type = StringUtils.isEmpty(query.getType()) ? retrieveTypeFromPersistentEntity(query.getObject().getClass())[0] + String type = StringUtils.isEmpty(query.getType()) + ? retrieveTypeFromPersistentEntity(query.getObject().getClass())[0] : query.getType(); IndexRequestBuilder indexRequestBuilder = null; diff --git a/src/main/java/org/springframework/data/elasticsearch/core/EntityMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/EntityMapper.java index 3bddc64b9..081ee36d4 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/EntityMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/EntityMapper.java @@ -16,6 +16,9 @@ package org.springframework.data.elasticsearch.core; import java.io.IOException; +import java.util.Map; + +import org.springframework.lang.Nullable; /** * DocumentMapper interface, it will allow to customize how we mapping object to json @@ -23,10 +26,32 @@ import java.io.IOException; * @author Artur Konczak * @author Rizwan Idrees * @author Mohsin Husen + * @author Christoph Strobl */ public interface EntityMapper { - public String mapToString(Object object) throws IOException; + String mapToString(Object object) throws IOException; - public T mapToObject(String source, Class clazz) throws IOException; + T mapToObject(String source, Class clazz) throws IOException; + + /** + * Map the given {@literal source} to {@link Map}. + * + * @param source must not be {@literal null}. + * @return never {@literal null} + * @since 3.2 + */ + Map mapObject(Object source); + + /** + * Map the given {@link Map} into an instance of the {@literal targetType}. + * + * @param source must not be {@literal null}. + * @param targetType must not be {@literal null}. + * @param + * @return can be {@literal null}. + * @since 3.2 + */ + @Nullable + T readObject(Map source, Class targetType); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/GetResultMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/GetResultMapper.java index eaa0c0406..fa0086b4f 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/GetResultMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/GetResultMapper.java @@ -16,12 +16,27 @@ package org.springframework.data.elasticsearch.core; import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.index.get.GetResult; +import org.springframework.lang.Nullable; /** * @author Artur Konczak * @author Mohsin Husen + * @author Christoph Strobl */ public interface GetResultMapper { T mapResult(GetResponse response, Class clazz); + + /** + * Map a single {@link GetResult} to the given {@link Class type}. + * + * @param getResult must not be {@literal null}. + * @param type must not be {@literal null}. + * @param + * @return can be {@literal null}. + * @since 3.2 + */ + @Nullable + T mapGetResult(GetResult getResult, Class type); } 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 12cf7ea61..7becb41d1 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplate.java @@ -98,11 +98,17 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera } public ReactiveElasticsearchTemplate(ReactiveElasticsearchClient client, ElasticsearchConverter converter) { + this(client, converter, new DefaultResultMapper(converter.getMappingContext())); + } + + public ReactiveElasticsearchTemplate(ReactiveElasticsearchClient client, ElasticsearchConverter converter, + ResultsMapper resultsMapper) { this.client = client; this.converter = converter; this.mappingContext = converter.getMappingContext(); - this.resultMapper = new DefaultResultMapper(converter.getMappingContext()); + + this.resultMapper = resultsMapper; this.exceptionTranslator = new ElasticsearchExceptionTranslator(); this.operations = new EntityOperations(this.mappingContext); } @@ -184,7 +190,7 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera Assert.notNull(id, "Id must not be null!"); return doFindById(id, getPersistentEntity(entityType), index, type) - .map(it -> resultMapper.mapEntity(it, entityType)); + .map(it -> resultMapper.mapGetResult(it, entityType)); } private Mono doFindById(String id, ElasticsearchPersistentEntity entity, @Nullable String index, @@ -201,6 +207,7 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera /* * (non-Javadoc) * @see org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations#exists(String, Class, String, String) + * @see org.springframework.data.elasticsearch.core.ReactiveElasticsearchOperations#exists(String, Class, String, String) */ @Override public Mono exists(String id, Class entityType, String index, String type) { @@ -230,7 +237,7 @@ public class ReactiveElasticsearchTemplate implements ReactiveElasticsearchOpera Class resultType) { return doFind(query, getPersistentEntity(entityType), index, type) - .map(it -> resultMapper.mapEntity(it, resultType)); + .map(it -> resultMapper.mapSearchHit(it, resultType)); } private Flux doFind(Query query, ElasticsearchPersistentEntity entity, @Nullable String index, diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ResultsMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/ResultsMapper.java index 25dccf6ca..fc38582fa 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ResultsMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ResultsMapper.java @@ -16,11 +16,15 @@ package org.springframework.data.elasticsearch.core; import java.io.IOException; +import java.util.Map; import org.elasticsearch.index.get.GetResult; import org.elasticsearch.search.SearchHit; import org.springframework.data.elasticsearch.ElasticsearchException; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; /** @@ -35,6 +39,16 @@ public interface ResultsMapper extends SearchResultMapper, GetResultMapper, Mult EntityMapper getEntityMapper(); + /** + * Get the configured {@link ProjectionFactory}.
+ * NOTE Should be overwritten in implementation to make use of the type cache. + * + * @since 3.2 + */ + default ProjectionFactory getProjectionFactory() { + return new SpelAwareProxyProjectionFactory(); + } + @Nullable default T mapEntity(String source, Class clazz) { @@ -58,19 +72,28 @@ public interface ResultsMapper extends SearchResultMapper, GetResultMapper, Mult * @since 3.2 */ @Nullable - default T mapEntity(GetResult getResult, Class type) { + default T mapGetResult(GetResult getResult, Class type) { if (getResult.isSourceEmpty()) { return null; } - String sourceString = getResult.sourceAsString(); - - if (sourceString.startsWith("{\"id\":null,")) { - sourceString = sourceString.replaceFirst("\"id\":null", "\"id\":\"" + getResult.getId() + "\""); + Map source = getResult.getSource(); + if (!source.containsKey("id") || source.get("id") == null) { + source.put("id", getResult.getId()); } - return mapEntity(sourceString, type); + Object mappedResult = getEntityMapper().readObject(source, type); + + if (mappedResult == null) { + return (T) mappedResult; + } + + if (type.isInterface() || !ClassUtils.isAssignableValue(type, mappedResult)) { + return getProjectionFactory().createProjection(type, mappedResult); + } + + return (T) mappedResult; } /** @@ -83,18 +106,27 @@ public interface ResultsMapper extends SearchResultMapper, GetResultMapper, Mult * @since 3.2 */ @Nullable - default T mapEntity(SearchHit searchHit, Class type) { + default T mapSearchHit(SearchHit searchHit, Class type) { if (!searchHit.hasSource()) { return null; } - String sourceString = searchHit.getSourceAsString(); - - if (sourceString.startsWith("{\"id\":null,")) { - sourceString = sourceString.replaceFirst("\"id\":null", "\"id\":\"" + searchHit.getId() + "\""); + Map source = searchHit.getSourceAsMap(); + if (!source.containsKey("id") || source.get("id") == null) { + source.put("id", searchHit.getId()); } - return mapEntity(sourceString, type); + Object mappedResult = getEntityMapper().readObject(source, type); + + if (mappedResult == null) { + return (T) mappedResult; + } + + if (type.isInterface()) { + return getProjectionFactory().createProjection(type, mappedResult); + } + + return (T) mappedResult; } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/SearchResultMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/SearchResultMapper.java index ebda7aaed..a72e425f9 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/SearchResultMapper.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/SearchResultMapper.java @@ -16,14 +16,29 @@ package org.springframework.data.elasticsearch.core; import org.elasticsearch.action.search.SearchResponse; +import org.elasticsearch.search.SearchHit; import org.springframework.data.domain.Pageable; import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage; +import org.springframework.lang.Nullable; /** * @author Artur Konczak * @author Petar Tahchiev + * @author Christoph Strobl */ public interface SearchResultMapper { AggregatedPage mapResults(SearchResponse response, Class clazz, Pageable pageable); + + /** + * Map a single {@link SearchHit} to the given {@link Class type}. + * + * @param searchHit must not be {@literal null}. + * @param type must not be {@literal null}. + * @param + * @return can be {@literal null}. + * @since 3.2 + */ + @Nullable + T mapSearchHit(SearchHit searchHit, Class type); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchCustomConversions.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchCustomConversions.java index 4594300cd..868e56357 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchCustomConversions.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchCustomConversions.java @@ -15,22 +15,94 @@ */ package org.springframework.data.elasticsearch.core.convert; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchSimpleTypes; +import org.springframework.util.NumberUtils; /** + * Elasticsearch specific {@link CustomConversions}. + * * @author Christoph Strobl * @since 3.2 */ public class ElasticsearchCustomConversions extends CustomConversions { + private static final StoreConversions STORE_CONVERSIONS; + private static final List STORE_CONVERTERS; + + static { + + List converters = new ArrayList<>(); + converters.addAll(GeoConverters.getConvertersToRegister()); + converters.add(StringToUUIDConverter.INSTANCE); + converters.add(UUIDToStringConverter.INSTANCE); + converters.add(BigDecimalToDoubleConverter.INSTANCE); + converters.add(DoubleToBigDecimalConverter.INSTANCE); + + STORE_CONVERTERS = Collections.unmodifiableList(converters); + STORE_CONVERSIONS = StoreConversions.of(ElasticsearchSimpleTypes.HOLDER, STORE_CONVERTERS); + } + /** * Creates a new {@link CustomConversions} instance registering the given converters. * * @param converters must not be {@literal null}. */ public ElasticsearchCustomConversions(Collection converters) { - super(StoreConversions.NONE, converters); + super(STORE_CONVERSIONS, converters); + } + + @ReadingConverter + enum StringToUUIDConverter implements Converter { + + INSTANCE; + + @Override + public UUID convert(String source) { + return UUID.fromString(source); + } + } + + @WritingConverter + enum UUIDToStringConverter implements Converter { + + INSTANCE; + + @Override + public String convert(UUID source) { + return source.toString(); + } + } + + @ReadingConverter + enum DoubleToBigDecimalConverter implements Converter { + + INSTANCE; + + @Override + public BigDecimal convert(Double source) { + return NumberUtils.convertNumberToTargetClass(source, BigDecimal.class); + } + } + + @WritingConverter + enum BigDecimalToDoubleConverter implements Converter { + + INSTANCE; + + @Override + public Double convert(BigDecimal source) { + return NumberUtils.convertNumberToTargetClass(source, Double.class); + } } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchDefaultTypeMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchDefaultTypeMapper.java new file mode 100644 index 000000000..4153f1f2c --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchDefaultTypeMapper.java @@ -0,0 +1,47 @@ +/* + * Copyright 2019 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 + * + * http://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.convert; + +import java.util.List; +import java.util.Map; + +import org.springframework.data.convert.DefaultTypeMapper; +import org.springframework.data.convert.TypeAliasAccessor; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.lang.Nullable; + +/** + * Elasticsearch specific {@link org.springframework.data.convert.TypeMapper} implementation. + * + * @author Christoph Strobl + * @since 3.2 + */ +class ElasticsearchDefaultTypeMapper extends DefaultTypeMapper> implements ElasticsearchTypeMapper { + + private final @Nullable String typeKey; + + ElasticsearchDefaultTypeMapper(@Nullable String typeKey, TypeAliasAccessor accessor, + @Nullable MappingContext mappingContext, List additionalMappers) { + + super(accessor, mappingContext, additionalMappers); + this.typeKey = typeKey; + } + + @Override + public boolean isTypeKey(String key) { + return typeKey != null && typeKey.equals(key); + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchTypeMapper.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchTypeMapper.java new file mode 100644 index 000000000..6f1270fb7 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/ElasticsearchTypeMapper.java @@ -0,0 +1,92 @@ +/* + * Copyright 2019 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 + * + * http://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.convert; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.data.convert.SimpleTypeInformationMapper; +import org.springframework.data.convert.TypeAliasAccessor; +import org.springframework.data.convert.TypeMapper; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentEntity; +import org.springframework.data.elasticsearch.core.mapping.ElasticsearchPersistentProperty; +import org.springframework.data.mapping.Alias; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.lang.Nullable; + +/** + * Elasticsearch specific {@link TypeMapper} definition. + * + * @author Christoph Strobl + * @since 3.2 + */ +public interface ElasticsearchTypeMapper extends TypeMapper> { + + String DEFAULT_TYPE_KEY = "_class"; + + /** + * Returns whether the given key is the type key. + * + * @return {@literal true} if given {@literal key} is used as type hint key. + */ + boolean isTypeKey(String key); + + default boolean containsTypeInformation(Map source) { + return readType(source) != null; + } + + static ElasticsearchTypeMapper defaultTypeMapper( + MappingContext, ElasticsearchPersistentProperty> mappingContext) { + return new ElasticsearchDefaultTypeMapper(DEFAULT_TYPE_KEY, new MapTypeAliasAccessor(DEFAULT_TYPE_KEY), + mappingContext, Collections.singletonList(new SimpleTypeInformationMapper())); + } + + /** + * {@link TypeAliasAccessor} to store aliases in a {@link Map}. + * + * @author Christoph Strobl + */ + final class MapTypeAliasAccessor implements TypeAliasAccessor> { + + private final @Nullable String typeKey; + + public MapTypeAliasAccessor(@Nullable String typeKey) { + this.typeKey = typeKey; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.TypeAliasAccessor#readAliasFrom(java.lang.Object) + */ + public Alias readAliasFrom(Map source) { + return Alias.ofNullable(source.get(typeKey)); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.convert.TypeAliasAccessor#writeTypeTo(java.lang.Object, java.lang.Object) + */ + public void writeTypeTo(Map sink, Object alias) { + + if (typeKey == null) { + return; + } + + sink.put(typeKey, alias); + } + } + +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/convert/GeoConverters.java b/src/main/java/org/springframework/data/elasticsearch/core/convert/GeoConverters.java new file mode 100644 index 000000000..ffbc365eb --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/convert/GeoConverters.java @@ -0,0 +1,100 @@ +/* + * Copyright 2019 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 + * + * http://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.convert; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.elasticsearch.core.geo.GeoPoint; +import org.springframework.data.geo.Point; +import org.springframework.util.NumberUtils; + +/** + * Set of {@link Converter converters} specific to Elasticsearch Geo types. + * + * @author Christoph Strobl + * @since 3.2 + */ +class GeoConverters { + + static Collection getConvertersToRegister() { + + return Arrays.asList(PointToMapConverter.INSTANCE, MapToPointConverter.INSTANCE, GeoPointToMapConverter.INSTANCE, + MapToGeoPointConverter.INSTANCE); + } + + @WritingConverter + enum PointToMapConverter implements Converter { + + INSTANCE; + + @Override + public Map convert(Point source) { + + Map target = new LinkedHashMap<>(); + target.put("lat", source.getX()); + target.put("lon", source.getY()); + return target; + } + } + + @WritingConverter + enum GeoPointToMapConverter implements Converter { + + INSTANCE; + + @Override + public Map convert(GeoPoint source) { + Map target = new LinkedHashMap<>(); + target.put("lat", source.getLat()); + target.put("lon", source.getLon()); + return target; + } + } + + @ReadingConverter + enum MapToPointConverter implements Converter { + + INSTANCE; + + @Override + public Point convert(Map source) { + Double x = NumberUtils.convertNumberToTargetClass((Number) source.get("lat"), Double.class); + Double y = NumberUtils.convertNumberToTargetClass((Number) source.get("lon"), Double.class); + + return new Point(x, y); + } + } + + @ReadingConverter + enum MapToGeoPointConverter implements Converter { + + INSTANCE; + + @Override + public GeoPoint convert(Map source) { + Double x = NumberUtils.convertNumberToTargetClass((Number) source.get("lat"), Double.class); + Double y = NumberUtils.convertNumberToTargetClass((Number) source.get("lon"), Double.class); + + return new GeoPoint(x, y); + } + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchSimpleTypes.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchSimpleTypes.java new file mode 100644 index 000000000..7c184e3eb --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchSimpleTypes.java @@ -0,0 +1,46 @@ +/* + * Copyright 2019 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 + * + * http://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.mapping; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.mapping.model.SimpleTypeHolder; + +/** + * @author Christoph Strobl + * @since 3.2 + */ +public class ElasticsearchSimpleTypes { + + static final Set> AUTOGENERATED_ID_TYPES; + + static { + Set> classes = new HashSet<>(); + classes.add(String.class); + AUTOGENERATED_ID_TYPES = Collections.unmodifiableSet(classes); + + Set> simpleTypes = new HashSet<>(); + ELASTICSEARCH_SIMPLE_TYPES = Collections.unmodifiableSet(simpleTypes); + } + + private static final Set> ELASTICSEARCH_SIMPLE_TYPES; + public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(ELASTICSEARCH_SIMPLE_TYPES, true); + + private ElasticsearchSimpleTypes() {} + +} diff --git a/src/test/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupportUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupportUnitTests.java index e245edc56..33262000b 100644 --- a/src/test/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupportUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/config/ElasticsearchConfigurationSupportUnitTests.java @@ -25,12 +25,15 @@ import org.apache.commons.lang.ClassUtils; import org.elasticsearch.client.RestHighLevelClient; import org.junit.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.client.reactive.ReactiveElasticsearchClient; +import org.springframework.data.elasticsearch.core.ElasticsearchEntityMapper; import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate; -import org.springframework.data.elasticsearch.core.ElasticsearchTemplate; +import org.springframework.data.elasticsearch.core.EntityMapper; import org.springframework.data.elasticsearch.core.ReactiveElasticsearchTemplate; import org.springframework.data.elasticsearch.core.convert.ElasticsearchConverter; import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; @@ -89,6 +92,13 @@ public class ElasticsearchConfigurationSupportUnitTests { assertThat(context.getBean(ReactiveElasticsearchTemplate.class)).isNotNull(); } + @Test // DATAES-530 + public void usesConfiguredEntityMapper() { + + AbstractApplicationContext context = new AnnotationConfigApplicationContext(EntityMapperConfig.class); + assertThat(context.getBean(EntityMapper.class)).isInstanceOf(ElasticsearchEntityMapper.class); + } + @Configuration static class StubConfig extends ElasticsearchConfigurationSupport { @@ -112,6 +122,21 @@ public class ElasticsearchConfigurationSupportUnitTests { } } + @Configuration + static class EntityMapperConfig extends ElasticsearchConfigurationSupport { + + @Bean + @Override + public EntityMapper entityMapper() { + + ElasticsearchEntityMapper entityMapper = new ElasticsearchEntityMapper(elasticsearchMappingContext(), + new DefaultConversionService()); + entityMapper.setConversions(elasticsearchCustomConversions()); + + return entityMapper; + } + } + @Document(indexName = "config-support-tests") static class Entity {} } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/CustomEntityMapper.java b/src/test/java/org/springframework/data/elasticsearch/core/CustomEntityMapper.java index 4cd6390ba..6bd3d784f 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/CustomEntityMapper.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/CustomEntityMapper.java @@ -16,6 +16,7 @@ package org.springframework.data.elasticsearch.core; import java.io.IOException; +import java.util.Map; /** * @author Artur Konczak @@ -38,4 +39,14 @@ public class CustomEntityMapper implements EntityMapper { //mapping text to Object return null; } + + @Override + public Map mapObject(Object source) { + return null; + } + + @Override + public T readObject(Map source, Class targetType) { + return null; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/DefaultResultMapperTests.java b/src/test/java/org/springframework/data/elasticsearch/core/DefaultResultMapperTests.java index 6f7cce3a2..5074bf733 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/DefaultResultMapperTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/DefaultResultMapperTests.java @@ -15,14 +15,20 @@ */ package org.springframework.data.elasticsearch.core; +import static java.util.Arrays.*; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import lombok.Getter; +import lombok.NoArgsConstructor; + import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.LinkedList; import java.util.Map; -import com.fasterxml.jackson.databind.util.ArrayIterator; -import lombok.Getter; -import lombok.NoArgsConstructor; import org.elasticsearch.action.get.GetResponse; import org.elasticsearch.action.get.MultiGetItemResponse; import org.elasticsearch.action.get.MultiGetResponse; @@ -36,8 +42,12 @@ import org.elasticsearch.search.aggregations.bucket.terms.Terms; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.elasticsearch.annotations.Document; @@ -46,11 +56,7 @@ import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMa import org.springframework.data.elasticsearch.entities.Car; import org.springframework.data.elasticsearch.entities.SampleEntity; -import static java.util.Arrays.asList; -import static org.assertj.core.api.Assertions.*; -import static org.hamcrest.Matchers.*; -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import com.fasterxml.jackson.databind.util.ArrayIterator; /** * @author Artur Konczak @@ -58,24 +64,43 @@ import static org.mockito.Mockito.*; * @author Chris White * @author Mark Paluch * @author Ilkang Na + * @author Christoph Strobl */ +@RunWith(Parameterized.class) public class DefaultResultMapperTests { private DefaultResultMapper resultMapper; + private SimpleElasticsearchMappingContext context; + private EntityMapper entityMapper; - @Mock - private SearchResponse response; + @Mock private SearchResponse response; + + public DefaultResultMapperTests(SimpleElasticsearchMappingContext context, EntityMapper entityMapper) { + + this.context = context; + this.entityMapper = entityMapper; + } + + @Parameters + public static Collection data() { + + SimpleElasticsearchMappingContext context = new SimpleElasticsearchMappingContext(); + + return Arrays.asList(new Object[] { context, new DefaultEntityMapper(context) }, + new Object[] { context, new ElasticsearchEntityMapper(context, new DefaultConversionService()) }); + } @Before public void init() { + MockitoAnnotations.initMocks(this); - resultMapper = new DefaultResultMapper(new SimpleElasticsearchMappingContext()); + resultMapper = new DefaultResultMapper(context, entityMapper); } @Test public void shouldMapAggregationsToPage() { - //Given - SearchHit[] hits = {createCarHit("Ford", "Grat"), createCarHit("BMW", "Arrow")}; + // Given + SearchHit[] hits = { createCarHit("Ford", "Grat"), createCarHit("BMW", "Arrow") }; SearchHits searchHits = mock(SearchHits.class); when(searchHits.getTotalHits()).thenReturn(2L); when(searchHits.iterator()).thenReturn(new ArrayIterator(hits)); @@ -84,10 +109,10 @@ public class DefaultResultMapperTests { Aggregations aggregations = new Aggregations(asList(createCarAggregation())); when(response.getAggregations()).thenReturn(aggregations); - //When + // When AggregatedPage page = (AggregatedPage) resultMapper.mapResults(response, Car.class, Pageable.unpaged()); - //Then + // Then page.hasFacets(); assertThat(page.hasAggregations(), is(true)); assertThat(page.getAggregation("Diesel").getName(), is("Diesel")); @@ -95,17 +120,17 @@ public class DefaultResultMapperTests { @Test public void shouldMapSearchRequestToPage() { - //Given - SearchHit[] hits = {createCarHit("Ford", "Grat"), createCarHit("BMW", "Arrow")}; + // Given + SearchHit[] hits = { createCarHit("Ford", "Grat"), createCarHit("BMW", "Arrow") }; SearchHits searchHits = mock(SearchHits.class); when(searchHits.getTotalHits()).thenReturn(2L); when(searchHits.iterator()).thenReturn(new ArrayIterator(hits)); when(response.getHits()).thenReturn(searchHits); - //When + // When Page page = resultMapper.mapResults(response, Car.class, Pageable.unpaged()); - //Then + // Then assertThat(page.hasContent(), is(true)); assertThat(page.getTotalElements(), is(2L)); assertThat(page.getContent().get(0).getName(), is("Ford")); @@ -113,17 +138,17 @@ public class DefaultResultMapperTests { @Test public void shouldMapPartialSearchRequestToObject() { - //Given - SearchHit[] hits = {createCarPartialHit("Ford", "Grat"), createCarPartialHit("BMW", "Arrow")}; + // Given + SearchHit[] hits = { createCarPartialHit("Ford", "Grat"), createCarPartialHit("BMW", "Arrow") }; SearchHits searchHits = mock(SearchHits.class); when(searchHits.getTotalHits()).thenReturn(2L); when(searchHits.iterator()).thenReturn(new ArrayIterator(hits)); when(response.getHits()).thenReturn(searchHits); - //When + // When Page page = resultMapper.mapResults(response, Car.class, Pageable.unpaged()); - //Then + // Then assertThat(page.hasContent(), is(true)); assertThat(page.getTotalElements(), is(2L)); assertThat(page.getContent().get(0).getName(), is("Ford")); @@ -131,14 +156,14 @@ public class DefaultResultMapperTests { @Test public void shouldMapGetRequestToObject() { - //Given + // Given GetResponse response = mock(GetResponse.class); when(response.getSourceAsString()).thenReturn(createJsonCar("Ford", "Grat")); - //When + // When Car result = resultMapper.mapResult(response, Car.class); - //Then + // Then assertThat(result, notNullValue()); assertThat(result.getModel(), is("Grat")); assertThat(result.getName(), is("Ford")); diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapperUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapperUnitTests.java new file mode 100644 index 000000000..b28e851d6 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchEntityMapperUnitTests.java @@ -0,0 +1,677 @@ +/* + * Copyright 2019 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 + * + * http://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.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.ReadOnlyProperty; +import org.springframework.data.annotation.Transient; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.elasticsearch.core.convert.ElasticsearchCustomConversions; +import org.springframework.data.elasticsearch.core.geo.GeoPoint; +import org.springframework.data.elasticsearch.core.mapping.SimpleElasticsearchMappingContext; +import org.springframework.data.elasticsearch.entities.Car; +import org.springframework.data.elasticsearch.entities.GeoEntity; +import org.springframework.data.geo.Point; + +/** + * @author Christoph Strobl + */ +public class ElasticsearchEntityMapperUnitTests { + + static final String JSON_STRING = "{\"_class\":\"org.springframework.data.elasticsearch.entities.Car\",\"name\":\"Grat\",\"model\":\"Ford\"}"; + static final String CAR_MODEL = "Ford"; + static final String CAR_NAME = "Grat"; + ElasticsearchEntityMapper entityMapper; + + Person sarahConnor; + Person kyleReese; + Person t800; + + Inventory gun = new Gun("Glock 19", 33); + Inventory grenade = new Grenade("40 mm"); + Inventory rifle = new Rifle("AR-18 Assault Rifle", 3.17, 40); + Inventory shotGun = new ShotGun("Ithaca 37 Pump Shotgun"); + + Address observatoryRoad; + Place bigBunsCafe; + + Map sarahAsMap; + Map t800AsMap; + Map kyleAsMap; + Map gratiotAveAsMap; + Map locationAsMap; + Map gunAsMap; + Map grenadeAsMap; + Map rifleAsMap; + Map shotGunAsMap; + Map bigBunsCafeAsMap; + + @Before + public void init() { + + SimpleElasticsearchMappingContext mappingContext = new SimpleElasticsearchMappingContext(); + mappingContext.setInitialEntitySet(Collections.singleton(Rifle.class)); + mappingContext.afterPropertiesSet(); + + entityMapper = new ElasticsearchEntityMapper(mappingContext, new GenericConversionService()); + entityMapper.setConversions( + new ElasticsearchCustomConversions(Arrays.asList(new ShotGunToMapConverter(), new MapToShotGunConverter()))); + entityMapper.afterPropertiesSet(); + + sarahConnor = new Person(); + sarahConnor.id = "sarah"; + sarahConnor.name = "Sarah Connor"; + sarahConnor.gender = Gender.MAN; + + kyleReese = new Person(); + kyleReese.id = "kyle"; + kyleReese.gender = Gender.MAN; + kyleReese.name = "Kyle Reese"; + + t800 = new Person(); + t800.id = "t800"; + t800.name = "T-800"; + t800.gender = Gender.MACHINE; + + t800AsMap = new LinkedHashMap<>(); + t800AsMap.put("id", "t800"); + t800AsMap.put("name", "T-800"); + t800AsMap.put("gender", "MACHINE"); + t800AsMap.put("_class", "org.springframework.data.elasticsearch.core.ElasticsearchEntityMapperUnitTests$Person"); + + observatoryRoad = new Address(); + observatoryRoad.city = "Los Angeles"; + observatoryRoad.street = "2800 East Observatory Road"; + observatoryRoad.location = new Point(34.118347D, -118.3026284D); + + bigBunsCafe = new Place(); + bigBunsCafe.name = "Big Buns Cafe"; + bigBunsCafe.city = "Los Angeles"; + bigBunsCafe.street = "15 South Fremont Avenue"; + bigBunsCafe.location = new Point(34.0945637D, -118.1545845D); + + sarahAsMap = new LinkedHashMap<>(); + sarahAsMap.put("id", "sarah"); + sarahAsMap.put("name", "Sarah Connor"); + sarahAsMap.put("gender", "MAN"); + sarahAsMap.put("_class", "org.springframework.data.elasticsearch.core.ElasticsearchEntityMapperUnitTests$Person"); + + kyleAsMap = new LinkedHashMap<>(); + kyleAsMap.put("id", "kyle"); + kyleAsMap.put("gender", "MAN"); + kyleAsMap.put("name", "Kyle Reese"); + + locationAsMap = new LinkedHashMap<>(); + locationAsMap.put("lat", 34.118347D); + locationAsMap.put("lon", -118.3026284D); + + gratiotAveAsMap = new LinkedHashMap<>(); + gratiotAveAsMap.put("city", "Los Angeles"); + gratiotAveAsMap.put("street", "2800 East Observatory Road"); + gratiotAveAsMap.put("location", locationAsMap); + + bigBunsCafeAsMap = new LinkedHashMap<>(); + bigBunsCafeAsMap.put("name", "Big Buns Cafe"); + bigBunsCafeAsMap.put("city", "Los Angeles"); + bigBunsCafeAsMap.put("street", "15 South Fremont Avenue"); + bigBunsCafeAsMap.put("location", new LinkedHashMap<>()); + ((HashMap) bigBunsCafeAsMap.get("location")).put("lat", 34.0945637D); + ((HashMap) bigBunsCafeAsMap.get("location")).put("lon", -118.1545845D); + bigBunsCafeAsMap.put("_class", + "org.springframework.data.elasticsearch.core.ElasticsearchEntityMapperUnitTests$Place"); + + gunAsMap = new LinkedHashMap<>(); + gunAsMap.put("label", "Glock 19"); + gunAsMap.put("shotsPerMagazine", 33); + gunAsMap.put("_class", Gun.class.getName()); + + grenadeAsMap = new LinkedHashMap<>(); + grenadeAsMap.put("label", "40 mm"); + grenadeAsMap.put("_class", Grenade.class.getName()); + + rifleAsMap = new LinkedHashMap<>(); + rifleAsMap.put("label", "AR-18 Assault Rifle"); + rifleAsMap.put("weight", 3.17D); + rifleAsMap.put("maxShotsPerMagazine", 40); + rifleAsMap.put("_class", "rifle"); + + shotGunAsMap = new LinkedHashMap<>(); + shotGunAsMap.put("model", "Ithaca 37 Pump Shotgun"); + shotGunAsMap.put("_class", ShotGun.class.getName()); + } + + @Test // DATAES-530 + public void shouldMapObjectToJsonString() throws IOException { + // Given + + // When + String jsonResult = entityMapper.mapToString(Car.builder().model(CAR_MODEL).name(CAR_NAME).build()); + + // Then + assertThat(jsonResult).isEqualTo(JSON_STRING); + } + + @Test // DATAES-530 + public void shouldMapJsonStringToObject() throws IOException { + // Given + + // When + Car result = entityMapper.mapToObject(JSON_STRING, Car.class); + + // Then + assertThat(result.getName()).isEqualTo(CAR_NAME); + assertThat(result.getModel()).isEqualTo(CAR_MODEL); + } + + @Test // DATAES-530 + public void shouldMapGeoPointElasticsearchNames() throws IOException { + // given + final Point point = new Point(10, 20); + final String pointAsString = point.getX() + "," + point.getY(); + final double[] pointAsArray = { point.getX(), point.getY() }; + final GeoEntity geoEntity = GeoEntity.builder().pointA(point).pointB(GeoPoint.fromPoint(point)) + .pointC(pointAsString).pointD(pointAsArray).build(); + // when + String jsonResult = entityMapper.mapToString(geoEntity); + + // then + assertThat(jsonResult).contains(pointTemplate("pointA", point)); + assertThat(jsonResult).contains(pointTemplate("pointB", point)); + assertThat(jsonResult).contains(String.format(Locale.ENGLISH, "\"%s\":\"%s\"", "pointC", pointAsString)); + assertThat(jsonResult) + .contains(String.format(Locale.ENGLISH, "\"%s\":[%.1f,%.1f]", "pointD", pointAsArray[0], pointAsArray[1])); + } + + @Test // DATAES-530 + public void ignoresReadOnlyProperties() throws IOException { + + // given + Sample sample = new Sample(); + sample.readOnly = "readOnly"; + sample.property = "property"; + sample.javaTransientProperty = "javaTransient"; + sample.annotatedTransientProperty = "transient"; + + // when + String result = entityMapper.mapToString(sample); + + // then + assertThat(result).contains("\"property\""); + assertThat(result).contains("\"javaTransient\""); + + assertThat(result).doesNotContain("readOnly"); + assertThat(result).doesNotContain("annotatedTransientProperty"); + } + + @Test // DATAES-530 + public void writesNestedEntity() { + + Person person = new Person(); + person.birthdate = new Date(); + person.gender = Gender.MAN; + person.address = observatoryRoad; + + LinkedHashMap sink = writeToMap(person); + + assertThat(sink.get("address")).isEqualTo(gratiotAveAsMap); + } + + @Test // DATAES-530 + public void writesConcreteList() throws IOException { + + Person ginger = new Person(); + ginger.id = "ginger"; + ginger.gender = Gender.MAN; + + sarahConnor.coWorkers = Arrays.asList(kyleReese, ginger); + + LinkedHashMap target = writeToMap(sarahConnor); + assertThat((List) target.get("coWorkers")).hasSize(2).contains(kyleAsMap); + } + + @Test // DATAES-530 + public void writesInterfaceList() throws IOException { + + Inventory gun = new Gun("Glock 19", 33); + Inventory grenade = new Grenade("40 mm"); + + sarahConnor.inventoryList = Arrays.asList(gun, grenade); + + LinkedHashMap target = writeToMap(sarahConnor); + assertThat((List) target.get("inventoryList")).containsExactly(gunAsMap, grenadeAsMap); + } + + @Test // DATAES-530 + public void readTypeCorrectly() { + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target).isEqualTo(sarahConnor); + } + + @Test // DATAES-530 + public void readListOfConcreteTypesCorrectly() { + + sarahAsMap.put("coWorkers", Arrays.asList(kyleAsMap)); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.getCoWorkers()).contains(kyleReese); + } + + @Test // DATAES-530 + public void readListOfInterfacesTypesCorrectly() { + + sarahAsMap.put("inventoryList", Arrays.asList(gunAsMap, grenadeAsMap)); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.getInventoryList()).containsExactly(gun, grenade); + } + + @Test // DATAES-530 + public void writeMapOfConcreteType() { + + sarahConnor.shippingAddresses = new LinkedHashMap<>(); + sarahConnor.shippingAddresses.put("home", observatoryRoad); + + LinkedHashMap target = writeToMap(sarahConnor); + assertThat(target.get("shippingAddresses")).isInstanceOf(Map.class); + assertThat(target.get("shippingAddresses")).isEqualTo(Collections.singletonMap("home", gratiotAveAsMap)); + } + + @Test // DATAES-530 + public void writeMapOfInterfaceType() { + + sarahConnor.inventoryMap = new LinkedHashMap<>(); + sarahConnor.inventoryMap.put("glock19", gun); + sarahConnor.inventoryMap.put("40 mm grenade", grenade); + + LinkedHashMap target = writeToMap(sarahConnor); + assertThat(target.get("inventoryMap")).isInstanceOf(Map.class); + assertThat((Map) target.get("inventoryMap")).containsEntry("glock19", gunAsMap).containsEntry("40 mm grenade", + grenadeAsMap); + } + + @Test // DATAES-530 + public void readConcreteMapCorrectly() { + + sarahAsMap.put("shippingAddresses", Collections.singletonMap("home", gratiotAveAsMap)); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.getShippingAddresses()).hasSize(1).containsEntry("home", observatoryRoad); + } + + @Test // DATAES-530 + public void readInterfaceMapCorrectly() { + + sarahAsMap.put("inventoryMap", Collections.singletonMap("glock19", gunAsMap)); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.getInventoryMap()).hasSize(1).containsEntry("glock19", gun); + } + + @Test // DATAES-530 + public void genericWriteList() { + + Skynet skynet = new Skynet(); + skynet.objectList = new ArrayList<>(); + skynet.objectList.add(t800); + skynet.objectList.add(gun); + + LinkedHashMap target = writeToMap(skynet); + + assertThat((List) target.get("objectList")).containsExactly(t800AsMap, gunAsMap); + } + + @Test // DATAES-530 + public void readGenericList() { + + LinkedHashMap source = new LinkedHashMap<>(); + source.put("objectList", Arrays.asList(t800AsMap, gunAsMap)); + + Skynet target = entityMapper.read(Skynet.class, source); + + assertThat(target.getObjectList()).containsExactly(t800, gun); + } + + @Test // DATAES-530 + public void genericWriteListWithList() { + + Skynet skynet = new Skynet(); + skynet.objectList = new ArrayList<>(); + skynet.objectList.add(Arrays.asList(t800, gun)); + + LinkedHashMap target = writeToMap(skynet); + + assertThat((List) target.get("objectList")).containsExactly(Arrays.asList(t800AsMap, gunAsMap)); + } + + @Test // DATAES-530 + public void readGenericListList() { + + LinkedHashMap source = new LinkedHashMap<>(); + source.put("objectList", Arrays.asList(Arrays.asList(t800AsMap, gunAsMap))); + + Skynet target = entityMapper.read(Skynet.class, source); + + assertThat(target.getObjectList()).containsExactly(Arrays.asList(t800, gun)); + } + + @Test // DATAES-530 + public void writeGenericMap() { + + Skynet skynet = new Skynet(); + skynet.objectMap = new LinkedHashMap<>(); + skynet.objectMap.put("gun", gun); + skynet.objectMap.put("grenade", grenade); + + LinkedHashMap target = writeToMap(skynet); + + assertThat((Map) target.get("objectMap")).containsEntry("gun", gunAsMap).containsEntry("grenade", + grenadeAsMap); + } + + @Test // DATAES-530 + public void readGenericMap() { + + LinkedHashMap source = new LinkedHashMap<>(); + source.put("objectMap", Collections.singletonMap("glock19", gunAsMap)); + + Skynet target = entityMapper.read(Skynet.class, source); + + assertThat(target.getObjectMap()).containsEntry("glock19", gun); + } + + @Test // DATAES-530 + public void writeGenericMapMap() { + + Skynet skynet = new Skynet(); + skynet.objectMap = new LinkedHashMap<>(); + skynet.objectMap.put("inventory", Collections.singletonMap("glock19", gun)); + + LinkedHashMap target = writeToMap(skynet); + + assertThat((Map) target.get("objectMap")).containsEntry("inventory", + Collections.singletonMap("glock19", gunAsMap)); + } + + @Test // DATAES-530 + public void readGenericMapMap() { + + LinkedHashMap source = new LinkedHashMap<>(); + source.put("objectMap", Collections.singletonMap("inventory", Collections.singletonMap("glock19", gunAsMap))); + + Skynet target = entityMapper.read(Skynet.class, source); + + assertThat(target.getObjectMap()).containsEntry("inventory", Collections.singletonMap("glock19", gun)); + } + + @Test // DATAES-530 + public void readsNestedEntity() { + + sarahAsMap.put("address", gratiotAveAsMap); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.getAddress()).isEqualTo(observatoryRoad); + } + + @Test // DATAES-530 + public void readsNestedObjectEntity() { + + LinkedHashMap source = new LinkedHashMap<>(); + source.put("object", t800AsMap); + + Skynet target = entityMapper.read(Skynet.class, source); + + assertThat(target.getObject()).isEqualTo(t800); + } + + @Test // DATAES-530 + public void writesAliased() { + assertThat(writeToMap(rifle)).containsEntry("_class", "rifle").doesNotContainValue(Rifle.class.getName()); + } + + @Test // DATAES-530 + public void writesNestedAliased() { + + t800.inventoryList = Collections.singletonList(rifle); + LinkedHashMap target = writeToMap(t800); + + assertThat((List) target.get("inventoryList")).contains(rifleAsMap); + } + + @Test // DATAES-530 + public void readsAliased() { + assertThat(entityMapper.read(Inventory.class, rifleAsMap)).isEqualTo(rifle); + } + + @Test // DATAES-530 + public void readsNestedAliased() { + + t800AsMap.put("inventoryList", Collections.singletonList(rifleAsMap)); + + assertThat(entityMapper.read(Person.class, t800AsMap).getInventoryList()).containsExactly(rifle); + } + + @Test // DATAES-530 + public void appliesCustomConverterForWrite() { + assertThat(writeToMap(shotGun)).isEqualTo(shotGunAsMap); + } + + @Test // DATAES-530 + public void appliesCustomConverterForRead() { + assertThat(entityMapper.read(Inventory.class, shotGunAsMap)).isEqualTo(shotGun); + } + + @Test // DATAES-530 + public void writeSubTypeCorrectly() { + + sarahConnor.address = bigBunsCafe; + + LinkedHashMap target = writeToMap(sarahConnor); + + assertThat(target.get("address")).isEqualTo(bigBunsCafeAsMap); + } + + @Test // DATAES-530 + public void readSubTypeCorrectly() { + + sarahAsMap.put("address", bigBunsCafeAsMap); + + Person target = entityMapper.read(Person.class, sarahAsMap); + + assertThat(target.address).isEqualTo(bigBunsCafe); + } + + private String pointTemplate(String name, Point point) { + return String.format(Locale.ENGLISH, "\"%s\":{\"lat\":%.1f,\"lon\":%.1f}", name, point.getX(), point.getY()); + } + + private LinkedHashMap writeToMap(Object source) { + + LinkedHashMap sink = new LinkedHashMap<>(); + entityMapper.write(source, sink); + return sink; + } + + public static class Sample { + + public @ReadOnlyProperty String readOnly; + public @Transient String annotatedTransientProperty; + public transient String javaTransientProperty; + public String property; + } + + @Data + static class Person { + + @Id String id; + String name; + Date birthdate; + Gender gender; + Address address; + + List coWorkers; + List inventoryList; + Map shippingAddresses; + Map inventoryMap; + } + + enum Gender { + + MAN("1"), MACHINE("0"); + + String theValue; + + Gender(String theValue) { + this.theValue = theValue; + } + + public String getTheValue() { + return theValue; + } + } + + interface Inventory { + + String getLabel(); + } + + @Getter + @RequiredArgsConstructor + @EqualsAndHashCode + static class Gun implements Inventory { + + final String label; + final int shotsPerMagazine; + + @Override + public String getLabel() { + return label; + } + } + + @RequiredArgsConstructor + @EqualsAndHashCode + static class Grenade implements Inventory { + + final String label; + + @Override + public String getLabel() { + return label; + } + } + + @TypeAlias("rifle") + @EqualsAndHashCode + @RequiredArgsConstructor + static class Rifle implements Inventory { + + final String label; + final double weight; + final int maxShotsPerMagazine; + + @Override + public String getLabel() { + return label; + } + } + + @EqualsAndHashCode + @RequiredArgsConstructor + static class ShotGun implements Inventory { + + final String label; + + @Override + public String getLabel() { + return label; + } + } + + @Data + static class Address { + + Point location; + String street; + String city; + } + + @Data + static class Place extends Address { + + String name; + } + + @Data + static class Skynet { + + Object object; + List objectList; + Map objectMap; + } + + @WritingConverter + static class ShotGunToMapConverter implements Converter> { + + @Override + public Map convert(ShotGun source) { + + LinkedHashMap target = new LinkedHashMap<>(); + target.put("model", source.getLabel()); + target.put("_class", ShotGun.class.getName()); + return target; + } + } + + @ReadingConverter + static class MapToShotGunConverter implements Converter, ShotGun> { + + @Override + public ShotGun convert(Map source) { + return new ShotGun(source.get("model").toString()); + } + } +} 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 0bf68164f..7b0ce4655 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateTests.java @@ -817,6 +817,11 @@ public class ElasticsearchTemplateTests { } return new AggregatedPageImpl<>((List) values); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); // then assertThat(page, is(notNullValue())); @@ -956,6 +961,11 @@ public class ElasticsearchTemplateTests { } return new AggregatedPageImpl(Collections.EMPTY_LIST, response.getScrollId()); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }; /* @@ -1356,6 +1366,11 @@ public class ElasticsearchTemplateTests { } return null; } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); assertThat(sampleEntities.getContent().get(0).getHighlightedMessage(), is(highlightedMessage)); @@ -1410,6 +1425,11 @@ public class ElasticsearchTemplateTests { } return null; } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); } @@ -1451,6 +1471,11 @@ public class ElasticsearchTemplateTests { } return null; } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); } @@ -1491,6 +1516,11 @@ public class ElasticsearchTemplateTests { } return null; } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); } @@ -1603,6 +1633,11 @@ public class ElasticsearchTemplateTests { } return new AggregatedPageImpl<>((List) values); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); assertThat(page, is(notNullValue())); assertThat(page.getContent().size(), is(1)); @@ -1812,6 +1847,11 @@ public class ElasticsearchTemplateTests { } return null; } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); assertThat(sampleEntities.getTotalElements(), is(equalTo(2L))); assertThat(sampleEntities.getContent().get(0).get("userId"), is(person1.get("userId"))); @@ -2372,6 +2412,11 @@ public class ElasticsearchTemplateTests { } return new AggregatedPageImpl<>((List) values); } + + @Override + public T mapSearchHit(SearchHit searchHit, Class type) { + return null; + } }); assertThat(page.getTotalElements(), is(2l)); diff --git a/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java b/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java index 2d410dd86..e4dbcae3c 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java @@ -101,7 +101,7 @@ public class MappingBuilderTests { assertThat(xContentBuilderToString(xContentBuilder), is(expected)); } - @Test + @Test // DATAES-530 public void shouldAddStockPriceDocumentToIndex() throws IOException { // Given @@ -112,8 +112,9 @@ public class MappingBuilderTests { String symbol = "AU"; double price = 2.34; String id = "abc"; + elasticsearchTemplate - .index(buildIndex(StockPrice.builder().id(id).symbol(symbol).price(new BigDecimal(price)).build())); + .index(buildIndex(StockPrice.builder().id(id).symbol(symbol).price(BigDecimal.valueOf(price)).build())); elasticsearchTemplate.refresh(StockPrice.class); SearchQuery searchQuery = new NativeSearchQueryBuilder().withQuery(matchAllQuery()).build(); @@ -122,7 +123,7 @@ public class MappingBuilderTests { assertThat(result.size(), is(1)); StockPrice entry = result.get(0); assertThat(entry.getSymbol(), is(symbol)); - assertThat(entry.getPrice(), is(new BigDecimal(price))); + assertThat(entry.getPrice(), is(BigDecimal.valueOf(price))); } @Test diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java index a2747a14c..f789ab661 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ReactiveElasticsearchTemplateTests.java @@ -38,6 +38,7 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.domain.PageRequest; @@ -86,7 +87,9 @@ public class ReactiveElasticsearchTemplateTests { restTemplate.putMapping(SampleEntity.class); restTemplate.refresh(SampleEntity.class); - template = new ReactiveElasticsearchTemplate(TestUtils.reactiveClient()); + template = new ReactiveElasticsearchTemplate(TestUtils.reactiveClient(), restTemplate.getElasticsearchConverter(), + new DefaultResultMapper(new ElasticsearchEntityMapper( + restTemplate.getElasticsearchConverter().getMappingContext(), new DefaultConversionService()))); } @Test // DATAES-504