diff --git a/changelog/unreleased/SOLR-17316-response-parsers.yml b/changelog/unreleased/SOLR-17316-response-parsers.yml new file mode 100644 index 000000000000..08cf0145ccf6 --- /dev/null +++ b/changelog/unreleased/SOLR-17316-response-parsers.yml @@ -0,0 +1,12 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc + +title: > + SolrJ's QueryResponse and other response objects now work when the client is configured with a + non-binary response parser (such as the JSON parser); previously their accessors could throw a + ClassCastException. +type: fixed +authors: + - name: Serhiy Bzhezytskyy +links: + - name: SOLR-17316 + url: https://issues.apache.org/jira/browse/SOLR-17316 diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java index fccc4357fcde..e86b881c6f7c 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpSolrClient.java @@ -50,6 +50,7 @@ import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.ContentStream; import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.ResponseNormalizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -229,6 +230,9 @@ protected NamedList processErrorsAndResponse( NamedList rsp; try { rsp = processor.processResponse(is, encoding); + if (!processor.producesCanonicalForm()) { + rsp = ResponseNormalizer.normalize(rsp); + } } catch (Exception e) { throw new RemoteSolrException(urlExceptionMessage, httpStatus, e.getMessage(), e); } diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java index f458bdc01c93..5c6f3851826f 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/AnalysisResponseBase.java @@ -117,9 +117,9 @@ protected TokenInfo buildTokenInfo(NamedList tokenNL) { String text = (String) tokenNL.get("text"); String rawText = (String) tokenNL.get("rawText"); String type = (String) tokenNL.get("type"); - int start = (Integer) tokenNL.get("start"); - int end = (Integer) tokenNL.get("end"); - int position = (Integer) tokenNL.get("position"); + int start = ((Number) tokenNL.get("start")).intValue(); + int end = ((Number) tokenNL.get("end")).intValue(); + int position = ((Number) tokenNL.get("position")).intValue(); Boolean match = (Boolean) tokenNL.get("match"); return new TokenInfo( text, rawText, type, start, end, position, (match == null ? false : match)); diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java index f23bb29cffab..bfe75abb4893 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/LukeResponse.java @@ -139,7 +139,7 @@ public void read(NamedList nl) { } else if ("docs".equals(entry.getKey())) { docs = ((Number) entry.getValue()).longValue(); } else if ("distinct".equals(entry.getKey())) { - distinct = (Integer) entry.getValue(); + distinct = ((Number) entry.getValue()).intValue(); } else if ("cacheableFaceting".equals(entry.getKey())) { cacheableFaceting = (Boolean) entry.getValue(); } else if ("topTerms".equals(entry.getKey())) { @@ -290,7 +290,8 @@ public Long getNumDocs() { public Integer getMaxDoc() { if (indexInfo == null) return null; - return (Integer) indexInfo.get("maxDoc"); + Object v = indexInfo.get("maxDoc"); + return v == null ? null : ((Number) v).intValue(); } public Long getDeletedDocs() { @@ -299,7 +300,8 @@ public Long getDeletedDocs() { public Integer getNumTerms() { if (indexInfo == null) return null; - return (Integer) indexInfo.get("numTerms"); + Object v = indexInfo.get("numTerms"); + return v == null ? null : ((Number) v).intValue(); } public Map getFieldTypeInfo() { diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java index 392d61801227..c669bca2ddeb 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/QueryResponse.java @@ -248,11 +248,11 @@ private void extractGroupedInfo(NamedList info) { } if (oGroups != null) { - Integer iMatches = (Integer) oMatches; + int iMatches = ((Number) oMatches).intValue(); ArrayList groupsArr = (ArrayList) oGroups; GroupCommand groupedCommand; if (oNGroups != null) { - Integer iNGroups = (Integer) oNGroups; + int iNGroups = ((Number) oNGroups).intValue(); groupedCommand = new GroupCommand(fieldName, iMatches, iNGroups); } else { groupedCommand = new GroupCommand(fieldName, iMatches); @@ -269,10 +269,10 @@ private void extractGroupedInfo(NamedList info) { _groupResponse.add(groupedCommand); } else if (queryCommand != null) { - Integer iMatches = (Integer) oMatches; + int iMatches = ((Number) oMatches).intValue(); GroupCommand groupCommand; if (oNGroups != null) { - Integer iNGroups = (Integer) oNGroups; + int iNGroups = ((Number) oNGroups).intValue(); groupCommand = new GroupCommand(fieldName, iMatches, iNGroups); } else { groupCommand = new GroupCommand(fieldName, iMatches); @@ -354,7 +354,9 @@ private void extractFacetInfo(NamedList info) { List counts = new ArrayList(intervalField.getValue().size()); for (Map.Entry interval : intervalField.getValue()) { - counts.add(new IntervalFacet.Count(interval.getKey(), (Integer) interval.getValue())); + counts.add( + new IntervalFacet.Count( + interval.getKey(), ((Number) interval.getValue()).intValue())); } _intervalFacets.add(new IntervalFacet(field, counts)); } @@ -433,7 +435,7 @@ protected List readPivots(List list) { switch (key) { case "field" -> field = (String) val; case "value" -> value = val; - case "count" -> count = ((Integer) val).intValue(); + case "count" -> count = ((Number) val).intValue(); case "pivot" -> { assert null != val : "Server sent back 'null' for sub pivots?"; assert val instanceof List : "Server sent non-List for sub pivots?"; diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java index 9884d8a1ef57..1538cecd4a20 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/ResponseParser.java @@ -66,4 +66,17 @@ public abstract NamedList processResponse(InputStream body, String encod * @return the MIME types that this parser is capable of parsing. Never null. */ public abstract Set getContentTypes(); + + /** + * Whether this parser already produces the canonical response shape the SolrJ response classes + * expect: a {@link NamedList} tree with {@link org.apache.solr.common.SolrDocumentList} for + * document sections. The binary and XML parsers do; a parser that yields raw {@code Map}s and + * {@code List}s (such as the JSON map parser) does not, and its output is normalized before the + * response classes read it. + * + * @return true unless the parser yields a raw, un-typed structure + */ + public boolean producesCanonicalForm() { + return true; + } } diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java index 9d90184ce429..86f883b2c781 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SolrResponseBase.java @@ -92,7 +92,9 @@ public NamedList getResponseHeader() { public int getStatus() { NamedList header = getResponseHeader(); if (header != null) { - return (Integer) header.get("status"); + // ResponseParsers vary in the numeric type they produce (e.g. JSON yields Long), so widen + // via Number rather than casting to Integer. See SOLR-17316. + return ((Number) header.get("status")).intValue(); } else { return 0; } @@ -101,7 +103,7 @@ public int getStatus() { public int getQTime() { NamedList header = getResponseHeader(); if (header != null) { - return (Integer) header.get("QTime"); + return ((Number) header.get("QTime")).intValue(); } else { return 0; } diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java index 4d3a077da510..ca6c056b8422 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/SpellCheckResponse.java @@ -144,10 +144,10 @@ public Suggestion(String token, NamedList suggestion) { suggestion.forEach( (n, val) -> { switch (n) { - case "numFound" -> numFound = (Integer) val; - case "startOffset" -> startOffset = (Integer) val; - case "endOffset" -> endOffset = (Integer) val; - case "origFreq" -> originalFrequency = (Integer) val; + case "numFound" -> numFound = ((Number) val).intValue(); + case "startOffset" -> startOffset = ((Number) val).intValue(); + case "endOffset" -> endOffset = ((Number) val).intValue(); + case "origFreq" -> originalFrequency = ((Number) val).intValue(); case "suggestion" -> { List list = (List) val; if (!list.isEmpty() && list.get(0) instanceof NamedList) { @@ -157,7 +157,7 @@ public Suggestion(String token, NamedList suggestion) { alternativeFrequencies = new ArrayList<>(); for (NamedList nl : extended) { alternatives.add((String) nl.get("word")); - alternativeFrequencies.add((Integer) nl.get("freq")); + alternativeFrequencies.add(((Number) nl.get("freq")).intValue()); } } else { @SuppressWarnings("unchecked") diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/JsonMapResponseParser.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/JsonMapResponseParser.java index 2eb2d376e7ef..6a5939f79115 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/JsonMapResponseParser.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/json/JsonMapResponseParser.java @@ -63,4 +63,9 @@ public NamedList processResponse(InputStream body, String encoding) thro public Set getContentTypes() { return CONTENT_TYPES; } + + @Override + public boolean producesCanonicalForm() { + return false; + } } diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java b/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java index 7f34859f0a31..35344e78a190 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/response/schema/SchemaResponse.java @@ -157,7 +157,8 @@ private static String getSchemaName(@SuppressWarnings({"rawtypes"}) Map schemaNa } private static Float getSchemaVersion(@SuppressWarnings({"rawtypes"}) Map schemaNamedList) { - return (Float) schemaNamedList.get("version"); + Object v = schemaNamedList.get("version"); + return v == null ? null : ((Number) v).floatValue(); } private static String getSchemaUniqueKey(@SuppressWarnings({"rawtypes"}) Map schemaNamedList) { diff --git a/solr/solrj/src/java/org/apache/solr/common/util/ResponseNormalizer.java b/solr/solrj/src/java/org/apache/solr/common/util/ResponseNormalizer.java new file mode 100644 index 000000000000..18f7a42b4068 --- /dev/null +++ b/solr/solrj/src/java/org/apache/solr/common/util/ResponseNormalizer.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.common.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.SolrDocumentList; + +/** + * Converts a parsed response into the canonical shape the SolrJ response classes expect (the shape + * the binary and XML parsers produce): nested JSON objects become {@link NamedList}s and a {@code + * {numFound, docs}} object becomes a {@link SolrDocumentList}. + * + *

