Repository: searchbox-io/Jest
Pull request #342 (2016-04-25 14:28:16)

add version metadata annotation & field support
We had the same need in our app for optimistic concurrency control as described in https://github.com/searchbox-io/Jest/issues/286.

This pull request implements support for https://www.elastic.co/guide/en/elasticsearch/guide/current/optimistic-concurrency-control.html.

Updates to the documentation are not yet part of the pr. Let us know if we should supply these.


Patch:
diff --git a/jest-common/src/main/java/io/searchbox/annotations/JestVersion.java b/jest-common/src/main/java/io/searchbox/annotations/JestVersion.java
new file mode 100644
index 00000000..86e3cc5a
--- /dev/null
+++ b/jest-common/src/main/java/io/searchbox/annotations/JestVersion.java
@@ -0,0 +1,16 @@
+package io.searchbox.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * @author Sebastian Hilbig
+ */
+
+
+@Target(value = ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface JestVersion {
+}
diff --git a/jest-common/src/main/java/io/searchbox/client/JestResult.java b/jest-common/src/main/java/io/searchbox/client/JestResult.java
index 002fb71e..6e251548 100644
--- a/jest-common/src/main/java/io/searchbox/client/JestResult.java
+++ b/jest-common/src/main/java/io/searchbox/client/JestResult.java
@@ -2,6 +2,7 @@ package io.searchbox.client;
 
 import com.google.gson.*;
 import io.searchbox.annotations.JestId;
+import io.searchbox.annotations.JestVersion;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -19,6 +20,7 @@ import java.util.Map;
 public class JestResult {
 
     public static final String ES_METADATA_ID = "es_metadata_id";
+    public static final String ES_METADATA_VERSION = "es_metadata_version";
     private static final Logger log = LoggerFactory.getLogger(JestResult.class);
 
     protected JsonObject jsonObject;
@@ -174,7 +176,7 @@ public class JestResult {
         return extractSource(true);
     }
 
-    protected List<JsonElement> extractSource(boolean addEsMetadataIdField) {
+    protected List<JsonElement> extractSource(boolean addEsMetadataFields) {
         List<JsonElement> sourceList = new ArrayList<JsonElement>();
 
         if (jsonObject != null) {
@@ -201,8 +203,9 @@ public class JestResult {
                                 JsonObject source = currentObj.getAsJsonObject(sourceKey);
                                 if (source != null) {
                                     JsonObject copy = GsonUtils.deepCopy(source);
-                                    if (addEsMetadataIdField) {
+                                    if (addEsMetadataFields) {
                                         copy.add(ES_METADATA_ID, currentObj.get("_id"));
+                                        copy.add(ES_METADATA_VERSION, currentObj.get("_version"));
                                     }
                                     sourceList.add(copy);
                                 }
@@ -211,9 +214,15 @@ public class JestResult {
                     }
                 } else if (obj != null) {
                     JsonElement copy = GsonUtils.deepCopy(obj);
-                    JsonElement objId = jsonObject.get("_id");
-                    if ((objId != null) && copy.isJsonObject() && addEsMetadataIdField) {
-                        copy.getAsJsonObject().add(ES_METADATA_ID, objId);
+                    if (addEsMetadataFields && copy.isJsonObject()) {
+                        JsonElement objId = jsonObject.get("_id");
+                        if (objId != null) {
+                            copy.getAsJsonObject().add(ES_METADATA_ID, objId);
+                        }
+                        JsonElement objVersion = jsonObject.get("_version");
+                        if (objVersion != null) {
+                            copy.getAsJsonObject().add(ES_METADATA_VERSION, objVersion);
+                        }
                     }
                     sourceList.add(copy);
                 }
@@ -232,20 +241,19 @@ public class JestResult {
 
             // Check if JestId is visible
             Field[] fields = type.getDeclaredFields();
+            boolean idFieldFound = false;
+            boolean versionFieldFound = false;
             for (Field field : fields) {
                 if (field.isAnnotationPresent(JestId.class)) {
-                    try {
-                        field.setAccessible(true);
-                        Object value = field.get(obj);
-                        if (value == null) {
-                            Class<?> fieldType = field.getType();
-                            JsonElement id = ((JsonObject) source).get(ES_METADATA_ID);
-                            field.set(obj, getAs(id, fieldType));
-                        }
-                    } catch (IllegalAccessException e) {
-                        log.error("Unhandled exception occurred while getting annotated id from source");
+                    idFieldFound = setAnnotatedField(obj, source, field, ES_METADATA_ID);
+                    if (versionFieldFound) {
+                        break;
+                    }
+                } else if (field.isAnnotationPresent(JestVersion.class)) {
+                    versionFieldFound = setAnnotatedField(obj, source, field, ES_METADATA_VERSION);
+                    if (idFieldFound) {
+                        break;
                     }
-                    break;
                 }
             }
 
@@ -255,6 +263,22 @@ public class JestResult {
         return obj;
     }
 
+    private <T> boolean setAnnotatedField(T obj, JsonElement source, Field field, String fieldname) {
+        try {
+            field.setAccessible(true);
+            Object value = field.get(obj);
+            if (value == null) {
+                Class<?> fieldType = field.getType();
+                JsonElement element = ((JsonObject) source).get(fieldname);
+                field.set(obj, getAs(element, fieldType));
+                return true;
+            }
+        } catch (IllegalAccessException e) {
+            log.error("Unhandled exception occurred while setting annotated field from source");
+        }
+        return false;
+    }
+
     @SuppressWarnings("unchecked")
     private <T> T getAs(JsonElement id, Class<T> fieldType) throws IllegalAccessException {
         if (id.isJsonNull()) {
diff --git a/jest-common/src/main/java/io/searchbox/core/DocumentResult.java b/jest-common/src/main/java/io/searchbox/core/DocumentResult.java
index dee5578c..3ff14b8f 100644
--- a/jest-common/src/main/java/io/searchbox/core/DocumentResult.java
+++ b/jest-common/src/main/java/io/searchbox/core/DocumentResult.java
@@ -24,6 +24,9 @@ public class DocumentResult extends JestResult {
         return getAsString(jsonObject.get("_id"));
     }
 
+    public Long getVersion() {
+        return getAsLong(jsonObject.get("_version"));
+    }
 
     private String getAsString(JsonElement jsonElement) {
         if(jsonElement == null) {
@@ -32,4 +35,13 @@ public class DocumentResult extends JestResult {
             return jsonElement.getAsString();
         }
     }
+
+    private Long getAsLong(JsonElement jsonElement) {
+        if(jsonElement == null) {
+            return null;
+        } else {
+            return jsonElement.getAsLong();
+        }
+    }
+
 }
diff --git a/jest-common/src/main/java/io/searchbox/core/Search.java b/jest-common/src/main/java/io/searchbox/core/Search.java
index dfea8834..f7553bbf 100644
--- a/jest-common/src/main/java/io/searchbox/core/Search.java
+++ b/jest-common/src/main/java/io/searchbox/core/Search.java
@@ -120,10 +120,6 @@ public class Search extends AbstractAction<SearchResult> {
     public static class Builder extends AbstractMultiTypeActionBuilder<Search, Builder> {
         protected String query;
         protected List<Sort> sortList = new LinkedList<Sort>();
-        
-        protected Builder() {
-        	
-        }
 
         public Builder(String query) {
             this.query = query;
@@ -148,12 +144,19 @@ public class Search extends AbstractAction<SearchResult> {
             return new Search(this);
         }
     }
-    
-    public static class TemplateBuilder extends Builder {    	
+
+    public static class VersionBuilder extends Builder {
+        public VersionBuilder(String query) {
+            super(query);
+            this.setParameter(Parameters.VERSION, "true");
+        }
+    }
+
+    public static class TemplateBuilder extends Builder {
     	public TemplateBuilder(String templatedQuery) {    		
-            super.query = templatedQuery;
+            super(templatedQuery);
         }
-    	
+
     	@Override
         public Search build() {
             return new Search(this);
diff --git a/jest-common/src/main/java/io/searchbox/core/SearchResult.java b/jest-common/src/main/java/io/searchbox/core/SearchResult.java
index 70cb117a..7e19322e 100644
--- a/jest-common/src/main/java/io/searchbox/core/SearchResult.java
+++ b/jest-common/src/main/java/io/searchbox/core/SearchResult.java
@@ -98,6 +98,7 @@ public class SearchResult extends JestResult {
 
             if (source != null) {
                 JsonElement id = hitObject.get("_id");
+                JsonElement version = hitObject.get("_version");
                 String index = hitObject.get("_index").getAsString();
                 String type = hitObject.get("_type").getAsString();
 
@@ -113,6 +114,9 @@ public class SearchResult extends JestResult {
                 if (id != null) {
                     source = GsonUtils.deepCopy(source);
                     source.add(ES_METADATA_ID, id);
+                    if (version != null) {
+                        source.add(ES_METADATA_VERSION, version);
+                    }
                 }
                 hit = new Hit<T, K>(
                         sourceType,
diff --git a/jest-common/src/main/java/io/searchbox/core/Update.java b/jest-common/src/main/java/io/searchbox/core/Update.java
index 583b8bcd..b973a6ff 100644
--- a/jest-common/src/main/java/io/searchbox/core/Update.java
+++ b/jest-common/src/main/java/io/searchbox/core/Update.java
@@ -2,6 +2,7 @@ package io.searchbox.core;
 
 import io.searchbox.action.BulkableAction;
 import io.searchbox.action.SingleResultAbstractDocumentTargetedAction;
+import io.searchbox.params.Parameters;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
 
@@ -74,4 +75,10 @@ public class Update extends SingleResultAbstractDocumentTargetedAction implement
         }
     }
 
+    public static class VersionBuilder extends Builder {
+        public VersionBuilder(Object payload, Long version) {
+            super(payload);
+            this.setParameter(Parameters.VERSION, version);
+        }
+    }
 }


Test patch:
diff --git a/jest-common/src/test/java/io/searchbox/client/JestResultTest.java b/jest-common/src/test/java/io/searchbox/client/JestResultTest.java
index 4ddf2c7b..b951d764 100644
--- a/jest-common/src/test/java/io/searchbox/client/JestResultTest.java
+++ b/jest-common/src/test/java/io/searchbox/client/JestResultTest.java
@@ -375,6 +375,7 @@ public class JestResultTest {
                 "                \"_index\" : \"twitter\",\n" +
                 "                \"_type\" : \"tweet\",\n" +
                 "                \"_id\" : \"1\", \n" +
+                "                \"_version\" : \"2\", \n" +
                 "                \"_source\" : {\n" +
                 "                    \"user\" : \"kimchy\",\n" +
                 "                    \"postDate\" : \"2009-11-15T14:12:12\",\n" +
@@ -391,7 +392,7 @@ public class JestResultTest {
         expectedResultMap.put("postDate", "2009-11-15T14:12:12");
         expectedResultMap.put("message", "trying out Elastic Search");
         JsonObject actualResultMap = result.extractSource().get(0).getAsJsonObject();
-        assertEquals(expectedResultMap.size() + 1, actualResultMap.entrySet().size());
+        assertEquals(expectedResultMap.size() + 2, actualResultMap.entrySet().size());
         for (String key : expectedResultMap.keySet()) {
             assertEquals(expectedResultMap.get(key).toString(), actualResultMap.get(key).getAsString());
         }
diff --git a/jest-common/src/test/java/io/searchbox/core/DocumentResultTest.java b/jest-common/src/test/java/io/searchbox/core/DocumentResultTest.java
index 37c21c0f..680ebf77 100644
--- a/jest-common/src/test/java/io/searchbox/core/DocumentResultTest.java
+++ b/jest-common/src/test/java/io/searchbox/core/DocumentResultTest.java
@@ -49,6 +49,11 @@ public class DocumentResultTest {
         assertEquals("testId", validResult.getId());
     }
 
+    @Test
+    public void shouldFetchVersionFromValidResponse() {
+        assertEquals(Long.valueOf(2), validResult.getVersion());
+    }
+
     @Test
     public void shouldReturnNullIndexOnInvalidResponse() {
         assertNull(invalidResult.getIndex());
diff --git a/jest-common/src/test/java/io/searchbox/core/SearchResultTest.java b/jest-common/src/test/java/io/searchbox/core/SearchResultTest.java
index 1606f6b4..f4016879 100644
--- a/jest-common/src/test/java/io/searchbox/core/SearchResultTest.java
+++ b/jest-common/src/test/java/io/searchbox/core/SearchResultTest.java
@@ -2,6 +2,8 @@ package io.searchbox.core;
 
 import com.google.gson.Gson;
 import com.google.gson.JsonParser;
+import io.searchbox.annotations.JestId;
+import io.searchbox.annotations.JestVersion;
 import org.junit.Test;
 
 import java.util.List;
@@ -249,4 +251,68 @@ public class SearchResultTest {
         assertNotNull(hit.score);
     }
 
+    @Test
+    public void testGetVersion() {
+        Long someVersion = Integer.MAX_VALUE + 10L;
+
+        String jsonWithVersion = "{\n" +
+                "    \"_shards\":{\n" +
+                "        \"total\" : 5,\n" +
+                "        \"successful\" : 5,\n" +
+                "        \"failed\" : 0\n" +
+                "    },\n" +
+                "    \"hits\":{\n" +
+                "        \"total\" : 1,\n" +
+                "        \"hits\" : [\n" +
+                "            {\n" +
+                "                \"_index\" : \"twitter\",\n" +
+                "                \"_type\" : \"tweet\",\n" +
+                "                \"_score\" : \"1.02332\",\n" +
+                "                \"_id\" : \"1\",\n" +
+                "                \"_version\" : \"" + someVersion + "\",\n" +
+                "                \"_source\" : {\n" +
+                "                    \"user\" : \"kimchy\",\n" +
+                "                    \"postDate\" : \"2009-11-15T14:12:12\",\n" +
+                "                    \"message\" : \"trying out Elasticsearch\"\n" +
+                "                },\n" +
+                "                \"sort\" : [\n" +
+                "                     1234.5678\n" +
+                "                ]\n" +
+                "            }\n" +
+                "        ]\n" +
+                "    }\n" +
+                "}";
+
+        SearchResult searchResult = new SearchResult(new Gson());
+        searchResult.setSucceeded(true);
+        searchResult.setJsonString(jsonWithVersion);
+        searchResult.setJsonObject(new JsonParser().parse(jsonWithVersion).getAsJsonObject());
+        searchResult.setPathToResult("hits/hits/_source");
+
+        SearchResult.Hit<TestObject, Void> hit = searchResult.getFirstHit(TestObject.class);
+        assertNotNull(hit.source);
+        assertNull(hit.explanation);
+        assertNotNull(hit.sort);
+        assertNotNull(hit.score);
+        assertEquals("Incorrect version", someVersion, hit.source.getVersion());
+    }
+
+    class TestObject {
+        @JestId
+        private String id;
+
+        @JestVersion
+        private Long version;
+
+        public TestObject() {}
+
+        public Long getVersion() {
+            return version;
+        }
+
+        public String getId() {
+            return id;
+        }
+    }
+
 }
diff --git a/jest-common/src/test/java/io/searchbox/core/SearchTest.java b/jest-common/src/test/java/io/searchbox/core/SearchTest.java
index 3f8d4626..ea80cd5b 100644
--- a/jest-common/src/test/java/io/searchbox/core/SearchTest.java
+++ b/jest-common/src/test/java/io/searchbox/core/SearchTest.java
@@ -80,6 +80,12 @@ public class SearchTest {
         assertEquals("twitter/tweet/_search/template", search.getURI());
     }
 
+    @Test
+    public void getURIWithVersion() {
+        Action search = new Search.VersionBuilder("").addIndex("twitter").addType("tweet").build();
+        assertTrue("Version Parameter missing", search.getURI().contains("version=true"));
+    }
+
     @Test
     public void sortTest() {
         String query = "{\"query\" : { \"term\" : { \"name\" : \"Milano\" } }}";
diff --git a/jest/src/test/java/io/searchbox/core/UpdateIntegrationTest.java b/jest/src/test/java/io/searchbox/core/UpdateIntegrationTest.java
index b6a10d5d..3680b0ad 100644
--- a/jest/src/test/java/io/searchbox/core/UpdateIntegrationTest.java
+++ b/jest/src/test/java/io/searchbox/core/UpdateIntegrationTest.java
@@ -2,9 +2,12 @@ package io.searchbox.core;
 
 
 import io.searchbox.common.AbstractIntegrationTest;
+import org.elasticsearch.action.ActionFuture;
 import org.elasticsearch.action.get.GetResponse;
 import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.index.IndexResponse;
 import org.elasticsearch.index.get.GetResult;
+import org.elasticsearch.rest.RestStatus;
 import org.elasticsearch.test.ESIntegTestCase;
 import org.junit.Test;
 
@@ -74,4 +77,82 @@ public class UpdateIntegrationTest extends AbstractIntegrationTest {
         assertFalse(getResult.isSourceEmpty());
         assertEquals("blue", getResult.getSource().get("tags"));
     }
+
+    @Test
+    public void partialDocUpdateWithValidVersion() throws Exception {
+        String id = "2";
+        String partialDoc = "{\n" +
+                "    \"doc\" : {\n" +
+                "        \"tags\" : \"blue\"\n" +
+                "    }\n" +
+                "}";
+
+        IndexResponse response = client().index(
+                new IndexRequest(INDEX, TYPE, id)
+                        .source("{\"user\":\"kimchy\", \"tags\":\"That is test\"}")
+                        .refresh(true)
+        ).actionGet();
+        long version = response.getVersion();
+
+        DocumentResult result = client.execute(new Update.VersionBuilder(partialDoc, version)
+                .index(INDEX)
+                .type(TYPE)
+                .id(id)
+                .build());
+        assertTrue(result.getErrorMessage(), result.isSucceeded());
+        assertEquals(INDEX, result.getIndex());
+        assertEquals(TYPE, result.getType());
+        assertEquals(id, result.getId());
+        assertEquals(version + 1, result.getVersion().longValue());
+
+        GetResponse getResult = get(INDEX, TYPE, id);
+        assertTrue(getResult.isExists());
+        assertFalse(getResult.isSourceEmpty());
+        assertEquals("blue", getResult.getSource().get("tags"));
+    }
+
+    @Test
+    public void partialDocUpdateWithInvalidVersion() throws Exception {
+        String id = "2";
+        String partialDoc = "{\n" +
+                "    \"doc\" : {\n" +
+                "        \"tags\" : \"blue\"\n" +
+                "    }\n" +
+                "}";
+        String partialDoc2 = "{\n" +
+                "    \"doc\" : {\n" +
+                "        \"tags\" : \"red\"\n" +
+                "    }\n" +
+                "}";
+
+        IndexResponse response = client().index(
+                new IndexRequest(INDEX, TYPE, id)
+                        .source("{\"user\":\"kimchy\", \"tags\":\"That is test\"}")
+                        .refresh(true)
+        ).actionGet();
+        long version = response.getVersion();
+
+        DocumentResult result = client.execute(new Update.VersionBuilder(partialDoc, version)
+                .index(INDEX)
+                .type(TYPE)
+                .id(id)
+                .build());
+        assertTrue(result.getErrorMessage(), result.isSucceeded());
+
+        // and again ...
+        result = client.execute(new Update.VersionBuilder(partialDoc2, version)
+                .index(INDEX)
+                .type(TYPE)
+                .id(id)
+                .build());
+
+        assertFalse(result.getErrorMessage(), result.isSucceeded());
+        assertEquals("Invalid response code", RestStatus.CONFLICT.getStatus(), result.getResponseCode());
+
+        GetResponse getResult = get(INDEX, TYPE, id);
+        assertTrue(getResult.isExists());
+        assertEquals(version + 1, getResult.getVersion());
+        assertEquals("blue", getResult.getSource().get("tags"));
+    }
+
 }