com.spotify.heroic.suggest.elasticsearch.SuggestBackendV1.java Source code

Java tutorial

Introduction

Here is the source code for com.spotify.heroic.suggest.elasticsearch.SuggestBackendV1.java

Source

/*
 * Copyright (c) 2015 Spotify AB.
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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 com.spotify.heroic.suggest.elasticsearch;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.hash.HashCode;
import com.spotify.heroic.common.DateRange;
import com.spotify.heroic.common.Grouped;
import com.spotify.heroic.common.Groups;
import com.spotify.heroic.common.OptionalLimit;
import com.spotify.heroic.common.RequestTimer;
import com.spotify.heroic.common.Series;
import com.spotify.heroic.elasticsearch.AbstractElasticsearchBackend;
import com.spotify.heroic.elasticsearch.BackendType;
import com.spotify.heroic.elasticsearch.Connection;
import com.spotify.heroic.elasticsearch.RateLimitedCache;
import com.spotify.heroic.elasticsearch.index.NoIndexSelectedException;
import com.spotify.heroic.filter.AndFilter;
import com.spotify.heroic.filter.FalseFilter;
import com.spotify.heroic.filter.Filter;
import com.spotify.heroic.filter.HasTagFilter;
import com.spotify.heroic.filter.MatchKeyFilter;
import com.spotify.heroic.filter.MatchTagFilter;
import com.spotify.heroic.filter.NotFilter;
import com.spotify.heroic.filter.OrFilter;
import com.spotify.heroic.filter.RegexFilter;
import com.spotify.heroic.filter.StartsWithFilter;
import com.spotify.heroic.filter.TrueFilter;
import com.spotify.heroic.lifecycle.LifeCycleRegistry;
import com.spotify.heroic.lifecycle.LifeCycles;
import com.spotify.heroic.statistics.SuggestBackendReporter;
import com.spotify.heroic.suggest.KeySuggest;
import com.spotify.heroic.suggest.MatchOptions;
import com.spotify.heroic.suggest.SuggestBackend;
import com.spotify.heroic.suggest.TagKeyCount;
import com.spotify.heroic.suggest.TagSuggest;
import com.spotify.heroic.suggest.TagSuggest.Suggestion;
import com.spotify.heroic.suggest.TagValueSuggest;
import com.spotify.heroic.suggest.TagValuesSuggest;
import com.spotify.heroic.suggest.WriteSuggest;
import eu.toolchain.async.AsyncFramework;
import eu.toolchain.async.AsyncFuture;
import eu.toolchain.async.Managed;
import lombok.ToString;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.elasticsearch.action.index.IndexRequest.OpType;
import org.elasticsearch.action.search.SearchRequestBuilder;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.action.search.SearchType;
import org.elasticsearch.common.bytes.BytesReference;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.common.xcontent.XContentFactory;
import org.elasticsearch.index.query.BoolFilterBuilder;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.FilterBuilder;
import org.elasticsearch.index.query.QueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.TermFilterBuilder;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.terms.StringTerms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms.Bucket;
import org.elasticsearch.search.aggregations.bucket.terms.Terms.Order;
import org.elasticsearch.search.aggregations.bucket.terms.TermsBuilder;
import org.elasticsearch.search.aggregations.metrics.cardinality.Cardinality;
import org.elasticsearch.search.aggregations.metrics.cardinality.CardinalityBuilder;
import org.elasticsearch.search.aggregations.metrics.max.MaxBuilder;
import org.elasticsearch.search.aggregations.metrics.tophits.TopHits;
import org.elasticsearch.search.aggregations.metrics.tophits.TopHitsBuilder;

import javax.inject.Inject;
import javax.inject.Named;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;

import static com.spotify.heroic.suggest.elasticsearch.ElasticsearchSuggestUtils.loadJsonResource;
import static com.spotify.heroic.suggest.elasticsearch.ElasticsearchSuggestUtils.variables;
import static org.elasticsearch.index.query.FilterBuilders.andFilter;
import static org.elasticsearch.index.query.FilterBuilders.boolFilter;
import static org.elasticsearch.index.query.FilterBuilders.matchAllFilter;
import static org.elasticsearch.index.query.FilterBuilders.nestedFilter;
import static org.elasticsearch.index.query.FilterBuilders.notFilter;
import static org.elasticsearch.index.query.FilterBuilders.orFilter;
import static org.elasticsearch.index.query.FilterBuilders.prefixFilter;
import static org.elasticsearch.index.query.FilterBuilders.regexpFilter;
import static org.elasticsearch.index.query.FilterBuilders.termFilter;

@ElasticsearchScope
@ToString(of = { "connection" })
public class SuggestBackendV1 extends AbstractElasticsearchBackend implements SuggestBackend, Grouped, LifeCycles {
    private static final StandardAnalyzer analyzer = new StandardAnalyzer();

    // different locations for the series used in filtering.
    private static final Utils.FilterContext SERIES_CTX = Utils.context();
    private static final Utils.FilterContext TAG_CTX = Utils.context(Utils.TAG_SERIES);

    private static final String[] KEY_SUGGEST_SOURCES = new String[] { Utils.SERIES_KEY_RAW };

    private static final String[] TAG_SUGGEST_SOURCES = new String[] { Utils.TAG_KEY, Utils.TAG_VALUE };

    private final Managed<Connection> connection;
    private final SuggestBackendReporter reporter;
    /**
     * prevent unnecessary writes if entry is already in cache. Integer is the hashCode of the
     * series.
     */
    private final RateLimitedCache<Pair<String, HashCode>> writeCache;
    private final Groups groups;
    private final boolean configure;

    @Inject
    public SuggestBackendV1(final AsyncFramework async, final Managed<Connection> connection,
            final SuggestBackendReporter reporter, final RateLimitedCache<Pair<String, HashCode>> writeCache,
            final Groups groups, @Named("configure") boolean configure) {
        super(async);
        this.connection = connection;
        this.reporter = reporter;
        this.writeCache = writeCache;
        this.groups = groups;
        this.configure = configure;
    }

    @Override
    public void register(LifeCycleRegistry registry) {
        registry.start(this::start);
        registry.stop(this::stop);
    }

    @Override
    public AsyncFuture<Void> configure() {
        return connection.doto(Connection::configure);
    }

    @Override
    public Groups groups() {
        return groups;
    }

    @Override
    public boolean isReady() {
        return connection.isReady();
    }

    @Override
    public AsyncFuture<TagValuesSuggest> tagValuesSuggest(final TagValuesSuggest.Request request) {
        return connection.doto((final Connection c) -> {
            final FilterBuilder f = TAG_CTX.filter(request.getFilter());

            final BoolQueryBuilder root = QueryBuilders.boolQuery();
            root.must(QueryBuilders.filteredQuery(QueryBuilders.matchAllQuery(), f));

            for (final String e : request.getExclude()) {
                root.mustNot(QueryBuilders.matchQuery(Utils.TAG_KEY_RAW, e));
            }

            final SearchRequestBuilder builder;

            try {
                builder = c.search(request.getRange(), Utils.TYPE_TAG).setSearchType(SearchType.COUNT)
                        .setQuery(root);
            } catch (NoIndexSelectedException e) {
                return async.failed(e);
            }

            final OptionalLimit limit = request.getLimit();
            final OptionalLimit groupLimit = request.getGroupLimit();

            {
                final TermsBuilder terms = AggregationBuilders.terms("keys").field(Utils.TAG_KEY_RAW);

                limit.asInteger().ifPresent(l -> terms.size(l + 1));

                builder.addAggregation(terms);
                // make value bucket one entry larger than necessary to figure out when limiting
                // is applied.
                final TermsBuilder cardinality = AggregationBuilders.terms("values").field(Utils.TAG_VALUE_RAW);

                groupLimit.asInteger().ifPresent(l -> cardinality.size(l + 1));
                terms.subAggregation(cardinality);
            }

            return bind(builder.execute()).directTransform((SearchResponse response) -> {
                final List<TagValuesSuggest.Suggestion> suggestions = new ArrayList<>();

                final Terms terms = response.getAggregations().get("keys");

                final List<Bucket> suggestionBuckets = terms.getBuckets();

                for (final Terms.Bucket bucket : limit.limitList(suggestionBuckets)) {
                    final Terms valueTerms = bucket.getAggregations().get("values");

                    final List<Bucket> valueBuckets = valueTerms.getBuckets();

                    final SortedSet<String> result = new TreeSet<>();

                    for (final Terms.Bucket valueBucket : valueBuckets) {
                        result.add(valueBucket.getKey());
                    }

                    final boolean limited = groupLimit.isGreater(valueBuckets.size());
                    final SortedSet<String> values = groupLimit.limitSortedSet(result);

                    suggestions.add(new TagValuesSuggest.Suggestion(bucket.getKey(), values, limited));
                }

                return TagValuesSuggest.of(ImmutableList.copyOf(suggestions),
                        limit.isGreater(suggestionBuckets.size()));
            });
        });
    }

    @Override
    public AsyncFuture<TagValueSuggest> tagValueSuggest(final TagValueSuggest.Request request) {
        return connection.doto((final Connection c) -> {
            final BoolQueryBuilder root = QueryBuilders.boolQuery();

            request.getKey().ifPresent(k -> {
                if (!k.isEmpty()) {
                    root.must(QueryBuilders.termQuery(Utils.TAG_KEY_RAW, k));
                }
            });

            root.must(QueryBuilders.filteredQuery(QueryBuilders.matchAllQuery(),
                    TAG_CTX.filter(request.getFilter())));

            final SearchRequestBuilder builder = c.search(request.getRange(), Utils.TYPE_TAG)
                    .setSearchType(SearchType.COUNT).setQuery(root);

            final OptionalLimit limit = request.getLimit();

            {
                final TermsBuilder terms = AggregationBuilders.terms("values").field(Utils.TAG_VALUE_RAW)
                        .order(Order.term(true));

                limit.asInteger().ifPresent(l -> terms.size(l + 1));
                builder.addAggregation(terms);
            }

            return bind(builder.execute()).directTransform((SearchResponse response) -> {
                final ImmutableList.Builder<String> suggestions = ImmutableList.builder();

                final Terms terms = response.getAggregations().get("values");

                final List<Bucket> all = terms.getBuckets();

                for (final Terms.Bucket bucket : limit.limitList(all)) {
                    suggestions.add(bucket.getKey());
                }

                return TagValueSuggest.of(suggestions.build(), limit.isGreater(all.size()));
            });
        });
    }

    @Override
    public AsyncFuture<TagKeyCount> tagKeyCount(final TagKeyCount.Request request) {
        return connection.doto((final Connection c) -> {
            final FilterBuilder f = TAG_CTX.filter(request.getFilter());

            final BoolQueryBuilder root = QueryBuilders.boolQuery();
            root.must(QueryBuilders.filteredQuery(QueryBuilders.matchAllQuery(), f));

            final SearchRequestBuilder builder;

            try {
                builder = c.search(request.getRange(), Utils.TYPE_TAG).setSearchType(SearchType.COUNT)
                        .setQuery(root);
            } catch (NoIndexSelectedException e) {
                return async.failed(e);
            }

            final OptionalLimit limit = request.getLimit();

            {
                final TermsBuilder keys = AggregationBuilders.terms("keys").field(Utils.TAG_KEY_RAW);

                limit.asInteger().ifPresent(keys::size);

                builder.addAggregation(keys);
                final CardinalityBuilder cardinality = AggregationBuilders.cardinality("cardinality")
                        .field(Utils.TAG_VALUE_RAW);
                keys.subAggregation(cardinality);
            }

            return bind(builder.execute()).directTransform((SearchResponse response) -> {
                final Set<TagKeyCount.Suggestion> suggestions = new LinkedHashSet<>();

                final Terms terms = response.getAggregations().get("keys");

                for (final Terms.Bucket bucket : terms.getBuckets()) {
                    final Cardinality cardinality = bucket.getAggregations().get("cardinality");
                    suggestions.add(
                            new TagKeyCount.Suggestion(bucket.getKey(), cardinality.getValue(), Optional.empty()));
                }

                return TagKeyCount.of(ImmutableList.copyOf(suggestions), false);
            });
        });
    }

    @Override
    public AsyncFuture<TagSuggest> tagSuggest(final TagSuggest.Request request) {
        return connection.doto((Connection c) -> {
            final QueryBuilder query;

            final BoolQueryBuilder fuzzy = QueryBuilders.boolQuery();

            request.getKey().ifPresent(k -> {
                if (!k.isEmpty()) {
                    fuzzy.should(match(Utils.TAG_KEY, k, request.getOptions()));
                }
            });

            request.getValue().ifPresent(v -> {
                if (!v.isEmpty()) {
                    fuzzy.should(match(Utils.TAG_VALUE, v, request.getOptions()));
                }
            });

            if (request.getFilter() instanceof TrueFilter) {
                query = fuzzy;
            } else {
                query = QueryBuilders.filteredQuery(fuzzy, TAG_CTX.filter(request.getFilter()));
            }

            final SearchRequestBuilder builder = c.search(request.getRange(), Utils.TYPE_TAG)
                    .setSearchType(SearchType.COUNT).setQuery(query);

            // aggregation
            {
                final MaxBuilder topHit = AggregationBuilders.max("topHit").script("_score");
                final TopHitsBuilder hits = AggregationBuilders.topHits("hits").setSize(1)
                        .setFetchSource(TAG_SUGGEST_SOURCES, new String[0]);

                final TermsBuilder kvs = AggregationBuilders.terms("kvs").field(Utils.TAG_KV)
                        .order(Order.aggregation("topHit", false)).subAggregation(hits).subAggregation(topHit);

                request.getLimit().asInteger().ifPresent(kvs::size);
                builder.addAggregation(kvs);
            }

            return bind(builder.execute()).directTransform((SearchResponse response) -> {
                final ImmutableList.Builder<Suggestion> suggestions = ImmutableList.builder();

                final StringTerms kvs = response.getAggregations().get("kvs");

                for (final Terms.Bucket bucket : kvs.getBuckets()) {
                    final TopHits topHits = bucket.getAggregations().get("hits");
                    final SearchHits hits = topHits.getHits();
                    final SearchHit hit = hits.getAt(0);
                    final Map<String, Object> doc = hit.getSource();

                    final String k = (String) doc.get(Utils.TAG_KEY);
                    final String v = (String) doc.get(Utils.TAG_VALUE);
                    suggestions.add(new Suggestion(hits.getMaxScore(), k, v));
                }

                return TagSuggest.of(suggestions.build());
            });
        });
    }

    @Override
    public AsyncFuture<KeySuggest> keySuggest(final KeySuggest.Request request) {
        return connection.doto((final Connection c) -> {
            final QueryBuilder query;

            final BoolQueryBuilder fuzzy = QueryBuilders.boolQuery();

            request.getKey().ifPresent(k -> {
                if (!k.isEmpty()) {
                    fuzzy.should(match(Utils.SERIES_KEY, k, request.getOptions()));
                }
            });

            if (request.getFilter() instanceof TrueFilter) {
                query = fuzzy;
            } else {
                query = QueryBuilders.filteredQuery(fuzzy, SERIES_CTX.filter(request.getFilter()));
            }

            final SearchRequestBuilder builder = c.search(request.getRange(), Utils.TYPE_SERIES)
                    .setSearchType(SearchType.COUNT).setQuery(query);

            // aggregation
            {
                final MaxBuilder topHit = AggregationBuilders.max("top_hit").script("_score");
                final TopHitsBuilder hits = AggregationBuilders.topHits("hits").setSize(1)
                        .setFetchSource(KEY_SUGGEST_SOURCES, new String[0]);

                final TermsBuilder keys = AggregationBuilders.terms("keys").field(Utils.SERIES_KEY_RAW)
                        .order(Order.aggregation("top_hit", false)).subAggregation(hits).subAggregation(topHit);

                request.getLimit().asInteger().ifPresent(keys::size);
                builder.addAggregation(keys);
            }

            return bind(builder.execute()).directTransform((SearchResponse response) -> {
                final Set<KeySuggest.Suggestion> suggestions = new LinkedHashSet<>();

                final StringTerms keys = response.getAggregations().get("keys");

                for (final Terms.Bucket bucket : keys.getBuckets()) {
                    final TopHits topHits = bucket.getAggregations().get("hits");
                    final SearchHits hits = topHits.getHits();
                    suggestions.add(new KeySuggest.Suggestion(hits.getMaxScore(), bucket.getKey()));
                }

                return KeySuggest.of(ImmutableList.copyOf(suggestions));
            });
        });
    }

    @Override
    public AsyncFuture<WriteSuggest> write(final WriteSuggest.Request request) {
        return connection.doto((final Connection c) -> {
            final Series series = request.getSeries();
            final DateRange range = request.getRange();

            final String[] indices;

            try {
                indices = c.writeIndices(range);
            } catch (NoIndexSelectedException e) {
                return async.failed(e);
            }

            final String seriesId = Integer.toHexString(series.hashCode());

            final XContentBuilder xSeries;
            final BytesReference rawSeries;

            try {
                // convert to bytes, to avoid having to rebuild it for every write.
                // @formatter:off
                xSeries = XContentFactory.jsonBuilder();
                xSeries.startObject();
                Utils.buildMetadataDoc(xSeries, series);
                xSeries.endObject();

                // for nested entry in suggestion.
                final XContentBuilder xSeriesRaw = XContentFactory.jsonBuilder();
                xSeriesRaw.startObject();
                xSeriesRaw.field("id", seriesId);
                Utils.buildMetadataDoc(xSeriesRaw, series);
                xSeriesRaw.endObject();

                rawSeries = xSeriesRaw.bytes();
                // @formatter:on
            } catch (IOException e) {
                return async.failed(e);
            }

            final List<AsyncFuture<WriteSuggest>> futures = new ArrayList<>();

            for (final String index : indices) {
                final Pair<String, HashCode> key = Pair.of(index, series.getHashCode());

                if (!writeCache.acquire(key)) {
                    reporter.reportWriteDroppedByRateLimit();
                    continue;
                }

                final RequestTimer<WriteSuggest> timer = WriteSuggest.timer();

                futures.add(bind(c.index(index, Utils.TYPE_SERIES).setId(seriesId).setSource(xSeries)
                        .setOpType(OpType.CREATE).execute()).directTransform(result -> timer.end()));

                try {
                    for (final Map.Entry<String, String> e : series.getTags().entrySet()) {
                        final String suggestId = seriesId + ":" + Integer.toHexString(e.hashCode());
                        final XContentBuilder suggest = XContentFactory.jsonBuilder();

                        suggest.startObject();
                        Utils.buildTagDoc(suggest, rawSeries, e);
                        suggest.endObject();

                        futures.add(bind(c.index(index, Utils.TYPE_TAG).setId(suggestId).setSource(suggest)
                                .setOpType(OpType.CREATE).execute()).directTransform(result -> timer.end()));
                    }
                } catch (final Exception e) {
                    return async.failed(e);
                }
            }

            return async.collect(futures, WriteSuggest.reduce());
        });
    }

    private AsyncFuture<Void> start() {
        final AsyncFuture<Void> future = connection.start();

        if (!configure) {
            return future;
        }

        return future.lazyTransform(v -> configure());
    }

    private AsyncFuture<Void> stop() {
        return connection.stop();
    }

    private QueryBuilder match(String field, String value, MatchOptions options) {
        final BoolQueryBuilder bool = QueryBuilders.boolQuery();

        // exact match
        bool.should(QueryBuilders.termQuery(field, value));

        final List<String> terms;

        try {
            terms = Utils.tokenize(analyzer, field, value);
        } catch (final IOException e) {
            throw new RuntimeException("failed to tokenize query", e);
        }

        for (final String term : terms) {
            // prefix on raw to match with non-term prefixes.
            bool.should(QueryBuilders.prefixQuery(String.format("%s.raw", field), term));
            // prefix on terms, to match on the prefix of any term.
            bool.should(QueryBuilders.prefixQuery(field, term));
            // prefix on exact term matches.
            bool.should(QueryBuilders.termQuery(field, term));
        }

        // optionall match fuzzy
        if (options.isFuzzy()) {
            bool.should(QueryBuilders.fuzzyQuery(field, value).prefixLength(options.getFuzzyPrefixLength())
                    .maxExpansions(options.getFuzzyMaxExpansions()));
        }

        return bool;
    }

    private static final class Utils {
        public static final String TYPE_TAG = "tag";
        public static final String TYPE_SERIES = "series";

        /**
         * Fields for type "series".
         **/
        public static final String SERIES_KEY = "key";
        public static final String SERIES_KEY_RAW = "key.raw";

        /**
         * Fields for type "metadata".
         */
        public static final String METADATA_KEY = "key";
        public static final String METADATA_TAGS = "tags";

        /**
         * Fields for type "tag".
         */
        public static final String TAG_KEY = "key";
        public static final String TAG_KEY_RAW = "key.raw";
        public static final String TAG_VALUE = "value";
        public static final String TAG_VALUE_RAW = "value.raw";
        public static final String TAG_KV = "kv";
        public static final String TAG_SERIES = "series";

        /**
         * common fields, but nested in different ways depending on document type.
         *
         * @see FilterContext
         */
        public static final String KEY = "key";
        public static final String TAGS = "tags";
        public static final String TAGS_KEY = "key";
        public static final String TAGS_KEY_RAW = "key.raw";
        public static final String TAGS_VALUE = "value";
        public static final String TAGS_VALUE_RAW = "value.raw";

        public static void buildMetadataDoc(final XContentBuilder b, Series series) throws IOException {
            b.field(METADATA_KEY, series.getKey());

            b.startArray(METADATA_TAGS);

            if (series.getTags() != null && !series.getTags().isEmpty()) {
                for (final Map.Entry<String, String> entry : series.getTags().entrySet()) {
                    b.startObject();
                    b.field(TAGS_KEY, entry.getKey());
                    b.field(TAGS_VALUE, entry.getValue());
                    b.endObject();
                }
            }

            b.endArray();
        }

        public static void buildTagDoc(final XContentBuilder b, BytesReference series, Entry<String, String> e)
                throws IOException {
            b.rawField(TAG_SERIES, series);
            b.field(TAG_KEY, e.getKey());
            b.field(TAG_VALUE, e.getValue());
            b.field(TAG_KV, e.getKey() + "\t" + e.getValue());
        }

        public static List<String> tokenize(Analyzer analyzer, String field, String keywords) throws IOException {
            final List<String> terms = new ArrayList<String>();

            try (final Reader reader = new StringReader(keywords)) {
                try (final TokenStream stream = analyzer.tokenStream(field, reader)) {
                    final CharTermAttribute term = stream.getAttribute(CharTermAttribute.class);

                    stream.reset();

                    final String first = term.toString();

                    if (!first.isEmpty()) {
                        terms.add(first);
                    }

                    while (stream.incrementToken()) {
                        final String next = term.toString();

                        if (next.isEmpty()) {
                            continue;
                        }

                        terms.add(next);
                    }

                    stream.end();
                }
            }

            return terms;
        }

        public static FilterContext context(String... path) {
            return new FilterContext(path);
        }

        public static final class FilterContext {
            private final String seriesKey;
            private final String tags;
            private final String tagsKey;
            private final String tagsValue;

            private FilterContext(String... path) {
                this(ImmutableList.<String>builder().add(path).build());
            }

            private FilterContext(List<String> path) {
                this.seriesKey = path(path, KEY);
                this.tags = path(path, TAGS);
                this.tagsKey = path(path, TAGS, TAGS_KEY_RAW);
                this.tagsValue = path(path, TAGS, TAGS_VALUE_RAW);
            }

            private String path(List<String> path, String tail) {
                return StringUtils.join(ImmutableList.builder().addAll(path).add(tail).build(), '.');
            }

            private String path(List<String> path, String tailN, String tail) {
                return StringUtils.join(ImmutableList.builder().addAll(path).add(tailN).add(tail).build(), '.');
            }

            public FilterBuilder filter(final Filter filter) {
                return filter.visit(new Filter.Visitor<FilterBuilder>() {
                    @Override
                    public FilterBuilder visitTrue(final TrueFilter t) {
                        return matchAllFilter();
                    }

                    @Override
                    public FilterBuilder visitFalse(final FalseFilter f) {
                        return notFilter(matchAllFilter());
                    }

                    @Override
                    public FilterBuilder visitAnd(final AndFilter and) {
                        final List<FilterBuilder> filters = new ArrayList<>(and.terms().size());

                        for (final Filter stmt : and.terms()) {
                            filters.add(filter(stmt));
                        }

                        return andFilter(filters.toArray(new FilterBuilder[0]));
                    }

                    @Override
                    public FilterBuilder visitOr(final OrFilter or) {
                        final List<FilterBuilder> filters = new ArrayList<>(or.terms().size());

                        for (final Filter stmt : or.terms()) {
                            filters.add(filter(stmt));
                        }

                        return orFilter(filters.toArray(new FilterBuilder[0]));
                    }

                    @Override
                    public FilterBuilder visitNot(final NotFilter not) {
                        return notFilter(filter(not.getFilter()));
                    }

                    @Override
                    public FilterBuilder visitMatchTag(final MatchTagFilter matchTag) {
                        final BoolFilterBuilder nested = boolFilter();
                        nested.must(termFilter(tagsKey, matchTag.getTag()));
                        nested.must(termFilter(tagsValue, matchTag.getValue()));
                        return nestedFilter(tags, nested);
                    }

                    @Override
                    public FilterBuilder visitStartsWith(final StartsWithFilter startsWith) {
                        final BoolFilterBuilder nested = boolFilter();
                        nested.must(termFilter(tagsKey, startsWith.getTag()));
                        nested.must(prefixFilter(tagsValue, startsWith.getValue()));
                        return nestedFilter(tags, nested);
                    }

                    @Override
                    public FilterBuilder visitRegex(final RegexFilter regex) {
                        final BoolFilterBuilder nested = boolFilter();
                        nested.must(termFilter(tagsKey, regex.getTag()));
                        nested.must(regexpFilter(tagsValue, regex.getValue()));
                        return nestedFilter(tags, nested);
                    }

                    @Override
                    public FilterBuilder visitHasTag(final HasTagFilter hasTag) {
                        final TermFilterBuilder nested = termFilter(tagsKey, hasTag.getTag());
                        return nestedFilter(tags, nested);
                    }

                    @Override
                    public FilterBuilder visitMatchKey(final MatchKeyFilter matchKey) {
                        return termFilter(seriesKey, matchKey.getValue());
                    }

                    @Override
                    public FilterBuilder defaultAction(final Filter filter) {
                        throw new IllegalArgumentException("Unsupported filter statement: " + filter);
                    }
                });
            }
        }
    }

    public static BackendType backendType() {
        final Map<String, Map<String, Object>> mappings = new HashMap<>();
        mappings.put(Utils.TYPE_TAG,
                loadJsonResource("v1/tag.json", variables(ImmutableMap.of("type", Utils.TYPE_TAG))));
        mappings.put(Utils.TYPE_SERIES,
                loadJsonResource("v1/series.json", variables(ImmutableMap.of("type", Utils.TYPE_SERIES))));
        return new BackendType(mappings, ImmutableMap.of(), SuggestBackendV1.class);
    }
}