From 29ecd484c58a29d57c3ac8d89bf65fd0b49a948d Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 12 Aug 2019 14:53:20 +0200 Subject: [PATCH] DATAES-628 - Introduce Document API. We now expose a Document API to encapsulate data interchanged with Elasticsearch (get responses, search hits, arbitrary maps) for a consistent API. --- .../data/elasticsearch/Document.java | 388 ++++++++++ .../data/elasticsearch/MapDocument.java | 285 ++++++++ .../data/elasticsearch/SearchDocument.java | 33 + .../elasticsearch/core/DocumentAdapters.java | 674 ++++++++++++++++++ .../data/elasticsearch/DocumentUnitTests.java | 196 +++++ .../core/DocumentAdaptersUnitTests.java | 163 +++++ 6 files changed, 1739 insertions(+) create mode 100644 src/main/java/org/springframework/data/elasticsearch/Document.java create mode 100644 src/main/java/org/springframework/data/elasticsearch/MapDocument.java create mode 100644 src/main/java/org/springframework/data/elasticsearch/SearchDocument.java create mode 100644 src/main/java/org/springframework/data/elasticsearch/core/DocumentAdapters.java create mode 100644 src/test/java/org/springframework/data/elasticsearch/DocumentUnitTests.java create mode 100644 src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java diff --git a/src/main/java/org/springframework/data/elasticsearch/Document.java b/src/main/java/org/springframework/data/elasticsearch/Document.java new file mode 100644 index 000000000..703e40c25 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/Document.java @@ -0,0 +1,388 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.IntSupplier; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * A representation of a Elasticsearch document as extended {@link Map} with {@link String} keys. All iterators preserve + * original insertion order. + *

+ * Document does not allow {@code null} keys. It allows {@literal null} values. + *

