diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f10b3c980..9ef4423196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add transparent gRPC transport with HybridTransport (bulk over gRPC, REST fallback), translation layer, TLS, basic auth, AWS SigV4, and JWT support ([#2062](https://github.com/opensearch-project/opensearch-java/pull/2062)) - Add search over gRPC with match_all query support, SearchRequestConverter, SearchResponseConverter, and _source deserialization ([#2071](https://github.com/opensearch-project/opensearch-java/pull/2071)) - Add `setAutomaticRetriesDisabled` to `ApacheHttpClient5TransportBuilder` to allow enabling automatic retries ([#2086](https://github.com/opensearch-project/opensearch-java/pull/2086)) +- Added typed support for scored named queries by mapping the polymorphic `Hit.matched_queries` field to a new `MatchedQueries` tagged union, whose `names` variant carries a `List` and whose `scores` variant carries a `Map`, and by sending `include_named_queries_score` as a query parameter ([#2098](https://github.com/opensearch-project/opensearch-java/pull/2098)) ### Fixed - Fix `unitTest` task not running the tests in the `test` source set ([#2074](https://github.com/opensearch-project/opensearch-java/pull/2074)) diff --git a/java-client/src/generated/java/org/opensearch/client/opensearch/core/SearchRequest.java b/java-client/src/generated/java/org/opensearch/client/opensearch/core/SearchRequest.java index cdcf7777df..12df02a1df 100644 --- a/java-client/src/generated/java/org/opensearch/client/opensearch/core/SearchRequest.java +++ b/java-client/src/generated/java/org/opensearch/client/opensearch/core/SearchRequest.java @@ -571,7 +571,8 @@ public final Boolean ignoreUnavailable() { } /** - * Whether to return scores with named queries. Default is false. + * Indicates whether hit.matched_queries should be rendered as a map that includes the name of the matched query associated + * with its score (true) or as an array containing the name of the matched queries (false) *

* API name: {@code include_named_queries_score} *

@@ -1055,11 +1056,6 @@ protected void serializeInternal(JsonGenerator generator, JsonpMapper mapper) { this.highlight.serialize(generator, mapper); } - if (this.includeNamedQueriesScore != null) { - generator.writeKey("include_named_queries_score"); - generator.write(this.includeNamedQueriesScore); - } - if (ApiTypeHelper.isDefined(this.indicesBoost)) { generator.writeKey("indices_boost"); generator.writeStartArray(); @@ -1954,7 +1950,8 @@ public final Builder ignoreUnavailable(@Nullable Boolean value) { } /** - * Whether to return scores with named queries. Default is false. + * Indicates whether hit.matched_queries should be rendered as a map that includes the name of the matched query + * associated with its score (true) or as an array containing the name of the matched queries (false) *

* API name: {@code include_named_queries_score} *

@@ -2728,7 +2725,6 @@ protected static void setupSearchRequestDeserializer(ObjectDeserializer params) { if (this.ignoreUnavailable != null) { params.put("ignore_unavailable", String.valueOf(this.ignoreUnavailable)); } + if (this.includeNamedQueriesScore != null) { + params.put("include_named_queries_score", String.valueOf(this.includeNamedQueriesScore)); + } if (this.lenient != null) { params.put("lenient", String.valueOf(this.lenient)); } diff --git a/java-client/src/generated/java/org/opensearch/client/opensearch/core/search/Hit.java b/java-client/src/generated/java/org/opensearch/client/opensearch/core/search/Hit.java index 9914352a3b..308f163ef5 100644 --- a/java-client/src/generated/java/org/opensearch/client/opensearch/core/search/Hit.java +++ b/java-client/src/generated/java/org/opensearch/client/opensearch/core/search/Hit.java @@ -91,7 +91,7 @@ public class Hit implements PlainJsonSerializable, ToCopyableBuilder< private final Map innerHits; @Nullable - private final JsonData matchedQueries; + private final MatchedQueries matchedQueries; @Nonnull private final Map metaFields; @@ -231,7 +231,7 @@ public final Map innerHits() { *

*/ @Nullable - public final JsonData matchedQueries() { + public final MatchedQueries matchedQueries() { return this.matchedQueries; } @@ -516,7 +516,7 @@ public static class Builder extends ObjectBuilderBase implements Copy @Nullable private Map innerHits; @Nullable - private JsonData matchedQueries; + private MatchedQueries matchedQueries; @Nullable private Map metaFields; @Nullable @@ -783,11 +783,23 @@ public final Builder innerHits(String key, Function */ @Nonnull - public final Builder matchedQueries(@Nullable JsonData value) { + public final Builder matchedQueries(@Nullable MatchedQueries value) { this.matchedQueries = value; return this; } + /** + * The names of queries that matched the document. When include_named_queries_score is false (default), returns an + * array of query names. When true, returns an object mapping query names to their scores. + *

+ * API name: {@code matched_queries} + *

+ */ + @Nonnull + public final Builder matchedQueries(Function> fn) { + return matchedQueries(fn.apply(new MatchedQueries.Builder()).build()); + } + /** * Contains metadata values for the documents. * @@ -996,7 +1008,7 @@ protected static void setupHitDeserializer( ); op.add(Builder::index, JsonpDeserializer.stringDeserializer(), "_index"); op.add(Builder::innerHits, JsonpDeserializer.stringMapDeserializer(InnerHitsResult._DESERIALIZER), "inner_hits"); - op.add(Builder::matchedQueries, JsonData._DESERIALIZER, "matched_queries"); + op.add(Builder::matchedQueries, MatchedQueries._DESERIALIZER, "matched_queries"); op.add(Builder::nested, NestedIdentity._DESERIALIZER, "_nested"); op.add(Builder::node, JsonpDeserializer.stringDeserializer(), "_node"); op.add(Builder::primaryTerm, JsonpDeserializer.longDeserializer(), "_primary_term"); diff --git a/java-client/src/main/java/org/opensearch/client/opensearch/core/search/MatchedQueries.java b/java-client/src/main/java/org/opensearch/client/opensearch/core/search/MatchedQueries.java new file mode 100644 index 0000000000..aa46dff338 --- /dev/null +++ b/java-client/src/main/java/org/opensearch/client/opensearch/core/search/MatchedQueries.java @@ -0,0 +1,207 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.core.search; + +import jakarta.json.stream.JsonGenerator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import javax.annotation.Nonnull; +import org.opensearch.client.json.JsonpDeserializable; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.json.PlainJsonSerializable; +import org.opensearch.client.json.UnionDeserializer; +import org.opensearch.client.util.ApiTypeHelper; +import org.opensearch.client.util.ObjectBuilder; +import org.opensearch.client.util.ObjectBuilderBase; +import org.opensearch.client.util.TaggedUnion; +import org.opensearch.client.util.TaggedUnionUtils; + +// typedef: core.search.MatchedQueries + +/** + * The names of queries that matched a hit, with optional per-query scores. + * + *

+ * The server returns matched_queries in one of two shapes, and never both: an array of query names when + * {@code include_named_queries_score} is not set, or an object mapping query names to scores when + * {@code include_named_queries_score} is true. + *

+ */ +@JsonpDeserializable +public class MatchedQueries implements TaggedUnion, PlainJsonSerializable { + + /** + * {@link MatchedQueries} variant kinds. + */ + public enum Kind { + Names, + Scores + } + + private final Kind _kind; + private final Object _value; + + @Override + public final Kind _kind() { + return _kind; + } + + @Override + public final Object _get() { + return _value; + } + + private MatchedQueries(Kind kind, Object value) { + this._kind = kind; + this._value = value; + } + + private MatchedQueries(Builder builder) { + this._kind = ApiTypeHelper.requireNonNull(builder._kind, builder, ""); + this._value = ApiTypeHelper.requireNonNull(builder._value, builder, ""); + } + + public static MatchedQueries of(Function> fn) { + return fn.apply(new Builder()).build(); + } + + public static MatchedQueries ofNames(List names) { + return new MatchedQueries(Kind.Names, ApiTypeHelper.requireNonNull(names, MatchedQueries.class, "names")); + } + + public static MatchedQueries ofScores(Map scores) { + return new MatchedQueries(Kind.Scores, ApiTypeHelper.requireNonNull(scores, MatchedQueries.class, "scores")); + } + + /** + * Is this variant instance of kind {@code names}? + */ + public boolean isNames() { + return _kind == Kind.Names; + } + + /** + * Get the {@code names} variant value: the names of the queries that matched the hit, returned by the server when + * {@code include_named_queries_score} is not set. + * + * @throws IllegalStateException if the current variant is not the {@code names} kind. + */ + public List names() { + return TaggedUnionUtils.get(this, Kind.Names); + } + + /** + * Is this variant instance of kind {@code scores}? + */ + public boolean isScores() { + return _kind == Kind.Scores; + } + + /** + * Get the {@code scores} variant value: a map from matched query name to score, returned by the server when + * {@code include_named_queries_score} is true. + * + * @throws IllegalStateException if the current variant is not the {@code scores} kind. + */ + public Map scores() { + return TaggedUnionUtils.get(this, Kind.Scores); + } + + @Override + @SuppressWarnings("unchecked") + public void serialize(JsonGenerator generator, JsonpMapper mapper) { + switch (_kind) { + case Names: + generator.writeStartArray(); + for (String name : (List) _value) { + generator.write(name); + } + generator.writeEnd(); + break; + case Scores: + generator.writeStartObject(); + for (Map.Entry entry : ((Map) _value).entrySet()) { + generator.writeKey(entry.getKey()); + generator.write(entry.getValue()); + } + generator.writeEnd(); + break; + } + } + + @Nonnull + public Builder toBuilder() { + return new Builder(this); + } + + @Nonnull + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ObjectBuilderBase implements ObjectBuilder { + private Kind _kind; + private Object _value; + + public Builder() {} + + private Builder(MatchedQueries o) { + this._kind = o._kind; + this._value = o._value; + } + + public ObjectBuilder names(List v) { + this._kind = Kind.Names; + this._value = v; + return this; + } + + public ObjectBuilder scores(Map v) { + this._kind = Kind.Scores; + this._value = v; + return this; + } + + @Override + public MatchedQueries build() { + _checkSingleUse(); + return new MatchedQueries(this); + } + } + + private static JsonpDeserializer buildMatchedQueriesDeserializer() { + return new UnionDeserializer.Builder(MatchedQueries::new, false).addMember( + Kind.Names, + JsonpDeserializer.arrayDeserializer(JsonpDeserializer.stringDeserializer()) + ).addMember(Kind.Scores, JsonpDeserializer.stringMapDeserializer(JsonpDeserializer.doubleDeserializer())).build(); + } + + public static final JsonpDeserializer _DESERIALIZER = JsonpDeserializer.lazy( + MatchedQueries::buildMatchedQueriesDeserializer + ); + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + Objects.hashCode(this._kind); + result = 31 * result + Objects.hashCode(this._value); + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || this.getClass() != o.getClass()) return false; + MatchedQueries other = (MatchedQueries) o; + return Objects.equals(this._kind, other._kind) && Objects.equals(this._value, other._value); + } +} diff --git a/java-client/src/test/java/org/opensearch/client/opensearch/core/search/MatchedQueriesTest.java b/java-client/src/test/java/org/opensearch/client/opensearch/core/search/MatchedQueriesTest.java new file mode 100644 index 0000000000..70b0043e4f --- /dev/null +++ b/java-client/src/test/java/org/opensearch/client/opensearch/core/search/MatchedQueriesTest.java @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +package org.opensearch.client.opensearch.core.search; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.opensearch.client.opensearch.model.ModelTestCase.toJson; + +import jakarta.json.stream.JsonParser; +import java.io.StringReader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import org.opensearch.client.json.JsonData; +import org.opensearch.client.json.JsonpDeserializer; +import org.opensearch.client.json.JsonpMapper; +import org.opensearch.client.json.jsonb.JsonbJsonpMapper; + +public class MatchedQueriesTest { + private final JsonpMapper mapper = new JsonbJsonpMapper(); + private final JsonpDeserializer> hitDeserializer = Hit.createHitDeserializer(JsonData._DESERIALIZER); + + private Hit parseHit(String json) { + JsonParser parser = mapper.jsonProvider().createParser(new StringReader(json)); + return hitDeserializer.deserialize(parser, mapper); + } + + @Test + public void deserializesArrayFormAsNamesVariant() { + Hit hit = parseHit("{\"_index\":\"i\",\"matched_queries\":[\"a\",\"b\"]}"); + MatchedQueries mq = hit.matchedQueries(); + assertTrue(mq.isNames()); + assertFalse(mq.isScores()); + assertEquals(Arrays.asList("a", "b"), mq.names()); + } + + @Test + public void deserializesObjectFormAsScoresVariant() { + Hit hit = parseHit("{\"_index\":\"i\",\"matched_queries\":{\"a\":1.5,\"b\":2.5}}"); + MatchedQueries mq = hit.matchedQueries(); + assertTrue(mq.isScores()); + assertFalse(mq.isNames()); + Map scores = mq.scores(); + assertEquals(Double.valueOf(1.5), scores.get("a")); + assertEquals(Double.valueOf(2.5), scores.get("b")); + } + + @Test + public void returnsNullWhenAbsent() { + Hit hit = parseHit("{\"_index\":\"i\"}"); + assertNull(hit.matchedQueries()); + } + + @Test + public void serializesNamesVariantAsArray() { + Hit hit = parseHit("{\"_index\":\"i\",\"matched_queries\":[\"a\",\"b\"]}"); + assertTrue(toJson(hit, mapper).contains("\"matched_queries\":[\"a\",\"b\"]")); + } + + @Test + public void serializesScoresVariantAsObject() { + Map scores = new LinkedHashMap<>(); + scores.put("a", 1.5); + scores.put("b", 2.5); + Hit hit = new Hit.Builder().index("i").matchedQueries(MatchedQueries.ofScores(scores)).build(); + assertTrue(toJson(hit, mapper).contains("\"matched_queries\":{\"a\":1.5,\"b\":2.5}")); + } +} diff --git a/java-codegen/src/main/java/org/opensearch/client/codegen/model/types/Types.java b/java-codegen/src/main/java/org/opensearch/client/codegen/model/types/Types.java index 75390a355a..3de97b2579 100644 --- a/java-codegen/src/main/java/org/opensearch/client/codegen/model/types/Types.java +++ b/java-codegen/src/main/java/org/opensearch/client/codegen/model/types/Types.java @@ -230,6 +230,16 @@ public static final class Cat { public static final String PACKAGE = OpenSearch.PACKAGE + ".cat"; public static final Type CatRequestBase = type(PACKAGE, "CatRequestBase"); } + + public static final class Core { + public static final String PACKAGE = OpenSearch.PACKAGE + ".core"; + + public static final class Search { + public static final String PACKAGE = Core.PACKAGE + ".search"; + + public static final Type MatchedQueries = type(PACKAGE, "MatchedQueries"); + } + } } public static final class Transport { diff --git a/java-codegen/src/main/java/org/opensearch/client/codegen/transformer/overrides/Overrides.java b/java-codegen/src/main/java/org/opensearch/client/codegen/transformer/overrides/Overrides.java index b41842eda3..8a2fac1cc0 100644 --- a/java-codegen/src/main/java/org/opensearch/client/codegen/transformer/overrides/Overrides.java +++ b/java-codegen/src/main/java/org/opensearch/client/codegen/transformer/overrides/Overrides.java @@ -157,6 +157,12 @@ private static JsonPointer schema(String namespace, String name) { .with(schema("_core.search", "Rescore"), so -> so.withShouldGenerate(ShouldGenerate.Always)) .with(schema("_core.search", "Suggester"), so -> so.withShouldGenerate(ShouldGenerate.Always)) .with(schema("_core.search", "TermSuggestOption"), so -> so.withShouldGenerate(ShouldGenerate.Always)) + .with( + schema("_core.search", "Hit"), + so -> so.withProperties( + p -> p.with("matched_queries", po -> po.withMappedType(Types.Client.OpenSearch.Core.Search.MatchedQueries)) + ) + ) .with( schema("_core.search", "HitsMetadata"), so -> so.withProperties( @@ -206,7 +212,12 @@ private static JsonPointer schema(String namespace, String name) { .with( requestBodySchema("search"), so -> so.withProperties( - p -> p.with("aggregations", po -> po.withAliases(Set.of("aggs"))).with("aggs", po -> po.withIgnore(true)) + p -> p.with("aggregations", po -> po.withAliases(Set.of("aggs"))) + .with("aggs", po -> po.withIgnore(true)) + // The server ignores include_named_queries_score in the request body and renders named query scores only when + // it is sent as a query parameter. See https://github.com/opensearch-project/OpenSearch/issues/22689. Remove + // this once the server honors the body value. + .with("include_named_queries_score", po -> po.withIgnore(true)) ) ) )