From ed7a65e0538eae0e5a9f0f91795fbf4b20ef2868 Mon Sep 17 00:00:00 2001 From: David Harsha Date: Tue, 27 Jul 2021 05:55:07 -0700 Subject: [PATCH] Allow specifying index in pinned queries (#74873) The current `ids` option doesn't allow pinning a specific document in a single index when searching over multiple indices. This introduces a `documents` option, which is an array of `_id` and `_index` fields to allow index-specific pins. Closes https://github.com/elastic/elasticsearch/issues/67855. --- .../org/elasticsearch/client/SearchIT.java | 26 +- .../reference/query-dsl/pinned-query.asciidoc | 53 +++- .../core/index/query/PinnedQueryBuilder.java | 113 +++++++- .../PinnedQueryBuilderIT.java | 188 ++++++++---- .../PinnedQueryBuilder.java | 272 +++++++++++++++--- .../PinnedQueryBuilderTests.java | 106 ++++++- .../search-business-rules/10_pinned_query.yml | 80 ++++++ 7 files changed, 700 insertions(+), 138 deletions(-) create mode 100644 x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/search-business-rules/10_pinned_query.yml diff --git a/client/rest-high-level/src/test/java/org/elasticsearch/client/SearchIT.java b/client/rest-high-level/src/test/java/org/elasticsearch/client/SearchIT.java index 0dbd982bb88a7..040004e822758 100644 --- a/client/rest-high-level/src/test/java/org/elasticsearch/client/SearchIT.java +++ b/client/rest-high-level/src/test/java/org/elasticsearch/client/SearchIT.java @@ -1367,13 +1367,25 @@ public void testCountAllIndicesMatchQuery() throws IOException { public void testSearchWithBasicLicensedQuery() throws IOException { SearchRequest searchRequest = new SearchRequest("index"); SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); - PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(), "2", "1"); - searchSourceBuilder.query(pinnedQuery); - searchRequest.source(searchSourceBuilder); - SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync); - assertSearchHeader(searchResponse); - assertFirstHit(searchResponse, hasId("2")); - assertSecondHit(searchResponse, hasId("1")); + { + PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(), "2", "1"); + searchSourceBuilder.query(pinnedQuery); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync); + assertSearchHeader(searchResponse); + assertFirstHit(searchResponse, hasId("2")); + assertSecondHit(searchResponse, hasId("1")); + } + { + PinnedQueryBuilder pinnedQuery = new PinnedQueryBuilder(new MatchAllQueryBuilder(), + new PinnedQueryBuilder.Item("index", "2"), new PinnedQueryBuilder.Item("index", "1")); + searchSourceBuilder.query(pinnedQuery); + searchRequest.source(searchSourceBuilder); + SearchResponse searchResponse = execute(searchRequest, highLevelClient()::search, highLevelClient()::searchAsync); + assertSearchHeader(searchResponse); + assertFirstHit(searchResponse, hasId("2")); + assertSecondHit(searchResponse, hasId("1")); + } } public void testPointInTime() throws Exception { diff --git a/docs/reference/query-dsl/pinned-query.asciidoc b/docs/reference/query-dsl/pinned-query.asciidoc index 729855e16de64..e0c314da541ad 100644 --- a/docs/reference/query-dsl/pinned-query.asciidoc +++ b/docs/reference/query-dsl/pinned-query.asciidoc @@ -4,7 +4,7 @@ === Pinned Query Promotes selected documents to rank higher than those matching a given query. This feature is typically used to guide searchers to curated documents that are -promoted over and above any "organic" matches for a search. +promoted over and above any "organic" matches for a search. The promoted or "pinned" documents are identified using the document IDs stored in the <> field. @@ -31,6 +31,53 @@ GET /_search ==== Top-level parameters for `pinned` `ids`:: -An array of <> listed in the order they are to appear in results. +(Optional, array) <> listed in the order they are to appear in results. +Required if `docs` is not specified. +`docs`:: +(Optional, array) Documents listed in the order they are to appear in results. +Required if `ids` is not specified. +You can specify the following attributes for each document: ++ +-- +`_id`:: +(Required, string) The unique <>. + +`_index`:: +(Required, string) The index that contains the document. +-- `organic`:: -Any choice of query used to rank documents which will be ranked below the "pinned" document ids. \ No newline at end of file +Any choice of query used to rank documents which will be ranked below the "pinned" documents. + +==== Pin documents in a specific index + +If you're searching over multiple indices, you can pin a document within a specific index using `docs`: + +[source,console] +-------------------------------------------------- +GET /_search +{ + "query": { + "pinned": { + "docs": [ + { + "_index": "my-index-000001", + "_id": "1" + }, + { + "_index": "my-index-000001", + "_id": "4" + }, + { + "_index": "my-index-000002", + "_id": "100" + } + ], + "organic": { + "match": { + "description": "iphone" + } + } + } + } +} +-------------------------------------------------- diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/index/query/PinnedQueryBuilder.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/index/query/PinnedQueryBuilder.java index 48c9595d90189..708a9b34471d1 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/index/query/PinnedQueryBuilder.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/index/query/PinnedQueryBuilder.java @@ -7,15 +7,18 @@ package org.elasticsearch.xpack.core.index.query; import org.apache.lucene.search.Query; +import org.elasticsearch.common.io.stream.Writeable; +import org.elasticsearch.common.regex.Regex; import org.elasticsearch.common.xcontent.ParseField; import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; import org.elasticsearch.index.query.AbstractQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.SearchExecutionContext; import java.io.IOException; -import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -29,34 +32,91 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder public static final String NAME = "pinned"; protected final QueryBuilder organicQuery; protected final List ids; + private final List docs; protected static final ParseField IDS_FIELD = new ParseField("ids"); + private static final ParseField DOCS_FIELD = new ParseField("docs"); protected static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic"); + /** + * A single item to be used for a {@link PinnedQueryBuilder}. + */ + public static final class Item implements ToXContentObject, Writeable { + private static final ParseField INDEX_FIELD = new ParseField("_index"); + private static final ParseField ID_FIELD = new ParseField("_id"); + + private final String index; + private final String id; + + public Item(String index, String id) { + if (index == null) { + throw new IllegalArgumentException("Item requires index to be non-null"); + } + if (Regex.isSimpleMatchPattern(index)) { + throw new IllegalArgumentException("Item index cannot contain wildcard expressions"); + } + if (id == null) { + throw new IllegalArgumentException("Item requires id to be non-null"); + } + this.index = index; + this.id = id; + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(index); + out.writeString(id); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(INDEX_FIELD.getPreferredName(), index); + builder.field(ID_FIELD.getPreferredName(), id); + return builder.endObject(); + } + } + @Override public String getWriteableName() { return NAME; } + public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) { + this(organicQuery, Arrays.asList(ids), null); + } + + public PinnedQueryBuilder(QueryBuilder organicQuery, Item... docs) { + this(organicQuery, null, Arrays.asList(docs)); + } + /** * Creates a new PinnedQueryBuilder */ - public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) { + private PinnedQueryBuilder(QueryBuilder organicQuery, List ids, List docs) { if (organicQuery == null) { throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null"); } this.organicQuery = organicQuery; - if (ids == null) { - throw new IllegalArgumentException("[" + NAME + "] ids cannot be null"); + if (ids == null && docs == null) { + throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be null"); } - this.ids = new ArrayList<>(); - Collections.addAll(this.ids, ids); - + if (ids != null && docs != null) { + throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be used"); + } + this.ids = ids; + this.docs = docs; } @Override protected void doWriteTo(StreamOutput out) throws IOException { - out.writeStringCollection(this.ids); + out.writeOptionalStringCollection(this.ids); + if (docs == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + out.writeList(docs); + } out.writeNamedWriteable(organicQuery); } @@ -71,9 +131,22 @@ public QueryBuilder organicQuery() { * Returns the pinned ids for the query. */ public List ids() { + if (this.ids == null) { + return Collections.emptyList(); + } return Collections.unmodifiableList(this.ids); } + /** + * Returns the pinned docs for the query. + */ + public List docs() { + if (this.docs == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(this.docs); + } + @Override protected void doXContent(XContentBuilder builder, Params params) throws IOException { @@ -82,11 +155,20 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep builder.field(ORGANIC_QUERY_FIELD.getPreferredName()); organicQuery.toXContent(builder, params); } - builder.startArray(IDS_FIELD.getPreferredName()); - for (String value : ids) { - builder.value(value); + if (ids != null) { + builder.startArray(IDS_FIELD.getPreferredName()); + for (String value : ids) { + builder.value(value); + } + builder.endArray(); + } + if (docs != null) { + builder.startArray(DOCS_FIELD.getPreferredName()); + for (Item item : docs) { + builder.value(item); + } + builder.endArray(); } - builder.endArray(); printBoostAndQueryName(builder); builder.endObject(); } @@ -99,12 +181,15 @@ protected Query doToQuery(SearchExecutionContext context) throws IOException { @Override protected int doHashCode() { - return Objects.hash(ids, organicQuery); + return Objects.hash(ids, docs, organicQuery); } @Override protected boolean doEquals(PinnedQueryBuilder other) { - return Objects.equals(ids, other.ids) && Objects.equals(organicQuery, other.organicQuery) && boost == other.boost; + return Objects.equals(ids, other.ids) + && Objects.equals(docs, other.docs) + && Objects.equals(organicQuery, other.organicQuery) + && boost == other.boost; } } diff --git a/x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java b/x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java index 6f92d37f05b6d..ab95df2fb0b86 100644 --- a/x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java +++ b/x-pack/plugin/search-business-rules/src/internalClusterTest/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderIT.java @@ -8,34 +8,36 @@ package org.elasticsearch.xpack.searchbusinessrules; import org.apache.lucene.search.Explanation; +import org.elasticsearch.action.admin.indices.alias.Alias; import org.elasticsearch.action.search.SearchResponse; import org.elasticsearch.action.search.SearchType; import org.elasticsearch.common.settings.Settings; -import org.elasticsearch.index.query.MatchAllQueryBuilder; import org.elasticsearch.index.query.Operator; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryBuilders; -import org.elasticsearch.index.query.QueryStringQueryBuilder; import org.elasticsearch.plugins.Plugin; import org.elasticsearch.search.SearchHit; import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder; import org.elasticsearch.search.fetch.subphase.highlight.HighlightField; import org.elasticsearch.test.ESIntegTestCase; +import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import static org.elasticsearch.action.search.SearchType.DFS_QUERY_THEN_FETCH; import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertFirstHit; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertFourthHit; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertHitCount; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertSecondHit; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertThirdHit; import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.hasId; +import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.hasIndex; +import static org.hamcrest.Matchers.both; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.lessThan; @@ -45,16 +47,6 @@ public class PinnedQueryBuilderIT extends ESIntegTestCase { - public void testIdInsertionOrderRetained() { - String[] ids = generateRandomStringArray(10, 50, false); - PinnedQueryBuilder pqb = new PinnedQueryBuilder(new MatchAllQueryBuilder(), ids); - List addedIds = pqb.ids(); - int pos = 0; - for (String key : addedIds) { - assertEquals(ids[pos++], key); - } - } - @Override protected Collection> nodePlugins() { return Collections.singleton(SearchBusinessRules.class); @@ -88,9 +80,12 @@ public void testPinnedPromotions() throws Exception { for (int i = 0; i < 100; i++) { int numPromotions = randomIntBetween(0, totalDocs); - LinkedHashSet pins = new LinkedHashSet<>(); + LinkedHashSet idPins = new LinkedHashSet<>(); + LinkedHashSet docPins = new LinkedHashSet<>(); for (int j = 0; j < numPromotions; j++) { - pins.add(Integer.toString(randomIntBetween(0, totalDocs))); + String id = Integer.toString(randomIntBetween(0, totalDocs)); + idPins.add(id); + docPins.add(new Item("test", id)); } QueryBuilder organicQuery = null; if (i % 5 == 0) { @@ -99,43 +94,48 @@ public void testPinnedPromotions() throws Exception { } else { organicQuery = QueryBuilders.matchQuery("field1", "red fox"); } - PinnedQueryBuilder pqb = new PinnedQueryBuilder(organicQuery, pins.toArray(new String[0])); - - int from = randomIntBetween(0, numRelevantDocs); - int size = randomIntBetween(10, 100); - SearchResponse searchResponse = client().prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSize(size).setFrom(from) - .setSearchType(DFS_QUERY_THEN_FETCH) - .get(); - - long numHits = searchResponse.getHits().getTotalHits().value; - assertThat(numHits, lessThanOrEqualTo((long) numRelevantDocs + pins.size())); - - // Check pins are sorted by increasing score, (unlike organic, there are no duplicate scores) - float lastScore = Float.MAX_VALUE; - SearchHit[] hits = searchResponse.getHits().getHits(); - for (int hitNumber = 0; hitNumber < Math.min(hits.length, pins.size() - from); hitNumber++) { - assertThat("Hit " + hitNumber + " in iter " + i + " wrong" + pins, hits[hitNumber].getScore(), lessThan(lastScore)); - lastScore = hits[hitNumber].getScore(); - } - // Check that the pins appear in the requested order (globalHitNumber is cursor independent of from and size window used) - int globalHitNumber = 0; - for (String id : pins) { - if (globalHitNumber < size && globalHitNumber >= from) { - assertThat("Hit " + globalHitNumber + " in iter " + i + " wrong" + pins, hits[globalHitNumber - from].getId(), - equalTo(id)); - } - globalHitNumber++; + + assertPinnedPromotions(new PinnedQueryBuilder(organicQuery, idPins.toArray(new String[0])), idPins, i, numRelevantDocs); + assertPinnedPromotions(new PinnedQueryBuilder(organicQuery, docPins.toArray(new Item[0])), idPins, i, numRelevantDocs); + } + + } + + private void assertPinnedPromotions(PinnedQueryBuilder pqb, LinkedHashSet pins, int iter, int numRelevantDocs) { + int from = randomIntBetween(0, numRelevantDocs); + int size = randomIntBetween(10, 100); + SearchResponse searchResponse = client().prepareSearch().setQuery(pqb).setTrackTotalHits(true).setSize(size).setFrom(from) + .setSearchType(DFS_QUERY_THEN_FETCH) + .get(); + + long numHits = searchResponse.getHits().getTotalHits().value; + assertThat(numHits, lessThanOrEqualTo((long) numRelevantDocs + pins.size())); + + // Check pins are sorted by increasing score, (unlike organic, there are no duplicate scores) + float lastScore = Float.MAX_VALUE; + SearchHit[] hits = searchResponse.getHits().getHits(); + for (int hitNumber = 0; hitNumber < Math.min(hits.length, pins.size() - from); hitNumber++) { + assertThat("Hit " + hitNumber + " in iter " + iter + " wrong" + pins, hits[hitNumber].getScore(), lessThan(lastScore)); + lastScore = hits[hitNumber].getScore(); + } + // Check that the pins appear in the requested order (globalHitNumber is cursor independent of from and size window used) + int globalHitNumber = 0; + for (String id : pins) { + if (globalHitNumber < size && globalHitNumber >= from) { + assertThat("Hit " + globalHitNumber + " in iter " + iter + " wrong" + pins, hits[globalHitNumber - from].getId(), + equalTo(id)); } - // Test the organic hits are sorted by text relevance - boolean highScoresExhausted = false; - for (; globalHitNumber < hits.length + from; globalHitNumber++) { - if (globalHitNumber >= from) { - int id = Integer.parseInt(hits[globalHitNumber - from].getId()); - if (id % 2 == 0) { - highScoresExhausted = true; - } else { - assertFalse("All odd IDs should have scored higher than even IDs in organic results", highScoresExhausted); - } + globalHitNumber++; + } + // Test the organic hits are sorted by text relevance + boolean highScoresExhausted = false; + for (; globalHitNumber < hits.length + from; globalHitNumber++) { + if (globalHitNumber >= from) { + int id = Integer.parseInt(hits[globalHitNumber - from].getId()); + if (id % 2 == 0) { + highScoresExhausted = true; + } else { + assertFalse("All odd IDs should have scored higher than even IDs in organic results", highScoresExhausted); } } @@ -160,8 +160,12 @@ public void testExhaustiveScoring() throws Exception { refresh(); - QueryStringQueryBuilder organicQuery = QueryBuilders.queryStringQuery("foo"); - PinnedQueryBuilder pqb = new PinnedQueryBuilder(organicQuery, "2"); + QueryBuilder organicQuery = QueryBuilders.queryStringQuery("foo"); + assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, "2")); + assertExhaustiveScoring(new PinnedQueryBuilder(organicQuery, new Item("test", "2"))); + } + + private void assertExhaustiveScoring(PinnedQueryBuilder pqb) { SearchResponse searchResponse = client().prepareSearch().setQuery(pqb).setTrackTotalHits(true) .setSearchType(DFS_QUERY_THEN_FETCH).get(); @@ -180,8 +184,12 @@ public void testExplain() throws Exception { client().prepareIndex("test").setId("4").setSource("field1", "slow brown cat").get(); refresh(); - PinnedQueryBuilder pqb = new PinnedQueryBuilder(QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR), "2"); + QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR); + assertExplain(new PinnedQueryBuilder(organicQuery, "2")); + assertExplain(new PinnedQueryBuilder(organicQuery, new Item("test", "2"))); + } + private void assertExplain(PinnedQueryBuilder pqb) { SearchResponse searchResponse = client().prepareSearch().setSearchType(SearchType.DFS_QUERY_THEN_FETCH).setQuery(pqb) .setExplain(true).get(); assertHitCount(searchResponse, 3); @@ -208,8 +216,12 @@ public void testHighlight() throws Exception { client().prepareIndex("test").setId("1").setSource("field1", "the quick brown fox").get(); refresh(); - PinnedQueryBuilder pqb = new PinnedQueryBuilder(QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR), "2"); + QueryBuilder organicQuery = QueryBuilders.matchQuery("field1", "the quick brown").operator(Operator.OR); + assertHighlight(new PinnedQueryBuilder(organicQuery, "2")); + assertHighlight(new PinnedQueryBuilder(organicQuery, new Item("test", "2"))); + } + private void assertHighlight(PinnedQueryBuilder pqb) { HighlightBuilder testHighlighter = new HighlightBuilder(); testHighlighter.field("field1"); @@ -222,5 +234,71 @@ public void testHighlight() throws Exception { HighlightField highlight = highlights.get("field1"); assertThat(highlight.fragments()[0].toString(), equalTo("the quick brown fox")); } + + public void testMultiIndexDocs() throws Exception { + assertAcked(prepareCreate("test1") + .setMapping(jsonBuilder().startObject().startObject("_doc").startObject("properties").startObject("field1") + .field("analyzer", "whitespace").field("type", "text").endObject().endObject().endObject().endObject()) + .setSettings(Settings.builder().put(indexSettings()).put("index.number_of_shards", randomIntBetween(2, 5)))); + + assertAcked(prepareCreate("test2") + .setMapping(jsonBuilder().startObject().startObject("_doc").startObject("properties").startObject("field1") + .field("analyzer", "whitespace").field("type", "text").endObject().endObject().endObject().endObject()) + .setSettings(Settings.builder().put(indexSettings()).put("index.number_of_shards", randomIntBetween(2, 5)))); + + client().prepareIndex("test1").setId("a").setSource("field1", "1a bar").get(); + client().prepareIndex("test1").setId("b").setSource("field1", "1b bar").get(); + client().prepareIndex("test1").setId("c").setSource("field1", "1c bar").get(); + client().prepareIndex("test2").setId("a").setSource("field1", "2a bar").get(); + client().prepareIndex("test2").setId("b").setSource("field1", "2b bar").get(); + client().prepareIndex("test2").setId("c").setSource("field1", "2c foo").get(); + + refresh(); + + PinnedQueryBuilder pqb = new PinnedQueryBuilder( + QueryBuilders.queryStringQuery("foo"), + new Item("test2", "a"), + new Item("test1", "a"), + new Item("test1", "b") + ); + + SearchResponse searchResponse = client().prepareSearch().setQuery(pqb).setTrackTotalHits(true) + .setSearchType(DFS_QUERY_THEN_FETCH).get(); + + assertHitCount(searchResponse, 4); + assertFirstHit(searchResponse, both(hasIndex("test2")).and(hasId("a"))); + assertSecondHit(searchResponse, both(hasIndex("test1")).and(hasId("a"))); + assertThirdHit(searchResponse, both(hasIndex("test1")).and(hasId("b"))); + assertFourthHit(searchResponse, both(hasIndex("test2")).and(hasId("c"))); + } + + public void testMultiIndexWithAliases() throws Exception { + assertAcked(prepareCreate("test") + .setMapping(jsonBuilder().startObject().startObject("_doc").startObject("properties").startObject("field1") + .field("analyzer", "whitespace").field("type", "text").endObject().endObject().endObject().endObject()) + .setSettings(Settings.builder().put(indexSettings()).put("index.number_of_shards", randomIntBetween(2, 5))) + .addAlias(new Alias("test-alias"))); + + client().prepareIndex("test").setId("a").setSource("field1", "document a").get(); + client().prepareIndex("test").setId("b").setSource("field1", "document b").get(); + client().prepareIndex("test").setId("c").setSource("field1", "document c").get(); + + refresh(); + + PinnedQueryBuilder pqb = new PinnedQueryBuilder( + QueryBuilders.queryStringQuery("document"), + new Item("test", "b"), + new Item("test-alias", "a"), + new Item("test", "a") + ); + + SearchResponse searchResponse = client().prepareSearch().setQuery(pqb).setTrackTotalHits(true) + .setSearchType(DFS_QUERY_THEN_FETCH).get(); + + assertHitCount(searchResponse, 3); + assertFirstHit(searchResponse, both(hasIndex("test")).and(hasId("b"))); + assertSecondHit(searchResponse, both(hasIndex("test")).and(hasId("a"))); + assertThirdHit(searchResponse, both(hasIndex("test")).and(hasId("c"))); + } } diff --git a/x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java b/x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java index 29940f0ea17ba..bb8fc13f69ae9 100644 --- a/x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java +++ b/x-pack/plugin/search-business-rules/src/main/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilder.java @@ -7,8 +7,6 @@ package org.elasticsearch.xpack.searchbusinessrules; -import org.apache.lucene.search.BooleanClause; -import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.BoostQuery; import org.apache.lucene.search.CappedScoreQuery; import org.apache.lucene.search.ConstantScoreQuery; @@ -16,12 +14,18 @@ import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.apache.lucene.util.NumericUtils; +import org.elasticsearch.Version; +import org.elasticsearch.common.regex.Regex; import org.elasticsearch.common.xcontent.ParseField; import org.elasticsearch.common.ParsingException; +import org.elasticsearch.common.Strings; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; +import org.elasticsearch.common.io.stream.Writeable; import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.ToXContentObject; import org.elasticsearch.common.xcontent.XContentBuilder; +import org.elasticsearch.common.xcontent.XContentFactory; import org.elasticsearch.common.xcontent.XContentParser; import org.elasticsearch.index.mapper.IdFieldMapper; import org.elasticsearch.index.mapper.MappedFieldType; @@ -32,12 +36,15 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; 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; /** * A query that will promote selected documents (identified by ID) above matches produced by an "organic" query. In practice, some upstream @@ -49,9 +56,11 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder public static final int MAX_NUM_PINNED_HITS = 100; private static final ParseField IDS_FIELD = new ParseField("ids"); + private static final ParseField DOCS_FIELD = new ParseField("docs"); public static final ParseField ORGANIC_QUERY_FIELD = new ParseField("organic"); private final List ids; + private final List docs; private QueryBuilder organicQuery; // Organic queries will have their scores capped to this number range, @@ -59,32 +68,160 @@ public class PinnedQueryBuilder extends AbstractQueryBuilder private static final float MAX_ORGANIC_SCORE = Float.intBitsToFloat((0xfe << 23)) - 1; /** - * Creates a new PinnedQueryBuilder + * A single item to be used for a {@link PinnedQueryBuilder}. */ + public static final class Item implements ToXContentObject, Writeable { + public static final String NAME = "item"; + + private static final ParseField INDEX_FIELD = new ParseField("_index"); + private static final ParseField ID_FIELD = new ParseField("_id"); + + private final String index; + private final String id; + + /** + * Constructor for a given item request + * + * @param index the index where the document is located + * @param id and its id + */ + public Item(String index, String id) { + if (index == null) { + throw new IllegalArgumentException("Item requires index to be non-null"); + } + if (Regex.isSimpleMatchPattern(index)) { + throw new IllegalArgumentException("Item index cannot contain wildcard expressions"); + } + if (id == null) { + throw new IllegalArgumentException("Item requires id to be non-null"); + } + this.index = index; + this.id = id; + } + + private Item(String id) { + this.index = null; + this.id = id; + } + + /** + * Read from a stream. + */ + Item(StreamInput in) throws IOException { + index = in.readString(); + id = in.readString(); + } + + @Override + public void writeTo(StreamOutput out) throws IOException { + out.writeString(index); + out.writeString(id); + } + + @Override + public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException { + builder.startObject(); + builder.field(INDEX_FIELD.getPreferredName(), this.index); + builder.field(ID_FIELD.getPreferredName(), this.id); + return builder.endObject(); + } + + private static final ConstructingObjectParser PARSER = new ConstructingObjectParser<>( + NAME, + a -> new Item((String) a[0], (String) a[1]) + ); + + static { + PARSER.declareString(constructorArg(), INDEX_FIELD); + PARSER.declareString(constructorArg(), ID_FIELD); + } + + @Override + public String toString() { + try { + XContentBuilder builder = XContentFactory.jsonBuilder(); + builder.prettyPrint(); + toXContent(builder, EMPTY_PARAMS); + return Strings.toString(builder); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public int hashCode() { + return Objects.hash(index, id); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if ((o instanceof Item) == false) { + return false; + } + Item other = (Item) o; + return Objects.equals(index, other.index) && Objects.equals(id, other.id); + } + } + public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) { - if (organicQuery == null) { - throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null"); - } - this.organicQuery = organicQuery; - if (ids == null) { - throw new IllegalArgumentException("[" + NAME + "] ids cannot be null"); - } - if (ids.length > MAX_NUM_PINNED_HITS) { - throw new IllegalArgumentException("[" + NAME + "] Max of "+MAX_NUM_PINNED_HITS+" ids exceeded: "+ - ids.length+" provided."); - } - LinkedHashSet deduped = new LinkedHashSet<>(); - for (String id : ids) { - if (id == null) { - throw new IllegalArgumentException("[" + NAME + "] id cannot be null"); - } - if(deduped.add(id) == false) { - throw new IllegalArgumentException("[" + NAME + "] duplicate id found in list: "+id); - } - } - this.ids = new ArrayList<>(); - Collections.addAll(this.ids, ids); + this(organicQuery, Arrays.asList(ids), null); + } + public PinnedQueryBuilder(QueryBuilder organicQuery, Item... docs) { + this(organicQuery, null, Arrays.asList(docs)); + } + + /** + * Creates a new PinnedQueryBuilder + */ + private PinnedQueryBuilder(QueryBuilder organicQuery, List ids, List docs) { + if (organicQuery == null) { + throw new IllegalArgumentException("[" + NAME + "] organicQuery cannot be null"); + } + this.organicQuery = organicQuery; + if (ids == null && docs == null) { + throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be null"); + } + if (ids != null && docs != null) { + throw new IllegalArgumentException("[" + NAME + "] ids and docs cannot both be used"); + } + if (ids != null) { + if (ids.size() > MAX_NUM_PINNED_HITS) { + throw new IllegalArgumentException( + "[" + NAME + "] Max of " + MAX_NUM_PINNED_HITS + " ids exceeded: " + ids.size() + " provided." + ); + } + LinkedHashSet deduped = new LinkedHashSet<>(); + for (String id : ids) { + if (id == null) { + throw new IllegalArgumentException("[" + NAME + "] id cannot be null"); + } + if (deduped.add(id) == false) { + throw new IllegalArgumentException("[" + NAME + "] duplicate id found in list: " + id); + } + } + } + if (docs != null) { + if (docs.size() > MAX_NUM_PINNED_HITS) { + throw new IllegalArgumentException( + "[" + NAME + "] Max of " + MAX_NUM_PINNED_HITS + " docs exceeded: " + docs.size() + " provided." + ); + } + LinkedHashSet deduped = new LinkedHashSet<>(); + for (Item doc : docs) { + if (doc == null) { + throw new IllegalArgumentException("[" + NAME + "] doc cannot be null"); + } + if (deduped.add(doc) == false) { + throw new IllegalArgumentException("[" + NAME + "] duplicate doc found in list: " + doc); + } + } + } + this.ids = ids; + this.docs = docs; } /** @@ -92,13 +229,29 @@ public PinnedQueryBuilder(QueryBuilder organicQuery, String... ids) { */ public PinnedQueryBuilder(StreamInput in) throws IOException { super(in); - ids = in.readStringList(); + if (in.getVersion().before(Version.V_8_0_0)) { + ids = in.readStringList(); + docs = null; + } else { + ids = in.readOptionalStringList(); + docs = in.readBoolean() ? in.readList(Item::new) : null; + } organicQuery = in.readNamedWriteable(QueryBuilder.class); } @Override protected void doWriteTo(StreamOutput out) throws IOException { - out.writeStringCollection(this.ids); + if (out.getVersion().before(Version.V_8_0_0)) { + out.writeStringCollection(this.ids); + } else { + out.writeOptionalStringCollection(this.ids); + if (docs == null) { + out.writeBoolean(false); + } else { + out.writeBoolean(true); + out.writeList(docs); + } + } out.writeNamedWriteable(organicQuery); } @@ -113,9 +266,22 @@ public QueryBuilder organicQuery() { * Returns the pinned ids for the query. */ public List ids() { + if (this.ids == null) { + return Collections.emptyList(); + } return Collections.unmodifiableList(this.ids); } + /** + * @return the pinned docs for the query. + */ + public List docs() { + if (this.docs == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(this.docs); + } + @Override protected void doXContent(XContentBuilder builder, Params params) throws IOException { builder.startObject(NAME); @@ -123,11 +289,20 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep builder.field(ORGANIC_QUERY_FIELD.getPreferredName()); organicQuery.toXContent(builder, params); } - builder.startArray(IDS_FIELD.getPreferredName()); - for (String value : ids) { - builder.value(value); + if (ids != null) { + builder.startArray(IDS_FIELD.getPreferredName()); + for (String value : ids) { + builder.value(value); + } + builder.endArray(); + } + if (docs != null) { + builder.startArray(DOCS_FIELD.getPreferredName()); + for (Item item : docs) { + builder.value(item); + } + builder.endArray(); } - builder.endArray(); printBoostAndQueryName(builder); builder.endObject(); } @@ -140,12 +315,15 @@ protected void doXContent(XContentBuilder builder, Params params) throws IOExcep QueryBuilder organicQuery = (QueryBuilder) a[0]; @SuppressWarnings("unchecked") List ids = (List) a[1]; - return new PinnedQueryBuilder(organicQuery, ids.toArray(String[]::new)); + @SuppressWarnings("unchecked") + List docs = (List) a[2]; + return new PinnedQueryBuilder(organicQuery, ids, docs); } ); static { PARSER.declareObject(constructorArg(), (p, c) -> parseInnerQueryBuilder(p), ORGANIC_QUERY_FIELD); - PARSER.declareStringArray(constructorArg(), IDS_FIELD); + PARSER.declareStringArray(optionalConstructorArg(), IDS_FIELD); + PARSER.declareObjectArray(optionalConstructorArg(), Item.PARSER, DOCS_FIELD); declareStandardFields(PARSER); } @@ -166,7 +344,7 @@ public String getWriteableName() { protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException { QueryBuilder newOrganicQuery = organicQuery.rewrite(queryRewriteContext); if (newOrganicQuery != organicQuery) { - PinnedQueryBuilder result = new PinnedQueryBuilder(newOrganicQuery, ids.toArray(String[]::new)); + PinnedQueryBuilder result = new PinnedQueryBuilder(newOrganicQuery, ids, docs); result.boost(this.boost); return result; } @@ -179,29 +357,32 @@ protected Query doToQuery(SearchExecutionContext context) throws IOException { if (idField == null) { return new MatchNoDocsQuery("No mappings"); } - if (this.ids.isEmpty()) { + List items = (docs != null) ? docs : ids.stream().map(id -> new Item(id)).collect(Collectors.toList()); + if (items.isEmpty()) { return new CappedScoreQuery(organicQuery.toQuery(context), MAX_ORGANIC_SCORE); } else { - BooleanQuery.Builder pinnedQueries = new BooleanQuery.Builder(); + List pinnedQueries = new ArrayList<>(); // Ensure each pin order using a Boost query with the relevant boost factor int minPin = NumericUtils.floatToSortableInt(MAX_ORGANIC_SCORE) + 1; - int boostNum = minPin + ids.size(); + int boostNum = minPin + items.size(); float lastScore = Float.MAX_VALUE; - for (String id : ids) { + for (Item item : items) { float pinScore = NumericUtils.sortableIntToFloat(boostNum); assert pinScore < lastScore; lastScore = pinScore; boostNum--; - // Ensure the pin order using a Boost query with the relevant boost factor - Query idQuery = new BoostQuery(new ConstantScoreQuery(idField.termQuery(id, context)), pinScore); - pinnedQueries.add(idQuery, BooleanClause.Occur.SHOULD); + if (item.index == null || context.indexMatches(item.index)) { + // Ensure the pin order using a Boost query with the relevant boost factor + Query idQuery = new BoostQuery(new ConstantScoreQuery(idField.termQuery(item.id, context)), pinScore); + pinnedQueries.add(idQuery); + } } // Score for any pinned query clause should be used, regardless of any organic clause score, to preserve pin order. // Use dismax to always take the larger (ie pinned) of the organic vs pinned scores List organicAndPinned = new ArrayList<>(); - organicAndPinned.add(pinnedQueries.build()); + organicAndPinned.add(new DisjunctionMaxQuery(pinnedQueries, 0)); // Cap the scores of the organic query organicAndPinned.add(new CappedScoreQuery(organicQuery.toQuery(context), MAX_ORGANIC_SCORE)); return new DisjunctionMaxQuery(organicAndPinned, 0); @@ -211,11 +392,14 @@ protected Query doToQuery(SearchExecutionContext context) throws IOException { @Override protected int doHashCode() { - return Objects.hash(ids, organicQuery); + return Objects.hash(ids, docs, organicQuery); } @Override protected boolean doEquals(PinnedQueryBuilder other) { - return Objects.equals(ids, other.ids) && Objects.equals(organicQuery, other.organicQuery) && boost == other.boost; + return Objects.equals(ids, other.ids) + && Objects.equals(docs, other.docs) + && Objects.equals(organicQuery, other.organicQuery) + && boost == other.boost; } } diff --git a/x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java b/x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java index 00fb17bb6acef..5d00758c16abc 100644 --- a/x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java +++ b/x-pack/plugin/search-business-rules/src/test/java/org/elasticsearch/xpack/searchbusinessrules/PinnedQueryBuilderTests.java @@ -9,8 +9,8 @@ import com.fasterxml.jackson.core.io.JsonStringEncoder; +import org.apache.lucene.search.CappedScoreQuery; import org.apache.lucene.search.DisjunctionMaxQuery; -import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.elasticsearch.common.ParsingException; import org.elasticsearch.common.xcontent.XContentBuilder; @@ -24,6 +24,7 @@ import org.elasticsearch.plugins.Plugin; import org.elasticsearch.test.AbstractQueryTestCase; import org.elasticsearch.test.TestGeoShapeFieldMapperPlugin; +import org.elasticsearch.xpack.searchbusinessrules.PinnedQueryBuilder.Item; import java.io.IOException; import java.util.ArrayList; @@ -35,7 +36,11 @@ public class PinnedQueryBuilderTests extends AbstractQueryTestCase { @Override protected PinnedQueryBuilder doCreateTestQueryBuilder() { - return new PinnedQueryBuilder(createRandomQuery(), generateRandomStringArray(100, 256, false, true)); + if (randomBoolean()) { + return new PinnedQueryBuilder(createRandomQuery(), generateRandomStringArray(100, 256, false, true)); + } else { + return new PinnedQueryBuilder(createRandomQuery(), generateRandomItems()); + } } private QueryBuilder createRandomQuery() { @@ -90,15 +95,17 @@ private QueryBuilder createTestTermQueryBuilder() { return new TermQueryBuilder(fieldName, value); } + private Item[] generateRandomItems() { + return randomArray(1, 100, Item[]::new, () -> new Item(randomAlphaOfLength(64), randomAlphaOfLength(256))); + } + @Override protected void doAssertLuceneQuery(PinnedQueryBuilder queryBuilder, Query query, SearchExecutionContext searchContext) { - if (queryBuilder.ids().size() == 0 && queryBuilder.organicQuery() == null) { - assertThat(query, instanceOf(MatchNoDocsQuery.class)); + if (queryBuilder.ids().size() == 0 && queryBuilder.docs().size() == 0) { + assertThat(query, instanceOf(CappedScoreQuery.class)); } else { - if (queryBuilder.ids().size() > 0) { - // Have IDs and an organic query - uses DisMax - assertThat(query, instanceOf(DisjunctionMaxQuery.class)); - } + // Have IDs/docs and an organic query - uses DisMax + assertThat(query, instanceOf(DisjunctionMaxQuery.class)); } } @@ -114,11 +121,30 @@ public void testIllegalArguments() { expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (String)null)); expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(null, "1")); expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), "1", null, "2")); - String[] bigList = new String[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1]; - for (int i = 0; i < bigList.length; i++) { - bigList[i] = String.valueOf(i); + expectThrows( + IllegalArgumentException.class, + () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), (PinnedQueryBuilder.Item)null) + ); + expectThrows( + IllegalArgumentException.class, + () -> new PinnedQueryBuilder(null, new Item("test", "1")) + ); + expectThrows( + IllegalArgumentException.class, + () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), new Item("test", "1"), null, new Item("test", "2")) + ); + expectThrows( + IllegalArgumentException.class, + () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), new Item("test*", "1")) + ); + String[] bigIdList = new String[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1]; + Item[] bigItemList = new Item[PinnedQueryBuilder.MAX_NUM_PINNED_HITS + 1]; + for (int i = 0; i < bigIdList.length; i++) { + bigIdList[i] = String.valueOf(i); + bigItemList[i] = new Item("test", String.valueOf(i)); } - expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigList)); + expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigIdList)); + expectThrows(IllegalArgumentException.class, () -> new PinnedQueryBuilder(new MatchAllQueryBuilder(), bigItemList)); } @@ -130,7 +156,7 @@ public void testEmptyPinnedQuery() throws Exception { } } - public void testFromJson() throws IOException { + public void testIdsFromJson() throws IOException { String query = "{" + "\"pinned\" : {" + @@ -154,6 +180,30 @@ public void testFromJson() throws IOException { assertThat(queryBuilder.organicQuery(), instanceOf(TermQueryBuilder.class)); } + public void testDocsFromJson() throws IOException { + String query = + "{" + + "\"pinned\" : {" + + " \"organic\" : {" + + " \"term\" : {" + + " \"tag\" : {" + + " \"value\" : \"tech\"," + + " \"boost\" : 1.0" + + " }" + + " }" + + " }, "+ + " \"docs\" : [{ \"_index\": \"test\", \"_id\": \"1\" }, { \"_index\": \"test\", \"_id\": \"2\" }]," + + " \"boost\":1.0 "+ + "}" + + "}"; + + PinnedQueryBuilder queryBuilder = (PinnedQueryBuilder) parseQuery(query); + checkGeneratedJson(query, queryBuilder); + + assertEquals(query, 2, queryBuilder.docs().size()); + assertThat(queryBuilder.organicQuery(), instanceOf(TermQueryBuilder.class)); + } + /** * test that unknown query names in the clauses throw an error */ @@ -167,19 +217,45 @@ public void testUnknownQueryName() throws IOException { assertEquals("[1:46] [pinned] failed to parse field [organic]", ex.getMessage()); } - public void testRewrite() throws IOException { + public void testIdsRewrite() throws IOException { PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("foo", 1), "1"); QueryBuilder rewritten = pinnedQueryBuilder.rewrite(createSearchExecutionContext()); assertThat(rewritten, instanceOf(PinnedQueryBuilder.class)); } + public void testDocsRewrite() throws IOException { + PinnedQueryBuilder pinnedQueryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("foo", 1), new Item("test", "1")); + QueryBuilder rewritten = pinnedQueryBuilder.rewrite(createSearchExecutionContext()); + assertThat(rewritten, instanceOf(PinnedQueryBuilder.class)); + } + @Override public void testMustRewrite() throws IOException { SearchExecutionContext context = createSearchExecutionContext(); context.setAllowUnmappedFields(true); - PinnedQueryBuilder queryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("unmapped_field", "42")); + PinnedQueryBuilder queryBuilder = new PinnedQueryBuilder(new TermQueryBuilder("unmapped_field", "42"), "42"); IllegalStateException e = expectThrows(IllegalStateException.class, () -> queryBuilder.toQuery(context)); assertEquals("Rewrite first", e.getMessage()); } + + public void testIdInsertionOrderRetained() { + String[] ids = generateRandomStringArray(10, 50, false); + PinnedQueryBuilder pqb = new PinnedQueryBuilder(new MatchAllQueryBuilder(), ids); + List addedIds = pqb.ids(); + int pos = 0; + for (String key : addedIds) { + assertEquals(ids[pos++], key); + } + } + + public void testDocInsertionOrderRetained() { + Item[] items = randomArray(10, Item[]::new, () -> new Item(randomAlphaOfLength(64), randomAlphaOfLength(256))); + PinnedQueryBuilder pqb = new PinnedQueryBuilder(new MatchAllQueryBuilder(), items); + List addedDocs = pqb.docs(); + int pos = 0; + for (Item item : addedDocs) { + assertEquals(items[pos++], item); + } + } } diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/search-business-rules/10_pinned_query.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/search-business-rules/10_pinned_query.yml new file mode 100644 index 0000000000000..50dab0ccd1f80 --- /dev/null +++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/search-business-rules/10_pinned_query.yml @@ -0,0 +1,80 @@ +setup: + - do: + index: + index: test1 + id: a + body: + title: "title one" + refresh: true + + - do: + index: + index: test1 + id: b + body: + title: "title two" + refresh: true + + - do: + index: + index: test2 + id: a + body: + title: "title" + refresh: true + + - do: + index: + index: test2 + id: c + body: + title: "another title" + refresh: true + +--- +"Test pinned query with IDs": + - do: + search: + index: test1 + body: + query: + pinned: + ids: [b] + organic: + match: + title: + query: "title" + + - match: { hits.total.value: 2 } + - match: { hits.hits.0._id: b } + - match: { hits.hits.1._id: a } + +--- +"Test pinned query with ID and index": + - skip: + version: " - 7.99.99" + reason: "the 'docs' option is not yet backported" + - do: + search: + index: test1,test2 + body: + query: + pinned: + docs: + - { _id: a, _index: test2 } + - { _id: c, _index: test2 } + - { _id: a, _index: test1 } + organic: + match: + title: + query: "title" + + - match: { hits.total.value: 4 } + + - match: { hits.hits.0._id: "a" } + - match: { hits.hits.0._index: "test2" } + - match: { hits.hits.1._id: "c" } + - match: { hits.hits.2._id: "a" } + - match: { hits.hits.2._index: "test1" } + - match: { hits.hits.3._id: "b" } +