Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.apache.solr.client.solrj.response.ResponseParser;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
Expand Down Expand Up @@ -66,9 +67,36 @@
* every odd-indexed element is a non-{@link String} value. This reliably
* distinguishes {@code ["term", 5, "term2", 3]} (facet NamedList) from
* {@code ["col1", "col2"]} (plain string list).
*
* <p>
* <strong>Why shape alone is not enough:</strong> an <em>empty</em> array is
* ambiguous. Solr writes a facet with no buckets as {@code []}, and also writes
* an ordinary empty list — {@code "collections": []} from the Collections API —
* as {@code []}. The first must decode to a {@link NamedList} because SolrJ's
* {@code QueryResponse.getFacetFields()} casts to one; the second must stay a
* {@link List} because {@code CollectionAdminResponse} callers cast to that. No
* rule based on the array can satisfy both, so the parser uses the enclosing
* key instead: arrays directly inside {@code facet_fields},
* {@code facet_queries} and {@code facet_intervals} are always NamedLists,
* empty or not, and every other array falls back to the shape heuristic above.
*
* <p>
* <strong>Known gap:</strong> {@code facet_ranges} nests its flat list one
* level deeper, under a {@code counts} key, so an empty range facet would still
* decode as a {@link List}. The {@code search} tool does not expose range
* faceting, so nothing currently reaches that path.
*/
class JsonResponseParser extends ResponseParser {

/**
* Response keys whose immediate children are always NamedLists of counts,
* regardless of how many buckets came back. Solr writes each child as a flat
* array under {@code json.nl=flat}, and writes an empty one as {@code []} —
* which is shape-identical to an ordinary empty list such as
* {@code "collections": []}. Only the enclosing key distinguishes them.
*/
private static final Set<String> FACET_CONTAINERS = Set.of("facet_fields", "facet_queries", "facet_intervals");

private final ObjectMapper mapper;

JsonResponseParser(ObjectMapper mapper) {
Expand Down Expand Up @@ -99,7 +127,27 @@ public NamedList<Object> processResponse(InputStream body, String encoding) {

private SimpleOrderedMap<Object> toNamedList(JsonNode objectNode) {
SimpleOrderedMap<Object> result = new SimpleOrderedMap<>();
objectNode.fields().forEachRemaining(entry -> result.add(entry.getKey(), convertValue(entry.getValue())));
objectNode.fields()
.forEachRemaining(entry -> result.add(entry.getKey(),
FACET_CONTAINERS.contains(entry.getKey()) && entry.getValue().isObject()
? toFacetContainer(entry.getValue())
: convertValue(entry.getValue())));
return result;
}

/**
* Converts a facet container, forcing every array child to a NamedList.
*
* <p>
* Inside these containers an array is a flat NamedList by definition, so the
* {@link #isFlatNamedList} heuristic is neither needed nor safe: it rejects
* empty arrays, which would hand SolrJ an {@code ArrayList} where
* {@code QueryResponse.getFacetFields()} casts to {@code NamedList}.
*/
private SimpleOrderedMap<Object> toFacetContainer(JsonNode containerNode) {
SimpleOrderedMap<Object> result = new SimpleOrderedMap<>();
containerNode.fields().forEachRemaining(entry -> result.add(entry.getKey(),
entry.getValue().isArray() ? flatArrayToNamedList(entry.getValue()) : convertValue(entry.getValue())));
return result;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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 com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.apache.solr.common.util.NamedList;
import org.junit.jupiter.api.Test;

/**
* Unit tests for how {@link JsonResponseParser} decodes Solr's facet payloads.
*
* <p>
* SolrJ's {@code QueryResponse.getFacetFields()} casts each facet field's value
* to {@link NamedList}. Solr's JSON writer emits a facet field as a flat array
* ({@code json.nl=flat}), and crucially emits an <em>empty</em> facet as
* {@code []} — indistinguishable, by shape alone, from an ordinary empty list
* such as {@code "collections": []}. Deciding by shape therefore cannot be
* correct for both; the parser has to use the enclosing key as context.
*/
class JsonResponseParserFacetTest {

private static final String EMPTY_FACET_RESPONSE = """
{
"responseHeader": { "status": 0, "QTime": 3 },
"response": { "numFound": 0, "start": 0, "docs": [] },
"facet_counts": {
"facet_queries": {},
"facet_fields": { "platform": [] },
"facet_ranges": {},
"facet_intervals": {},
"facet_heatmaps": {}
}
}
""";

private static final String POPULATED_FACET_RESPONSE = """
{
"responseHeader": { "status": 0, "QTime": 3 },
"response": { "numFound": 27, "start": 0, "docs": [] },
"facet_counts": {
"facet_queries": {},
"facet_fields": { "platform": ["Netflix", 20, "HBO Max", 7] },
"facet_ranges": {},
"facet_intervals": {},
"facet_heatmaps": {}
}
}
""";

private static final String EMPTY_COLLECTION_LIST_RESPONSE = """
{
"responseHeader": { "status": 0, "QTime": 1 },
"collections": []
}
""";

private NamedList<Object> parse(String json) {
return new JsonResponseParser(new ObjectMapper())
.processResponse(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8");
}

private Object facetField(NamedList<Object> response, String field) {
NamedList<?> facetCounts = (NamedList<?>) response.get("facet_counts");
NamedList<?> facetFields = (NamedList<?>) facetCounts.get("facet_fields");
return facetFields.get(field);
}

@Test
void facetFieldWithNoBucketsParsesAsNamedList() {
Object platform = facetField(parse(EMPTY_FACET_RESPONSE), "platform");

// SolrJ casts this to NamedList; a List here throws ClassCastException
// for any faceted query whose filter happens to match zero documents.
NamedList<?> buckets = assertInstanceOf(NamedList.class, platform,
"An empty facet field must decode as a NamedList, not a List");
assertEquals(0, buckets.size(), "An empty facet field has no buckets");
}

@Test
void facetFieldWithBucketsParsesAsNamedList() {
Object platform = facetField(parse(POPULATED_FACET_RESPONSE), "platform");

NamedList<?> buckets = assertInstanceOf(NamedList.class, platform,
"A populated facet field must decode as a NamedList");
assertEquals(2, buckets.size(), "Two facet buckets were returned");
assertEquals(20, buckets.get("Netflix"), "Bucket counts survive decoding");
assertEquals(7, buckets.get("HBO Max"), "Bucket counts survive decoding");
}

@Test
void emptyTopLevelArrayStaysAList() {
Object collections = parse(EMPTY_COLLECTION_LIST_RESPONSE).get("collections");

// CollectionService.listCollections() casts this to List<String>. Treating
// every empty array as a NamedList would move the ClassCastException here,
// breaking list-collections against an empty cluster.
List<?> names = assertInstanceOf(List.class, collections,
"An empty non-facet array must stay a List so list-collections keeps working");
assertEquals(0, names.size(), "No collections exist");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,25 @@ void testBasicSearch() throws SolrServerException, IOException {
assertEquals(10, documents.size());
}

/**
* Zero matches is an ordinary search outcome, not an error. Solr writes an
* empty facet as {@code []}, which the response parser must still decode as a
* NamedList — SolrJ's {@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.
*/
@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
Expand Down