fix(search): decode an empty facet as a NamedList, not a List - #185
fix(search): decode an empty facet as a NamedList, not a List#185adityamparikh wants to merge 1 commit into
Conversation
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>
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>
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>
|
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 #175 is also the better implementation. It anchors on the full path The one thing this PR had that #175 lacked was an end-to-end test against real Solr. That has Nothing here is lost. Review effort should go to #175. |
Closes #182.
The bug
A faceted search whose query matched nothing failed with
ClassCastException: ArrayList cannot be cast to NamedListinstead of returning an emptyresult. 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:
Cause
Solr writes a facet field as a flat array under
json.nl=flat, and writes an empty one as[].JsonResponseParser.isFlatNamedListrejects zero-length arrays:so the value fell through to the plain-list branch, and SolrJ's
QueryResponse.getFacetFields()— which casts toNamedList— threw.Why the one-line version is wrong
Allowing
size == 0in that heuristic looks like the fix and isn't. A bare[]is genuinelyambiguous:
facet_counts.facet_fields.<field>[]NamedListQueryResponse.getFacetFields()casts to itcollections[]ListCollectionService.listCollections()casts toList<String>Treating every empty array as a NamedList just moves the
ClassCastExceptiontolist-collectionsagainst 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_queriesandfacet_intervalsare flat NamedLists by definition,empty or not. Every other array keeps the existing heuristic untouched.
json.nl=mapwas considered and rejected:ResponseParserexposes no hook for query paramsand
HttpJdkSolrClient.Builderhas no default-params method, so it would mean wrapping everyrequest — and
json.nlis global, silently collapsing the duplicate keys aNamedListpermits. (I'd originally suggested it on the issue before reading the code; noting the
correction here.)
Known gap, documented in the class javadoc:
facet_rangesnests its flat list one leveldeeper under a
countskey, so an empty range facet would still decode as aList. Thesearchtool does not expose range faceting, so nothing reaches that path today — flaggedrather than silently left.
Tests
Written first, each watched failing for the right reason before the fix went in:
JsonResponseParserFacetTest(new) — empty facet decodes asNamedList; populatedfacet still does; empty non-facet array stays a
List. That last one is the regressionguard 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.