瀏覽代碼

serialize suggestion responses as named writeables (#30284)

Suggestion responses were previously serialized as streamables which
made writing suggesters in plugins with custom suggestion response types
impossible. This commit makes them serialized as named writeables and
provides a facility for registering a reader for suggestion responses
when registering a suggester.

This also makes Suggestion responses abstract, requiring a suggester
implementation to provide its own types. Suggesters which do not need
anything additional to what is defined in Suggest.Suggestion should
provide a minimal subclass.

The existing plugin suggester integration tests are removed and
replaced with an equivalent implementation as an example
plugin.
Andy Bristol 7 年之前
父節點
當前提交
8bfb0f3f8d
共有 30 個文件被更改,包括 1269 次插入562 次删除
  1. 6 3
      client/rest-high-level/src/main/java/org/elasticsearch/client/RestHighLevelClient.java
  2. 7 1
      docs/reference/release-notes/7.0.0-alpha1.asciidoc
  3. 33 0
      plugins/examples/custom-suggester/build.gradle
  4. 62 0
      plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggester.java
  5. 40 0
      plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggesterPlugin.java
  6. 227 0
      plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestion.java
  7. 143 0
      plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestionBuilder.java
  8. 35 0
      plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestionContext.java
  9. 37 0
      plugins/examples/custom-suggester/src/test/java/org/elasticsearch/example/customsuggester/CustomSuggesterClientYamlTestSuiteIT.java
  10. 13 0
      plugins/examples/custom-suggester/src/test/resources/rest-api-spec/test/custom-suggester/10_basic.yml
  11. 55 0
      plugins/examples/custom-suggester/src/test/resources/rest-api-spec/test/custom-suggester/20_suggest.yml
  12. 43 12
      server/src/main/java/org/elasticsearch/plugins/SearchPlugin.java
  13. 16 3
      server/src/main/java/org/elasticsearch/search/SearchModule.java
  14. 2 2
      server/src/main/java/org/elasticsearch/search/internal/InternalSearchResponse.java
  15. 1 1
      server/src/main/java/org/elasticsearch/search/query/QuerySearchResult.java
  16. 177 171
      server/src/main/java/org/elasticsearch/search/suggest/Suggest.java
  17. 52 31
      server/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestion.java
  18. 3 1
      server/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestionBuilder.java
  19. 2 2
      server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggester.java
  20. 87 11
      server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggestion.java
  21. 1 1
      server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggestionBuilder.java
  22. 62 24
      server/src/main/java/org/elasticsearch/search/suggest/term/TermSuggestion.java
  23. 1 1
      server/src/main/java/org/elasticsearch/search/suggest/term/TermSuggestionBuilder.java
  24. 99 5
      server/src/test/java/org/elasticsearch/search/SearchModuleTests.java
  25. 0 63
      server/src/test/java/org/elasticsearch/search/suggest/CustomSuggester.java
  26. 0 212
      server/src/test/java/org/elasticsearch/search/suggest/CustomSuggesterSearchIT.java
  27. 50 6
      server/src/test/java/org/elasticsearch/search/suggest/SuggestTests.java
  28. 5 4
      server/src/test/java/org/elasticsearch/search/suggest/SuggestionEntryTests.java
  29. 4 3
      server/src/test/java/org/elasticsearch/search/suggest/SuggestionOptionTests.java
  30. 6 5
      server/src/test/java/org/elasticsearch/search/suggest/SuggestionTests.java

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

@@ -163,8 +163,11 @@ import org.elasticsearch.search.aggregations.pipeline.derivative.DerivativePipel
 import org.elasticsearch.search.aggregations.pipeline.derivative.ParsedDerivative;
 import org.elasticsearch.search.suggest.Suggest;
 import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
+import org.elasticsearch.search.suggest.completion.CompletionSuggestionBuilder;
 import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
+import org.elasticsearch.search.suggest.phrase.PhraseSuggestionBuilder;
 import org.elasticsearch.search.suggest.term.TermSuggestion;
+import org.elasticsearch.search.suggest.term.TermSuggestionBuilder;
 
 import java.io.Closeable;
 import java.io.IOException;
@@ -1141,11 +1144,11 @@ public class RestHighLevelClient implements Closeable {
         List<NamedXContentRegistry.Entry> entries = map.entrySet().stream()
                 .map(entry -> new NamedXContentRegistry.Entry(Aggregation.class, new ParseField(entry.getKey()), entry.getValue()))
                 .collect(Collectors.toList());
-        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(TermSuggestion.NAME),
+        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(TermSuggestionBuilder.SUGGESTION_NAME),
                 (parser, context) -> TermSuggestion.fromXContent(parser, (String)context)));
-        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(PhraseSuggestion.NAME),
+        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(PhraseSuggestionBuilder.SUGGESTION_NAME),
                 (parser, context) -> PhraseSuggestion.fromXContent(parser, (String)context)));
-        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(CompletionSuggestion.NAME),
+        entries.add(new NamedXContentRegistry.Entry(Suggest.Suggestion.class, new ParseField(CompletionSuggestionBuilder.SUGGESTION_NAME),
                 (parser, context) -> CompletionSuggestion.fromXContent(parser, (String)context)));
         return entries;
     }

+ 7 - 1
docs/reference/release-notes/7.0.0-alpha1.asciidoc

@@ -21,4 +21,10 @@ Aggregations::
 * The Percentiles and PercentileRanks aggregations now return `null` in the REST response,
   instead of `NaN`.  This makes it consistent with the rest of the aggregations.  Note:
   this only applies to the REST response, the java objects continue to return `NaN` (also
-  consistent with other aggregations)
+  consistent with other aggregations)
+
+Suggesters::
+* Plugins that register suggesters can now define their own types of suggestions and must
+  explicitly indicate the type of suggestion that they produce. Existing plugins will
+  require changes to their plugin registration. See the `custom-suggester` example
+  plugin {pull}30284[#30284]

+ 33 - 0
plugins/examples/custom-suggester/build.gradle

@@ -0,0 +1,33 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'elasticsearch.esplugin'
+
+esplugin {
+    name 'custom-suggester'
+    description 'An example plugin showing how to write and register a custom suggester'
+    classname 'org.elasticsearch.example.customsuggester.CustomSuggesterPlugin'
+}
+
+integTestCluster {
+    numNodes = 2
+}
+
+// this plugin has no unit tests, only rest tests
+tasks.test.enabled = false

+ 62 - 0
plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggester.java

@@ -0,0 +1,62 @@
+/*
+ * 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.example.customsuggester;
+
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.util.CharsRefBuilder;
+import org.elasticsearch.common.text.Text;
+import org.elasticsearch.search.suggest.Suggest;
+import org.elasticsearch.search.suggest.Suggester;
+
+import java.util.Locale;
+
+public class CustomSuggester extends Suggester<CustomSuggestionContext> {
+
+    // This is a pretty dumb implementation which returns the original text + fieldName + custom config option + 12 or 123
+    @Override
+    public Suggest.Suggestion<? extends Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>> innerExecute(
+        String name,
+        CustomSuggestionContext suggestion,
+        IndexSearcher searcher,
+        CharsRefBuilder spare) {
+
+        // Get the suggestion context
+        String text = suggestion.getText().utf8ToString();
+
+        // create two suggestions with 12 and 123 appended
+        CustomSuggestion response = new CustomSuggestion(name, suggestion.getSize(), "suggestion-dummy-value");
+
+        CustomSuggestion.Entry entry = new CustomSuggestion.Entry(new Text(text), 0, text.length(), "entry-dummy-value");
+
+        String firstOption =
+            String.format(Locale.ROOT, "%s-%s-%s-%s", text, suggestion.getField(), suggestion.options.get("suffix"), "12");
+        CustomSuggestion.Entry.Option option12 = new CustomSuggestion.Entry.Option(new Text(firstOption), 0.9f, "option-dummy-value-1");
+        entry.addOption(option12);
+
+        String secondOption =
+            String.format(Locale.ROOT, "%s-%s-%s-%s", text, suggestion.getField(), suggestion.options.get("suffix"), "123");
+        CustomSuggestion.Entry.Option option123 = new CustomSuggestion.Entry.Option(new Text(secondOption), 0.8f, "option-dummy-value-2");
+        entry.addOption(option123);
+
+        response.addTerm(entry);
+
+        return response;
+    }
+}

+ 40 - 0
plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggesterPlugin.java

@@ -0,0 +1,40 @@
+/*
+ * 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.example.customsuggester;
+
+import org.elasticsearch.plugins.Plugin;
+import org.elasticsearch.plugins.SearchPlugin;
+
+import java.util.Collections;
+import java.util.List;
+
+public class CustomSuggesterPlugin extends Plugin implements SearchPlugin {
+    @Override
+    public List<SearchPlugin.SuggesterSpec<?>> getSuggesters() {
+        return Collections.singletonList(
+            new SearchPlugin.SuggesterSpec<>(
+                CustomSuggestionBuilder.SUGGESTION_NAME,
+                CustomSuggestionBuilder::new,
+                CustomSuggestionBuilder::fromXContent,
+                CustomSuggestion::new
+            )
+        );
+    }
+}

+ 227 - 0
plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestion.java

@@ -0,0 +1,227 @@
+/*
+ * 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.example.customsuggester;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.text.Text;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
+import org.elasticsearch.common.xcontent.ObjectParser;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.search.suggest.Suggest;
+
+import java.io.IOException;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+
+public class CustomSuggestion extends Suggest.Suggestion<CustomSuggestion.Entry> {
+
+    public static final int TYPE = 999;
+
+    public static final ParseField DUMMY = new ParseField("dummy");
+
+    private String dummy;
+
+    public CustomSuggestion(String name, int size, String dummy) {
+        super(name, size);
+        this.dummy = dummy;
+    }
+
+    public CustomSuggestion(StreamInput in) throws IOException {
+        super(in);
+        dummy = in.readString();
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+        out.writeString(dummy);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return CustomSuggestionBuilder.SUGGESTION_NAME;
+    }
+
+    @Override
+    public int getWriteableType() {
+        return TYPE;
+    }
+
+    /**
+     * A meaningless value used to test that plugin suggesters can add fields to their Suggestion types
+     *
+     * This can't be serialized to xcontent because Suggestions appear in xcontent as an array of entries, so there is no place
+     * to add a custom field. But we can still use a custom field internally and use it to define a Suggestion's behavior
+     */
+    public String getDummy() {
+        return dummy;
+    }
+
+    @Override
+    protected Entry newEntry() {
+        return new Entry();
+    }
+
+    @Override
+    protected Entry newEntry(StreamInput in) throws IOException {
+        return new Entry(in);
+    }
+
+    public static CustomSuggestion fromXContent(XContentParser parser, String name) throws IOException {
+        CustomSuggestion suggestion = new CustomSuggestion(name, -1, null);
+        parseEntries(parser, suggestion, Entry::fromXContent);
+        return suggestion;
+    }
+
+    public static class Entry extends Suggest.Suggestion.Entry<CustomSuggestion.Entry.Option> {
+
+        private static final ObjectParser<Entry, Void> PARSER = new ObjectParser<>("CustomSuggestionEntryParser", true, Entry::new);
+
+        static {
+            declareCommonFields(PARSER);
+            PARSER.declareString((entry, dummy) -> entry.dummy = dummy, DUMMY);
+            PARSER.declareObjectArray(Entry::addOptions, (p, c) -> Option.fromXContent(p), new ParseField(OPTIONS));
+        }
+
+        private String dummy;
+
+        public Entry() {}
+
+        public Entry(Text text, int offset, int length, String dummy) {
+            super(text, offset, length);
+            this.dummy = dummy;
+        }
+
+        public Entry(StreamInput in) throws IOException {
+            super(in);
+            dummy = in.readString();
+        }
+
+        @Override
+        public void writeTo(StreamOutput out) throws IOException {
+            super.writeTo(out);
+            out.writeString(dummy);
+        }
+
+        @Override
+        protected Option newOption() {
+            return new Option();
+        }
+
+        @Override
+        protected Option newOption(StreamInput in) throws IOException {
+            return new Option(in);
+        }
+
+        /*
+         * the value of dummy will always be the same, so this just tests that we can merge entries with custom fields
+         */
+        @Override
+        protected void merge(Suggest.Suggestion.Entry<Option> otherEntry) {
+            dummy = ((Entry) otherEntry).getDummy();
+        }
+
+        /**
+         * Meaningless field used to test that plugin suggesters can add fields to their entries
+         */
+        public String getDummy() {
+            return dummy;
+        }
+
+        @Override
+        public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+            builder = super.toXContent(builder, params);
+            builder.field(DUMMY.getPreferredName(), getDummy());
+            return builder;
+        }
+
+        public static Entry fromXContent(XContentParser parser) {
+            return PARSER.apply(parser, null);
+        }
+
+        public static class Option extends Suggest.Suggestion.Entry.Option {
+
+            private static final ConstructingObjectParser<Option, Void> PARSER = new ConstructingObjectParser<>(
+                "CustomSuggestionObjectParser", true,
+                args -> {
+                    Text text = new Text((String) args[0]);
+                    float score = (float) args[1];
+                    String dummy = (String) args[2];
+                    return new Option(text, score, dummy);
+                });
+
+            static {
+                PARSER.declareString(constructorArg(), TEXT);
+                PARSER.declareFloat(constructorArg(), SCORE);
+                PARSER.declareString(constructorArg(), DUMMY);
+            }
+
+            private String dummy;
+
+            public Option() {}
+
+            public Option(Text text, float score, String dummy) {
+                super(text, score);
+                this.dummy = dummy;
+            }
+
+            public Option(StreamInput in) throws IOException {
+                super(in);
+                dummy = in.readString();
+            }
+
+            @Override
+            public void writeTo(StreamOutput out) throws IOException {
+                super.writeTo(out);
+                out.writeString(dummy);
+            }
+
+            /**
+             * A meaningless value used to test that plugin suggesters can add fields to their options
+             */
+            public String getDummy() {
+                return dummy;
+            }
+
+            /*
+             * the value of dummy will always be the same, so this just tests that we can merge options with custom fields
+             */
+            @Override
+            protected void mergeInto(Suggest.Suggestion.Entry.Option otherOption) {
+                super.mergeInto(otherOption);
+                dummy = ((Option) otherOption).getDummy();
+            }
+
+            @Override
+            public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+                builder = super.toXContent(builder, params);
+                builder.field(DUMMY.getPreferredName(), dummy);
+                return builder;
+            }
+
+            public static Option fromXContent(XContentParser parser) {
+                return PARSER.apply(parser, null);
+            }
+        }
+    }
+}

