This is an automated email from the ASF dual-hosted git repository.
epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git
The following commit(s) were added to refs/heads/main by this push:
new 9b1d0e7 fix(config): keep empty facet arrays as NamedList, not List
(#175)
9b1d0e7 is described below
commit 9b1d0e798c785a7bdff512dbb912c1a0b7b8d8de
Author: Aditya Parikh <[email protected]>
AuthorDate: Fri Sep 11 10:03:40 2026 -0400
fix(config): keep empty facet arrays as NamedList, not List (#175)
* fix(config): keep empty facet arrays as NamedList, not List
JsonResponseParser classified arrays purely by shape: an array of
[String, non-String, ...] pairs became a NamedList, anything else a List.
An empty array has no shape to inspect, so a facet on a field that matched
zero documents was converted to an empty List.
SolrJ's QueryResponse casts every facet_counts/facet_fields entry to
NamedList, so that produced a ClassCastException whenever a faceted field
had no matches - a plausible query, not an edge case.
Give the traversal positional context instead of guessing: thread the node
path through toNamedList/convertValue and treat anything directly under
facet_counts/facet_fields as a NamedList regardless of shape. The shape
heuristic still covers other flat-NamedList sites.
Adds JsonResponseParserTest, which was confirmed to fail without this change
(2 of its 5 cases) and to pass with it, including an end-to-end case that
feeds the parsed response into a real QueryResponse.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Aditya Parikh <[email protected]>
* test(search): cover the empty facet end to end against real Solr
JsonResponseParserTest pins the decoding at the parser boundary using a
hand-written payload. That leaves one assumption untested: that a real Solr
actually emits [] for a facet on a zero-hit query. If Solr ever emitted {}
instead, the unit tests would keep passing while the bug they guard no
longer
matched reality.
Adds the end-to-end counterpart via Testcontainers: facet a filter designed
to
match nothing, and assert an empty facet map comes back rather than an
exception.
Verified by reverting only the JsonResponseParser change on this branch and
re-running: the test fails with java.lang.ClassCastException. With the fix
it
passes, and the full build is 378 tests, 0 failures.
Ported from #185, which duplicated this PR and is being closed in its
favour.
Signed-off-by: Aditya Parikh <[email protected]>
---------
Signed-off-by: Aditya Parikh <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../solr/mcp/server/config/JsonResponseParser.java | 51 ++++++---
.../mcp/server/config/JsonResponseParserTest.java | 123 +++++++++++++++++++++
.../search/SearchServiceIntegrationTest.java | 25 +++++
3 files changed, 186 insertions(+), 13 deletions(-)
diff --git
a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
index 545d5e8..8e50490 100644
--- a/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
+++ b/src/main/java/org/apache/solr/mcp/server/config/JsonResponseParser.java
@@ -88,22 +88,33 @@ class JsonResponseParser extends ResponseParser {
return List.of(MediaType.APPLICATION_JSON_VALUE,
MediaType.TEXT_PLAIN_VALUE);
}
+ /**
+ * Path of the object whose direct children are per-field facet arrays.
Arrays
+ * found one level below this path are always NamedLists, regardless of
shape.
+ */
+ private static final String FACET_FIELDS_PATH =
"facet_counts/facet_fields";
+
@Override
public NamedList<Object> processResponse(InputStream body, String
encoding) {
try {
- return toNamedList(mapper.readTree(body));
+ return toNamedList(mapper.readTree(body), "");
} catch (IOException e) {
throw new
SolrException(SolrException.ErrorCode.SERVER_ERROR, "Failed to parse Solr JSON
response", e);
}
}
- private SimpleOrderedMap<Object> toNamedList(JsonNode objectNode) {
+ private SimpleOrderedMap<Object> toNamedList(JsonNode objectNode,
String path) {
SimpleOrderedMap<Object> result = new SimpleOrderedMap<>();
- objectNode.fields().forEachRemaining(entry ->
result.add(entry.getKey(), convertValue(entry.getValue())));
+ objectNode.fields().forEachRemaining(
+ entry -> result.add(entry.getKey(),
convertValue(entry.getValue(), child(path, entry.getKey()))));
return result;
}
- private @Nullable Object convertValue(JsonNode node) {
+ private static String child(String path, String key) {
+ return path.isEmpty() ? key : path + "/" + key;
+ }
+
+ private @Nullable Object convertValue(JsonNode node, String path) {
if (node.isNull())
return null;
if (node.isBoolean())
@@ -117,21 +128,28 @@ class JsonResponseParser extends ResponseParser {
if (node.isDouble() || node.isFloat())
return node.floatValue();
if (node.isObject())
- return convertObject(node);
+ return convertObject(node, path);
if (node.isArray())
- return convertArray(node);
+ return convertArray(node, path);
return node.asText();
}
- private Object convertObject(JsonNode node) {
+ private Object convertObject(JsonNode node, String path) {
// Detect a Solr query result set by the presence of numFound +
docs
if (node.has("numFound") && node.has("docs")) {
return toSolrDocumentList(node);
}
- return toNamedList(node);
+ return toNamedList(node, path);
}
- private Object convertArray(JsonNode arrayNode) {
+ private Object convertArray(JsonNode arrayNode, String path) {
+ // Facet field values are always NamedLists, even when empty.
The shape
+ // heuristic below cannot recognise an empty array, and
returning a List
+ // for a facet on a zero-hit field would break SolrJ's
QueryResponse,
+ // which casts each facet_fields entry to NamedList.
+ if (isFacetFieldValue(path)) {
+ return flatArrayToNamedList(arrayNode);
+ }
// Detect Solr's flat NamedList encoding: [String, non-String,
String,
// non-String, ...]
// Used for facet counts (json.nl=flat default). Distinguished
from plain string
@@ -141,10 +159,16 @@ class JsonResponseParser extends ResponseParser {
return flatArrayToNamedList(arrayNode);
}
List<Object> list = new ArrayList<>(arrayNode.size());
- arrayNode.forEach(element -> list.add(convertValue(element)));
+ arrayNode.forEach(element -> list.add(convertValue(element,
path)));
return list;
}
+ /** True for {@code facet_counts/facet_fields/<fieldName>}. */
+ private static boolean isFacetFieldValue(String path) {
+ int lastSlash = path.lastIndexOf('/');
+ return lastSlash > 0 && path.substring(0,
lastSlash).equals(FACET_FIELDS_PATH);
+ }
+
/**
* Returns true when the array has even length, every even-indexed
element is a
* string (the key), and every odd-indexed element is NOT a string (the
value).
@@ -168,7 +192,8 @@ class JsonResponseParser extends ResponseParser {
private SimpleOrderedMap<Object> flatArrayToNamedList(JsonNode
arrayNode) {
SimpleOrderedMap<Object> result = new SimpleOrderedMap<>();
for (int i = 0; i < arrayNode.size(); i += 2) {
- result.add(arrayNode.get(i).textValue(),
convertValue(arrayNode.get(i + 1)));
+ // Values here are facet counts (scalars), never nested
facet arrays.
+ result.add(arrayNode.get(i).textValue(),
convertValue(arrayNode.get(i + 1), ""));
}
return result;
}
@@ -196,10 +221,10 @@ class JsonResponseParser extends ResponseParser {
if (val.isArray()) {
// Multi-valued field — always a plain list,
never a flat NamedList
List<Object> values = new
ArrayList<>(val.size());
- val.forEach(v -> values.add(convertValue(v)));
+ val.forEach(v -> values.add(convertValue(v,
"")));
doc.setField(entry.getKey(), values);
} else {
- doc.setField(entry.getKey(), convertValue(val));
+ doc.setField(entry.getKey(), convertValue(val,
""));
}
});
return doc;
diff --git
a/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserTest.java
b/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserTest.java
new file mode 100644
index 0000000..1b70cb3
--- /dev/null
+++
b/src/test/java/org/apache/solr/mcp/server/config/JsonResponseParserTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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 org.apache.solr.mcp.server.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.common.util.NamedList;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link JsonResponseParser}'s conversion of Solr's JSON wire
+ * format into the {@link NamedList} tree SolrJ expects.
+ *
+ * <p>
+ * The facet cases are regression coverage: SolrJ's {@link QueryResponse} casts
+ * every {@code facet_counts/facet_fields} entry to a {@link NamedList}, so an
+ * empty facet array must not be converted to a {@link java.util.List}.
+ */
+class JsonResponseParserTest {
+
+ private final JsonResponseParser parser = new JsonResponseParser(new
ObjectMapper());
+
+ private NamedList<Object> parse(String json) {
+ return parser.processResponse(new
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8");
+ }
+
+ @SuppressWarnings("unchecked")
+ private static NamedList<Object> facetFields(NamedList<Object>
response) {
+ NamedList<Object> facetCounts = (NamedList<Object>)
response.get("facet_counts");
+ return (NamedList<Object>) facetCounts.get("facet_fields");
+ }
+
+ @Test
+ @DisplayName("populated facet array converts to a NamedList of counts")
+ void populatedFacetBecomesNamedList() {
+ NamedList<Object> response = parse("""
+
{"facet_counts":{"facet_fields":{"genre":["fantasy",10,"scifi",5]}}}
+ """);
+
+ Object genre = facetFields(response).get("genre");
+ NamedList<Object> counts = assertInstanceOf(NamedList.class,
genre);
+ assertEquals(2, counts.size());
+ assertEquals(10, counts.get("fantasy"));
+ assertEquals(5, counts.get("scifi"));
+ }
+
+ @Test
+ @DisplayName("empty facet array still converts to a NamedList, not a
List")
+ void emptyFacetBecomesEmptyNamedList() {
+ // A facet on a field where nothing matched. The shape
heuristic cannot
+ // recognise [] as a flat NamedList, so position in the tree
must decide.
+ NamedList<Object> response = parse("""
+ {"facet_counts":{"facet_fields":{"genre":[]}}}
+ """);
+
+ Object genre = facetFields(response).get("genre");
+ NamedList<Object> counts = assertInstanceOf(NamedList.class,
genre);
+ assertEquals(0, counts.size());
+ }
+
+ @Test
+ @DisplayName("empty array outside facet_fields stays a List")
+ void emptyArrayElsewhereStaysList() {
+ NamedList<Object> response = parse("""
+ {"responseHeader":{"warnings":[]}}
+ """);
+
+ @SuppressWarnings("unchecked")
+ NamedList<Object> header = (NamedList<Object>)
response.get("responseHeader");
+ assertInstanceOf(java.util.List.class, header.get("warnings"));
+ }
+
+ @Test
+ @DisplayName("plain string array is not mistaken for a flat NamedList")
+ void plainStringArrayStaysList() {
+ NamedList<Object> response = parse("""
+ {"responseHeader":{"fields":["col1","col2"]}}
+ """);
+
+ @SuppressWarnings("unchecked")
+ NamedList<Object> header = (NamedList<Object>)
response.get("responseHeader");
+ assertInstanceOf(java.util.List.class, header.get("fields"));
+ }
+
+ @Test
+ @DisplayName("QueryResponse can read facets when one field has zero
matches")
+ void queryResponseHandlesEmptyFacet() {
+ // End-to-end guard: this is the cast that used to throw
ClassCastException.
+ NamedList<Object> response = parse("""
+ {"responseHeader":{"status":0,"QTime":1},
+ "response":{"numFound":0,"start":0,"docs":[]},
+
"facet_counts":{"facet_fields":{"genre":[],"author":["asimov",3]}}}
+ """);
+
+ QueryResponse queryResponse = new QueryResponse();
+ queryResponse.setResponse(response);
+
+ assertEquals(2, queryResponse.getFacetFields().size());
+
assertTrue(queryResponse.getFacetField("genre").getValues().isEmpty());
+ assertEquals(1,
queryResponse.getFacetField("author").getValues().size());
+ }
+}
diff --git
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
index bb3c842..99fb607 100644
---
a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
+++
b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceIntegrationTest.java
@@ -185,6 +185,31 @@ class SearchServiceIntegrationTest {
assertEquals(10, documents.size());
}
+ /**
+ * Zero matches is an ordinary search outcome, not an error. Solr
writes an
+ * empty facet as {@code []}, which must still reach SolrJ as a
NamedList —
+ * {@code QueryResponse.getFacetFields()} casts to one, so a plain list
surfaces
+ * as {@code ClassCastException: ArrayList cannot be cast to
+ * NamedList} instead of an empty result.
+ *
+ * <p>
+ * {@link org.apache.solr.mcp.server.config.JsonResponseParserTest}
pins the
+ * same behaviour at the parser boundary against a hand-written
payload. This
+ * test is the end-to-end counterpart: it proves a real Solr actually
emits
+ * {@code []} for a zero-hit facet, which is the premise the unit tests
assume.
+ */
+ @Test
+ void facetingAQueryThatMatchesNothingReturnsEmptyFacets() throws
SolrServerException, IOException {
+ SearchResponse result = searchService.search(COLLECTION_NAME,
"genre_s:no_such_genre_exists", null,
+ List.of("genre_s"), null, null, 0);
+
+ assertNotNull(result);
+ assertEquals(0, result.numFound(), "the filter is designed to
match nothing");
+ assertNotNull(result.facets(), "facets must be present even
when nothing matched");
+ assertTrue(result.facets().getOrDefault("genre_s",
Map.of()).isEmpty(),
+ () -> "expected no facet buckets, got: " +
result.facets().get("genre_s"));
+ }
+
/**
* Remediation hints classify Solr's error text, which this server
cannot see at
* compile time — the strings are produced by solr-core, and only
solr-solrj is