+ * Implementing classes can bei either mutable or immutable. In case a subclass is immutable, its methods may throw + * {@link UnsupportedOperationException} when calling modifying methods. + * + * @author Mark Paluch + * @since 4.0 + */ +public interface Document extends Map { + + /** + * Create a new mutable {@link Document}. + * + * @return a new {@link Document}. + */ + static Document create() { + return new MapDocument(); + } + + /** + * Create a {@link Document} from a {@link Map} containing key-value pairs and sub-documents. + * + * @param map source map containing key-value pairs and sub-documents. + * @return a new {@link Document}. + */ + static Document from(Map map) { + + Assert.notNull(map, "Map must not be null"); + + if (map instanceof LinkedHashMap) { + return new MapDocument(map); + } + + return new MapDocument(new LinkedHashMap<>(map)); + } + + /** + * Parse JSON to {@link Document}. + * + * @param json must not be {@literal null}. + * @return the parsed {@link Document}. + */ + static Document parse(String json) { + try { + return new MapDocument(MapDocument.OBJECT_MAPPER.readerFor(Map.class).readValue(json)); + } catch (IOException e) { + throw new ElasticsearchException("Cannot parse JSON", e); + } + } + + /** + * {@link #put(Object, Object)} the {@code key}/{@code value} tuple and return {@code this} {@link Document}. + * + * @param key key with which the specified value is to be associated. + * @param value value to be associated with the specified key. + * @return {@code this} {@link Document}. + */ + default Document append(String key, Object value) { + put(key, value); + return this; + } + + /** + * Return {@literal true} if this {@link Document} is associated with an identifier. + * + * @return {@literal true} if this {@link Document} is associated with an identifier, {@literal false} otherwise. + */ + default boolean hasId() { + return false; + } + + /** + * Retrieve the identifier associated with this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. It's recommended to check {@link #hasId()} + * prior to calling this method. + * + * @return the identifier associated with this {@link Document}. + * @throws IllegalStateException if the underlying implementation supports Id's but no Id was yet associated with the + * document. + */ + default String getId() { + throw new UnsupportedOperationException(); + } + + /** + * Set the identifier for this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + */ + default void setId(String id) { + throw new UnsupportedOperationException(); + } + + /** + * Return {@literal true} if this {@link Document} is associated with a version. + * + * @return {@literal true} if this {@link Document} is associated with a version, {@literal false} otherwise. + */ + default boolean hasVersion() { + return false; + } + + /** + * Retrieve the version associated with this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. It's recommended to check + * {@link #hasVersion()} prior to calling this method. + * + * @return the version associated with this {@link Document}. + * @throws IllegalStateException if the underlying implementation supports Id's but no Id was yet associated with the + * document. + */ + default long getVersion() { + throw new UnsupportedOperationException(); + } + + /** + * Set the version for this {@link Document}. + *

+ * The default implementation throws {@link UnsupportedOperationException}. + */ + default void setVersion(long version) { + throw new UnsupportedOperationException(); + } + + /** + * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no + * mapping for the key. The value is casted within the method which makes it useful for calling code as it does not + * require casting on the calling side. If the value type is not assignable to {@code type}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @param type the expected return value type. + * @param expected return type. + * @return the value to which the specified key is mapped, or {@literal null} if this document contains no mapping for + * the key. + * @throws ClassCastException if the value of the given key is not of {@code type T}. + */ + @Nullable + default T get(Object key, Class type) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(type, "Type must not be null"); + + return type.cast(get(key)); + } + + /** + * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no + * mapping for the key. If the value type is not a {@link Boolean}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped, or {@literal null} if this document contains no mapping for + * the key. + * @throws ClassCastException if the value of the given key is not a {@link Boolean}. + */ + @Nullable + default Boolean getBoolean(String key) { + return get(key, Boolean.class); + } + + /** + * Returns the value to which the specified {@code key} is mapped or {@code defaultValue} if this document contains no + * mapping for the key. If the value type is not a {@link Boolean}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or {@code defaultValue} if this document contains no mapping + * for the key. + * @throws ClassCastException if the value of the given key is not a {@link Boolean}. + */ + default boolean getBooleanOrDefault(String key, boolean defaultValue) { + return getBooleanOrDefault(key, () -> defaultValue); + } + + /** + * Returns the value to which the specified {@code key} is mapped or the value from {@code defaultValue} if this + * document contains no mapping for the key. If the value type is not a {@link Boolean}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or the value from {@code defaultValue} if this document + * contains no mapping for the key. + * @throws ClassCastException if the value of the given key is not a {@link Boolean}. + * @see BooleanSupplier + */ + default boolean getBooleanOrDefault(String key, BooleanSupplier defaultValue) { + + Boolean value = getBoolean(key); + + return value == null ? defaultValue.getAsBoolean() : value; + } + + /** + * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no + * mapping for the key. If the value type is not a {@link Integer}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped, or {@literal null} if this document contains no mapping for + * the key. + * @throws ClassCastException if the value of the given key is not a {@link Integer}. + */ + @Nullable + default Integer getInt(String key) { + return get(key, Integer.class); + } + + /** + * Returns the value to which the specified {@code key} is mapped or {@code defaultValue} if this document contains no + * mapping for the key. If the value type is not a {@link Integer}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or {@code defaultValue} if this document contains no mapping + * for the key. + * @throws ClassCastException if the value of the given key is not a {@link Integer}. + */ + default int getIntOrDefault(String key, int defaultValue) { + return getIntOrDefault(key, () -> defaultValue); + } + + /** + * Returns the value to which the specified {@code key} is mapped or the value from {@code defaultValue} if this + * document contains no mapping for the key. If the value type is not a {@link Integer}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or the value from {@code defaultValue} if this document + * contains no mapping for the key. + * @throws ClassCastException if the value of the given key is not a {@link Integer}. + * @see IntSupplier + */ + default int getIntOrDefault(String key, IntSupplier defaultValue) { + + Integer value = getInt(key); + + return value == null ? defaultValue.getAsInt() : value; + } + + /** + * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no + * mapping for the key. If the value type is not a {@link Long}, then this method throws {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped, or {@literal null} if this document contains no mapping for + * the key. + * @throws ClassCastException if the value of the given key is not a {@link Long}. + */ + @Nullable + default Long getLong(String key) { + return get(key, Long.class); + } + + /** + * Returns the value to which the specified {@code key} is mapped or {@code defaultValue} if this document contains no + * mapping for the key. If the value type is not a {@link Long}, then this method throws {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or {@code defaultValue} if this document contains no mapping + * for the key. + * @throws ClassCastException if the value of the given key is not a {@link Long}. + */ + default long getLongOrDefault(String key, long defaultValue) { + return getLongOrDefault(key, () -> defaultValue); + } + + /** + * Returns the value to which the specified {@code key} is mapped or the value from {@code defaultValue} if this + * document contains no mapping for the key. If the value type is not a {@link Long}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or the value from {@code defaultValue} if this document + * contains no mapping for the key. + * @throws ClassCastException if the value of the given key is not a {@link Long}. + * @see LongSupplier + */ + default long getLongOrDefault(String key, LongSupplier defaultValue) { + + Long value = getLong(key); + + return value == null ? defaultValue.getAsLong() : value; + } + + /** + * Returns the value to which the specified {@code key} is mapped, or {@literal null} if this document contains no + * mapping for the key. If the value type is not a {@link String}, then this method throws {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped, or {@literal null} if this document contains no mapping for + * the key. + * @throws ClassCastException if the value of the given key is not a {@link String}. + */ + @Nullable + default String getString(String key) { + return get(key, String.class); + } + + /** + * Returns the value to which the specified {@code key} is mapped or {@code defaultValue} if this document contains no + * mapping for the key. If the value type is not a {@link String}, then this method throws {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or {@code defaultValue} if this document contains no mapping + * for the key. + * @throws ClassCastException if the value of the given key is not a {@link String}. + */ + default String getStringOrDefault(String key, String defaultValue) { + return getStringOrDefault(key, () -> defaultValue); + } + + /** + * Returns the value to which the specified {@code key} is mapped or the value from {@code defaultValue} if this + * document contains no mapping for the key. If the value type is not a {@link String}, then this method throws + * {@link ClassCastException}. + * + * @param key the key whose associated value is to be returned + * @return the value to which the specified key is mapped or the value from {@code defaultValue} if this document + * contains no mapping for the key. + * @throws ClassCastException if the value of the given key is not a {@link String}. + * @see Supplier + */ + default String getStringOrDefault(String key, Supplier defaultValue) { + + String value = getString(key); + + return value == null ? defaultValue.get() : value; + } + + /** + * This method allows the application of a function to {@code this} {@link Document}. The function should expect a + * single {@link Document} argument and produce an {@code R} result. + *

+ * Any exception thrown by the function will be propagated to the caller. + * + * @param transformer functional interface to a apply + * @param class of the result + * @return the result of applying the function to this string + * @see java.util.function.Function + */ + default R transform(Function transformer) { + return transformer.apply(this); + } + + /** + * Render this {@link Document} to JSON. Auxiliary values such as Id and version are not considered within the JSON + * representation. + * + * @return a JSON representation of this document. + */ + String toJson(); +} diff --git a/src/main/java/org/springframework/data/elasticsearch/MapDocument.java b/src/main/java/org/springframework/data/elasticsearch/MapDocument.java new file mode 100644 index 000000000..317a1894d --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/MapDocument.java @@ -0,0 +1,285 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import org.springframework.lang.Nullable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * {@link Document} implementation backed by a {@link LinkedHashMap}. + * + * @author Mark Paluch + * @since 4.0 + */ +class MapDocument implements Document { + + static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final LinkedHashMap documentAsMap; + + private @Nullable String id; + private @Nullable Long version; + + MapDocument() { + this(new LinkedHashMap<>()); + } + + MapDocument(Map documentAsMap) { + this.documentAsMap = new LinkedHashMap<>(documentAsMap); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasId() + */ + @Override + public boolean hasId() { + return this.id != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getId() + */ + @Override + public String getId() { + + if (!hasId()) { + throw new IllegalStateException("No Id associated with this Document"); + } + + return this.id; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#setId(java.lang.String) + */ + @Override + public void setId(String id) { + this.id = id; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasVersion() + */ + @Override + public boolean hasVersion() { + return this.version != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getVersion() + */ + @Override + public long getVersion() { + + if (!hasVersion()) { + throw new IllegalStateException("No version associated with this Document"); + } + + return this.version; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#setVersion(long) + */ + @Override + public void setVersion(long version) { + this.version = version; + } + + /* + * (non-Javadoc) + * @see java.util.Map#size() + */ + @Override + public int size() { + return documentAsMap.size(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#isEmpty() + */ + @Override + public boolean isEmpty() { + return documentAsMap.isEmpty(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsKey(java.lang.Object) + */ + @Override + public boolean containsKey(Object key) { + return documentAsMap.containsKey(key); + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsValue(java.lang.Object) + */ + @Override + public boolean containsValue(Object value) { + return documentAsMap.containsValue(value); + } + + /* + * (non-Javadoc) + * @see java.util.Map#get(java.lang.Object) + */ + @Override + public Object get(Object key) { + return documentAsMap.get(key); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#getOrDefault(java.lang.Object, java.lang.Object) + */ + @Override + public Object getOrDefault(Object key, Object defaultValue) { + return documentAsMap.getOrDefault(key, defaultValue); + } + + /* + * (non-Javadoc) + * @see java.util.Map#put(java.lang.Object, java.lang.Object) + */ + @Override + public Object put(String key, Object value) { + return documentAsMap.put(key, value); + } + + /* + * (non-Javadoc) + * @see java.util.Map#remove(java.lang.Object) + */ + @Override + public Object remove(Object key) { + return documentAsMap.remove(key); + } + + /* + * (non-Javadoc) + * @see java.util.Map#putAll(Map) + */ + @Override + public void putAll(Map m) { + documentAsMap.putAll(m); + } + + /* + * (non-Javadoc) + * @see java.util.Map#clear() + */ + @Override + public void clear() { + documentAsMap.clear(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#keySet() + */ + @Override + public Set keySet() { + return documentAsMap.keySet(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#values() + */ + @Override + public Collection values() { + return documentAsMap.values(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#entrySet() + */ + @Override + public Set> entrySet() { + return documentAsMap.entrySet(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object o) { + return documentAsMap.equals(o); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return documentAsMap.hashCode(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#forEach(java.util.function.BiConsumer) + */ + @Override + public void forEach(BiConsumer action) { + documentAsMap.forEach(action); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#toJson() + */ + @Override + public String toJson() { + try { + return OBJECT_MAPPER.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new ElasticsearchException("Cannot render document to JSON", e); + } + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + + String id = hasId() ? getId() : "?"; + String version = hasVersion() ? Long.toString(getVersion()) : "?"; + + return getClass().getSimpleName() + "@" + id + "#" + version + " " + toJson(); + } +} diff --git a/src/main/java/org/springframework/data/elasticsearch/SearchDocument.java b/src/main/java/org/springframework/data/elasticsearch/SearchDocument.java new file mode 100644 index 000000000..3ac4b9eb4 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/SearchDocument.java @@ -0,0 +1,33 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch; + +/** + * Extension to {@link Document} exposing a search {@link #getScore() score}. + * + * @author Mark Paluch + * @since 4.0 + * @see Document + */ +public interface SearchDocument extends Document { + + /** + * Return the search {@code score}. + * + * @return the search {@code score}. + */ + float getScore(); +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/DocumentAdapters.java b/src/main/java/org/springframework/data/elasticsearch/core/DocumentAdapters.java new file mode 100644 index 000000000..dd44763d6 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/core/DocumentAdapters.java @@ -0,0 +1,674 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; + +import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.common.bytes.BytesReference; +import org.elasticsearch.common.document.DocumentField; +import org.elasticsearch.index.get.GetResult; +import org.elasticsearch.search.SearchHit; +import org.springframework.data.elasticsearch.Document; +import org.springframework.data.elasticsearch.ElasticsearchException; +import org.springframework.data.elasticsearch.SearchDocument; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; + +/** + * Utility class to adapt {@link org.elasticsearch.common.document.DocumentField} to + * {@link org.springframework.data.elasticsearch.Document}. + * + * @author Mark Paluch + * @since 4.0 + */ +class DocumentAdapters { + + /** + * Create a {@link Document} from {@link GetResponse}. + *

+ * Returns a {@link Document} using the source if available. + * + * @param source the source {@link GetResponse}. + * @return the adapted {@link Document}. + */ + public static Document from(GetResponse source) { + + Assert.notNull(source, "GetResponse must not be null"); + + if (source.isSourceEmpty()) { + return fromDocumentFields(source, source.getId(), source.getVersion()); + } + + Document document = Document.from(source.getSourceAsMap()); + document.setId(source.getId()); + document.setVersion(source.getVersion()); + + return document; + } + + /** + * Create a {@link Document} from {@link GetResult}. + *

+ * Returns a {@link Document} using the source if available. + * + * @param source the source {@link GetResult}. + * @return the adapted {@link Document}. + */ + public static Document from(GetResult source) { + + Assert.notNull(source, "GetResult must not be null"); + + if (source.isSourceEmpty()) { + return fromDocumentFields(source, source.getId(), source.getVersion()); + } + + Document document = Document.from(source.getSource()); + document.setId(source.getId()); + document.setVersion(source.getVersion()); + + return document; + } + + /** + * Create a {@link SearchDocument} from {@link SearchHit}. + *

+ * Returns a {@link SearchDocument} using the source if available. + * + * @param source the source {@link SearchHit}. + * @return the adapted {@link SearchDocument}. + */ + public static SearchDocument from(SearchHit source) { + + Assert.notNull(source, "SearchHit must not be null"); + + BytesReference sourceRef = source.getSourceRef(); + + if (sourceRef == null || sourceRef.length() == 0) { + return new SearchDocumentAdapter(source.getScore(), + fromDocumentFields(source, source.getId(), source.getVersion())); + } + + Document document = Document.from(source.getSourceAsMap()); + document.setId(source.getId()); + + if (source.getVersion() >= 0) { + document.setVersion(source.getVersion()); + } + + return new SearchDocumentAdapter(source.getScore(), document); + } + + /** + * Create an unmodifiable {@link Document} from {@link Iterable} of {@link DocumentField}s. + * + * @param documentFields the {@link DocumentField}s backing the {@link Document}. + * @return the adapted {@link Document}. + */ + public static Document fromDocumentFields(Iterable documentFields, String id, long version) { + + if (documentFields instanceof Collection) { + return new DocumentFieldAdapter((Collection) documentFields, id, version); + } + + List fields = new ArrayList<>(); + for (DocumentField documentField : documentFields) { + fields.add(documentField); + } + + return new DocumentFieldAdapter(fields, id, version); + } + + // TODO: Performance regarding keys/values/entry-set + static class DocumentFieldAdapter implements Document { + + private final Collection documentFields; + private final String id; + private final long version; + + DocumentFieldAdapter(Collection documentFields, String id, long version) { + this.documentFields = documentFields; + this.id = id; + this.version = version; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasId() + */ + @Override + public boolean hasId() { + return id != null; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getId() + */ + @Override + public String getId() { + return this.id; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasVersion() + */ + @Override + public boolean hasVersion() { + return this.version >= 0; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getVersion() + */ + @Override + public long getVersion() { + + if (!hasVersion()) { + throw new IllegalStateException("No version associated with this Document"); + } + + return this.version; + } + + /* + * (non-Javadoc) + * @see java.util.Map#size() + */ + @Override + public int size() { + return documentFields.size(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#isEmpty() + */ + @Override + public boolean isEmpty() { + return documentFields.isEmpty(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsKey(java.lang.Object) + */ + @Override + public boolean containsKey(Object key) { + + for (DocumentField documentField : documentFields) { + if (documentField.getName().equals(key)) { + return true; + } + } + + return false; + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsValue(java.lang.Object) + */ + @Override + public boolean containsValue(Object value) { + + for (DocumentField documentField : documentFields) { + + Object fieldValue = getValue(documentField); + if (fieldValue != null && fieldValue.equals(value) || value == fieldValue) { + return true; + } + } + + return false; + } + + /* + * (non-Javadoc) + * @see java.util.Map#get(java.lang.Object) + */ + @Override + public Object get(Object key) { + + for (DocumentField documentField : documentFields) { + if (documentField.getName().equals(key)) { + + return getValue(documentField); + } + } + + return null; + } + + /* + * (non-Javadoc) + * @see java.util.Map#put(java.lang.Object, java.lang.Object) + */ + @Override + public Object put(String key, Object value) { + throw new UnsupportedOperationException(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#remove(java.lang.Object) + */ + @Override + public Object remove(Object key) { + throw new UnsupportedOperationException(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#putAll(Map) + */ + @Override + public void putAll(Map m) { + throw new UnsupportedOperationException(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#clear() + */ + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#keySet() + */ + @Override + public Set keySet() { + return documentFields.stream().map(DocumentField::getName).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /* + * (non-Javadoc) + * @see java.util.Map#values() + */ + @Override + public Collection values() { + return documentFields.stream().map(DocumentFieldAdapter::getValue).collect(Collectors.toList()); + } + + /* + * (non-Javadoc) + * @see java.util.Map#entrySet() + */ + @Override + public Set> entrySet() { + return documentFields.stream().collect(Collectors.toMap(DocumentField::getName, DocumentFieldAdapter::getValue)) + .entrySet(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#forEach(java.util.function.BiConsumer) + */ + @Override + public void forEach(BiConsumer action) { + + Objects.requireNonNull(action); + + documentFields.forEach(field -> { + action.accept(field.getName(), getValue(field)); + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#toJson() + */ + @Override + public String toJson() { + + JsonFactory nodeFactory = new JsonFactory(); + try { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + JsonGenerator generator = nodeFactory.createGenerator(stream, JsonEncoding.UTF8); + generator.writeStartObject(); + for (DocumentField value : documentFields) { + if (value.getValues().size() > 1) { + generator.writeArrayFieldStart(value.getName()); + for (Object val : value.getValues()) { + generator.writeObject(val); + } + generator.writeEndArray(); + } else { + generator.writeObjectField(value.getName(), value.getValue()); + } + } + generator.writeEndObject(); + generator.flush(); + return new String(stream.toByteArray(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new ElasticsearchException("Cannot render JSON", e); + } + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return getClass().getSimpleName() + "@" + this.id + "#" + this.version + " " + toJson(); + } + + @Nullable + private static Object getValue(DocumentField documentField) { + + if (documentField.getValues().isEmpty()) { + return null; + } + + if (documentField.getValues().size() == 1) { + return documentField.getValue(); + } + + return documentField.getValues(); + } + } + + /** + * Adapter for a {@link SearchDocument}. + */ + static class SearchDocumentAdapter implements SearchDocument { + + private final float score; + private final Document delegate; + + SearchDocumentAdapter(float score, Document delegate) { + this.score = score; + this.delegate = delegate; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#append(java.lang.String, java.lang.Object) + */ + @Override + public SearchDocument append(String key, Object value) { + delegate.append(key, value); + + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.SearchDocument#getScore() + */ + @Override + public float getScore() { + return score; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasId() + */ + @Override + public boolean hasId() { + return delegate.hasId(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getId() + */ + @Override + public String getId() { + return delegate.getId(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#setId(java.lang.String) + */ + @Override + public void setId(String id) { + delegate.setId(id); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#hasVersion() + */ + @Override + public boolean hasVersion() { + return delegate.hasVersion(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#getVersion() + */ + @Override + public long getVersion() { + return delegate.getVersion(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#setVersion(long) + */ + @Override + public void setVersion(long version) { + delegate.setVersion(version); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#get(java.lang.Object, java.lang.Class) + */ + @Override + @Nullable + public T get(Object key, Class type) { + return delegate.get(key, type); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.elasticsearch.Document#toJson() + */ + @Override + public String toJson() { + return delegate.toJson(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#size() + */ + @Override + public int size() { + return delegate.size(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#isEmpty() + */ + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsKey(java.lang.Object) + */ + @Override + public boolean containsKey(Object key) { + return delegate.containsKey(key); + } + + /* + * (non-Javadoc) + * @see java.util.Map#containsValue(java.lang.Object) + */ + @Override + public boolean containsValue(Object value) { + return delegate.containsValue(value); + } + + /* + * (non-Javadoc) + * @see java.util.Map#get(java.lang.Object) + */ + @Override + public Object get(Object key) { + return delegate.get(key); + } + + /* + * (non-Javadoc) + * @see java.util.Map#put(java.lang.Object, java.lang.Object) + */ + @Override + public Object put(String key, Object value) { + return delegate.put(key, value); + } + + /* + * (non-Javadoc) + * @see java.util.Map#remove(java.lang.Object) + */ + @Override + public Object remove(Object key) { + return delegate.remove(key); + } + + /* + * (non-Javadoc) + * @see java.util.Map#putAll(Map) + */ + @Override + public void putAll(Map m) { + delegate.putAll(m); + } + + /* + * (non-Javadoc) + * @see java.util.Map#clear() + */ + @Override + public void clear() { + delegate.clear(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#keySet() + */ + @Override + public Set keySet() { + return delegate.keySet(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#values() + */ + @Override + public Collection values() { + return delegate.values(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#entrySet() + */ + @Override + public Set> entrySet() { + return delegate.entrySet(); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (!(o instanceof SearchDocumentAdapter)) + return false; + SearchDocumentAdapter that = (SearchDocumentAdapter) o; + return Float.compare(that.score, score) == 0 && delegate.equals(that.delegate); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ + @Override + public int hashCode() { + return delegate.hashCode(); + } + + /* + * (non-Javadoc) + * @see java.util.Map#forEach(java.util.function.BiConsumer) + */ + @Override + public void forEach(BiConsumer action) { + delegate.forEach(action); + } + + /* + * (non-Javadoc) + * @see java.util.Map#remove(java.lang.Object, java.lang.Object) + */ + @Override + public boolean remove(Object key, Object value) { + return delegate.remove(key, value); + } + + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + + String id = hasId() ? getId() : "?"; + String version = hasVersion() ? Long.toString(getVersion()) : "?"; + + return getClass().getSimpleName() + "@" + id + "#" + version + " " + toJson(); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/DocumentUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/DocumentUnitTests.java new file mode 100644 index 000000000..eb2717a32 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/DocumentUnitTests.java @@ -0,0 +1,196 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +/** + * Unit tests for {@link Document}. + * + * @author Mark Paluch + */ +public class DocumentUnitTests { + + @Test // DATAES-628 + public void shouldCreateNewDocument() { + + Document document = Document.create().append("key", "value"); + + assertThat(document).containsEntry("key", "value"); + } + + @Test // DATAES-628 + public void shouldCreateNewDocumentFromMap() { + + Document document = Document.from(Collections.singletonMap("key", "value")); + + assertThat(document).containsEntry("key", "value"); + } + + @Test // DATAES-628 + public void shouldRenderDocumentToJson() { + + Document document = Document.from(Collections.singletonMap("key", "value")); + + assertThat(document.toJson()).isEqualTo("{\"key\":\"value\"}"); + } + + @Test // DATAES-628 + public void shouldParseDocumentFromJson() { + + Document document = Document.parse("{\"key\":\"value\"}"); + + assertThat(document).containsEntry("key", "value"); + } + + @Test // DATAES-628 + public void shouldReturnContainsKey() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.containsKey("string")).isTrue(); + assertThat(document.containsKey("not-set")).isFalse(); + } + + @Test // DATAES-628 + public void shouldReturnContainsValue() { + + Document document = Document.create().append("string", "value").append("bool", Arrays.asList(true, true, false)) + .append("int", 43).append("long", 42L); + + assertThat(document.containsValue("value")).isTrue(); + assertThat(document.containsValue(43)).isTrue(); + assertThat(document.containsValue(44)).isFalse(); + assertThat(document.containsValue(Arrays.asList(true, true, false))).isTrue(); + } + + @Test // DATAES-628 + public void shouldReturnTypedValue() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.get("string")).isEqualTo("value"); + assertThat(document.getString("string")).isEqualTo("value"); + + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.get("long", String.class)); + } + + @Test // DATAES-628 + public void shouldReturnTypedValueString() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.getString("string")).isEqualTo("value"); + assertThat(document.getStringOrDefault("not-set", "default")).isEqualTo("default"); + assertThat(document.getStringOrDefault("not-set", () -> "default")).isEqualTo("default"); + + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.getString("long")); + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.get("long", String.class)); + } + + @Test // DATAES-628 + public void shouldReturnTypedValueBoolean() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.getBoolean("bool")).isTrue(); + assertThat(document.getBooleanOrDefault("not-set", true)).isTrue(); + assertThat(document.getBooleanOrDefault("not-set", () -> true)).isTrue(); + + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.getString("bool")); + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.get("bool", String.class)); + } + + @Test // DATAES-628 + public void shouldReturnTypedValueInt() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.getInt("int")).isEqualTo(43); + assertThat(document.getIntOrDefault("not-set", 44)).isEqualTo(44); + assertThat(document.getIntOrDefault("not-set", () -> 44)).isEqualTo(44); + + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.getString("int")); + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.get("int", String.class)); + } + + @Test // DATAES-628 + public void shouldReturnTypedValueLong() { + + Document document = Document.create().append("string", "value").append("bool", true).append("int", 43) + .append("long", 42L); + + assertThat(document.getLong("long")).isEqualTo(42); + assertThat(document.getLongOrDefault("not-set", 44)).isEqualTo(44); + assertThat(document.getLongOrDefault("not-set", () -> 44)).isEqualTo(44); + + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.getString("long")); + assertThatExceptionOfType(ClassCastException.class).isThrownBy(() -> document.get("long", String.class)); + } + + @Test // DATAES-628 + public void shouldApplyTransformer() { + + Document document = Document.create(); + document.setId("value"); + + assertThat(document.transform(Document::getId)).isEqualTo("value"); + } + + @Test // DATAES-628 + public void shouldSetId() { + + Document document = Document.create(); + + assertThat(document.hasId()).isFalse(); + assertThatIllegalStateException().isThrownBy(document::getId); + + document.setId("foo"); + assertThat(document.getId()).isEqualTo("foo"); + } + + @Test // DATAES-628 + public void shouldSetVersion() { + + Document document = Document.create(); + + assertThat(document.hasVersion()).isFalse(); + assertThatIllegalStateException().isThrownBy(document::getVersion); + + document.setVersion(14); + assertThat(document.getVersion()).isEqualTo(14); + } + + @Test // DATAES-628 + public void shouldRenderToString() { + + Document document = Document.from(Collections.singletonMap("key", "value")); + document.setId("123"); + document.setVersion(42); + + assertThat(document).hasToString("MapDocument@123#42 {\"key\":\"value\"}"); + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java new file mode 100644 index 000000000..77804f211 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/DocumentAdaptersUnitTests.java @@ -0,0 +1,163 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.elasticsearch.action.get.GetResponse; +import org.elasticsearch.common.bytes.BytesArray; +import org.elasticsearch.common.document.DocumentField; +import org.elasticsearch.common.text.Text; +import org.elasticsearch.index.get.GetResult; +import org.elasticsearch.search.SearchHit; +import org.junit.Test; + +import org.springframework.data.elasticsearch.Document; +import org.springframework.data.elasticsearch.SearchDocument; + +/** + * Unit tests for {@link DocumentAdapters}. + * + * @author Mark Paluch + */ +public class DocumentAdaptersUnitTests { + + @Test // DATAES-628 + public void shouldAdaptGetResponse() { + + Map fields = Collections.singletonMap("field", + new DocumentField("field", Arrays.asList("value"))); + + GetResult getResult = new GetResult("index", "type", "my-id", 1, 0, 42, true, null, fields); + GetResponse response = new GetResponse(getResult); + + Document document = DocumentAdapters.from(response); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isTrue(); + assertThat(document.getVersion()).isEqualTo(42); + assertThat(document.get("field")).isEqualTo("value"); + } + + @Test // DATAES-628 + public void shouldAdaptGetResponseSource() { + + BytesArray source = new BytesArray("{\"field\":\"value\"}"); + + GetResult getResult = new GetResult("index", "type", "my-id", 1, 0, 42, true, source, Collections.emptyMap()); + GetResponse response = new GetResponse(getResult); + + Document document = DocumentAdapters.from(response); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isTrue(); + assertThat(document.getVersion()).isEqualTo(42); + assertThat(document.get("field")).isEqualTo("value"); + } + + @Test // DATAES-628 + public void shouldAdaptSearchResponse() { + + Map fields = Collections.singletonMap("field", + new DocumentField("field", Arrays.asList("value"))); + + SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), fields); + searchHit.score(42); + + SearchDocument document = DocumentAdapters.from(searchHit); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isFalse(); + assertThat(document.getScore()).isBetween(42f, 42f); + assertThat(document.get("field")).isEqualTo("value"); + } + + @Test // DATAES-628 + public void searchResponseShouldReturnContainsKey() { + + Map fields = new LinkedHashMap<>(); + + fields.put("string", new DocumentField("string", Arrays.asList("value"))); + fields.put("bool", new DocumentField("bool", Arrays.asList(true, true, false))); + + SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), fields); + + SearchDocument document = DocumentAdapters.from(searchHit); + + assertThat(document.containsKey("string")).isTrue(); + assertThat(document.containsKey("not-set")).isFalse(); + } + + @Test // DATAES-628 + public void searchResponseShouldReturnContainsValue() { + + Map fields = new LinkedHashMap<>(); + + fields.put("string", new DocumentField("string", Arrays.asList("value"))); + fields.put("bool", new DocumentField("bool", Arrays.asList(true, true, false))); + fields.put("null", new DocumentField("null", Collections.emptyList())); + + SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), fields); + + SearchDocument document = DocumentAdapters.from(searchHit); + + assertThat(document.containsValue("value")).isTrue(); + assertThat(document.containsValue(Arrays.asList(true, true, false))).isTrue(); + assertThat(document.containsValue(null)).isTrue(); + } + + @Test // DATAES-628 + public void shouldRenderToJson() { + + Map fields = new LinkedHashMap<>(); + + fields.put("string", new DocumentField("string", Arrays.asList("value"))); + fields.put("bool", new DocumentField("bool", Arrays.asList(true, true, false))); + + SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), fields); + + SearchDocument document = DocumentAdapters.from(searchHit); + + assertThat(document.toJson()).isEqualTo("{\"string\":\"value\",\"bool\":[true,true,false]}"); + } + + @Test // DATAES-628 + public void shouldAdaptSearchResponseSource() { + + BytesArray source = new BytesArray("{\"field\":\"value\"}"); + + SearchHit searchHit = new SearchHit(123, "my-id", new Text("type"), Collections.emptyMap()); + searchHit.sourceRef(source).score(42); + searchHit.version(22); + + SearchDocument document = DocumentAdapters.from(searchHit); + + assertThat(document.hasId()).isTrue(); + assertThat(document.getId()).isEqualTo("my-id"); + assertThat(document.hasVersion()).isTrue(); + assertThat(document.getVersion()).isEqualTo(22); + assertThat(document.getScore()).isBetween(42f, 42f); + assertThat(document.get("field")).isEqualTo("value"); + } +}