+ 143 - 0
plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestionBuilder.java

@@ -0,0 +1,143 @@
+/*
+ * 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.example.customsuggester;
+
+import org.elasticsearch.common.ParseField;
+import org.elasticsearch.common.ParsingException;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.common.lucene.BytesRefs;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.common.xcontent.XContentParser;
+import org.elasticsearch.index.query.QueryShardContext;
+import org.elasticsearch.search.suggest.SuggestionBuilder;
+import org.elasticsearch.search.suggest.SuggestionSearchContext;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+public class CustomSuggestionBuilder extends SuggestionBuilder<CustomSuggestionBuilder> {
+
+    public static final String SUGGESTION_NAME = "custom";
+
+    protected static final ParseField RANDOM_SUFFIX_FIELD = new ParseField("suffix");
+
+    private String randomSuffix;
+
+    public CustomSuggestionBuilder(String randomField, String randomSuffix) {
+        super(randomField);
+        this.randomSuffix = randomSuffix;
+    }
+
+    /**
+     * Read from a stream.
+     */
+    public CustomSuggestionBuilder(StreamInput in) throws IOException {
+        super(in);
+        this.randomSuffix = in.readString();
+    }
+
+    @Override
+    public void doWriteTo(StreamOutput out) throws IOException {
+        out.writeString(randomSuffix);
+    }
+
+    @Override
+    protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
+        builder.field(RANDOM_SUFFIX_FIELD.getPreferredName(), randomSuffix);
+        return builder;
+    }
+
+    @Override
+    public String getWriteableName() {
+        return SUGGESTION_NAME;
+    }
+
+    @Override
+    protected boolean doEquals(CustomSuggestionBuilder other) {
+        return Objects.equals(randomSuffix, other.randomSuffix);
+    }
+
+    @Override
+    protected int doHashCode() {
+        return Objects.hash(randomSuffix);
+    }
+
+    public static CustomSuggestionBuilder fromXContent(XContentParser parser) throws IOException {
+        XContentParser.Token token;
+        String currentFieldName = null;
+        String fieldname = null;
+        String suffix = null;
+        String analyzer = null;
+        int sizeField = -1;
+        int shardSize = -1;
+        while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
+            if (token == XContentParser.Token.FIELD_NAME) {
+                currentFieldName = parser.currentName();
+            } else if (token.isValue()) {
+                if (SuggestionBuilder.ANALYZER_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    analyzer = parser.text();
+                } else if (SuggestionBuilder.FIELDNAME_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    fieldname = parser.text();
+                } else if (SuggestionBuilder.SIZE_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    sizeField = parser.intValue();
+                } else if (SuggestionBuilder.SHARDSIZE_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    shardSize = parser.intValue();
+                } else if (RANDOM_SUFFIX_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+                    suffix = parser.text();
+                }
+            } else {
+                throw new ParsingException(parser.getTokenLocation(),
+                    "suggester[custom] doesn't support field [" + currentFieldName + "]");
+            }
+        }
+
+        // now we should have field name, check and copy fields over to the suggestion builder we return
+        if (fieldname == null) {
+            throw new ParsingException(parser.getTokenLocation(), "the required field option is missing");
+        }
+        CustomSuggestionBuilder builder = new CustomSuggestionBuilder(fieldname, suffix);
+        if (analyzer != null) {
+            builder.analyzer(analyzer);
+        }
+        if (sizeField != -1) {
+            builder.size(sizeField);
+        }
+        if (shardSize != -1) {
+            builder.shardSize(shardSize);
+        }
+        return builder;
+    }
+
+    @Override
+    public SuggestionSearchContext.SuggestionContext build(QueryShardContext context) throws IOException {
+        Map<String, Object> options = new HashMap<>();
+        options.put(FIELDNAME_FIELD.getPreferredName(), field());
+        options.put(RANDOM_SUFFIX_FIELD.getPreferredName(), randomSuffix);
+        CustomSuggestionContext customSuggestionsContext = new CustomSuggestionContext(context, options);
+        customSuggestionsContext.setField(field());
+        assert text != null;
+        customSuggestionsContext.setText(BytesRefs.toBytesRef(text));
+        return customSuggestionsContext;
+    }
+
+}

+ 35 - 0
plugins/examples/custom-suggester/src/main/java/org/elasticsearch/example/customsuggester/CustomSuggestionContext.java

