Skip to content

fix(search): decode an empty facet as a NamedList, not a List - #185

Closed
adityamparikh wants to merge 1 commit into
apache:mainfrom
adityamparikh:fix/empty-facet-classcastexception
Closed

fix(search): decode an empty facet as a NamedList, not a List#185
adityamparikh wants to merge 1 commit into
apache:mainfrom
adityamparikh:fix/empty-facet-classcastexception

Conversation

@adityamparikh

Copy link
Copy Markdown
Contributor

Closes #182.

The bug

A faceted search whose query matched nothing failed with
ClassCastException: ArrayList cannot be cast to NamedList instead of returning an empty
result. Zero matches is an ordinary search outcome, so this is not an edge case — it fires
on properly-typed fields whenever a filter happens to match no documents.

Same collection, same field, only the filter differs:

// works
search("shows", "*:*", null, List.of("platform"), ...)
  -> facets = {platform={Netflix=20, HBO Max=7, ...}}

// throws
search("shows", "platform:NoSuchPlatform", null, List.of("platform"), ...)
  -> ClassCastException: ArrayList cannot be cast to NamedList

Cause

Solr writes a facet field as a flat array under json.nl=flat, and writes an empty one as
[]. JsonResponseParser.isFlatNamedList rejects zero-length arrays:

if (size == 0 || size % 2 != 0)
    return false;

so the value fell through to the plain-list branch, and SolrJ's
QueryResponse.getFacetFields() — which casts to NamedList — threw.

Why the one-line version is wrong

Allowing size == 0 in that heuristic looks like the fix and isn't. A bare [] is genuinely
ambiguous:

Response key Empty form Must decode as Because
facet_counts.facet_fields.<field> [] NamedList QueryResponse.getFacetFields() casts to it
collections [] List CollectionService.listCollections() casts to List<String>

Treating every empty array as a NamedList just moves the ClassCastException to
list-collections against an empty cluster — a first-run experience, not an obscure path.

The fix

Shape cannot distinguish them, so use the enclosing key. Arrays directly inside
facet_fields, facet_queries and facet_intervals are flat NamedLists by definition,
empty or not. Every other array keeps the existing heuristic untouched.

json.nl=map was considered and rejected: ResponseParser exposes no hook for query params
and HttpJdkSolrClient.Builder has no default-params method, so it would mean wrapping every
request — and json.nl is global, silently collapsing the duplicate keys a NamedList
permits. (I'd originally suggested it on the issue before reading the code; noting the
correction here.)

Known gap, documented in the class javadoc: facet_ranges nests its flat list one level
deeper under a counts key, so an empty range facet would still decode as a List. The
search tool does not expose range faceting, so nothing reaches that path today — flagged
rather than silently left.

Tests

Written first, each watched failing for the right reason before the fix went in:

  • JsonResponseParserFacetTest (new) — empty facet decodes as NamedList; populated
    facet still does; empty non-facet array stays a List. That last one is the regression
    guard for the trap above. Before the fix: 1 failed, expected: <NamedList> but was: <java.util.ArrayList>; the other two passed, confirming they pin existing behaviour.
  • SearchServiceIntegrationTest.facetingAQueryThatMatchesNothingReturnsEmptyFacets
    end-to-end against real Solr via Testcontainers. Before the fix: java.lang.ClassCastException.

Full ./gradlew build: 376 tests, 0 failures, 7 skipped.

A faceted search whose query matched no documents failed with
"ClassCastException: ArrayList cannot be cast to NamedList" instead of
returning an empty result. Zero matches is an ordinary search outcome, so
this fires on well-typed fields too — any filter that happens to match
nothing.

Solr writes a facet field as a flat array under json.nl=flat, and writes an
empty one as []. JsonResponseParser.isFlatNamedList rejects zero-length
arrays, so the value fell through to the plain-list branch and SolrJ's
QueryResponse.getFacetFields(), which casts to NamedList, threw.

Allowing size == 0 in that heuristic is not a fix: [] is genuinely ambiguous.
An empty facet must become a NamedList, while an empty "collections": [] must
stay a List, since CollectionService.listCollections() casts it to
List<String> — so the naive change just moves the ClassCastException to
list-collections against an empty cluster.

Since shape cannot distinguish them, use the enclosing key. Arrays directly
inside facet_fields, facet_queries and facet_intervals are flat NamedLists by
definition, empty or not; every other array keeps the existing heuristic.

Not fixed by requesting json.nl=map: ResponseParser exposes no hook for query
params and the client builder has no default-params method, so it would mean
wrapping every request — and json.nl is global, silently collapsing the
duplicate keys a NamedList permits.

Known gap, documented in the class javadoc: facet_ranges nests its flat list
one level deeper under a counts key, so an empty range facet would still
decode as a List. The search tool does not expose range faceting, so nothing
reaches that path today.

Tests, each watched failing first:
- JsonResponseParserFacetTest covers the empty facet, a populated facet, and
  an empty non-facet array as a regression guard for list-collections
- SearchServiceIntegrationTest.facetingAQueryThatMatchesNothingReturnsEmptyFacets
  reproduces it end to end against real Solr via Testcontainers

Closes apache#182

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
adityamparikh added a commit to adityamparikh/solr-mcp that referenced this pull request Aug 20, 2026
Rewrites Step 2 for the behaviour after apache#185. The schemaless facet no longer
throws; it returns numFound=61 with facets.platform={} — the query succeeds
and simply has no breakdown to give.

That is a better illustration of the point than the exception was. A stack
trace reads as "something is broken"; a successful query with an empty answer
is precisely the trap schemaless sets, because nothing tells you the data is
fine and the field type is at fault.

Also drops the apache#182 entry from Known issues, and sharpens the platform row:
searching platform:prime matches all 20 Amazon Prime Video shows, which shows
concretely why tokenizing a category is wrong.

Re-verified against a build of the apache#185 branch over the MCP protocol: the
empty facet, the 20-hit token match, the three guessed types, and the
array-wrapped documents.

Depends on apache#185.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
adityamparikh added a commit to adityamparikh/solr-mcp that referenced this pull request Aug 20, 2026
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 apache#185, which duplicated this PR and is being closed in its favour.

Signed-off-by: Aditya Parikh <aditya.m.parikh@gmail.com>
@adityamparikh

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate of #175, which fixes the same bug and predates this by sixteen days.

I filed issue #182 and opened this PR without finding #175 — my duplicate check searched issues
for "facet" and found nothing, but I never searched pull requests. That's the gap.

#175 is also the better implementation. It anchors on the full path
facet_counts/facet_fields/<field>, so a key named facet_fields occurring elsewhere in a
response can't be misinterpreted; this PR matched the bare key at any depth in the tree. The
extra facet_queries / facet_intervals coverage here adds nothing either — Solr emits both as
JSON objects, which already decode correctly via convertObject.

The one thing this PR had that #175 lacked was an end-to-end test against real Solr. That has
been ported over in adityamparikh/solr-mcpe4d0646 and verified on #175's branch: reverting only
its JsonResponseParser change makes the ported test fail with ClassCastException.

Nothing here is lost. Review effort should go to #175.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

search: ClassCastException when a facet returns zero buckets

1 participant