Browse Source

HLRC support for string_stats (#52163)

This adds a builder and parsed results for the `string_stats`
aggregation directly to the high level rest client. Without this the
HLRC can't access the `string_stats` API without the elastic licensed
`analytics` module.

While I'm in there this adds a few of our usual unit tests and
modernizes the parsing.
Nik Everett 5 years ago
parent
commit
75d83db8c7
17 changed files with 727 additions and 21 deletions
  1. 3 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/RestHighLevelClient.java
  2. 172 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/analytics/ParsedStringStats.java
  3. 116 0
      client/rest-high-level/src/main/java/org/elasticsearch/client/analytics/StringStatsAggregationBuilder.java
  4. 2 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/RestHighLevelClientTests.java
  5. 58 0
      client/rest-high-level/src/test/java/org/elasticsearch/client/analytics/AnalyticsAggsIT.java
  6. 1 0
      docs/java-rest/high-level/aggs-builders.asciidoc
  7. 27 1
      libs/x-content/src/main/java/org/elasticsearch/common/xcontent/AbstractObjectParser.java
  8. 48 0
      libs/x-content/src/test/java/org/elasticsearch/common/xcontent/ObjectParserTests.java
  9. 2 2
      server/src/main/java/org/elasticsearch/search/aggregations/ParsedAggregation.java
  10. 24 6
      test/framework/src/main/java/org/elasticsearch/test/NotEqualMessageBuilder.java
  11. 1 1
      test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/MatchAssertion.java
  12. 13 0
      test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/MatchAssertionTests.java
  13. 1 1
      x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/AnalyticsPlugin.java
  14. 13 0
      x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/stringstats/InternalStringStats.java
  15. 3 10
      x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/stringstats/StringStatsAggregationBuilder.java
  16. 146 0
      x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/stringstats/InternalStringStatsTests.java
  17. 97 0
      x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/stringstats/StringStatsAggregationBuilderTests.java

+ 3 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/RestHighLevelClient.java

@@ -54,6 +54,8 @@ import org.elasticsearch.action.search.SearchScrollRequest;
 import org.elasticsearch.action.support.master.AcknowledgedResponse;
 import org.elasticsearch.action.update.UpdateRequest;
 import org.elasticsearch.action.update.UpdateResponse;
+import org.elasticsearch.client.analytics.ParsedStringStats;
+import org.elasticsearch.client.analytics.StringStatsAggregationBuilder;
 import org.elasticsearch.client.core.CountRequest;
 import org.elasticsearch.client.core.CountResponse;
 import org.elasticsearch.client.core.GetSourceRequest;
@@ -1926,6 +1928,7 @@ public class RestHighLevelClient implements Closeable {
         map.put(IpRangeAggregationBuilder.NAME, (p, c) -> ParsedBinaryRange.fromXContent(p, (String) c));
         map.put(TopHitsAggregationBuilder.NAME, (p, c) -> ParsedTopHits.fromXContent(p, (String) c));
         map.put(CompositeAggregationBuilder.NAME, (p, c) -> ParsedComposite.fromXContent(p, (String) c));
+        map.put(StringStatsAggregationBuilder.NAME, (p, c) -> ParsedStringStats.PARSER.parse(p, (String) c));
         List<NamedXContentRegistry.Entry> entries = map.entrySet().stream()
                 .map(entry -> new NamedXContentRegistry.Entry(Aggregation.class, new ParseField(entry.getKey()), entry.getValue()))
                 .collect(Collectors.toList());

+ 172 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/analytics/ParsedStringStats.java

@@ -0,0 +1,172 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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.elasticsearch.client.analytics;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.search.aggregations.ParsedAggregation;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.util.Collections.unmodifiableMap;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
+
+/**
+ * Results from the {@code string_stats} aggregation.
+ */
+public class ParsedStringStats extends ParsedAggregation {
+    private static final ParseField COUNT_FIELD = new ParseField("count");
+    private static final ParseField MIN_LENGTH_FIELD = new ParseField("min_length");
+    private static final ParseField MAX_LENGTH_FIELD = new ParseField("max_length");
+    private static final ParseField AVG_LENGTH_FIELD = new ParseField("avg_length");
+    private static final ParseField ENTROPY_FIELD = new ParseField("entropy");
+    private static final ParseField DISTRIBUTION_FIELD = new ParseField("distribution");
+
+    private final long count;
+    private final int minLength;
+    private final int maxLength;
+    private final double avgLength;
+    private final double entropy;
+    private final boolean showDistribution;
+    private final Map<String, Double> distribution;
+
+    private ParsedStringStats(String name, long count, int minLength, int maxLength, double avgLength, double entropy,
+            boolean showDistribution, Map<String, Double> distribution) {
+        setName(name);
+        this.count = count;
+        this.minLength = minLength;
+        this.maxLength = maxLength;
+        this.avgLength = avgLength;
+        this.entropy = entropy;
+        this.showDistribution = showDistribution;
+        this.distribution = distribution;
+    }
+
+    /**
+     * The number of non-empty fields counted.
+     */
+    public long getCount() {
+        return count;
+    }
+
+    /**
+     * The length of the shortest term.
+     */
+    public int getMinLength() {
+        return minLength;
+    }
+
+    /**
+     * The length of the longest term.
+     */
+    public int getMaxLength() {
+        return maxLength;
+    }
+
+    /**
+     * The average length computed over all terms.
+     */
+    public double getAvgLength() {
+        return avgLength;
+    }
+
+    /**
+     * The <a href="https://en.wikipedia.org/wiki/Entropy_(information_theory)">Shannon Entropy</a>
+     * value computed over all terms collected by the aggregation.
+     * Shannon entropy quantifies the amount of information contained in
+     * the field. It is a very useful metric for measuring a wide range of
+     * properties of a data set, such as diversity, similarity,
+     * randomness etc.
+     */
+    public double getEntropy() {
+        return entropy;
+    }
+
+    /**
+     * The probability distribution for all characters. {@code null} unless
+     * explicitly requested with {@link StringStatsAggregationBuilder#showDistribution(boolean)}.
+     */
+    public Map<String, Double> getDistribution() {
+        return distribution;
+    }
+
+    @Override
+    public String getType() {
+        return StringStatsAggregationBuilder.NAME;
+    }
+
+    private static final Object NULL_DISTRIBUTION_MARKER = new Object();
+    public static final ConstructingObjectParser<ParsedStringStats, String> PARSER = new ConstructingObjectParser<>(
+            StringStatsAggregationBuilder.NAME, true, (args, name) -> {
+                long count = (long) args[0];
+                boolean disributionWasExplicitNull = args[5] == NULL_DISTRIBUTION_MARKER;
+                if (count == 0) {
+                    return new ParsedStringStats(name, count, 0, 0, 0, 0, disributionWasExplicitNull, null);
+                }
+                int minLength = (int) args[1];
+                int maxLength = (int) args[2];
+                double averageLength = (double) args[3];
+                double entropy = (double) args[4];
+                if (disributionWasExplicitNull) {
+                    return new ParsedStringStats(name, count, minLength, maxLength, averageLength, entropy,
+                            disributionWasExplicitNull, null);
+                } else {
+                    @SuppressWarnings("unchecked")
+                    Map<String, Double> distribution = (Map<String, Double>) args[5];
+                    return new ParsedStringStats(name, count, minLength, maxLength, averageLength, entropy,
+                            distribution != null, distribution);
+                }
+            });
+    static {
+        PARSER.declareLong(constructorArg(), COUNT_FIELD);
+        PARSER.declareIntOrNull(constructorArg(), 0, MIN_LENGTH_FIELD);
+        PARSER.declareIntOrNull(constructorArg(), 0, MAX_LENGTH_FIELD);
+        PARSER.declareDoubleOrNull(constructorArg(), 0, AVG_LENGTH_FIELD);
+        PARSER.declareDoubleOrNull(constructorArg(), 0, ENTROPY_FIELD);
+        PARSER.declareObjectOrNull(optionalConstructorArg(), (p, c) -> unmodifiableMap(p.map(HashMap::new, XContentParser::doubleValue)),
+                NULL_DISTRIBUTION_MARKER, DISTRIBUTION_FIELD);
+        ParsedAggregation.declareAggregationFields(PARSER);
+    }
+
+    @Override
+    protected XContentBuilder doXContentBody(XContentBuilder builder, Params params) throws IOException {
+        builder.field(COUNT_FIELD.getPreferredName(), count);
+        if (count == 0) {
+            builder.nullField(MIN_LENGTH_FIELD.getPreferredName());
+            builder.nullField(MAX_LENGTH_FIELD.getPreferredName());
+            builder.nullField(AVG_LENGTH_FIELD.getPreferredName());
+            builder.field(ENTROPY_FIELD.getPreferredName(), 0.0);
+        } else {
+            builder.field(MIN_LENGTH_FIELD.getPreferredName(), minLength);
+            builder.field(MAX_LENGTH_FIELD.getPreferredName(), maxLength);
+            builder.field(AVG_LENGTH_FIELD.getPreferredName(), avgLength);
+            builder.field(ENTROPY_FIELD.getPreferredName(), entropy);
+        }
+        if (showDistribution) {
+            builder.field(DISTRIBUTION_FIELD.getPreferredName(), distribution);
+        }
+        return builder;
+    }
+}

+ 116 - 0
client/rest-high-level/src/main/java/org/elasticsearch/client/analytics/StringStatsAggregationBuilder.java

@@ -0,0 +1,116 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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.elasticsearch.client.analytics;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.io.stream.Writeable;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.index.query.QueryRewriteContext;
+import org.elasticsearch.index.query.QueryShardContext;
+import org.elasticsearch.search.aggregations.AbstractAggregationBuilder;
+import org.elasticsearch.search.aggregations.AggregationBuilder;
+import org.elasticsearch.search.aggregations.AggregatorFactories.Builder;
+import org.elasticsearch.search.aggregations.AggregatorFactory;
+import org.elasticsearch.search.aggregations.support.CoreValuesSourceType;
+import org.elasticsearch.search.aggregations.support.ValueType;
+import org.elasticsearch.search.aggregations.support.ValuesSource;
+import org.elasticsearch.search.aggregations.support.ValuesSource.Bytes;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregationBuilder;
+import org.elasticsearch.search.aggregations.support.ValuesSourceAggregatorFactory;
+import org.elasticsearch.search.aggregations.support.ValuesSourceConfig;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Builds the {@code string_stats} aggregation request.
+ * <p>
+ * NOTE: This extends {@linkplain AbstractAggregationBuilder} for compatibility
+ * with {@link SearchSourceBuilder#aggregation(AggregationBuilder)} but it
+ * doesn't support any "server" side things like
+ * {@linkplain Writeable#writeTo(StreamOutput)},
+ * {@linkplain AggregationBuilder#rewrite(QueryRewriteContext)}, or
+ * {@linkplain AbstractAggregationBuilder#build(QueryShardContext, AggregatorFactory)}.
+ */
+public class StringStatsAggregationBuilder extends ValuesSourceAggregationBuilder<ValuesSource.Bytes, StringStatsAggregationBuilder> {
+    public static final String NAME = "string_stats";
+    private static final ParseField SHOW_DISTRIBUTION_FIELD = new ParseField("show_distribution");
+
+    private boolean showDistribution = false;
+
+    public StringStatsAggregationBuilder(String name) {
+        super(name, CoreValuesSourceType.BYTES, ValueType.STRING);
+    }
+
+    /**
+     * Compute the distribution of each character. Disabled by default.
+     * @return this for chaining
+     */
+    public StringStatsAggregationBuilder showDistribution(boolean showDistribution) {
+        this.showDistribution = showDistribution;
+        return this;
+    }
+
+    @Override
+    public String getType() {
+        return NAME;
+    }
+
+    @Override
+    public XContentBuilder doXContentBody(XContentBuilder builder, Params params) throws IOException {
+        return builder.field(StringStatsAggregationBuilder.SHOW_DISTRIBUTION_FIELD.getPreferredName(), showDistribution);
+    }
+
+    @Override
+    protected void innerWriteTo(StreamOutput out) throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    protected ValuesSourceAggregatorFactory<Bytes> innerBuild(QueryShardContext queryShardContext, ValuesSourceConfig<Bytes> config,
+            AggregatorFactory parent, Builder subFactoriesBuilder) throws IOException {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    protected AggregationBuilder shallowCopy(Builder factoriesBuilder, Map<String, Object> metaData) {
+        throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), showDistribution);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        if (false == super.equals(obj)) {
+            return false;
+        }
+        StringStatsAggregationBuilder other = (StringStatsAggregationBuilder) obj;
+        return showDistribution == other.showDistribution;
+    }
+}

+ 2 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/RestHighLevelClientTests.java

@@ -20,6 +20,7 @@
 package org.elasticsearch.client;
 
 import com.fasterxml.jackson.core.JsonParseException;
+
 import org.apache.http.HttpEntity;
 import org.apache.http.HttpHost;
 import org.apache.http.HttpResponse;
@@ -675,6 +676,7 @@ public class RestHighLevelClientTests extends ESTestCase {
         List<NamedXContentRegistry.Entry> namedXContents = RestHighLevelClient.getDefaultNamedXContents();
         int expectedInternalAggregations = InternalAggregationTestCase.getDefaultNamedXContents().size();
         int expectedSuggestions = 3;
+        assertTrue(namedXContents.removeIf(e -> e.name.getPreferredName().equals("string_stats")));
         assertEquals(expectedInternalAggregations + expectedSuggestions, namedXContents.size());
         Map<Class<?>, Integer> categories = new HashMap<>();
         for (NamedXContentRegistry.Entry namedXContent : namedXContents) {

+ 58 - 0
client/rest-high-level/src/test/java/org/elasticsearch/client/analytics/AnalyticsAggsIT.java

@@ -0,0 +1,58 @@
+/*
+ * Licensed to Elasticsearch under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch licenses this file to you 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.elasticsearch.client.analytics;
+
+import org.elasticsearch.action.bulk.BulkRequest;
+import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.search.SearchRequest;
+import org.elasticsearch.action.search.SearchResponse;
+import org.elasticsearch.action.support.WriteRequest.RefreshPolicy;
+import org.elasticsearch.client.ESRestHighLevelClientTestCase;
+import org.elasticsearch.client.RequestOptions;
+import org.elasticsearch.common.xcontent.XContentType;
+
+import java.io.IOException;
+
+import static org.hamcrest.Matchers.aMapWithSize;
+import static org.hamcrest.Matchers.closeTo;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasEntry;
+
+public class AnalyticsAggsIT extends ESRestHighLevelClientTestCase {
+    public void testBasic() throws IOException {
+        BulkRequest bulk = new BulkRequest("test").setRefreshPolicy(RefreshPolicy.IMMEDIATE);
+        bulk.add(new IndexRequest().source(XContentType.JSON, "message", "trying out elasticsearch"));
+        bulk.add(new IndexRequest().source(XContentType.JSON, "message", "more words"));
+        highLevelClient().bulk(bulk, RequestOptions.DEFAULT);
+        SearchRequest search = new SearchRequest("test");
+        search.source().aggregation(new StringStatsAggregationBuilder("test").field("message.keyword").showDistribution(true));
+        SearchResponse response = highLevelClient().search(search, RequestOptions.DEFAULT);
+        ParsedStringStats stats = response.getAggregations().get("test");
+        assertThat(stats.getCount(), equalTo(2L));
+        assertThat(stats.getMinLength(), equalTo(10));
+        assertThat(stats.getMaxLength(), equalTo(24));
+        assertThat(stats.getAvgLength(), equalTo(17.0));
+        assertThat(stats.getEntropy(), closeTo(4, .1));
+        assertThat(stats.getDistribution(), aMapWithSize(18));
+        assertThat(stats.getDistribution(), hasEntry(equalTo("o"), closeTo(.09, .005)));
+        assertThat(stats.getDistribution(), hasEntry(equalTo("r"), closeTo(.12, .005)));
+        assertThat(stats.getDistribution(), hasEntry(equalTo("t"), closeTo(.09, .005)));
+    }
+}

+ 1 - 0
docs/java-rest/high-level/aggs-builders.asciidoc

@@ -26,6 +26,7 @@ This page lists all the available aggregations with their corresponding `Aggrega
 | {ref}/search-aggregations-metrics-sum-aggregation.html[Sum]                                        | {agg-ref}/metrics/sum/SumAggregationBuilder.html[SumAggregationBuilder]                                 | {agg-ref}/AggregationBuilders.html#sum-java.lang.String-[AggregationBuilders.sum()]
 | {ref}/search-aggregations-metrics-top-hits-aggregation.html[Top hits]                              | {agg-ref}/metrics/tophits/TopHitsAggregationBuilder.html[TopHitsAggregationBuilder]                     | {agg-ref}/AggregationBuilders.html#topHits-java.lang.String-[AggregationBuilders.topHits()]
 | {ref}/search-aggregations-metrics-valuecount-aggregation.html[Value Count]                         | {agg-ref}/metrics/valuecount/ValueCountAggregationBuilder.html[ValueCountAggregationBuilder]            | {agg-ref}/AggregationBuilders.html#count-java.lang.String-[AggregationBuilders.count()]
+| {ref}/search-aggregations-metrics-string-stats-aggregation.html[String Stats]                      | {javadoc-client}/analytics/StringStatsAggregationBuilder.html[StringStatsAggregationBuilder]            | None
 |======
 
 ==== Bucket Aggregations

+ 27 - 1
libs/x-content/src/main/java/org/elasticsearch/common/xcontent/AbstractObjectParser.java

@@ -148,6 +148,15 @@ public abstract class AbstractObjectParser<Value, Context>
         declareField(consumer, (p, c) -> objectParser.parse(p, c), field, ValueType.OBJECT);
     }
 
+    /**
+     * Declare an object field that parses explicit {@code null}s in the json to a default value.
+     */
+    public <T> void declareObjectOrNull(BiConsumer<Value, T> consumer, ContextParser<Context, T> objectParser, T nullValue,
+            ParseField field) {
+        declareField(consumer, (p, c) -> p.currentToken() == XContentParser.Token.VALUE_NULL ? nullValue : objectParser.parse(p, c),
+                field, ValueType.OBJECT_OR_NULL);
+    }
+
     public void declareFloat(BiConsumer<Value, Float> consumer, ParseField field) {
         // Using a method reference here angers some compilers
         declareField(consumer, p -> p.floatValue(), field, ValueType.FLOAT);
@@ -158,16 +167,33 @@ public abstract class AbstractObjectParser<Value, Context>
         declareField(consumer, p -> p.doubleValue(), field, ValueType.DOUBLE);
     }
 
+    /**
+     * Declare a double field that parses explicit {@code null}s in the json to a default value.
+     */
+    public void declareDoubleOrNull(BiConsumer<Value, Double> consumer, double nullValue, ParseField field) {
+        declareField(consumer, p -> p.currentToken() == XContentParser.Token.VALUE_NULL ? nullValue : p.doubleValue(),
+                field, ValueType.DOUBLE_OR_NULL);
+    }
+
     public void declareLong(BiConsumer<Value, Long> consumer, ParseField field) {
         // Using a method reference here angers some compilers
         declareField(consumer, p -> p.longValue(), field, ValueType.LONG);
     }
 
     public void declareInt(BiConsumer<Value, Integer> consumer, ParseField field) {
-     // Using a method reference here angers some compilers
+        // Using a method reference here angers some compilers
         declareField(consumer, p -> p.intValue(), field, ValueType.INT);
     }
 
+    /**
+     * Declare a double field that parses explicit {@code null}s in the json to a default value.
+     */
+    public void declareIntOrNull(BiConsumer<Value, Integer> consumer, int nullValue, ParseField field) {
+        declareField(consumer, p -> p.currentToken() == XContentParser.Token.VALUE_NULL ? nullValue : p.intValue(),
+                field, ValueType.INT_OR_NULL);
+    }
+
+
     public void declareString(BiConsumer<Value, String> consumer, ParseField field) {
         declareField(consumer, XContentParser::text, field, ValueType.STRING);
     }

+ 48 - 0
libs/x-content/src/test/java/org/elasticsearch/common/xcontent/ObjectParserTests.java

@@ -42,6 +42,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.Matchers.nullValue;
 
 public class ObjectParserTests extends ESTestCase {
@@ -274,6 +275,24 @@ public class ObjectParserTests extends ESTestCase {
         assertNotNull(s.object);
     }
 
+    public void testObjectOrNullWhenNull() throws IOException {
+        StaticTestStruct nullMarker = new StaticTestStruct();
+        XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"object\" : null}");
+        ObjectParser<StaticTestStruct, Void> objectParser = new ObjectParser<>("foo", StaticTestStruct::new);
+        objectParser.declareObjectOrNull(StaticTestStruct::setObject, objectParser, nullMarker, new ParseField("object"));
+        StaticTestStruct s = objectParser.parse(parser, null);
+        assertThat(s.object, equalTo(nullMarker));
+    }
+
+    public void testObjectOrNullWhenNonNull() throws IOException {
+        StaticTestStruct nullMarker = new StaticTestStruct();
+        XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"object\" : {}}");
+        ObjectParser<StaticTestStruct, Void> objectParser = new ObjectParser<>("foo", StaticTestStruct::new);
+        objectParser.declareObjectOrNull(StaticTestStruct::setObject, objectParser, nullMarker, new ParseField("object"));
+        StaticTestStruct s = objectParser.parse(parser, null);
+        assertThat(s.object, not(nullValue()));
+    }
+
     public void testEmptyObjectInArray() throws IOException {
         XContentParser parser = createParser(JsonXContent.jsonXContent, "{\"object_array\" : [{}]}");
         ObjectParser<StaticTestStruct, Void> objectParser = new ObjectParser<>("foo", StaticTestStruct::new);
@@ -320,15 +339,32 @@ public class ObjectParserTests extends ESTestCase {
     }
 
     public void testAllVariants() throws IOException {
+        double expectedNullableDouble;
+        int expectedNullableInt;
+
         XContentBuilder builder = XContentBuilder.builder(XContentType.JSON.xContent());
         builder.startObject();
         builder.field("int_field", randomBoolean() ? "1" : 1);
+        if (randomBoolean()) {
+            builder.nullField("nullable_int_field");
+            expectedNullableInt = -1;
+        } else {
+            expectedNullableInt = randomInt();
+            builder.field("nullable_int_field", expectedNullableInt);
+        }
         if (randomBoolean()) {
             builder.array("int_array_field", randomBoolean() ? "1" : 1);
         } else {
             builder.field("int_array_field", randomBoolean() ? "1" : 1);
         }
         builder.field("double_field", randomBoolean() ? "2.1" : 2.1d);
+        if (randomBoolean()) {
+            builder.nullField("nullable_double_field");
+            expectedNullableDouble = Double.NaN;
+        } else {
+            expectedNullableDouble = randomDouble();
+            builder.field("nullable_double_field", expectedNullableDouble);
+        }
         if (randomBoolean()) {
             builder.array("double_array_field", randomBoolean() ? "2.1" : 2.1d);
         } else {
@@ -363,9 +399,11 @@ public class ObjectParserTests extends ESTestCase {
         XContentParser parser = createParser(JsonXContent.jsonXContent, Strings.toString(builder));
         class TestStruct {
             int int_field;
+            int nullableIntField;
             long long_field;
             float float_field;
             double double_field;
+            double nullableDoubleField;
             String string_field;
             List<Integer> int_array_field;
             List<Long> long_array_field;
@@ -377,6 +415,9 @@ public class ObjectParserTests extends ESTestCase {
             public void setInt_field(int int_field) {
                 this.int_field = int_field;
             }
+            public void setNullableIntField(int nullableIntField) {
+                this.nullableIntField = nullableIntField;
+            }
             public void setLong_field(long long_field) {
                 this.long_field = long_field;
             }
@@ -386,6 +427,9 @@ public class ObjectParserTests extends ESTestCase {
             public void setDouble_field(double double_field) {
                 this.double_field = double_field;
             }
+            public void setNullableDoubleField(double nullableDoubleField) {
+                this.nullableDoubleField = nullableDoubleField;
+            }
             public void setString_field(String string_field) {
                 this.string_field = string_field;
             }
@@ -415,10 +459,12 @@ public class ObjectParserTests extends ESTestCase {
         }
         ObjectParser<TestStruct, Void> objectParser = new ObjectParser<>("foo");
         objectParser.declareInt(TestStruct::setInt_field, new ParseField("int_field"));
+        objectParser.declareIntOrNull(TestStruct::setNullableIntField, -1, new ParseField("nullable_int_field"));
         objectParser.declareIntArray(TestStruct::setInt_array_field, new ParseField("int_array_field"));
         objectParser.declareLong(TestStruct::setLong_field, new ParseField("long_field"));
         objectParser.declareLongArray(TestStruct::setLong_array_field, new ParseField("long_array_field"));
         objectParser.declareDouble(TestStruct::setDouble_field, new ParseField("double_field"));
+        objectParser.declareDoubleOrNull(TestStruct::setNullableDoubleField, Double.NaN, new ParseField("nullable_double_field"));
         objectParser.declareDoubleArray(TestStruct::setDouble_array_field, new ParseField("double_array_field"));
         objectParser.declareFloat(TestStruct::setFloat_field, new ParseField("float_field"));
         objectParser.declareFloatArray(TestStruct::setFloat_array_field, new ParseField("float_array_field"));
@@ -430,6 +476,7 @@ public class ObjectParserTests extends ESTestCase {
         TestStruct parse = objectParser.parse(parser, new TestStruct(), null);
         assertArrayEquals(parse.double_array_field.toArray(), Collections.singletonList(2.1d).toArray());
         assertEquals(parse.double_field, 2.1d, 0.0d);
+        assertThat(parse.nullableDoubleField, equalTo(expectedNullableDouble));
 
         assertArrayEquals(parse.long_array_field.toArray(), Collections.singletonList(4L).toArray());
         assertEquals(parse.long_field, 4L);
@@ -439,6 +486,7 @@ public class ObjectParserTests extends ESTestCase {
 
         assertArrayEquals(parse.int_array_field.toArray(), Collections.singletonList(1).toArray());
         assertEquals(parse.int_field, 1);
+        assertThat(parse.nullableIntField, equalTo(expectedNullableInt));
 
         assertArrayEquals(parse.float_array_field.toArray(), Collections.singletonList(3.1f).toArray());
         assertEquals(parse.float_field, 3.1f, 0.0f);

+ 2 - 2
server/src/main/java/org/elasticsearch/search/aggregations/ParsedAggregation.java

@@ -19,7 +19,7 @@
 
 package org.elasticsearch.search.aggregations;
 
-import org.elasticsearch.common.xcontent.ObjectParser;
+import org.elasticsearch.common.xcontent.AbstractObjectParser;
 import org.elasticsearch.common.xcontent.ToXContent;
 import org.elasticsearch.common.xcontent.ToXContentFragment;
 import org.elasticsearch.common.xcontent.XContentBuilder;
@@ -36,7 +36,7 @@ import java.util.Map;
  */
 public abstract class ParsedAggregation implements Aggregation, ToXContentFragment {
 
-    protected static void declareAggregationFields(ObjectParser<? extends ParsedAggregation, Void> objectParser) {
+    protected static void declareAggregationFields(AbstractObjectParser<? extends ParsedAggregation, ?> objectParser) {
         objectParser.declareObject((parsedAgg, metadata) -> parsedAgg.metadata = Collections.unmodifiableMap(metadata),
                 (parser, context) -> parser.map(), InternalAggregation.CommonFields.META);
     }

+ 24 - 6
test/framework/src/main/java/org/elasticsearch/test/NotEqualMessageBuilder.java

@@ -56,7 +56,9 @@ public class NotEqualMessageBuilder {
         actual = new TreeMap<>(actual);
         expected = new TreeMap<>(expected);
         for (Map.Entry<String, Object> expectedEntry : expected.entrySet()) {
-            compare(expectedEntry.getKey(), actual.remove(expectedEntry.getKey()), expectedEntry.getValue());
+            boolean hadKey = actual.containsKey(expectedEntry.getKey());
+            Object actualValue = actual.remove(expectedEntry.getKey());
+            compare(expectedEntry.getKey(), hadKey, actualValue, expectedEntry.getValue());
         }
         for (Map.Entry<String, Object> unmatchedEntry : actual.entrySet()) {
             field(unmatchedEntry.getKey(), "unexpected but found [" + unmatchedEntry.getValue() + "]");
@@ -69,7 +71,7 @@ public class NotEqualMessageBuilder {
     public void compareLists(List<Object> actual, List<Object> expected) {
         int i = 0;
         while (i < actual.size() && i < expected.size()) {
-            compare(Integer.toString(i), actual.get(i), expected.get(i));
+            compare(Integer.toString(i), true, actual.get(i), expected.get(i));
             i++;
         }
         if (actual.size() == expected.size()) {
@@ -87,12 +89,16 @@ public class NotEqualMessageBuilder {
      * Compare two values.
      * @param field the name of the field being compared.
      */
-    public void compare(String field, @Nullable Object actual, Object expected) {
+    public void compare(String field, boolean hadKey, @Nullable Object actual, Object expected) {
         if (expected instanceof Map) {
-            if (actual == null) {
+            if (false == hadKey) {
                 field(field, "expected map but not found");
                 return;
             }
+            if (actual == null) {
+                field(field, "expected map but was [null]");
+                return;
+            }
             if (false == actual instanceof Map) {
                 field(field, "expected map but found [" + actual + "]");
                 return;
@@ -112,10 +118,14 @@ public class NotEqualMessageBuilder {
             return;
         }
         if (expected instanceof List) {
-            if (actual == null) {
+            if (false == hadKey) {
                 field(field, "expected list but not found");
                 return;
             }
+            if (actual == null) {
+                field(field, "expected list but was [null]");
+                return;
+            }
             if (false == actual instanceof List) {
                 field(field, "expected list but found [" + actual + "]");
                 return;
@@ -134,10 +144,18 @@ public class NotEqualMessageBuilder {
             indent -= 1;
             return;
         }
-        if (actual == null) {
+        if (false == hadKey) {
             field(field, "expected [" + expected + "] but not found");
             return;
         }
+        if (actual == null) {
+            if (expected == null) {
+                field(field, "same [" + expected + "]");
+                return;
+            }
+            field(field, "expected [" + expected + "] but was [null]");
+            return;
+        }
         if (Objects.equals(expected, actual)) {
             if (expected instanceof String) {
                 String expectedString = (String) expected;

+ 1 - 1
test/framework/src/main/java/org/elasticsearch/test/rest/yaml/section/MatchAssertion.java

@@ -89,7 +89,7 @@ public class MatchAssertion extends Assertion {
 
         if (expectedValue.equals(actualValue) == false) {
             NotEqualMessageBuilder message = new NotEqualMessageBuilder();
-            message.compare(getField(), actualValue, expectedValue);
+            message.compare(getField(), true, actualValue, expectedValue);
             throw new AssertionError(getField() + " didn't match expected value:\n" + message);
         }
     }

+ 13 - 0
test/framework/src/test/java/org/elasticsearch/test/rest/yaml/section/MatchAssertionTests.java

@@ -21,6 +21,10 @@ package org.elasticsearch.test.rest.yaml.section;
 import org.elasticsearch.common.xcontent.XContentLocation;
 import org.elasticsearch.test.ESTestCase;
 
+import static java.util.Collections.emptyMap;
+import static java.util.Collections.singletonMap;
+import static org.hamcrest.Matchers.containsString;
+
 public class MatchAssertionTests extends ESTestCase  {
 
     public void testNull() {
@@ -39,4 +43,13 @@ public class MatchAssertionTests extends ESTestCase  {
             expectThrows(AssertionError.class, () -> matchAssertion.doAssert(null, "/exp/"));
         }
     }
+
+    public void testNullInMap() {
+        XContentLocation xContentLocation = new XContentLocation(0, 0);
+        MatchAssertion matchAssertion = new MatchAssertion(xContentLocation, "field", singletonMap("a", null));
+        matchAssertion.doAssert(singletonMap("a", null), matchAssertion.getExpectedValue());
+        AssertionError e = expectThrows(AssertionError.class, () ->
+            matchAssertion.doAssert(emptyMap(), matchAssertion.getExpectedValue()));
+        assertThat(e.getMessage(), containsString("expected [null] but not found"));
+    }
 }

+ 1 - 1
x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/AnalyticsPlugin.java

@@ -64,7 +64,7 @@ public class AnalyticsPlugin extends Plugin implements SearchPlugin, ActionPlugi
             new AggregationSpec(
                 StringStatsAggregationBuilder.NAME,
                 StringStatsAggregationBuilder::new,
-                StringStatsAggregationBuilder::parse).addResultReader(InternalStringStats::new),
+                StringStatsAggregationBuilder.PARSER).addResultReader(InternalStringStats::new),
             new AggregationSpec(
                 BoxplotAggregationBuilder.NAME,
                 BoxplotAggregationBuilder::new,

+ 13 - 0
x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/stringstats/InternalStringStats.java

@@ -107,6 +107,10 @@ public class InternalStringStats extends InternalAggregation {
         return count;
     }
 
+    long getTotalLength () {
+        return totalLength;
+    }
+
     public int getMinLength() {
         return minLength;
     }
@@ -153,6 +157,14 @@ public class InternalStringStats extends InternalAggregation {
         return Math.log(d) / Math.log(2.0);
     }
 
+    Map<String, Long> getCharOccurrences() {
+        return charOccurrences;
+    }
+
+    boolean getShowDistribution() {
+        return showDistribution;
+    }
+
     public String getCountAsString() {
         return format.format(getCount()).toString();
     }
@@ -282,6 +294,7 @@ public class InternalStringStats extends InternalAggregation {
             minLength == other.minLength &&
             maxLength == other.maxLength &&
             totalLength == other.totalLength &&
+            Objects.equals(charOccurrences, other.charOccurrences) &&
             showDistribution == other.showDistribution;
     }
 }

+ 3 - 10
x-pack/plugin/analytics/src/main/java/org/elasticsearch/xpack/analytics/stringstats/StringStatsAggregationBuilder.java

@@ -10,7 +10,6 @@ import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.xcontent.ObjectParser;
 import org.elasticsearch.common.xcontent.XContentBuilder;
-import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.index.query.QueryShardContext;
 import org.elasticsearch.search.aggregations.AggregationBuilder;
 import org.elasticsearch.search.aggregations.AggregatorFactories;
@@ -27,23 +26,17 @@ import java.util.Map;
 import java.util.Objects;
 
 public class StringStatsAggregationBuilder extends ValuesSourceAggregationBuilder<ValuesSource.Bytes, StringStatsAggregationBuilder> {
-
     public static final String NAME = "string_stats";
-    private boolean showDistribution = false;
 
-    private static final ObjectParser<StringStatsAggregationBuilder, Void> PARSER;
     private static final ParseField SHOW_DISTRIBUTION_FIELD = new ParseField("show_distribution");
-
+    public static final ObjectParser<StringStatsAggregationBuilder, String> PARSER =
+            ObjectParser.fromBuilder(NAME, StringStatsAggregationBuilder::new);
     static {
-        PARSER = new ObjectParser<>(StringStatsAggregationBuilder.NAME);
         ValuesSourceParserHelper.declareBytesFields(PARSER, true, true);
-
         PARSER.declareBoolean(StringStatsAggregationBuilder::showDistribution, StringStatsAggregationBuilder.SHOW_DISTRIBUTION_FIELD);
     }
 
-    public static StringStatsAggregationBuilder parse(String aggregationName, XContentParser parser) throws IOException {
-        return PARSER.parse(parser, new StringStatsAggregationBuilder(aggregationName), null);
-    }
+    private boolean showDistribution = false;
 
     public StringStatsAggregationBuilder(String name) {
         super(name, CoreValuesSourceType.BYTES, ValueType.STRING);

+ 146 - 0
x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/stringstats/InternalStringStatsTests.java

@@ -0,0 +1,146 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.analytics.stringstats;
+
+import org.elasticsearch.client.analytics.ParsedStringStats;
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.Writeable.Reader;
+import org.elasticsearch.common.xcontent.NamedXContentRegistry;
+import org.elasticsearch.search.DocValueFormat;
+import org.elasticsearch.search.aggregations.Aggregation;
+import org.elasticsearch.search.aggregations.ParsedAggregation;
+import org.elasticsearch.search.aggregations.pipeline.PipelineAggregator;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.InternalAggregationTestCase;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+import static java.util.Collections.emptyMap;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.nullValue;
+
+public class InternalStringStatsTests extends InternalAggregationTestCase<InternalStringStats> {
+    @Override
+    protected List<NamedXContentRegistry.Entry> getNamedXContents() {
+        List<NamedXContentRegistry.Entry> result = new ArrayList<>(super.getNamedXContents());
+        result.add(new NamedXContentRegistry.Entry(Aggregation.class, new ParseField(StringStatsAggregationBuilder.NAME),
+                (p, c) -> ParsedStringStats.PARSER.parse(p, (String) c)));
+        return result;
+    }
+
+    protected InternalStringStats createTestInstance(
+            String name, List<PipelineAggregator> pipelineAggregators, Map<String, Object> metaData) {
+        if (randomBoolean()) {
+            return new InternalStringStats(name, 0, 0, 0, 0, emptyMap(), randomBoolean(), DocValueFormat.RAW,
+                    pipelineAggregators, metaData);
+        }
+        return new InternalStringStats(name, randomLongBetween(1, Long.MAX_VALUE),
+                randomNonNegativeLong(), between(0, Integer.MAX_VALUE), between(0, Integer.MAX_VALUE), randomCharOccurrences(),
+                randomBoolean(), DocValueFormat.RAW,
+                pipelineAggregators, metaData);
+    };
+
+    @Override
+    protected InternalStringStats mutateInstance(InternalStringStats instance) throws IOException {
+         String name = instance.getName();
+         long count = instance.getCount();
+         long totalLength = instance.getTotalLength();
+         int minLength = instance.getMinLength();
+         int maxLength = instance.getMaxLength();
+         Map<String, Long> charOccurrences = instance.getCharOccurrences();
+         boolean showDistribution = instance.getShowDistribution();
+         switch (between(0, 6)) {
+         case 0:
+             name = name + "a";
+             break;
+         case 1:
+             count = randomValueOtherThan(count, () -> randomLongBetween(1, Long.MAX_VALUE));
+             break;
+         case 2:
+             totalLength = randomValueOtherThan(totalLength, ESTestCase::randomNonNegativeLong);
+             break;
+         case 3:
+             minLength = randomValueOtherThan(minLength, () -> between(0, Integer.MAX_VALUE));
+             break;
+         case 4:
+             maxLength = randomValueOtherThan(maxLength, () -> between(0, Integer.MAX_VALUE));
+             break;
+         case 5:
+             charOccurrences = randomValueOtherThan(charOccurrences, this::randomCharOccurrences);
+             break;
+         case 6:
+             showDistribution = !showDistribution;
+             break;
+         }
+        return new InternalStringStats(name, count, totalLength, minLength, maxLength, charOccurrences, showDistribution,
+                DocValueFormat.RAW, instance.pipelineAggregators(), instance.getMetaData());
+    }
+
+    @Override
+    protected Reader<InternalStringStats> instanceReader() {
+        return InternalStringStats::new;
+    }
+
+    @Override
+    protected void assertFromXContent(InternalStringStats aggregation, ParsedAggregation parsedAggregation) throws IOException {
+        ParsedStringStats parsed = (ParsedStringStats) parsedAggregation;
+        assertThat(parsed.getName(), equalTo(aggregation.getName()));
+        if (aggregation.getCount() == 0) {
+            assertThat(parsed.getCount(), equalTo(0L));
+            assertThat(parsed.getMinLength(), equalTo(0));
+            assertThat(parsed.getMaxLength(), equalTo(0));
+            assertThat(parsed.getAvgLength(), equalTo(0d));
+            assertThat(parsed.getEntropy(), equalTo(0d));
+            assertThat(parsed.getDistribution(), nullValue());
+            return;
+        }
+        assertThat(parsed.getCount(), equalTo(aggregation.getCount()));
+        assertThat(parsed.getMinLength(), equalTo(aggregation.getMinLength()));
+        assertThat(parsed.getMaxLength(), equalTo(aggregation.getMaxLength()));
+        assertThat(parsed.getAvgLength(), equalTo(aggregation.getAvgLength()));
+        assertThat(parsed.getEntropy(), equalTo(aggregation.getEntropy()));
+        if (aggregation.getShowDistribution()) {
+            assertThat(parsed.getDistribution(), equalTo(aggregation.getDistribution()));
+        } else {
+            assertThat(parsed.getDistribution(), nullValue());
+        }
+    }
+
+    @Override
+    protected Predicate<String> excludePathsFromXContentInsertion() {
+        return path -> path.endsWith(".distribution");
+    }
+
+    @Override
+    protected void assertReduced(InternalStringStats reduced, List<InternalStringStats> inputs) {
+        assertThat(reduced.getCount(), equalTo(inputs.stream().mapToLong(InternalStringStats::getCount).sum()));
+        assertThat(reduced.getMinLength(), equalTo(inputs.stream().mapToInt(InternalStringStats::getMinLength).min().getAsInt()));
+        assertThat(reduced.getMaxLength(), equalTo(inputs.stream().mapToInt(InternalStringStats::getMaxLength).max().getAsInt()));
+        assertThat(reduced.getTotalLength(), equalTo(inputs.stream().mapToLong(InternalStringStats::getTotalLength).sum()));
+        Map<String, Long> reducedChars = new HashMap<>();
+        for (InternalStringStats stats : inputs) {
+            for (Map.Entry<String, Long> e : stats.getCharOccurrences().entrySet()) {
+                reducedChars.merge(e.getKey(), e.getValue(), (lhs, rhs) -> lhs + rhs);
+            }
+        }
+        assertThat(reduced.getCharOccurrences(), equalTo(reducedChars));
+    }
+
+    private Map<String, Long> randomCharOccurrences() {
+        Map<String, Long> charOccurrences = new HashMap<String, Long>();
+        int occurrencesSize = between(0, 1000);
+        while (charOccurrences.size() < occurrencesSize) {
+            charOccurrences.put(randomAlphaOfLength(5), randomNonNegativeLong());
+        }
+        return charOccurrences;
+    }
+}

+ 97 - 0
x-pack/plugin/analytics/src/test/java/org/elasticsearch/xpack/analytics/stringstats/StringStatsAggregationBuilderTests.java

@@ -0,0 +1,97 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+package org.elasticsearch.xpack.analytics.stringstats;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.Writeable.Reader;
+import org.elasticsearch.common.xcontent.NamedXContentRegistry;
+import org.elasticsearch.common.xcontent.ToXContent;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.search.aggregations.AggregatorFactories;
+import org.elasticsearch.search.aggregations.BaseAggregationBuilder;
+import org.elasticsearch.test.AbstractSerializingTestCase;
+import org.elasticsearch.test.AbstractXContentTestCase;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasSize;
+
+public class StringStatsAggregationBuilderTests extends AbstractSerializingTestCase<StringStatsAggregationBuilder> {
+    @Override
+    protected NamedXContentRegistry xContentRegistry() {
+        return new NamedXContentRegistry(Arrays.asList(
+                new NamedXContentRegistry.Entry(BaseAggregationBuilder.class, new ParseField(StringStatsAggregationBuilder.NAME),
+                        (p, c) -> StringStatsAggregationBuilder.PARSER.parse(p, (String) c))));
+    }
+
+    @Override
+    protected StringStatsAggregationBuilder doParseInstance(XContentParser parser) throws IOException {
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+        String name = parser.currentName();
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+        assertThat(parser.currentName(), equalTo("string_stats"));
+        StringStatsAggregationBuilder parsed = StringStatsAggregationBuilder.PARSER.apply(parser, name);
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+        assertThat(parser.nextToken(), equalTo(XContentParser.Token.END_OBJECT));
+        return parsed;
+    }
+
+    @Override
+    protected Reader<StringStatsAggregationBuilder> instanceReader() {
+        return StringStatsAggregationBuilder::new;
+    }
+
+    @Override
+    protected StringStatsAggregationBuilder createTestInstance() {
+        StringStatsAggregationBuilder builder = new StringStatsAggregationBuilder(randomAlphaOfLength(5));
+        builder.showDistribution(randomBoolean());
+        return builder;
+    }
+
+    @Override
+    protected StringStatsAggregationBuilder mutateInstance(StringStatsAggregationBuilder instance) throws IOException {
+        if (randomBoolean()) {
+            StringStatsAggregationBuilder mutant = new StringStatsAggregationBuilder(instance.getName());
+            mutant.showDistribution(!instance.showDistribution());
+            return mutant;
+        }
+        StringStatsAggregationBuilder mutant = new StringStatsAggregationBuilder(randomAlphaOfLength(4));
+        mutant.showDistribution(instance.showDistribution());
+        return mutant;
+    }
+
+    public void testClientBuilder() throws IOException {
+        AbstractXContentTestCase.xContentTester(
+                this::createParser, this::createTestInstance, this::toXContentThroughClientBuilder,
+                p -> {
+                    p.nextToken();
+                    AggregatorFactories.Builder b = AggregatorFactories.parseAggregators(p);
+                    assertThat(b.getAggregatorFactories(), hasSize(1));
+                    assertThat(b.getPipelineAggregatorFactories(), empty());
+                    return (StringStatsAggregationBuilder) b.getAggregatorFactories().iterator().next();
+                } ).test();
+    }
+
+    private void toXContentThroughClientBuilder(StringStatsAggregationBuilder serverBuilder, XContentBuilder builder) throws IOException {
+        builder.startObject();
+        createClientBuilder(serverBuilder).toXContent(builder, ToXContent.EMPTY_PARAMS);
+        builder.endObject();
+    }
+
+    private org.elasticsearch.client.analytics.StringStatsAggregationBuilder createClientBuilder(
+            StringStatsAggregationBuilder serverBuilder) {
+        org.elasticsearch.client.analytics.StringStatsAggregationBuilder builder =
+                new org.elasticsearch.client.analytics.StringStatsAggregationBuilder(serverBuilder.getName());
+        return builder.showDistribution(serverBuilder.showDistribution());
+    }
+}