@@ -0,0 +1,35 @@
+/*
+ * 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.example.customsuggester;
+
+import org.elasticsearch.index.query.QueryShardContext;
+import org.elasticsearch.search.suggest.SuggestionSearchContext;
+
+import java.util.Map;
+
+public class CustomSuggestionContext extends SuggestionSearchContext.SuggestionContext {
+
+    public Map<String, Object> options;
+
+    public CustomSuggestionContext(QueryShardContext context, Map<String, Object> options) {
+        super(new CustomSuggester(), context);
+        this.options = options;
+    }
+}

+ 37 - 0
plugins/examples/custom-suggester/src/test/java/org/elasticsearch/example/customsuggester/CustomSuggesterClientYamlTestSuiteIT.java

@@ -0,0 +1,37 @@
+/*
+ * 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.example.customsuggester;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+import org.elasticsearch.test.rest.yaml.ClientYamlTestCandidate;
+import org.elasticsearch.test.rest.yaml.ESClientYamlSuiteTestCase;
+
+public class CustomSuggesterClientYamlTestSuiteIT extends ESClientYamlSuiteTestCase {
+
+    public CustomSuggesterClientYamlTestSuiteIT(@Name("yaml") ClientYamlTestCandidate testCandidate) {
+        super(testCandidate);
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() throws Exception {
+        return ESClientYamlSuiteTestCase.createParameters();
+    }
+}

+ 13 - 0
plugins/examples/custom-suggester/src/test/resources/rest-api-spec/test/custom-suggester/10_basic.yml

@@ -0,0 +1,13 @@
+# tests that the custom suggester plugin is installed
+---
+"plugin loaded":
+    - do:
+        cluster.state: {}
+
+    # Get master node id
+    - set: { master_node: master }
+
+    - do:
+        nodes.info: {}
+
+    - contains:  { nodes.$master.plugins: { name: custom-suggester } }

+ 55 - 0
plugins/examples/custom-suggester/src/test/resources/rest-api-spec/test/custom-suggester/20_suggest.yml

@@ -0,0 +1,55 @@
+# tests that the custom suggester works
+
+# the issue that prompted serializing Suggestion as a registered named writeable was not revealed until
+# a user found that it would fail when reducing suggestions in a multi node envrionment
+# https://github.com/elastic/elasticsearch/issues/26585
+"test custom suggester":
+  - do:
+      cluster.health:
+        wait_for_nodes: 2
+
+  - is_true: cluster_name
+  - is_false: timed_out
+  - gte: { number_of_nodes: 2 }
+  - gte: { number_of_data_nodes: 2 }
+
+  - do:
+      indices.create:
+        index: test
+        body:
+          settings:
+            number_of_shards: 2
+            number_of_replicas: 0
+
+  - do:
+      bulk:
+        index: test
+        type: test
+        refresh: true
+        body: |
+          { "index": {} }
+          { "content": "these" }
+          { "index": {} }
+          { "content": "aren't" }
+          { "index": {} }
+          { "content": "actually" }
+          { "index": {} }
+          { "content": "used" }
+
+  - do:
+      search:
+        size: 0
+        index: test
+        body:
+          suggest:
+            test:
+              text: my suggestion text
+              custom:
+                field: arbitraryField
+                suffix: arbitrarySuffix
+
+  - match: { suggest.test.0.dummy: entry-dummy-value }
+  - match: { suggest.test.0.options.0.text: my suggestion text-arbitraryField-arbitrarySuffix-12 }
+  - match: { suggest.test.0.options.0.dummy: option-dummy-value-1 }
+  - match: { suggest.test.0.options.1.text: my suggestion text-arbitraryField-arbitrarySuffix-123 }
+  - match: { suggest.test.0.options.1.dummy: option-dummy-value-2 }

+ 43 - 12
server/src/main/java/org/elasticsearch/plugins/SearchPlugin.java

@@ -48,6 +48,7 @@ import org.elasticsearch.search.fetch.FetchSubPhase;
 import org.elasticsearch.search.fetch.subphase.highlight.Highlighter;
 import org.elasticsearch.search.rescore.RescorerBuilder;
 import org.elasticsearch.search.rescore.Rescorer;
+import org.elasticsearch.search.suggest.Suggest;
 import org.elasticsearch.search.suggest.Suggester;
 import org.elasticsearch.search.suggest.SuggestionBuilder;
 
@@ -149,31 +150,61 @@ public interface SearchPlugin {
      * Specification for a {@link Suggester}.
      */
     class SuggesterSpec<T extends SuggestionBuilder<T>> extends SearchExtensionSpec<T, CheckedFunction<XContentParser, T, IOException>> {
+
+        private Writeable.Reader<? extends Suggest.Suggestion> suggestionReader;
+
         /**
          * Specification of custom {@link Suggester}.
          *
          * @param name holds the names by which this suggester might be parsed. The {@link ParseField#getPreferredName()} is special as it
-         *        is the name by under which the reader is registered. So it is the name that the query should use as its
-         *        {@link NamedWriteable#getWriteableName()} too.
-         * @param reader the reader registered for this suggester's builder. Typically a reference to a constructor that takes a
+         *        is the name by under which the request builder and Suggestion response readers are registered. So it is the name that the
+         *        query and Suggestion response should use as their {@link NamedWriteable#getWriteableName()} return values too.
+         * @param builderReader the reader registered for this suggester's builder. Typically a reference to a constructor that takes a
          *        {@link StreamInput}
-         * @param parser the parser the reads the query suggester from xcontent
+         * @param builderParser a parser that reads the suggester's builder from xcontent
+         * @param suggestionReader the reader registered for this suggester's Suggestion response. Typically a reference to a constructor
+         *        that takes a {@link StreamInput}
          */
-        public SuggesterSpec(ParseField name, Writeable.Reader<T> reader, CheckedFunction<XContentParser, T, IOException> parser) {
-            super(name, reader, parser);
+        public SuggesterSpec(
+                ParseField name,
+                Writeable.Reader<T> builderReader,
+                CheckedFunction<XContentParser, T, IOException> builderParser,
+                Writeable.Reader<? extends Suggest.Suggestion> suggestionReader) {
+
+            super(name, builderReader, builderParser);
+            setSuggestionReader(suggestionReader);
         }
 
         /**
          * Specification of custom {@link Suggester}.
          *
-         * @param name the name by which this suggester might be parsed or deserialized. Make sure that the query builder returns this name
-         *        for {@link NamedWriteable#getWriteableName()}.
-         * @param reader the reader registered for this suggester's builder. Typically a reference to a constructor that takes a
+         * @param name the name by which this suggester might be parsed or deserialized. Make sure that the query builder and Suggestion
+         *        response reader return this name for {@link NamedWriteable#getWriteableName()}.
+         * @param builderReader the reader registered for this suggester's builder. Typically a reference to a constructor that takes a
          *        {@link StreamInput}
-         * @param parser the parser the reads the suggester builder from xcontent
+         * @param builderParser a parser that reads the suggester's builder from xcontent
+         * @param suggestionReader the reader registered for this suggester's Suggestion response. Typically a reference to a constructor
+         *        that takes a {@link StreamInput}
          */
-        public SuggesterSpec(String name, Writeable.Reader<T> reader, CheckedFunction<XContentParser, T, IOException> parser) {
-            super(name, reader, parser);
+        public SuggesterSpec(
+                String name,
+                Writeable.Reader<T> builderReader,
+                CheckedFunction<XContentParser, T, IOException> builderParser,
+                Writeable.Reader<? extends Suggest.Suggestion> suggestionReader) {
+
+            super(name, builderReader, builderParser);
+            setSuggestionReader(suggestionReader);
+        }
+
+        private void setSuggestionReader(Writeable.Reader<? extends Suggest.Suggestion> reader) {
+            this.suggestionReader = reader;
+        }
+
+        /**
+         * Returns the reader used to read the {@link Suggest.Suggestion} generated by this suggester
+         */
+        public Writeable.Reader<? extends Suggest.Suggestion> getSuggestionReader() {
+            return this.suggestionReader;
         }
     }
 

+ 16 - 3
server/src/main/java/org/elasticsearch/search/SearchModule.java

@@ -247,13 +247,17 @@ import org.elasticsearch.search.sort.GeoDistanceSortBuilder;
 import org.elasticsearch.search.sort.ScoreSortBuilder;
 import org.elasticsearch.search.sort.ScriptSortBuilder;
 import org.elasticsearch.search.sort.SortBuilder;
+import org.elasticsearch.search.suggest.Suggest;
 import org.elasticsearch.search.suggest.SuggestionBuilder;
+import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
 import org.elasticsearch.search.suggest.completion.CompletionSuggestionBuilder;
 import org.elasticsearch.search.suggest.phrase.Laplace;
 import org.elasticsearch.search.suggest.phrase.LinearInterpolation;
+import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
 import org.elasticsearch.search.suggest.phrase.PhraseSuggestionBuilder;
 import org.elasticsearch.search.suggest.phrase.SmoothingModel;
 import org.elasticsearch.search.suggest.phrase.StupidBackoff;
+import org.elasticsearch.search.suggest.term.TermSuggestion;
 import org.elasticsearch.search.suggest.term.TermSuggestionBuilder;
 
 import java.util.ArrayList;
@@ -590,9 +594,14 @@ public class SearchModule {
     private void registerSuggesters(List<SearchPlugin> plugins) {
         registerSmoothingModels(namedWriteables);
 
-        registerSuggester(new SuggesterSpec<>("term", TermSuggestionBuilder::new, TermSuggestionBuilder::fromXContent));
-        registerSuggester(new SuggesterSpec<>("phrase", PhraseSuggestionBuilder::new, PhraseSuggestionBuilder::fromXContent));
-        registerSuggester(new SuggesterSpec<>("completion", CompletionSuggestionBuilder::new, CompletionSuggestionBuilder::fromXContent));
+        registerSuggester(new SuggesterSpec<>(TermSuggestionBuilder.SUGGESTION_NAME,
+            TermSuggestionBuilder::new, TermSuggestionBuilder::fromXContent, TermSuggestion::new));
+
+        registerSuggester(new SuggesterSpec<>(PhraseSuggestionBuilder.SUGGESTION_NAME,
+            PhraseSuggestionBuilder::new, PhraseSuggestionBuilder::fromXContent, PhraseSuggestion::new));
+
+        registerSuggester(new SuggesterSpec<>(CompletionSuggestionBuilder.SUGGESTION_NAME,
+            CompletionSuggestionBuilder::new, CompletionSuggestionBuilder::fromXContent, CompletionSuggestion::new));
 
         registerFromPlugin(plugins, SearchPlugin::getSuggesters, this::registerSuggester);
     }
@@ -602,6 +611,10 @@ public class SearchModule {
                 SuggestionBuilder.class, suggester.getName().getPreferredName(), suggester.getReader()));
         namedXContents.add(new NamedXContentRegistry.Entry(SuggestionBuilder.class, suggester.getName(),
                 suggester.getParser()));
+
+        namedWriteables.add(new NamedWriteableRegistry.Entry(
+            Suggest.Suggestion.class, suggester.getName().getPreferredName(), suggester.getSuggestionReader()
+        ));
     }
 
     private Map<String, Highlighter> setupHighlighters(Settings settings, List<SearchPlugin> plugins) {

+ 2 - 2
server/src/main/java/org/elasticsearch/search/internal/InternalSearchResponse.java

@@ -50,7 +50,7 @@ public class InternalSearchResponse extends SearchResponseSections implements Wr
         super(
                 SearchHits.readSearchHits(in),
                 in.readBoolean() ? InternalAggregations.readAggregations(in) : null,
-                in.readBoolean() ? Suggest.readSuggest(in) : null,
+                in.readBoolean() ? new Suggest(in) : null,
                 in.readBoolean(),
                 in.readOptionalBoolean(),
                 in.readOptionalWriteable(SearchProfileShardResults::new),
@@ -62,7 +62,7 @@ public class InternalSearchResponse extends SearchResponseSections implements Wr
     public void writeTo(StreamOutput out) throws IOException {
         hits.writeTo(out);
         out.writeOptionalStreamable((InternalAggregations)aggregations);
-        out.writeOptionalStreamable(suggest);
+        out.writeOptionalWriteable(suggest);
         out.writeBoolean(timedOut);
         out.writeOptionalBoolean(terminatedEarly);
         out.writeOptionalWriteable(profileResults);

+ 1 - 1
server/src/main/java/org/elasticsearch/search/query/QuerySearchResult.java

@@ -293,7 +293,7 @@ public final class QuerySearchResult extends SearchPhaseResult {
         pipelineAggregators = in.readNamedWriteableList(PipelineAggregator.class).stream().map(a -> (SiblingPipelineAggregator) a)
                 .collect(Collectors.toList());
         if (in.readBoolean()) {
-            suggest = Suggest.readSuggest(in);
+            suggest = new Suggest(in);
         }
         searchTimedOut = in.readBoolean();
         terminatedEarly = in.readOptionalBoolean();

+ 177 - 171
server/src/main/java/org/elasticsearch/search/suggest/Suggest.java

@@ -20,18 +20,18 @@ package org.elasticsearch.search.suggest;
 
 import org.apache.lucene.util.CollectionUtil;
 import org.apache.lucene.util.SetOnce;
+import org.elasticsearch.Version;
 import org.elasticsearch.common.CheckedFunction;
 import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.common.Strings;
+import org.elasticsearch.common.io.stream.NamedWriteable;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.io.stream.Streamable;
+import org.elasticsearch.common.io.stream.Writeable;
 import org.elasticsearch.common.text.Text;
-import org.elasticsearch.common.xcontent.ConstructingObjectParser;
 import org.elasticsearch.common.xcontent.ObjectParser;
 import org.elasticsearch.common.xcontent.ToXContentFragment;
-import org.elasticsearch.common.xcontent.ToXContentObject;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentFactory;
 import org.elasticsearch.common.xcontent.XContentParser;
@@ -53,16 +53,15 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
-import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
-import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
 import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
 
 /**
  * Top level suggest result, containing the result for each suggestion.
  */
-public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? extends Option>>>, Streamable, ToXContentFragment {
+public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? extends Option>>>, Writeable, ToXContentFragment {
 
     public static final String NAME = "suggest";
 
@@ -92,6 +91,40 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         this.hasScoreDocs = filter(CompletionSuggestion.class).stream().anyMatch(CompletionSuggestion::hasScoreDocs);
     }
 
+    public Suggest(StreamInput in) throws IOException {
+        // in older versions, Suggestion types were serialized as Streamable
+        if (in.getVersion().before(Version.V_7_0_0_alpha1)) {
+            final int size = in.readVInt();
+            suggestions = new ArrayList<>(size);
+            for (int i = 0; i < size; i++) {
+                Suggestion<? extends Entry<? extends Option>> suggestion;
+                final int type = in.readVInt();
+                switch (type) {
+                    case TermSuggestion.TYPE:
+                        suggestion = new TermSuggestion(in);
+                        break;
+                    case CompletionSuggestion.TYPE:
+                        suggestion = new CompletionSuggestion(in);
+                        break;
+                    case PhraseSuggestion.TYPE:
+                        suggestion = new PhraseSuggestion(in);
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Unknown suggestion type with ordinal " + type);
+                }
+                suggestions.add(suggestion);
+            }
+        } else {
+            int suggestionCount = in.readVInt();
+            suggestions = new ArrayList<>(suggestionCount);
+            for (int i = 0; i < suggestionCount; i++) {
+                suggestions.add(in.readNamedWriteable(Suggestion.class));
+            }
+        }
+
+        hasScoreDocs = filter(CompletionSuggestion.class).stream().anyMatch(CompletionSuggestion::hasScoreDocs);
+    }
+
     @Override
     public Iterator<Suggestion<? extends Entry<? extends Option>>> iterator() {
         return suggestions.iterator();
@@ -125,42 +158,20 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         return hasScoreDocs;
     }
 
-    @Override
-    public void readFrom(StreamInput in) throws IOException {
-        final int size = in.readVInt();
-        suggestions = new ArrayList<>(size);
-        for (int i = 0; i < size; i++) {
-            // TODO: remove these complicated generics
-            Suggestion<? extends Entry<? extends Option>> suggestion;
-            final int type = in.readVInt();
-            switch (type) {
-            case TermSuggestion.TYPE:
-                suggestion = new TermSuggestion();
-                break;
-            case CompletionSuggestion.TYPE:
-                suggestion = new CompletionSuggestion();
-                break;
-            case 2: // CompletionSuggestion.TYPE
-                throw new IllegalArgumentException("Completion suggester 2.x is not supported anymore");
-            case PhraseSuggestion.TYPE:
-                suggestion = new PhraseSuggestion();
-                break;
-            default:
-                suggestion = new Suggestion();
-                break;
-            }
-            suggestion.readFrom(in);
-            suggestions.add(suggestion);
-        }
-        hasScoreDocs = filter(CompletionSuggestion.class).stream().anyMatch(CompletionSuggestion::hasScoreDocs);
-    }
-
     @Override
     public void writeTo(StreamOutput out) throws IOException {
-        out.writeVInt(suggestions.size());
-        for (Suggestion<?> command : suggestions) {
-            out.writeVInt(command.getWriteableType());
-            command.writeTo(out);
+        // in older versions, Suggestion types were serialized as Streamable
+        if (out.getVersion().before(Version.V_7_0_0_alpha1)) {
+            out.writeVInt(suggestions.size());
+            for (Suggestion<?> command : suggestions) {
+                out.writeVInt(command.getWriteableType());
+                command.writeTo(out);
+            }
+        } else {
+            out.writeVInt(suggestions.size());
+            for (Suggestion<? extends Entry<? extends Option>> suggestion : suggestions) {
+                out.writeNamedWriteable(suggestion);
+            }
         }
     }
 
@@ -195,12 +206,6 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         return new Suggest(suggestions);
     }
 
-    public static Suggest readSuggest(StreamInput in) throws IOException {
-        Suggest result = new Suggest();
-        result.readFrom(in);
-        return result;
-    }
-
     public static List<Suggestion<? extends Entry<? extends Option>>> reduce(Map<String, List<Suggest.Suggestion>> groupedSuggestions) {
         List<Suggestion<? extends Entry<? extends Option>>> reduced = new ArrayList<>(groupedSuggestions.size());
         for (java.util.Map.Entry<String, List<Suggestion>> unmergedResults : groupedSuggestions.entrySet()) {
@@ -232,10 +237,27 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
             .collect(Collectors.toList());
     }
 
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        }
+        if (other == null || getClass() != other.getClass()) {
+            return false;
+        }
+
+        return Objects.equals(suggestions, ((Suggest) other).suggestions);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(suggestions);
+    }
+
     /**
      * The suggestion responses corresponding with the suggestions in the request.
      */
-    public static class Suggestion<T extends Suggestion.Entry> implements Iterable<T>, Streamable, ToXContentFragment {
+    public abstract static class Suggestion<T extends Suggestion.Entry> implements Iterable<T>, NamedWriteable, ToXContentFragment {
 
         private static final String NAME = "suggestion";
 
@@ -252,6 +274,24 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
             this.size = size; // The suggested term size specified in request, only used for merging shard responses
         }
 
+        public Suggestion(StreamInput in) throws IOException {
+            name = in.readString();
+            size = in.readVInt();
+
+            // this is a hack to work around slightly different serialization order of earlier versions of TermSuggestion
+            if (in.getVersion().before(Version.V_7_0_0_alpha1) && this instanceof TermSuggestion) {
+                TermSuggestion t = (TermSuggestion) this;
+                t.setSort(SortBy.readFromStream(in));
+            }
+
+            int entriesCount = in.readVInt();
+            entries.clear();
+            for (int i = 0; i < entriesCount; i++) {
+                T newEntry = newEntry(in);
+                entries.add(newEntry);
+            }
+        }
+
         public void addTerm(T entry) {
             entries.add(entry);
         }
@@ -259,20 +299,14 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         /**
          * Returns a integer representing the type of the suggestion. This is used for
          * internal serialization over the network.
+         *
+         * This class is now serialized as a NamedWriteable and this method only remains for backwards compatibility
          */
-        public int getWriteableType() { // TODO remove this in favor of NamedWriteable
+        @Deprecated
+        public int getWriteableType() {
             return TYPE;
         }
 
-        /**
-         * Returns a string representing the type of the suggestion. This type is added to
-         * the suggestion name in the XContent response, so that it can later be used by
-         * REST clients to determine the internal type of the suggestion.
-         */
-        protected String getType() {
-            return NAME;
-        }
-
         @Override
         public Iterator<T> iterator() {
             return entries.iterator();
@@ -346,57 +380,67 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
             }
         }
 
-        @Override
-        public void readFrom(StreamInput in) throws IOException {
-            innerReadFrom(in);
-            int size = in.readVInt();
-            entries.clear();
-            for (int i = 0; i < size; i++) {
-                T newEntry = newEntry();
-                newEntry.readFrom(in);
-                entries.add(newEntry);
-            }
-        }
-
-        protected T newEntry() {
-            return (T)new Entry();
-        }
-
-
-        protected void innerReadFrom(StreamInput in) throws IOException {
-            name = in.readString();
-            size = in.readVInt();
-        }
+        protected abstract T newEntry();
+        protected abstract T newEntry(StreamInput in) throws IOException;
 
         @Override
         public void writeTo(StreamOutput out) throws IOException {
-            innerWriteTo(out);
+            out.writeString(name);
+            out.writeVInt(size);
+
+            // this is a hack to work around slightly different serialization order in older versions of TermSuggestion
+            if (out.getVersion().before(Version.V_7_0_0_alpha1) && this instanceof TermSuggestion) {
+                TermSuggestion termSuggestion = (TermSuggestion) this;
+                termSuggestion.getSort().writeTo(out);
+            }
+
             out.writeVInt(entries.size());
             for (Entry<?> entry : entries) {
                 entry.writeTo(out);
             }
         }
 
-        public void innerWriteTo(StreamOutput out) throws IOException {
-            out.writeString(name);
-            out.writeVInt(size);
-        }
+        @Override
+        public abstract String getWriteableName();
 
         @Override
         public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
             if (params.paramAsBoolean(RestSearchAction.TYPED_KEYS_PARAM, false)) {
                 // Concatenates the type and the name of the suggestion (ex: completion#foo)
-                builder.startArray(String.join(Aggregation.TYPED_KEYS_DELIMITER, getType(), getName()));
+                builder.startArray(String.join(Aggregation.TYPED_KEYS_DELIMITER, getWriteableName(), getName()));
             } else {
                 builder.startArray(getName());
             }
             for (Entry<?> entry : entries) {
+                builder.startObject();
                 entry.toXContent(builder, params);
+                builder.endObject();
             }
             builder.endArray();
             return builder;
         }
 
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+
+            if (other == null || getClass() != other.getClass()) {
+                return false;
+            }
+
+            Suggestion otherSuggestion = (Suggestion) other;
+            return Objects.equals(name, otherSuggestion.name)
+                && Objects.equals(size, otherSuggestion.size)
+                && Objects.equals(entries, otherSuggestion.entries);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name, size, entries);
+        }
+
         @SuppressWarnings("unchecked")
         public static Suggestion<? extends Entry<? extends Option>> fromXContent(XContentParser parser) throws IOException {
             ensureExpectedToken(XContentParser.Token.START_ARRAY, parser.currentToken(), parser::getTokenLocation);
@@ -417,7 +461,7 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
         /**
          * Represents a part from the suggest text with suggested options.
          */
-        public static class Entry<O extends Entry.Option> implements Iterable<O>, Streamable, ToXContentObject {
+        public abstract static class Entry<O extends Option> implements Iterable<O>, Writeable, ToXContentFragment {
 
             private static final String TEXT = "text";
             private static final String OFFSET = "offset";
@@ -436,7 +480,18 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
                 this.length = length;
             }
 
-            protected Entry() {
+            protected Entry() {}
+
+            public Entry(StreamInput in) throws IOException {
+                text = in.readText();
+                offset = in.readVInt();
+                length = in.readVInt();
+                int suggestedWords = in.readVInt();
+                options = new ArrayList<>(suggestedWords);
+                for (int j = 0; j < suggestedWords; j++) {
+                    O newOption = newOption(in);
+                    options.add(newOption);
+                }
             }
 
             public void addOption(O option) {
@@ -534,44 +589,27 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
 
             @Override
             public boolean equals(Object o) {
-                if (this == o) return true;
-                if (o == null || getClass() != o.getClass()) return false;
+                if (this == o) {
+                    return true;
+                }
+                if (o == null || getClass() != o.getClass()) {
+                    return false;
+                }
 
                 Entry<?> entry = (Entry<?>) o;
-
-                if (length != entry.length) return false;
-                if (offset != entry.offset) return false;
-                if (!this.text.equals(entry.text)) return false;
-
-                return true;
+                return Objects.equals(length, entry.length)
+                    && Objects.equals(offset, entry.offset)
+                    && Objects.equals(text, entry.text)
+                    && Objects.equals(options, entry.options);
             }
 
             @Override
             public int hashCode() {
-                int result = text.hashCode();
-                result = 31 * result + offset;
-                result = 31 * result + length;
-                return result;
+                return Objects.hash(text, offset, length, options);
             }
 
-            @Override
-            public void readFrom(StreamInput in) throws IOException {
-                text = in.readText();
-                offset = in.readVInt();
-                length = in.readVInt();
-                int suggestedWords = in.readVInt();
-                options = new ArrayList<>(suggestedWords);
-                for (int j = 0; j < suggestedWords; j++) {
-                    O newOption = newOption();
-                    newOption.readFrom(in);
-                    options.add(newOption);
-                }
-            }
-
-            @SuppressWarnings("unchecked")
-            protected O newOption(){
-                return (O) new Option();
-            }
+            protected abstract O newOption();
+            protected abstract O newOption(StreamInput in) throws IOException;
 
             @Override
             public void writeTo(StreamOutput out) throws IOException {
@@ -586,40 +624,29 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
 
             @Override
             public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
-                builder.startObject();
                 builder.field(TEXT, text);
                 builder.field(OFFSET, offset);
                 builder.field(LENGTH, length);
                 builder.startArray(OPTIONS);
                 for (Option option : options) {
+                    builder.startObject();
                     option.toXContent(builder, params);
+                    builder.endObject();
                 }
                 builder.endArray();
-                builder.endObject();
                 return builder;
             }
 
-            private static ObjectParser<Entry<Option>, Void> PARSER = new ObjectParser<>("SuggestionEntryParser", true, Entry::new);
-
-            static {
-                declareCommonFields(PARSER);
-                PARSER.declareObjectArray(Entry::addOptions, (p,c) -> Option.fromXContent(p), new ParseField(OPTIONS));
-            }
-
             protected static void declareCommonFields(ObjectParser<? extends Entry<? extends Option>, Void> parser) {
                 parser.declareString((entry, text) -> entry.text = new Text(text), new ParseField(TEXT));
                 parser.declareInt((entry, offset) -> entry.offset = offset, new ParseField(OFFSET));
                 parser.declareInt((entry, length) -> entry.length = length, new ParseField(LENGTH));
             }
 
-            public static Entry<? extends Option> fromXContent(XContentParser parser) {
-                return PARSER.apply(parser, null);
-            }
-
             /**
              * Contains the suggested text with its document frequency and score.
              */
-            public static class Option implements Streamable, ToXContentObject {
+            public abstract static class Option implements Writeable, ToXContentFragment {
 
                 public static final ParseField TEXT = new ParseField("text");
                 public static final ParseField HIGHLIGHTED = new ParseField("highlighted");
@@ -646,7 +673,13 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
                     this(text, null, score);
                 }
 
-                public Option() {
+                public Option() {}
+
+                public Option(StreamInput in) throws IOException {
+                    text = in.readText();
+                    score = in.readFloat();
+                    highlighted = in.readOptionalText();
+                    collateMatch = in.readOptionalBoolean();
                 }
 
                 /**
@@ -683,14 +716,6 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
                     this.score = score;
                 }
 
-                @Override
-                public void readFrom(StreamInput in) throws IOException {
-                    text = in.readText();
-                    score = in.readFloat();
-                    highlighted = in.readOptionalText();
-                    collateMatch = in.readOptionalBoolean();
-                }
-
                 @Override
                 public void writeTo(StreamOutput out) throws IOException {
                     out.writeText(text);
@@ -701,43 +726,17 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
 
                 @Override
                 public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
-                    builder.startObject();
-                    innerToXContent(builder, params);
-                    builder.endObject();
-                    return builder;
-                }
-
-                protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
                     builder.field(TEXT.getPreferredName(), text);
                     if (highlighted != null) {
                         builder.field(HIGHLIGHTED.getPreferredName(), highlighted);
                     }
+
                     builder.field(SCORE.getPreferredName(), score);
                     if (collateMatch != null) {
                         builder.field(COLLATE_MATCH.getPreferredName(), collateMatch.booleanValue());
                     }
-                    return builder;
-                }
-
-                private static final ConstructingObjectParser<Option, Void> PARSER = new ConstructingObjectParser<>("SuggestOptionParser",
-                        true, args -> {
-                            Text text = new Text((String) args[0]);
-                            float score = (Float) args[1];
-                            String highlighted = (String) args[2];
-                            Text highlightedText = highlighted == null ? null : new Text(highlighted);
-                            Boolean collateMatch = (Boolean) args[3];
-                            return new Option(text, highlightedText, score, collateMatch);
-                        });
-
-                static {
-                    PARSER.declareString(constructorArg(), TEXT);
-                    PARSER.declareFloat(constructorArg(), SCORE);
-                    PARSER.declareString(optionalConstructorArg(), HIGHLIGHTED);
-                    PARSER.declareBoolean(optionalConstructorArg(), COLLATE_MATCH);
-                }
 
-                public static Option fromXContent(XContentParser parser) {
-                    return PARSER.apply(parser, null);
+                    return builder;
                 }
 
                 protected void mergeInto(Option otherOption) {
@@ -751,18 +750,25 @@ public class Suggest implements Iterable<Suggest.Suggestion<? extends Entry<? ex
                     }
                 }
 
+                /*
+                 * We consider options equal if they have the same text, even if their other fields may differ
+                 */
                 @Override
                 public boolean equals(Object o) {
-                    if (this == o) return true;
-                    if (o == null || getClass() != o.getClass()) return false;
+                    if (this == o) {
+                        return true;
+                    }
+                    if (o == null || getClass() != o.getClass()) {
+                        return false;
+                    }
 
                     Option that = (Option) o;
-                    return text.equals(that.text);
+                    return Objects.equals(text, that.text);
                 }
 
                 @Override
                 public int hashCode() {
-                    return text.hashCode();
+                    return Objects.hash(text);
                 }
             }
         }

+ 52 - 31
server/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestion.java

@@ -66,8 +66,7 @@ import static org.elasticsearch.search.suggest.Suggest.COMPARATOR;
  */
 public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSuggestion.Entry> {
 
-    public static final String NAME = "completion";
-
+    @Deprecated
     public static final int TYPE = 4;
 
     private boolean skipDuplicates;
@@ -86,14 +85,18 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
         this.skipDuplicates = skipDuplicates;
     }
 
-    @Override
-    public void readFrom(StreamInput in) throws IOException {
-        super.readFrom(in);
+    public CompletionSuggestion(StreamInput in) throws IOException {
+        super(in);
         if (in.getVersion().onOrAfter(Version.V_6_1_0)) {
             skipDuplicates = in.readBoolean();
         }
     }
 
+    @Override
+    public String getWriteableName() {
+        return CompletionSuggestionBuilder.SUGGESTION_NAME;
+    }
+
     @Override
     public void writeTo(StreamOutput out) throws IOException {
         super.writeTo(out);
@@ -121,6 +124,17 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
         return getOptions().size() > 0;
     }
 
+    @Override
+    public boolean equals(Object other) {
+        return super.equals(other)
+            && Objects.equals(skipDuplicates, ((CompletionSuggestion) other).skipDuplicates);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), skipDuplicates);
+    }
+
     public static CompletionSuggestion fromXContent(XContentParser parser, String name) throws IOException {
         CompletionSuggestion suggestion = new CompletionSuggestion(name, -1, false);
         parseEntries(parser, suggestion, CompletionSuggestion.Entry::fromXContent);
@@ -222,13 +236,13 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
     }
 
     @Override
-    protected String getType() {
-        return NAME;
+    protected Entry newEntry() {
+        return new Entry();
     }
 
     @Override
-    protected Entry newEntry() {
-        return new Entry();
+    protected Entry newEntry(StreamInput in) throws IOException {
+        return new Entry(in);
     }
 
     public static final class Entry extends Suggest.Suggestion.Entry<CompletionSuggestion.Entry.Option> {
@@ -237,7 +251,10 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
             super(text, offset, length);
         }
 
-        Entry() {
+        Entry() {}
+
+        public Entry(StreamInput in) throws IOException {
+            super(in);
         }
 
         @Override
@@ -245,6 +262,11 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
             return new Option();
         }
 
+        @Override
+        protected Option newOption(StreamInput in) throws IOException {
+            return new Option(in);
+        }
+
         private static ObjectParser<Entry, Void> PARSER = new ObjectParser<>("CompletionSuggestionEntryParser", true,
                 Entry::new);
 
@@ -274,6 +296,25 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
                 super();
             }
 
+            public Option(StreamInput in) throws IOException {
+                super(in);
+                this.doc = Lucene.readScoreDoc(in);
+                if (in.readBoolean()) {
+                    this.hit = SearchHit.readSearchHit(in);
+                }
+                int contextSize = in.readInt();
+                this.contexts = new LinkedHashMap<>(contextSize);
+                for (int i = 0; i < contextSize; i++) {
+                    String contextName = in.readString();
+                    int nContexts = in.readVInt();
+                    Set<CharSequence> contexts = new HashSet<>(nContexts);
+                    for (int j = 0; j < nContexts; j++) {
+                        contexts.add(in.readString());
+                    }
+                    this.contexts.put(contextName, contexts);
+                }
+            }
+
             @Override
             protected void mergeInto(Suggest.Suggestion.Entry.Option otherOption) {
                 // Completion suggestions are reduced by
@@ -302,7 +343,7 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
             }
 
             @Override
-            protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
+            public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
                 builder.field(TEXT.getPreferredName(), getText());
                 if (hit != null) {
                     hit.toInnerXContent(builder, params);
@@ -375,26 +416,6 @@ public final class CompletionSuggestion extends Suggest.Suggestion<CompletionSug
                 return option;
             }
 
-            @Override
-            public void readFrom(StreamInput in) throws IOException {
-                super.readFrom(in);
-                this.doc = Lucene.readScoreDoc(in);
-                if (in.readBoolean()) {
-                    this.hit = SearchHit.readSearchHit(in);
-                }
-                int contextSize = in.readInt();
-                this.contexts = new LinkedHashMap<>(contextSize);
-                for (int i = 0; i < contextSize; i++) {
-                    String contextName = in.readString();
-                    int nContexts = in.readVInt();
-                    Set<CharSequence> contexts = new HashSet<>(nContexts);
-                    for (int j = 0; j < nContexts; j++) {
-                        contexts.add(in.readString());
-                    }
-                    this.contexts.put(contextName, contexts);
-                }
-            }
-
             @Override
             public void writeTo(StreamOutput out) throws IOException {
                 super.writeTo(out);

+ 3 - 1
server/src/main/java/org/elasticsearch/search/suggest/completion/CompletionSuggestionBuilder.java

@@ -59,10 +59,12 @@ import java.util.Objects;
 public class CompletionSuggestionBuilder extends SuggestionBuilder<CompletionSuggestionBuilder> {
 
     private static final XContentType CONTEXT_BYTES_XCONTENT_TYPE = XContentType.JSON;
-    static final String SUGGESTION_NAME = "completion";
+
     static final ParseField CONTEXTS_FIELD = new ParseField("contexts", "context");
     static final ParseField SKIP_DUPLICATES_FIELD = new ParseField("skip_duplicates");
 
+    public static final String SUGGESTION_NAME = "completion";
+
     /**
      * {
      *     "field" : STRING

+ 2 - 2
server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggester.java

@@ -133,9 +133,9 @@ public final class PhraseSuggester extends Suggester<PhraseSuggestionContext> {
                     highlighted = new Text(spare.toString());
                 }
                 if (collatePrune) {
-                    resultEntry.addOption(new Suggestion.Entry.Option(phrase, highlighted, (float) (correction.score), collateMatch));
+                    resultEntry.addOption(new PhraseSuggestion.Entry.Option(phrase, highlighted, (float) (correction.score), collateMatch));
                 } else {
-                    resultEntry.addOption(new Suggestion.Entry.Option(phrase, highlighted, (float) (correction.score)));
+                    resultEntry.addOption(new PhraseSuggestion.Entry.Option(phrase, highlighted, (float) (correction.score)));
                 }
             }
         } else {

+ 87 - 11
server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggestion.java

@@ -23,36 +23,44 @@ import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
 import org.elasticsearch.common.text.Text;
+import org.elasticsearch.common.xcontent.ConstructingObjectParser;
 import org.elasticsearch.common.xcontent.ObjectParser;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.search.suggest.Suggest;
 import org.elasticsearch.search.suggest.Suggest.Suggestion;
 
 import java.io.IOException;
+import java.util.Objects;
+
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
+import static org.elasticsearch.common.xcontent.ConstructingObjectParser.optionalConstructorArg;
 
 /**
  * Suggestion entry returned from the {@link PhraseSuggester}.
  */
 public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry> {
 
-    public static final String NAME = "phrase";
+    @Deprecated
     public static final int TYPE = 3;
 
-    public PhraseSuggestion() {
-    }
+    public PhraseSuggestion() {}
 
     public PhraseSuggestion(String name, int size) {
         super(name, size);
     }
 
+    public PhraseSuggestion(StreamInput in) throws IOException {
+        super(in);
+    }
+
     @Override
-    public int getWriteableType() {
-        return TYPE;
+    public String getWriteableName() {
+        return PhraseSuggestionBuilder.SUGGESTION_NAME;
     }
 
     @Override
-    protected String getType() {
-        return NAME;
+    public int getWriteableType() {
+        return TYPE;
     }
 
     @Override
@@ -60,6 +68,11 @@ public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry>
         return new Entry();
     }
 
+    @Override
+    protected Entry newEntry(StreamInput in) throws IOException {
+        return new Entry(in);
+    }
+
     public static PhraseSuggestion fromXContent(XContentParser parser, String name) throws IOException {
         PhraseSuggestion suggestion = new PhraseSuggestion(name, -1);
         parseEntries(parser, suggestion, PhraseSuggestion.Entry::fromXContent);
@@ -75,7 +88,15 @@ public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry>
             this.cutoffScore = cutoffScore;
         }
 
-        Entry() {
+        public Entry(Text text, int offset, int length) {
+            super(text, offset, length);
+        }
+
+        Entry() {}
+
+        public Entry(StreamInput in) throws IOException {
+            super(in);
+            cutoffScore = in.readDouble();
         }
 
         /**
@@ -118,9 +139,13 @@ public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry>
         }
 
         @Override
-        public void readFrom(StreamInput in) throws IOException {
-            super.readFrom(in);
-            cutoffScore = in.readDouble();
+        protected Option newOption() {
+            return new Option();
+        }
+
+        @Override
+        protected Option newOption(StreamInput in) throws IOException {
+            return new Option(in);
         }
 
         @Override
@@ -128,5 +153,56 @@ public class PhraseSuggestion extends Suggest.Suggestion<PhraseSuggestion.Entry>
             super.writeTo(out);
             out.writeDouble(cutoffScore);
         }
+
+        @Override
+        public boolean equals(Object other) {
+            return super.equals(other)
+                && Objects.equals(cutoffScore, ((Entry) other).cutoffScore);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(super.hashCode(), cutoffScore);
+        }
+
+        public static class Option extends Suggestion.Entry.Option {
+
+            public Option() {
+                super();
+            }
+
+            public Option(Text text, Text highlighted, float score, Boolean collateMatch) {
+                super(text, highlighted, score, collateMatch);
+            }
+
+            public Option(Text text, Text highlighted, float score) {
+                super(text, highlighted, score);
+            }
+
+            public Option(StreamInput in) throws IOException {
+                super(in);
+            }
+
+            private static final ConstructingObjectParser<Option, Void> PARSER = new ConstructingObjectParser<>("PhraseOptionParser",
+                true, args -> {
+                    Text text = new Text((String) args[0]);
+                    float score = (Float) args[1];
+                    String highlighted = (String) args[2];
+                    Text highlightedText = highlighted == null ? null : new Text(highlighted);
+                    Boolean collateMatch = (Boolean) args[3];
+                    return new Option(text, highlightedText, score, collateMatch);
+            });
+
+            static {
+                PARSER.declareString(constructorArg(), TEXT);
+                PARSER.declareFloat(constructorArg(), SCORE);
+                PARSER.declareString(optionalConstructorArg(), HIGHLIGHTED);
+                PARSER.declareBoolean(optionalConstructorArg(), COLLATE_MATCH);
+            }
+
+            public static Option fromXContent(XContentParser parser) {
+                    return PARSER.apply(parser, null);
+            }
+        }
     }
 }

+ 1 - 1
server/src/main/java/org/elasticsearch/search/suggest/phrase/PhraseSuggestionBuilder.java

@@ -59,7 +59,7 @@ import java.util.Set;
  */
 public class PhraseSuggestionBuilder extends SuggestionBuilder<PhraseSuggestionBuilder> {
 
-    private static final String SUGGESTION_NAME = "phrase";
+    public static final String SUGGESTION_NAME = "phrase";
 
     protected static final ParseField MAXERRORS_FIELD = new ParseField("max_errors");
     protected static final ParseField RWE_LIKELIHOOD_FIELD = new ParseField("real_word_error_likelihood");

+ 62 - 24
server/src/main/java/org/elasticsearch/search/suggest/term/TermSuggestion.java

@@ -19,6 +19,7 @@
 package org.elasticsearch.search.suggest.term;
 
 import org.elasticsearch.ElasticsearchException;
+import org.elasticsearch.Version;
 import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
@@ -28,11 +29,13 @@ import org.elasticsearch.common.xcontent.ObjectParser;
 import org.elasticsearch.common.xcontent.XContentBuilder;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.search.suggest.SortBy;
+import org.elasticsearch.search.suggest.Suggest;
 import org.elasticsearch.search.suggest.Suggest.Suggestion;
 import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option;
 
 import java.io.IOException;
 import java.util.Comparator;
+import java.util.Objects;
 
 import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constructorArg;
 
@@ -41,22 +44,29 @@ import static org.elasticsearch.common.xcontent.ConstructingObjectParser.constru
  */
 public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
 
-    public static final String NAME = "term";
+    @Deprecated
+    public static final int TYPE = 1;
 
     public static final Comparator<Suggestion.Entry.Option> SCORE = new Score();
     public static final Comparator<Suggestion.Entry.Option> FREQUENCY = new Frequency();
-    public static final int TYPE = 1;
 
     private SortBy sort;
 
-    public TermSuggestion() {
-    }
+    public TermSuggestion() {}
 
     public TermSuggestion(String name, int size, SortBy sort) {
         super(name, size);
         this.sort = sort;
     }
 
+    public TermSuggestion(StreamInput in) throws IOException {
+        super(in);
+
+        if (in.getVersion().onOrAfter(Version.V_7_0_0_alpha1)) {
+            sort = SortBy.readFromStream(in);
+        }
+    }
+
     // Same behaviour as comparators in suggest module, but for SuggestedWord
     // Highest score first, then highest freq first, then lowest term first
     public static class Score implements Comparator<Suggestion.Entry.Option> {
@@ -103,9 +113,12 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
         return TYPE;
     }
 
-    @Override
-    protected String getType() {
-        return NAME;
+    public void setSort(SortBy sort) {
+        this.sort = sort;
+    }
+
+    public SortBy getSort() {
+        return sort;
     }
 
     @Override
@@ -121,15 +134,17 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
     }
 
     @Override
-    protected void innerReadFrom(StreamInput in) throws IOException {
-        super.innerReadFrom(in);
-        sort = SortBy.readFromStream(in);
+    public void writeTo(StreamOutput out) throws IOException {
+        super.writeTo(out);
+
+        if (out.getVersion().onOrAfter(Version.V_7_0_0_alpha1)) {
+            sort.writeTo(out);
+        }
     }
 
     @Override
-    public void innerWriteTo(StreamOutput out) throws IOException {
-        super.innerWriteTo(out);
-        sort.writeTo(out);
+    public String getWriteableName() {
+        return TermSuggestionBuilder.SUGGESTION_NAME;
     }
 
     public static TermSuggestion fromXContent(XContentParser parser, String name) throws IOException {
@@ -144,16 +159,35 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
         return new Entry();
     }
 
+    @Override
+    protected Entry newEntry(StreamInput in) throws IOException {
+        return new Entry(in);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        return super.equals(other)
+            && Objects.equals(sort, ((TermSuggestion) other).sort);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(super.hashCode(), sort);
+    }
+
     /**
      * Represents a part from the suggest text with suggested options.
      */
-    public static class Entry extends org.elasticsearch.search.suggest.Suggest.Suggestion.Entry<TermSuggestion.Entry.Option> {
+    public static class Entry extends Suggest.Suggestion.Entry<TermSuggestion.Entry.Option> {
 
         public Entry(Text text, int offset, int length) {
             super(text, offset, length);
         }
 
-        Entry() {
+        public Entry() {}
+
+        public Entry(StreamInput in) throws IOException {
+            super(in);
         }
 
         @Override
@@ -161,6 +195,11 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
             return new Option();
         }
 
+        @Override
+        protected Option newOption(StreamInput in) throws IOException {
+            return new Option(in);
+        }
+
         private static ObjectParser<Entry, Void> PARSER = new ObjectParser<>("TermSuggestionEntryParser", true, Entry::new);
 
         static {
@@ -175,7 +214,7 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
         /**
          * Contains the suggested text with its document frequency and score.
          */
-        public static class Option extends org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option {
+        public static class Option extends Suggest.Suggestion.Entry.Option {
 
             public static final ParseField FREQ = new ParseField("freq");
 
@@ -186,6 +225,11 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
                 this.freq = freq;
             }
 
+            public Option(StreamInput in) throws IOException {
+                super(in);
+                freq = in.readVInt();
+            }
+
             @Override
             protected void mergeInto(Suggestion.Entry.Option otherOption) {
                 super.mergeInto(otherOption);
@@ -207,12 +251,6 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
                 return freq;
             }
 
-            @Override
-            public void readFrom(StreamInput in) throws IOException {
-                super.readFrom(in);
-                freq = in.readVInt();
-            }
-
             @Override
             public void writeTo(StreamOutput out) throws IOException {
                 super.writeTo(out);
@@ -220,8 +258,8 @@ public class TermSuggestion extends Suggestion<TermSuggestion.Entry> {
             }
 
             @Override
-            protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
-                builder = super.innerToXContent(builder, params);
+            public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
+                builder = super.toXContent(builder, params);
                 builder.field(FREQ.getPreferredName(), freq);
                 return builder;
             }

+ 1 - 1
server/src/main/java/org/elasticsearch/search/suggest/term/TermSuggestionBuilder.java

@@ -67,7 +67,7 @@ import static org.elasticsearch.search.suggest.phrase.DirectCandidateGeneratorBu
  */
 public class TermSuggestionBuilder extends SuggestionBuilder<TermSuggestionBuilder> {
 
-    private static final String SUGGESTION_NAME = "term";
+    public static final String SUGGESTION_NAME = "term";
 
     private SuggestMode suggestMode = SuggestMode.MISSING;
     private float accuracy = DEFAULT_ACCURACY;

+ 99 - 5
server/src/test/java/org/elasticsearch/search/SearchModuleTests.java

@@ -18,6 +18,8 @@
  */
 package org.elasticsearch.search;
 
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.util.CharsRefBuilder;
 import org.elasticsearch.common.inject.ModuleTestCase;
 import org.elasticsearch.common.io.stream.StreamInput;
 import org.elasticsearch.common.io.stream.StreamOutput;
@@ -64,8 +66,11 @@ import org.elasticsearch.search.internal.SearchContext;
 import org.elasticsearch.search.rescore.QueryRescorerBuilder;
 import org.elasticsearch.search.rescore.RescoreContext;
 import org.elasticsearch.search.rescore.RescorerBuilder;
-import org.elasticsearch.search.suggest.CustomSuggesterSearchIT.CustomSuggestionBuilder;
+import org.elasticsearch.search.suggest.Suggest.Suggestion;
+import org.elasticsearch.search.suggest.Suggester;
 import org.elasticsearch.search.suggest.SuggestionBuilder;
+import org.elasticsearch.search.suggest.SuggestionSearchContext;
+import org.elasticsearch.search.suggest.term.TermSuggestion;
 import org.elasticsearch.search.suggest.term.TermSuggestionBuilder;
 
 import java.io.IOException;
@@ -98,7 +103,8 @@ public class SearchModuleTests extends ModuleTestCase {
         SearchPlugin registersDupeSuggester = new SearchPlugin() {
             @Override
             public List<SearchPlugin.SuggesterSpec<?>> getSuggesters() {
-                return singletonList(new SuggesterSpec<>("term", TermSuggestionBuilder::new, TermSuggestionBuilder::fromXContent));
+                return singletonList(new SuggesterSpec<>(TermSuggestionBuilder.SUGGESTION_NAME,
+                    TermSuggestionBuilder::new, TermSuggestionBuilder::fromXContent, TermSuggestion::new));
             }
         };
         expectThrows(IllegalArgumentException.class, registryForPlugin(registersDupeSuggester));
@@ -183,9 +189,15 @@ public class SearchModuleTests extends ModuleTestCase {
         SearchModule module = new SearchModule(Settings.EMPTY, false, singletonList(new SearchPlugin() {
             @Override
             public List<SuggesterSpec<?>> getSuggesters() {
-                return singletonList(new SuggesterSpec<>("custom", CustomSuggestionBuilder::new, CustomSuggestionBuilder::fromXContent));
+                return singletonList(
+                    new SuggesterSpec<>(
+                        TestSuggestionBuilder.SUGGESTION_NAME,
+                        TestSuggestionBuilder::new,
+                        TestSuggestionBuilder::fromXContent,
+                        TestSuggestion::new));
             }
         }));
+
         assertEquals(1, module.getNamedXContents().stream()
                 .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) &&
                     e.name.match("term", LoggingDeprecationHandler.INSTANCE)).count());
@@ -197,7 +209,7 @@ public class SearchModuleTests extends ModuleTestCase {
                     e.name.match("completion", LoggingDeprecationHandler.INSTANCE)).count());
         assertEquals(1, module.getNamedXContents().stream()
                 .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) &&
-                    e.name.match("custom", LoggingDeprecationHandler.INSTANCE)).count());
+                    e.name.match("test", LoggingDeprecationHandler.INSTANCE)).count());
 
         assertEquals(1, module.getNamedWriteables().stream()
                 .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) && e.name.equals("term")).count());
@@ -206,7 +218,16 @@ public class SearchModuleTests extends ModuleTestCase {
         assertEquals(1, module.getNamedWriteables().stream()
                 .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) && e.name.equals("completion")).count());
         assertEquals(1, module.getNamedWriteables().stream()
-                .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) && e.name.equals("custom")).count());
+                .filter(e -> e.categoryClass.equals(SuggestionBuilder.class) && e.name.equals("test")).count());
+
+        assertEquals(1, module.getNamedWriteables().stream()
+            .filter(e -> e.categoryClass.equals(Suggestion.class) && e.name.equals("term")).count());
+        assertEquals(1, module.getNamedWriteables().stream()
+            .filter(e -> e.categoryClass.equals(Suggestion.class) && e.name.equals("phrase")).count());
+        assertEquals(1, module.getNamedWriteables().stream()
+            .filter(e -> e.categoryClass.equals(Suggestion.class) && e.name.equals("completion")).count());
+        assertEquals(1, module.getNamedWriteables().stream()
+            .filter(e -> e.categoryClass.equals(Suggestion.class) && e.name.equals("test")).count());
     }
 
     public void testRegisterHighlighter() {
@@ -498,4 +519,77 @@ public class SearchModuleTests extends ModuleTestCase {
             return null;
         }
     }
+
+    private static class TestSuggester extends Suggester<SuggestionSearchContext.SuggestionContext> {
+        @Override
+        protected Suggestion<? extends Suggestion.Entry<? extends Suggestion.Entry.Option>> innerExecute(
+                String name,
+                SuggestionSearchContext.SuggestionContext suggestion,
+                IndexSearcher searcher,
+                CharsRefBuilder spare) throws IOException {
+            return null;
+        }
+    }
+
+    private static class TestSuggestionBuilder extends SuggestionBuilder<TestSuggestionBuilder> {
+
+        public static final String SUGGESTION_NAME = "test";
+
+        TestSuggestionBuilder(StreamInput in) throws IOException {
+            super(in);
+        }
+
+        @Override
+        protected void doWriteTo(StreamOutput out) throws IOException {}
+
+        public static TestSuggestionBuilder fromXContent(XContentParser parser) {
+            return null;
+        }
+
+        @Override
+        protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
+            return null;
+        }
+
+        @Override
+        protected SuggestionSearchContext.SuggestionContext build(QueryShardContext context) throws IOException {
+            return null;
+        }
+
+        @Override
+        protected boolean doEquals(TestSuggestionBuilder other) {
+            return false;
+        }
+
+        @Override
+        protected int doHashCode() {
+            return 0;
+        }
+
+        @Override
+        public String getWriteableName() {
+            return "test";
+        }
+    }
+
+    private static class TestSuggestion extends Suggestion {
+        TestSuggestion(StreamInput in) throws IOException {
+            super(in);
+        }
+
+        @Override
+        protected Entry newEntry() {
+            return null;
+        }
+
+        @Override
+        protected Entry newEntry(StreamInput in) throws IOException {
+            return null;
+        }
+
+        @Override
+        public String getWriteableName() {
+            return "test";
+        }
+    }
 }

+ 0 - 63
server/src/test/java/org/elasticsearch/search/suggest/CustomSuggester.java

@@ -1,63 +0,0 @@
-/*
- * 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.search.suggest;
-
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.util.CharsRefBuilder;
-import org.elasticsearch.common.text.Text;
-import org.elasticsearch.index.query.QueryShardContext;
-
-import java.io.IOException;
-import java.util.Locale;
-import java.util.Map;
-
-public class CustomSuggester extends Suggester<CustomSuggester.CustomSuggestionsContext> {
-
-    public static final CustomSuggester INSTANCE = new CustomSuggester();
-
-    // This is a pretty dumb implementation which returns the original text + fieldName + custom config option + 12 or 123
-    @Override
-    public Suggest.Suggestion<? extends Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>> innerExecute(String name, CustomSuggestionsContext suggestion, IndexSearcher searcher, CharsRefBuilder spare) throws IOException {
-        // Get the suggestion context
-        String text = suggestion.getText().utf8ToString();
-
-        // create two suggestions with 12 and 123 appended
-        Suggest.Suggestion<Suggest.Suggestion.Entry<Suggest.Suggestion.Entry.Option>> response = new Suggest.Suggestion<>(name, suggestion.getSize());
-
-        String firstSuggestion = String.format(Locale.ROOT, "%s-%s-%s-%s", text, suggestion.getField(), suggestion.options.get("suffix"), "12");
-        Suggest.Suggestion.Entry<Suggest.Suggestion.Entry.Option> resultEntry12 = new Suggest.Suggestion.Entry<>(new Text(firstSuggestion), 0, text.length() + 2);
-        response.addTerm(resultEntry12);
-
-        String secondSuggestion = String.format(Locale.ROOT, "%s-%s-%s-%s", text, suggestion.getField(), suggestion.options.get("suffix"), "123");
-        Suggest.Suggestion.Entry<Suggest.Suggestion.Entry.Option> resultEntry123 = new Suggest.Suggestion.Entry<>(new Text(secondSuggestion), 0, text.length() + 3);
-        response.addTerm(resultEntry123);
-
-        return response;
-    }
-
-    public static class CustomSuggestionsContext extends SuggestionSearchContext.SuggestionContext {
-
-        public Map<String, Object> options;
-
-        public CustomSuggestionsContext(QueryShardContext context, Map<String, Object> options) {
-            super(new CustomSuggester(), context);
-            this.options = options;
-        }
-    }
-}

+ 0 - 212
server/src/test/java/org/elasticsearch/search/suggest/CustomSuggesterSearchIT.java

@@ -1,212 +0,0 @@
-/*
- * 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.search.suggest;
-
-import org.elasticsearch.action.search.SearchRequestBuilder;
-import org.elasticsearch.action.search.SearchResponse;
-import org.elasticsearch.common.ParseField;
-import org.elasticsearch.common.ParsingException;
-import org.elasticsearch.common.io.stream.StreamInput;
-import org.elasticsearch.common.io.stream.StreamOutput;
-import org.elasticsearch.common.lucene.BytesRefs;
-import org.elasticsearch.common.util.CollectionUtils;
-import org.elasticsearch.common.xcontent.XContentBuilder;
-import org.elasticsearch.common.xcontent.XContentParser;
-import org.elasticsearch.index.query.QueryShardContext;
-import org.elasticsearch.plugins.Plugin;
-import org.elasticsearch.plugins.SearchPlugin;
-import org.elasticsearch.search.suggest.SuggestionSearchContext.SuggestionContext;
-import org.elasticsearch.test.ESIntegTestCase;
-import org.elasticsearch.test.ESIntegTestCase.ClusterScope;
-import org.elasticsearch.test.ESIntegTestCase.Scope;
-
-import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
-
-import static java.util.Collections.singletonList;
-import static org.elasticsearch.action.support.WriteRequest.RefreshPolicy.IMMEDIATE;
-import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
-import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.Matchers.is;
-
-/**
- * Integration test for registering a custom suggester.
- */
-@ClusterScope(scope= Scope.SUITE, numDataNodes =1)
-public class CustomSuggesterSearchIT extends ESIntegTestCase {
-    @Override
-    protected Collection<Class<? extends Plugin>> nodePlugins() {
-        return Arrays.asList(CustomSuggesterPlugin.class);
-    }
-
-    @Override
-    protected Collection<Class<? extends Plugin>> transportClientPlugins() {
-        return Arrays.asList(CustomSuggesterPlugin.class);
-    }
-
-    public static class CustomSuggesterPlugin extends Plugin implements SearchPlugin {
-        @Override
-        public List<SuggesterSpec<?>> getSuggesters() {
-            return singletonList(new SuggesterSpec<CustomSuggestionBuilder>("custom", CustomSuggestionBuilder::new,
-                    CustomSuggestionBuilder::fromXContent));
-        }
-    }
-
-    public void testThatCustomSuggestersCanBeRegisteredAndWork() throws Exception {
-        createIndex("test");
-        client().prepareIndex("test", "test", "1").setSource(jsonBuilder()
-                .startObject()
-                .field("name", "arbitrary content")
-                .endObject())
-                .setRefreshPolicy(IMMEDIATE).get();
-
-        String randomText = randomAlphaOfLength(10);
-        String randomField = randomAlphaOfLength(10);
-        String randomSuffix = randomAlphaOfLength(10);
-        SuggestBuilder suggestBuilder = new SuggestBuilder();
-        suggestBuilder.addSuggestion("someName", new CustomSuggestionBuilder(randomField, randomSuffix).text(randomText));
-        SearchRequestBuilder searchRequestBuilder = client().prepareSearch("test").setTypes("test").setFrom(0).setSize(1)
-                .suggest(suggestBuilder);
-
-        SearchResponse searchResponse = searchRequestBuilder.execute().actionGet();
-
-        // TODO: infer type once JI-9019884 is fixed
-        // TODO: see also JDK-8039214
-        List<Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>> suggestions =
-            CollectionUtils.<Suggest.Suggestion.Entry<? extends Suggest.Suggestion.Entry.Option>>iterableAsArrayList(
-                searchResponse.getSuggest().getSuggestion("someName"));
-        assertThat(suggestions, hasSize(2));
-        assertThat(suggestions.get(0).getText().string(),
-            is(String.format(Locale.ROOT, "%s-%s-%s-12", randomText, randomField, randomSuffix)));
-        assertThat(suggestions.get(1).getText().string(),
-            is(String.format(Locale.ROOT, "%s-%s-%s-123", randomText, randomField, randomSuffix)));
-    }
-
-    public static class CustomSuggestionBuilder extends SuggestionBuilder<CustomSuggestionBuilder> {
-        protected static final ParseField RANDOM_SUFFIX_FIELD = new ParseField("suffix");
-
-        private String randomSuffix;
-
-        public CustomSuggestionBuilder(String randomField, String randomSuffix) {
-            super(randomField);
-            this.randomSuffix = randomSuffix;
-        }
-
-        /**
-         * Read from a stream.
-         */
-        public CustomSuggestionBuilder(StreamInput in) throws IOException {
-            super(in);
-            this.randomSuffix = in.readString();
-        }
-
-        @Override
-        public void doWriteTo(StreamOutput out) throws IOException {
-            out.writeString(randomSuffix);
-        }
-
-        @Override
-        protected XContentBuilder innerToXContent(XContentBuilder builder, Params params) throws IOException {
-            builder.field(RANDOM_SUFFIX_FIELD.getPreferredName(), randomSuffix);
-            return builder;
-        }
-
-        @Override
-        public String getWriteableName() {
-            return "custom";
-        }
-
-        @Override
-        protected boolean doEquals(CustomSuggestionBuilder other) {
-            return Objects.equals(randomSuffix, other.randomSuffix);
-        }
-
-        @Override
-        protected int doHashCode() {
-            return Objects.hash(randomSuffix);
-        }
-
-        public static CustomSuggestionBuilder fromXContent(XContentParser parser) throws IOException {
-            XContentParser.Token token;
-            String currentFieldName = null;
-            String fieldname = null;
-            String suffix = null;
-            String analyzer = null;
-            int sizeField = -1;
-            int shardSize = -1;
-            while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
-                if (token == XContentParser.Token.FIELD_NAME) {
-                    currentFieldName = parser.currentName();
-                } else if (token.isValue()) {
-                    if (SuggestionBuilder.ANALYZER_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-                        analyzer = parser.text();
-                    } else if (SuggestionBuilder.FIELDNAME_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-                        fieldname = parser.text();
-                    } else if (SuggestionBuilder.SIZE_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-                        sizeField = parser.intValue();
-                    } else if (SuggestionBuilder.SHARDSIZE_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-                        shardSize = parser.intValue();
-                    } else if (RANDOM_SUFFIX_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-                        suffix = parser.text();
-                    }
-                } else {
-                    throw new ParsingException(parser.getTokenLocation(),
-                                               "suggester[custom] doesn't support field [" + currentFieldName + "]");
-                }
-            }
-
-            // now we should have field name, check and copy fields over to the suggestion builder we return
-            if (fieldname == null) {
-                throw new ParsingException(parser.getTokenLocation(), "the required field option is missing");
-            }
-            CustomSuggestionBuilder builder = new CustomSuggestionBuilder(fieldname, suffix);
-            if (analyzer != null) {
-                builder.analyzer(analyzer);
-            }
-            if (sizeField != -1) {
-                builder.size(sizeField);
-            }
-            if (shardSize != -1) {
-                builder.shardSize(shardSize);
-            }
-            return builder;
-        }
-
-        @Override
-        public SuggestionContext build(QueryShardContext context) throws IOException {
-            Map<String, Object> options = new HashMap<>();
-            options.put(FIELDNAME_FIELD.getPreferredName(), field());
-            options.put(RANDOM_SUFFIX_FIELD.getPreferredName(), randomSuffix);
-            CustomSuggester.CustomSuggestionsContext customSuggestionsContext =
-                new CustomSuggester.CustomSuggestionsContext(context, options);
-            customSuggestionsContext.setField(field());
-            assert text != null;
-            customSuggestionsContext.setText(BytesRefs.toBytesRef(text));
-            return customSuggestionsContext;
-        }
-
-    }
-
-}

+ 50 - 6
server/src/test/java/org/elasticsearch/search/suggest/SuggestTests.java

@@ -19,9 +19,14 @@
 
 package org.elasticsearch.search.suggest;
 
+import org.elasticsearch.Version;
 import org.elasticsearch.common.ParseField;
 import org.elasticsearch.common.ParsingException;
 import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.io.stream.BytesStreamOutput;
+import org.elasticsearch.common.io.stream.NamedWriteableAwareStreamInput;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.settings.Settings;
 import org.elasticsearch.common.text.Text;
 import org.elasticsearch.common.xcontent.NamedXContentRegistry;
 import org.elasticsearch.common.xcontent.ToXContent;
@@ -30,6 +35,7 @@ import org.elasticsearch.common.xcontent.XContentFactory;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.common.xcontent.XContentType;
 import org.elasticsearch.rest.action.search.RestSearchAction;
+import org.elasticsearch.search.SearchModule;
 import org.elasticsearch.search.suggest.Suggest.Suggestion;
 import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry;
 import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option;
@@ -37,6 +43,7 @@ import org.elasticsearch.search.suggest.completion.CompletionSuggestion;
 import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
 import org.elasticsearch.search.suggest.term.TermSuggestion;
 import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.test.VersionUtils;
 
 import java.io.IOException;
 import java.util.ArrayList;
@@ -44,6 +51,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
+import static java.util.Collections.emptyList;
 import static org.elasticsearch.common.xcontent.XContentHelper.toXContent;
 import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureExpectedToken;
 import static org.elasticsearch.common.xcontent.XContentParserUtils.ensureFieldName;
@@ -114,10 +122,11 @@ public class SuggestTests extends ESTestCase {
     }
 
     public void testToXContent() throws IOException {
-        Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
-        Entry<Option> entry = new Entry<>(new Text("entryText"), 42, 313);
+        PhraseSuggestion.Entry.Option option = new PhraseSuggestion.Entry.Option(new Text("someText"), new Text("somethingHighlighted"),
+            1.3f, true);
+        PhraseSuggestion.Entry entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313);
         entry.addOption(option);
-        Suggestion<Entry<Option>> suggestion = new Suggestion<>("suggestionName", 5);
+        PhraseSuggestion suggestion = new PhraseSuggestion("suggestionName", 5);
         suggestion.addTerm(entry);
         Suggest suggest = new Suggest(Collections.singletonList(suggestion));
         BytesReference xContent = toXContent(suggest, XContentType.JSON, randomBoolean());
@@ -196,9 +205,9 @@ public class SuggestTests extends ESTestCase {
         String secondWord = randomAlphaOfLength(10);
         Text suggestionText = new Text(suggestedWord + " " + secondWord);
         Text highlighted = new Text("<em>" + suggestedWord + "</em> " + secondWord);
-        PhraseSuggestion.Entry.Option option1 = new Option(suggestionText, highlighted, 0.7f, false);
-        PhraseSuggestion.Entry.Option option2 = new Option(suggestionText, highlighted, 0.8f, true);
-        PhraseSuggestion.Entry.Option option3 = new Option(suggestionText, highlighted, 0.6f);
+        PhraseSuggestion.Entry.Option option1 = new PhraseSuggestion.Entry.Option(suggestionText, highlighted, 0.7f, false);
+        PhraseSuggestion.Entry.Option option2 = new PhraseSuggestion.Entry.Option(suggestionText, highlighted, 0.8f, true);
+        PhraseSuggestion.Entry.Option option3 = new PhraseSuggestion.Entry.Option(suggestionText, highlighted, 0.6f);
         assertEquals(suggestionText, option1.getText());
         assertEquals(highlighted, option1.getHighlighted());
         assertFalse(option1.collateMatch());
@@ -214,4 +223,39 @@ public class SuggestTests extends ESTestCase {
         assertTrue(option1.getScore() > 0.7f);
         assertTrue(option1.collateMatch());
     }
+
+    public void testSerialization() throws IOException {
+        final Version bwcVersion = VersionUtils.randomVersionBetween(random(),
+            Version.CURRENT.minimumCompatibilityVersion(), Version.CURRENT);
+
+        final Suggest suggest = createTestItem();
+        final Suggest bwcSuggest;
+
+        NamedWriteableRegistry registry = new NamedWriteableRegistry
+            (new SearchModule(Settings.EMPTY, false, emptyList()).getNamedWriteables());
+
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.setVersion(bwcVersion);
+            suggest.writeTo(out);
+            try (NamedWriteableAwareStreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), registry)) {
+                in.setVersion(bwcVersion);
+                bwcSuggest = new Suggest(in);
+            }
+        }
+
+        assertEquals(suggest, bwcSuggest);
+
+        final Suggest backAgain;
+
+        try (BytesStreamOutput out = new BytesStreamOutput()) {
+            out.setVersion(Version.CURRENT);
+            bwcSuggest.writeTo(out);
+            try (NamedWriteableAwareStreamInput in = new NamedWriteableAwareStreamInput(out.bytes().streamInput(), registry)) {
+                in.setVersion(Version.CURRENT);
+                backAgain = new Suggest(in);
+            }
+        }
+
+        assertEquals(suggest, backAgain);
+    }
 }

+ 5 - 4
server/src/test/java/org/elasticsearch/search/suggest/SuggestionEntryTests.java

@@ -129,8 +129,9 @@ public class SuggestionEntryTests extends ESTestCase {
     }
 
     public void testToXContent() throws IOException {
-        Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
-        Entry<Option> entry = new Entry<>(new Text("entryText"), 42, 313);
+        PhraseSuggestion.Entry.Option option = new PhraseSuggestion.Entry.Option(new Text("someText"), new Text("somethingHighlighted"),
+            1.3f, true);
+        PhraseSuggestion.Entry entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313);
         entry.addOption(option);
         BytesReference xContent = toXContent(entry, XContentType.JSON, randomBoolean());
         assertEquals(
@@ -146,7 +147,7 @@ public class SuggestionEntryTests extends ESTestCase {
 
         org.elasticsearch.search.suggest.term.TermSuggestion.Entry.Option termOption =
                 new org.elasticsearch.search.suggest.term.TermSuggestion.Entry.Option(new Text("termSuggestOption"), 42, 3.13f);
-        entry = new Entry<>(new Text("entryText"), 42, 313);
+        entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313);
         entry.addOption(termOption);
         xContent = toXContent(entry, XContentType.JSON, randomBoolean());
         assertEquals(
@@ -162,7 +163,7 @@ public class SuggestionEntryTests extends ESTestCase {
         org.elasticsearch.search.suggest.completion.CompletionSuggestion.Entry.Option completionOption =
                 new org.elasticsearch.search.suggest.completion.CompletionSuggestion.Entry.Option(-1, new Text("completionOption"),
                         3.13f, Collections.singletonMap("key", Collections.singleton("value")));
-        entry = new Entry<>(new Text("entryText"), 42, 313);
+        entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313);
         entry.addOption(completionOption);
         xContent = toXContent(entry, XContentType.JSON, randomBoolean());
         assertEquals(

+ 4 - 3
server/src/test/java/org/elasticsearch/search/suggest/SuggestionOptionTests.java

@@ -25,6 +25,7 @@ import org.elasticsearch.common.xcontent.ToXContent;
 import org.elasticsearch.common.xcontent.XContentParser;
 import org.elasticsearch.common.xcontent.XContentType;
 import org.elasticsearch.search.suggest.Suggest.Suggestion.Entry.Option;
+import org.elasticsearch.search.suggest.phrase.PhraseSuggestion;
 import org.elasticsearch.test.ESTestCase;
 
 import java.io.IOException;
@@ -41,7 +42,7 @@ public class SuggestionOptionTests extends ESTestCase {
         float score = randomFloat();
         Text highlighted = randomFrom((Text) null, new Text(randomAlphaOfLengthBetween(5, 15)));
         Boolean collateMatch = randomFrom((Boolean) null, randomBoolean());
-        return new Option(text, highlighted, score, collateMatch);
+        return new PhraseSuggestion.Entry.Option(text, highlighted, score, collateMatch);
     }
 
     public void testFromXContent() throws IOException {
@@ -66,7 +67,7 @@ public class SuggestionOptionTests extends ESTestCase {
         Option parsed;
         try (XContentParser parser = createParser(xContentType.xContent(), mutated)) {
             ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser::getTokenLocation);
-            parsed = Option.fromXContent(parser);
+            parsed = PhraseSuggestion.Entry.Option.fromXContent(parser);
             assertEquals(XContentParser.Token.END_OBJECT, parser.currentToken());
             assertNull(parser.nextToken());
         }
@@ -78,7 +79,7 @@ public class SuggestionOptionTests extends ESTestCase {
     }
 
     public void testToXContent() throws IOException {
-        Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
+        Option option = new PhraseSuggestion.Entry.Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
         BytesReference xContent = toXContent(option, XContentType.JSON, randomBoolean());
         assertEquals("{\"text\":\"someText\","
                       + "\"highlighted\":\"somethingHighlighted\","

+ 6 - 5
server/src/test/java/org/elasticsearch/search/suggest/SuggestionTests.java

@@ -188,14 +188,15 @@ public class SuggestionTests extends ESTestCase {
     public void testToXContent() throws IOException {
         ToXContent.Params params = new ToXContent.MapParams(Collections.singletonMap(RestSearchAction.TYPED_KEYS_PARAM, "true"));
         {
-            Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
-            Entry<Option> entry = new Entry<>(new Text("entryText"), 42, 313);
+            PhraseSuggestion.Entry.Option option = new PhraseSuggestion.Entry.Option(new Text("someText"), new Text("somethingHighlighted"),
+                1.3f, true);
+            PhraseSuggestion.Entry entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313);
             entry.addOption(option);
-            Suggestion<Entry<Option>> suggestion = new Suggestion<>("suggestionName", 5);
+            PhraseSuggestion suggestion = new PhraseSuggestion("suggestionName", 5);
             suggestion.addTerm(entry);
             BytesReference xContent = toXContent(suggestion, XContentType.JSON, params, randomBoolean());
             assertEquals(
-                    "{\"suggestion#suggestionName\":[{"
+                    "{\"phrase#suggestionName\":[{"
                             + "\"text\":\"entryText\","
                             + "\"offset\":42,"
                             + "\"length\":313,"
@@ -208,7 +209,7 @@ public class SuggestionTests extends ESTestCase {
                     + "}", xContent.utf8ToString());
         }
         {
-            Option option = new Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
+            Option option = new PhraseSuggestion.Entry.Option(new Text("someText"), new Text("somethingHighlighted"), 1.3f, true);
             PhraseSuggestion.Entry entry = new PhraseSuggestion.Entry(new Text("entryText"), 42, 313, 1.0);
             entry.addOption(option);
             PhraseSuggestion suggestion = new PhraseSuggestion("suggestionName", 5);