Only unambiguous, self-describing conversions are performed. It is a no-op for values already + * in canonical form (so binary/XML responses pass through unchanged). It does not attempt to + * interpret the ambiguous flat arrays produced by {@code json.nl=flat}; a typed JSON parser should + * request {@code json.nl=map} for its own reads. + */ +public final class ResponseNormalizer { + + private ResponseNormalizer() {} + + /** Returns a normalized copy of the given response NamedList. */ + public static NamedList normalize(NamedList response) { + if (response == null) { + return null; + } + SimpleOrderedMap out = new SimpleOrderedMap<>(response.size()); + for (Map.Entry e : response) { + out.add(e.getKey(), normalizeValue(e.getValue())); + } + return out; + } + + @SuppressWarnings("unchecked") + private static Object normalizeValue(Object val) { + if (val instanceof SolrDocumentList || val instanceof SolrDocument) { + // Already canonical (binary/XML produce these directly); leave untouched. Must precede the + // List/Map branches since SolrDocumentList is a List and SolrDocument is a Map. + return val; + } else if (val instanceof NamedList) { + // Already canonical (binary/XML), but its children may still need normalizing. + NamedList in = (NamedList) val; + SimpleOrderedMap out = new SimpleOrderedMap<>(in.size()); + for (Map.Entry e : in) { + out.add(e.getKey(), normalizeValue(e.getValue())); + } + return out; + } else if (val instanceof Map) { + Map m = (Map) val; + if (isDocList(m)) { + return toDocList(m); + } + // SimpleOrderedMap (not plain NamedList): it is the canonical map type the binary parser + // produces, and some response extractors cast to it specifically. + SimpleOrderedMap out = new SimpleOrderedMap<>(m.size()); + for (Map.Entry e : m.entrySet()) { + out.add(e.getKey(), normalizeValue(e.getValue())); + } + return out; + } else if (val instanceof List) { + List in = (List) val; + List out = new ArrayList<>(in.size()); + for (Object item : in) { + out.add(normalizeValue(item)); + } + return out; + } + return val; + } + + private static boolean isDocList(Map m) { + return m.get("numFound") instanceof Number && m.get("docs") instanceof List; + } + + @SuppressWarnings("unchecked") + private static SolrDocumentList toDocList(Map m) { + SolrDocumentList docs = new SolrDocumentList(); + docs.setNumFound(((Number) m.get("numFound")).longValue()); + if (m.get("start") instanceof Number start) { + docs.setStart(start.longValue()); + } + if (m.get("maxScore") instanceof Number maxScore) { + docs.setMaxScore(maxScore.floatValue()); + } + if (m.get("numFoundExact") instanceof Boolean exact) { + docs.setNumFoundExact(exact); + } + for (Object d : (List) m.get("docs")) { + docs.add(toDoc(d)); + } + return docs; + } + + @SuppressWarnings("unchecked") + private static SolrDocument toDoc(Object o) { + SolrDocument doc = new SolrDocument(); + if (o instanceof Map) { + for (Map.Entry f : ((Map) o).entrySet()) { + // setField (not addField): addField unwraps a Collection value into a plain list, which + // would drop the type of a reconstructed SolrDocumentList held as a field value. + doc.setField(f.getKey(), normalizeValue(f.getValue())); + } + } + return doc; + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java new file mode 100644 index 000000000000..6218efcbc8f7 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/AdminResponseNumericTypeTest.java @@ -0,0 +1,104 @@ +/* + * 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.client.solrj.response; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.junit.Test; + +/** + * Non-binary parsers deliver integers as Long. These response classes widen via Number rather than + * casting to Integer/Long/Float, so a Long value must not throw (SOLR-17316). Each assertion fails + * with a ClassCastException without the widening. + */ +public class AdminResponseNumericTypeTest extends SolrTestCase { + + /** AnalysisResponseBase.buildTokenInfo: start/end/position widened from Number. */ + @Test + public void testAnalysisTokenInfo() { + NamedList token = new SimpleOrderedMap<>(); + token.add("text", "foo"); + token.add("start", 1L); // JSON yields Long + token.add("end", 4L); + token.add("position", 2L); + + var probe = + new AnalysisResponseBase() { + TokenInfo build(NamedList nl) { + return buildTokenInfo(nl); + } + }; + AnalysisResponseBase.TokenInfo info = probe.build(token); + assertEquals(1, info.getStart()); + assertEquals(4, info.getEnd()); + assertEquals(2, info.getPosition()); + } + + /** LukeResponse.getMaxDoc/getNumTerms: widened from Number. */ + @Test + public void testLukeIndexInfo() { + NamedList index = new SimpleOrderedMap<>(); + index.add("maxDoc", 10L); // JSON yields Long + index.add("numTerms", 42L); + NamedList body = new SimpleOrderedMap<>(); + body.add("index", index); + + LukeResponse r = new LukeResponse(); + r.setResponse(body); + assertEquals(Integer.valueOf(10), r.getMaxDoc()); + assertEquals(Integer.valueOf(42), r.getNumTerms()); + } + + /** LukeResponse.FieldInfo.distinct: widened from Number. */ + @Test + public void testLukeFieldDistinct() { + NamedList field = new SimpleOrderedMap<>(); + field.add("type", "string"); + field.add("distinct", 5L); // JSON yields Long + NamedList fields = new SimpleOrderedMap<>(); + fields.add("cat", field); + NamedList body = new SimpleOrderedMap<>(); + body.add("fields", fields); + + LukeResponse r = new LukeResponse(); + r.setResponse(body); + assertEquals(5, r.getFieldInfo("cat").getDistinct()); + } + + /** SchemaResponse.getSchemaVersion: widened from Number (JSON yields Double for 1.6). */ + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void testSchemaVersion() { + Map schema = new LinkedHashMap(); + schema.put("version", 1.6d); // JSON yields Double + schema.put("fields", new java.util.ArrayList<>()); + schema.put("dynamicFields", new java.util.ArrayList<>()); + schema.put("fieldTypes", new java.util.ArrayList<>()); + schema.put("copyFields", new java.util.ArrayList<>()); + NamedList body = new SimpleOrderedMap<>(); + body.add("schema", schema); + + org.apache.solr.client.solrj.response.schema.SchemaResponse r = + new org.apache.solr.client.solrj.response.schema.SchemaResponse(); + r.setResponse(body); + Float version = r.getSchemaRepresentation().getVersion(); + assertEquals(1.6f, version.floatValue(), 0.0001f); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseCrossFormatTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseCrossFormatTest.java new file mode 100644 index 000000000000..594833c09716 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseCrossFormatTest.java @@ -0,0 +1,144 @@ +/* + * 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.client.solrj.response; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.SolrDocumentList; +import org.apache.solr.common.util.JavaBinCodec; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.ResponseNormalizer; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.junit.Test; + +/** + * Proves QueryResponse reaches identical typed results whether the wire format was binary or JSON + * (json.nl=map), once the response is passed through {@link ResponseNormalizer}. Binary is the + * canonical baseline; JSON must match it. + */ +public class QueryResponseCrossFormatTest extends SolrTestCase { + + private static NamedList canonical() { + NamedList header = new SimpleOrderedMap<>(); + header.add("status", 0); + header.add("QTime", 7); + + SolrDocumentList docs = new SolrDocumentList(); + docs.setNumFound(2); + docs.setStart(0); + SolrDocument d1 = new SolrDocument(); + d1.addField("id", "1"); + SolrDocument d2 = new SolrDocument(); + d2.addField("id", "2"); + docs.add(d1); + docs.add(d2); + + NamedList catCounts = new SimpleOrderedMap<>(); + catCounts.add("electronics", 3); + catCounts.add("books", 1); + NamedList facetFields = new SimpleOrderedMap<>(); + facetFields.add("cat", catCounts); + NamedList facetCounts = new SimpleOrderedMap<>(); + facetCounts.add("facet_queries", new SimpleOrderedMap<>()); + facetCounts.add("facet_fields", facetFields); + + NamedList body = new SimpleOrderedMap<>(); + body.add("responseHeader", header); + body.add("response", docs); + body.add("facet_counts", facetCounts); + return body; + } + + private static void assertResponse(String fmt, QueryResponse r) { + assertEquals(fmt + " status", 0, r.getStatus()); + assertEquals(fmt + " qtime", 7, r.getQTime()); + assertNotNull(fmt + " results", r.getResults()); + assertEquals(fmt + " numFound", 2, r.getResults().getNumFound()); + assertEquals(fmt + " doc0", "1", r.getResults().get(0).getFirstValue("id")); + + // facet section parity + assertNotNull(fmt + " facetFields", r.getFacetFields()); + assertEquals(fmt + " facet name", "cat", r.getFacetFields().get(0).getName()); + assertEquals(fmt + " facet valueCount", 2, r.getFacetFields().get(0).getValueCount()); + assertEquals(fmt + " facet count", 3L, r.getFacetFields().get(0).getValues().get(0).getCount()); + } + + @Test + @SuppressWarnings("unchecked") + public void testBinaryBaseline() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JavaBinCodec codec = new JavaBinCodec()) { + codec.marshal(canonical(), out); + } + NamedList parsed; + try (JavaBinCodec codec = new JavaBinCodec()) { + parsed = (NamedList) codec.unmarshal(new ByteArrayInputStream(out.toByteArray())); + } + QueryResponse r = new QueryResponse(); + r.setResponse(ResponseNormalizer.normalize(parsed)); + assertResponse("binary", r); + } + + @Test + public void testJsonMapMatchesBinary() throws Exception { + String json = + "{\"responseHeader\":{\"status\":0,\"QTime\":7}," + + "\"response\":{\"numFound\":2,\"start\":0," + + "\"docs\":[{\"id\":\"1\"},{\"id\":\"2\"}]}," + + "\"facet_counts\":{\"facet_queries\":{}," + + "\"facet_fields\":{\"cat\":{\"electronics\":3,\"books\":1}}}}"; + NamedList parsed = + new JsonMapResponseParser() + .processResponse( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8"); + QueryResponse r = new QueryResponse(); + r.setResponse(ResponseNormalizer.normalize(parsed)); + assertResponse("json-map", r); + } + + @Test + public void testXmlMatchesBinary() throws Exception { + String xml = + "\n" + + "\n" + + " 0" + + "7\n" + + " \n" + + " 1\n" + + " 2\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " 3" + + "1\n" + + " \n" + + " \n" + + ""; + NamedList parsed = + new XMLResponseParser() + .processResponse( + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)), "UTF-8"); + QueryResponse r = new QueryResponse(); + r.setResponse(ResponseNormalizer.normalize(parsed)); + assertResponse("xml", r); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java new file mode 100644 index 000000000000..72fb78eb6f56 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseJsonParserIntegrationTest.java @@ -0,0 +1,95 @@ +/* + * 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.client.solrj.response; + +import static org.apache.solr.SolrTestCaseJ4.sdoc; + +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.util.ExternalPaths; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** + * End-to-end: a real HTTP query with the JSON map response parser must return a fully typed + * QueryResponse, proving SolrRequest.process() normalizes the non-canonical JSON response at the + * boundary before the response classes read it (SOLR-17316). + */ +public class QueryResponseJsonParserIntegrationTest extends SolrTestCase { + + @ClassRule public static SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void beforeClass() throws Exception { + System.setProperty("solr.security.allow.paths", "*"); + solrTestRule.startSolr(); + solrTestRule.newCollection().withConfigSet(ExternalPaths.TECHPRODUCTS_CONFIGSET).create(); + + try (SolrClient client = solrTestRule.getSolrClient()) { + client.add( + java.util.List.of( + sdoc("id", "1", "cat", "electronics"), + sdoc("id", "2", "cat", "electronics"), + sdoc("id", "3", "cat", "books"))); + client.commit(); + } + } + + /** The default (Jetty) transport. */ + @Test + public void testTypedQueryResponseOverJsonJetty() throws Exception { + try (SolrClient client = + solrTestRule + .newSolrClientBuilder() + .withResponseParser(new JsonMapResponseParser()) + .build()) { + assertTypedResponse(client); + } + } + + /** The JDK transport shares the same response boundary, so it must behave identically. */ + @Test + public void testTypedQueryResponseOverJsonJdk() throws Exception { + try (SolrClient client = + new org.apache.solr.client.solrj.impl.HttpJdkSolrClient.Builder(solrTestRule.getBaseUrl()) + .withResponseParser(new JsonMapResponseParser()) + .build()) { + assertTypedResponse(client); + } + } + + private void assertTypedResponse(SolrClient client) throws Exception { + SolrQuery q = new SolrQuery("*:*"); + q.setRows(10); + q.addFacetField("cat"); + q.setParam("json.nl", "map"); // the round-trippable NamedList style + + QueryResponse rsp = client.query("collection1", q); + + assertEquals(0, rsp.getStatus()); + assertEquals(3, rsp.getResults().getNumFound()); + assertNotNull(rsp.getResults().get(0).getFirstValue("id")); + + FacetField cat = rsp.getFacetField("cat"); + assertNotNull("facet field cat", cat); + assertEquals(2, cat.getValueCount()); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java new file mode 100644 index 000000000000..a822f29dd510 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/QueryResponseSectionParityTest.java @@ -0,0 +1,162 @@ +/* + * 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.client.solrj.response; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.ResponseNormalizer; +import org.junit.Test; + +/** + * Each test feeds a JSON (json.nl=map) response for one QueryResponse section through the + * normalizer and asserts the typed accessor works. Sections with a numeric cast (grouping, facets, + * spellcheck) also guard the Number widening; the rest guard the structural Map -> NamedList / + * SolrDocumentList reconstruction the section relies on. + */ +public class QueryResponseSectionParityTest extends SolrTestCase { + + private static QueryResponse parse(String json) throws Exception { + NamedList parsed = + new JsonMapResponseParser() + .processResponse( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8"); + QueryResponse r = new QueryResponse(); + r.setResponse(ResponseNormalizer.normalize(parsed)); + return r; + } + + private static final String HEADER = "\"responseHeader\":{\"status\":0,\"QTime\":1},"; + + /** pivot facets: count is an Integer cast (QueryResponse readPivots). */ + @Test + public void testPivotFacets() throws Exception { + String json = + "{" + + HEADER + + "\"facet_counts\":{\"facet_queries\":{},\"facet_fields\":{}," + + "\"facet_pivot\":{\"cat\":[{\"field\":\"cat\",\"value\":\"electronics\",\"count\":3}]}}}"; + QueryResponse r = parse(json); + assertNotNull("facetPivot", r.getFacetPivot()); + assertEquals(3, r.getFacetPivot().get("cat").get(0).getCount()); + } + + /** grouping: matches / ngroups are Integer casts (QueryResponse extractGroupedInfo). */ + @Test + public void testGrouping() throws Exception { + String json = + "{" + + HEADER + + "\"grouped\":{\"cat\":{\"matches\":3,\"ngroups\":2,\"groups\":[" + + "{\"groupValue\":\"a\",\"doclist\":{\"numFound\":2,\"start\":0,\"docs\":[{\"id\":\"1\"}]}}," + + "{\"groupValue\":\"b\",\"doclist\":{\"numFound\":1,\"start\":0,\"docs\":[{\"id\":\"2\"}]}}" + + "]}}}"; + QueryResponse r = parse(json); + GroupResponse gr = r.getGroupResponse(); + assertNotNull("groupResponse", gr); + assertEquals(3, gr.getValues().get(0).getMatches()); + assertEquals(Integer.valueOf(2), gr.getValues().get(0).getNGroups()); + } + + /** interval facets: count is an Integer cast (QueryResponse extractFacetInfo). */ + @Test + public void testIntervalFacets() throws Exception { + String json = + "{" + + HEADER + + "\"facet_counts\":{\"facet_queries\":{},\"facet_fields\":{}," + + "\"facet_intervals\":{\"price\":{\"[0,10]\":5,\"[11,100]\":3}}}}"; + QueryResponse r = parse(json); + assertNotNull("intervalFacets", r.getIntervalFacets()); + assertEquals(2, r.getIntervalFacets().get(0).getIntervals().size()); + assertEquals(5, r.getIntervalFacets().get(0).getIntervals().get(0).getCount()); + } + + /** field stats: count/missing (Long) and sumOfSquares/stddev (Double) casts (FieldStatsInfo). */ + @Test + public void testFieldStats() throws Exception { + String json = + "{" + + HEADER + + "\"stats\":{\"stats_fields\":{\"price\":{" + + "\"min\":9.0,\"max\":12.0,\"count\":2,\"missing\":0," + + "\"sumOfSquares\":225.0,\"stddev\":1.5,\"countDistinct\":2,\"cardinality\":2}}}}"; + QueryResponse r = parse(json); + assertNotNull("fieldStatsInfo", r.getFieldStatsInfo()); + FieldStatsInfo price = r.getFieldStatsInfo().get("price"); + assertNotNull("price stats", price); + assertEquals(Long.valueOf(2), price.getCount()); + assertEquals(Long.valueOf(0), price.getMissing()); + assertEquals(Double.valueOf(1.5), price.getStddev()); + assertEquals(Long.valueOf(2), price.getCardinality()); + } + + /** spellcheck: numFound / startOffset / origFreq are Integer casts (SpellCheckResponse). */ + @Test + public void testSpellCheck() throws Exception { + String json = + "{" + + HEADER + + "\"spellcheck\":{\"suggestions\":{" + + "\"helo\":{\"numFound\":1,\"startOffset\":0,\"endOffset\":4,\"origFreq\":0," + + "\"suggestion\":[{\"word\":\"hello\",\"freq\":5}]}}}}"; + QueryResponse r = parse(json); + SpellCheckResponse sc = r.getSpellCheckResponse(); + assertNotNull("spellcheck", sc); + SpellCheckResponse.Suggestion s = sc.getSuggestion("helo"); + assertNotNull("suggestion", s); + assertEquals(1, s.getNumFound()); + assertEquals(0, s.getStartOffset()); + assertEquals(Integer.valueOf(5), s.getAlternativeFrequencies().get(0)); + } + + /** highlighting: no numeric cast, but exercises Map->NamedList reconstruction over JSON. */ + @Test + public void testHighlighting() throws Exception { + String json = "{" + HEADER + "\"highlighting\":{\"1\":{\"name\":[\"foo\"]}}}"; + QueryResponse r = parse(json); + assertNotNull("highlighting", r.getHighlighting()); + assertEquals("foo", r.getHighlighting().get("1").get("name").get(0)); + } + + /** terms: df/ttf are read via Number, and the section is a nested NamedList over JSON. */ + @Test + public void testTerms() throws Exception { + String json = "{" + HEADER + "\"terms\":{\"cat\":{\"electronics\":3,\"books\":1}}}"; + QueryResponse r = parse(json); + assertNotNull("termsResponse", r.getTermsResponse()); + assertEquals(2, r.getTermsResponse().getTerms("cat").size()); + assertEquals(3L, r.getTermsResponse().getTerms("cat").get(0).getFrequency()); + } + + /** + * moreLikeThis: each value is a {numFound,docs} object -> must reconstruct as SolrDocumentList. + */ + @Test + public void testMoreLikeThis() throws Exception { + String json = + "{" + + HEADER + + "\"moreLikeThis\":{\"1\":{\"numFound\":1,\"start\":0,\"docs\":[{\"id\":\"2\"}]}}}"; + QueryResponse r = parse(json); + assertNotNull("moreLikeThis", r.getMoreLikeThis()); + assertEquals(1, r.getMoreLikeThis().get("1").getNumFound()); + assertEquals("2", r.getMoreLikeThis().get("1").get(0).getFirstValue("id")); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalFormTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalFormTest.java new file mode 100644 index 000000000000..44a41ad611b3 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/ResponseParserCanonicalFormTest.java @@ -0,0 +1,41 @@ +/* + * 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.client.solrj.response; + +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.junit.Test; + +/** + * Pins the producesCanonicalForm() contract that gates response normalization: only the JSON map + * parser (which yields raw Maps/Lists) needs normalizing; the parsers that already produce the + * canonical NamedList/SolrDocumentList shape must report true so they pass through untouched. + */ +public class ResponseParserCanonicalFormTest extends SolrTestCase { + + @Test + public void testCanonicalParsersReportTrue() { + assertTrue(new JavaBinResponseParser().producesCanonicalForm()); + assertTrue(new XMLResponseParser().producesCanonicalForm()); + assertTrue(new InputStreamResponseParser("json").producesCanonicalForm()); + } + + @Test + public void testJsonMapParserReportsFalse() { + assertFalse(new JsonMapResponseParser().producesCanonicalForm()); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java new file mode 100644 index 000000000000..ff4a16b3ae07 --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/client/solrj/response/SolrResponseBaseTest.java @@ -0,0 +1,71 @@ +/* + * 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.client.solrj.response; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.response.json.JsonMapResponseParser; +import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.junit.Test; + +/** Tests that {@link SolrResponseBase} getters work across ResponseParsers (SOLR-17316). */ +public class SolrResponseBaseTest extends SolrTestCase { + + /** The JSON parser yields a Map header with Long numbers, the case that regressed. */ + @Test + public void testStatusAndQTimeWithJsonParser() throws Exception { + String json = "{\"responseHeader\":{\"status\":0,\"QTime\":7}}"; + NamedList parsed = + new JsonMapResponseParser() + .processResponse( + new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)), "UTF-8"); + + SolrResponseBase response = new SolrResponseBase(); + response.setResponse(parsed); + + assertEquals(0, response.getStatus()); + assertEquals(7, response.getQTime()); + } + + /** The binary parser yields a NamedList header with Integer numbers (the original happy path). */ + @Test + public void testStatusAndQTimeWithBinaryStyleHeader() { + NamedList header = new SimpleOrderedMap<>(); + header.add("status", 0); + header.add("QTime", 7); + NamedList body = new SimpleOrderedMap<>(); + body.add("responseHeader", header); + + SolrResponseBase response = new SolrResponseBase(); + response.setResponse(body); + + assertEquals(0, response.getStatus()); + assertEquals(7, response.getQTime()); + } + + /** With no responseHeader the getters return 0 rather than throwing. */ + @Test + public void testStatusAndQTimeWithNoHeader() { + SolrResponseBase response = new SolrResponseBase(); + response.setResponse(new SimpleOrderedMap<>()); + + assertEquals(0, response.getStatus()); + assertEquals(0, response.getQTime()); + } +} diff --git a/solr/solrj/src/test/org/apache/solr/common/util/ResponseNormalizerTest.java b/solr/solrj/src/test/org/apache/solr/common/util/ResponseNormalizerTest.java new file mode 100644 index 000000000000..c3e43d950e1f --- /dev/null +++ b/solr/solrj/src/test/org/apache/solr/common/util/ResponseNormalizerTest.java @@ -0,0 +1,169 @@ +/* + * 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.common.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrDocument; +import org.apache.solr.common.SolrDocumentList; +import org.junit.Test; + +/** Intensive tests for {@link ResponseNormalizer}. */ +public class ResponseNormalizerTest extends SolrTestCase { + + @Test + public void testNullAndEmpty() { + assertNull(ResponseNormalizer.normalize(null)); + assertEquals(0, ResponseNormalizer.normalize(new NamedList<>()).size()); + } + + @Test + public void testAlreadyCanonicalPassesThrough() { + NamedList header = new SimpleOrderedMap<>(); + header.add("status", 0); + NamedList in = new SimpleOrderedMap<>(); + in.add("responseHeader", header); + + NamedList out = ResponseNormalizer.normalize(in); + assertTrue(out.get("responseHeader") instanceof NamedList); + assertEquals(0, ((NamedList) out.get("responseHeader")).get("status")); + } + + @Test + public void testMapBecomesNamedListRecursively() { + Map inner = new LinkedHashMap<>(); + inner.put("a", 1); + Map mid = new LinkedHashMap<>(); + mid.put("inner", inner); + NamedList in = new NamedList<>(); + in.add("mid", mid); + + NamedList out = ResponseNormalizer.normalize(in); + Object midOut = out.get("mid"); + assertTrue("mid should be NamedList", midOut instanceof NamedList); + Object innerOut = ((NamedList) midOut).get("inner"); + assertTrue("inner should be NamedList", innerOut instanceof NamedList); + assertEquals(1, ((NamedList) innerOut).get("a")); + } + + @Test + public void testDocListReconstruction() { + Map doc1 = new LinkedHashMap<>(); + doc1.put("id", "1"); + Map response = new LinkedHashMap<>(); + response.put("numFound", 5L); + response.put("start", 0L); + response.put("maxScore", 1.5); + response.put("docs", new ArrayList<>(List.of(doc1))); + NamedList in = new NamedList<>(); + in.add("response", response); + + NamedList out = ResponseNormalizer.normalize(in); + Object r = out.get("response"); + assertTrue("response should be SolrDocumentList", r instanceof SolrDocumentList); + SolrDocumentList docs = (SolrDocumentList) r; + assertEquals(5L, docs.getNumFound()); + assertEquals(0L, docs.getStart()); + assertEquals(Float.valueOf(1.5f), docs.getMaxScore()); + assertEquals(1, docs.size()); + assertEquals("1", docs.get(0).getFirstValue("id")); + } + + @Test + public void testEmptyDocList() { + Map response = new LinkedHashMap<>(); + response.put("numFound", 0L); + response.put("docs", new ArrayList<>()); + NamedList in = new NamedList<>(); + in.add("response", response); + + SolrDocumentList docs = (SolrDocumentList) ResponseNormalizer.normalize(in).get("response"); + assertEquals(0L, docs.getNumFound()); + assertTrue(docs.isEmpty()); + } + + @Test + public void testDocListValuedFieldIsReconstructed() { + // a doc field whose value is itself a {numFound,docs} object becomes a nested SolrDocumentList + Map child = new LinkedHashMap<>(); + child.put("id", "child-1"); + Map childList = new LinkedHashMap<>(); + childList.put("numFound", 1L); + childList.put("docs", new ArrayList<>(List.of(child))); + + Map parent = new LinkedHashMap<>(); + parent.put("id", "parent-1"); + parent.put("nested", childList); + + Map response = new LinkedHashMap<>(); + response.put("numFound", 1L); + response.put("docs", new ArrayList<>(List.of(parent))); + NamedList in = new NamedList<>(); + in.add("response", response); + + SolrDocumentList docs = (SolrDocumentList) ResponseNormalizer.normalize(in).get("response"); + SolrDocument parentDoc = docs.get(0); + Object nested = parentDoc.getFieldValue("nested"); + assertTrue("nested docList field reconstructed", nested instanceof SolrDocumentList); + assertEquals("child-1", ((SolrDocumentList) nested).get(0).getFirstValue("id")); + } + + @Test + public void testListOfMapsNormalized() { + Map a = new LinkedHashMap<>(); + a.put("x", 1); + Map b = new LinkedHashMap<>(); + b.put("y", 2); + NamedList in = new NamedList<>(); + in.add("things", new ArrayList<>(Arrays.asList(a, b))); + + NamedList out = ResponseNormalizer.normalize(in); + List things = (List) out.get("things"); + assertTrue(things.get(0) instanceof NamedList); + assertEquals(1, ((NamedList) things.get(0)).get("x")); + } + + @Test + public void testMixedNumberTypesPreserved() { + // normalizer preserves numeric values as-is (widening happens at the getter layer) + Map header = new LinkedHashMap<>(); + header.put("status", 0L); // JSON Long + header.put("QTime", 7L); + NamedList in = new NamedList<>(); + in.add("responseHeader", header); + + NamedList out = ResponseNormalizer.normalize(in); + NamedList h = (NamedList) out.get("responseHeader"); + assertEquals(0L, h.get("status")); + assertEquals(7L, h.get("QTime")); + } + + @Test + public void testNotADocListWhenNumFoundMissing() { + // a map with "docs" but no numeric numFound is NOT a doc list -> stays a NamedList + Map notDocs = new LinkedHashMap<>(); + notDocs.put("docs", new ArrayList<>()); + NamedList in = new NamedList<>(); + in.add("x", notDocs); + + assertTrue(ResponseNormalizer.normalize(in).get("x") instanceof NamedList); + } +}