From 9bf9c15601ea02492dc1d1d437723a9abd9d2d29 Mon Sep 17 00:00:00 2001
From: Sotaro Hikita <70102274+lawofcycles@users.noreply.github.com>
Date: Fri, 14 Aug 2026 05:58:28 +0900
Subject: [PATCH] Support named query scores in Hit.matchedQueries() (#2098)
* Support named query scores in Hit.matchedQueries()
matched_queries is now generated as Map instead of the
generic JsonData fallback. A schema driven rule in the code generator
detects the oneOf of a string array and a scalar value map and maps it to
a typed map. The deserializer reads the object form as name to score and
the array form as names with null values.
include_named_queries_score is now sent as a query parameter rather than
in the request body. The server enables the scored map rendering only from
the query parameter, so sending the flag in the body returns names without
scores. The request body property is ignored in the code generator so the
generated client emits the query parameter. See
https://github.com/opensearch-project/OpenSearch/issues/22689 for the
server behavior.
Adds MatchedQueriesTest covering the array and object forms.
Signed-off-by: Sotaro Hikita
* Apply spotless formatting to Overrides.java
Signed-off-by: Sotaro Hikita
* Rework matched_queries as a MatchedQueries tagged union" -m "Replace the
previous Map shape for Hit.matched_queries with a MatchedQueries tagged union
that carries a Names variant (List) and a Scores variant (Map). The
Union is bound through Overrides.PropertyOverride.withMappedType and reuses UnionDeserializer,
following the SourceConfig and FieldValue precedent, so no changes are needed to the object
shape templates or to Field, SpecTransformer, and TypeMapper. Remove the previously added
stringArrayOrMapDeserializer along with the arrayOrMapUnion flag and its wiring.
Signed-off-by: Sotaro Hikita
---------
Signed-off-by: Sotaro Hikita
---
CHANGELOG.md | 1 +
.../client/opensearch/core/SearchRequest.java | 15 +-
.../client/opensearch/core/search/Hit.java | 22 +-
.../core/search/MatchedQueries.java | 207 ++++++++++++++++++
.../core/search/MatchedQueriesTest.java | 77 +++++++
.../client/codegen/model/types/Types.java | 10 +
.../transformer/overrides/Overrides.java | 13 +-
7 files changed, 331 insertions(+), 14 deletions(-)
create mode 100644 java-client/src/main/java/org/opensearch/client/opensearch/core/search/MatchedQueries.java
create mode 100644 java-client/src/test/java/org/opensearch/client/opensearch/core/search/MatchedQueriesTest.java
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))
)
)
)