From 90c4ad711bae76a23eca0011af465d3194f2c505 Mon Sep 17 00:00:00 2001 From: Sonu Sharma Date: Sat, 18 Jul 2026 21:49:30 +0530 Subject: [PATCH 1/4] SOLR-18201: docValues based optimization for range fields --- ...201-range-field-docvalues-optimization.yml | 10 + .../apache/solr/bench/search/RangeSearch.java | 249 ++++++++++++++++ .../configs/cloud-minimal/conf/schema.xml | 9 +- .../org/apache/solr/schema/FieldType.java | 30 ++ .../AbstractNumericRangeField.java | 137 +++++++-- .../schema/numericrange/DoubleRangeField.java | 58 +++- .../schema/numericrange/FloatRangeField.java | 58 +++- .../schema/numericrange/IntRangeField.java | 58 +++- .../schema/numericrange/LongRangeField.java | 58 +++- .../MultiBinaryRangeDocValuesQuery.java | 172 +++++++++++ .../NumericRangeQParserPlugin.java | 8 +- .../apache/solr/update/DocumentBuilder.java | 36 +++ .../collection1/conf/schema-numericrange.xml | 18 ++ .../MultiBinaryRangeDocValuesQueryTest.java | 217 ++++++++++++++ .../NumericRangeDocValuesTest.java | 274 ++++++++++++++++++ .../NumericRangeMultiValuedDocValuesTest.java | 187 ++++++++++++ .../pages/field-types-included-with-solr.adoc | 8 +- 17 files changed, 1509 insertions(+), 78 deletions(-) create mode 100644 changelog/unreleased/SOLR-18201-range-field-docvalues-optimization.yml create mode 100644 solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java create mode 100644 solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java create mode 100644 solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java create mode 100644 solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java create mode 100644 solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java diff --git a/changelog/unreleased/SOLR-18201-range-field-docvalues-optimization.yml b/changelog/unreleased/SOLR-18201-range-field-docvalues-optimization.yml new file mode 100644 index 000000000000..ab6aa7627bd1 --- /dev/null +++ b/changelog/unreleased/SOLR-18201-range-field-docvalues-optimization.yml @@ -0,0 +1,10 @@ +title: > + Range field types (Int/Long/Float/DoubleRangeField) now support docValues="true" + as a query-time filter optimization. +type: added +authors: + - name: Sonu Sharma + nick: ercsonusharma +links: + - name: SOLR-18201 + url: https://issues.apache.org/jira/browse/SOLR-18201 diff --git a/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java new file mode 100644 index 000000000000..b52a59cca00a --- /dev/null +++ b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java @@ -0,0 +1,249 @@ +/* + * 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.bench.search; + +import static org.apache.solr.bench.BaseBenchState.log; +import static org.apache.solr.bench.Docs.docs; +import static org.apache.solr.bench.generators.SourceDSL.integers; +import static org.apache.solr.bench.generators.SourceDSL.lists; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.solr.bench.CircularIterator; +import org.apache.solr.bench.Docs; +import org.apache.solr.bench.SolrBenchState; +import org.apache.solr.bench.generators.SolrGen; +import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.QueryRequest; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.FacetField; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Benchmarks the docValues filter optimization for range field types. + * + *

Docs hold wide ranges {@code [m, m+SPAN]} in indexed-only (BKD) and indexed+docValues fields, + * single-valued ({@code r_ir}/{@code r_ir_dv}) and multiValued ({@code r_mv_ir}/{@code + * r_mv_ir_dv}). A central window matches ~half the docs, so the range clause is costly to + * materialize but cheap to verify per-doc. It is AND-ed with a selective term filter whose + * doc-frequency is controlled, so we know -- and the {@code [range-bench]} setup log confirms -- + * which {@link org.apache.lucene.search.IndexOrDocValuesQuery} branch runs. + * + *

Params: {@code criteria} (the {@code {!numericRange criteria=...}} relation), {@code field} + * (the DV / non-DV pairs), and {@code scenario} -- the lead's selectivity vs the {@code + * rangeCost/8} cutover: {@code selective_dv} (~{@value #SELECTIVE_LEAD_DF} docs -> docValues + * branch), {@code broad_points} (~{@value #BROAD_LEAD_DF} docs -> points branch), {@code + * none_points} (standalone -> points). + */ +@Fork(value = 1) +@Warmup(time = 5, iterations = 5) +@Measurement(time = 5, iterations = 5) +@Threads(value = 1) +public class RangeSearch { + + static final String COLLECTION = "c1"; + + static final int NUM_DOCS = 1_000_000; + + /** Width of each indexed document range: docs store {@code [m, m+SPAN]}. */ + static final int SPAN = 500_000; + + /** + * Narrow window for {@code contains}/{@code intersects}/{@code crosses}: contained by ~half the + * docs. + */ + static final String OTHER_QUERY = "[500000 TO 500100]"; + + /** + * Wide window for {@code within} (wider than {@code SPAN}), so ~half the docs' ranges fit inside. + */ + static final String WITHIN_QUERY = "[0 TO 1000000]"; + + /** "Selective" lead df: far below {@code rangeCost/8} (~62.5k) -> docValues branch. */ + static final int SELECTIVE_LEAD_DF = 2_000; + + /** "Broad" lead df: above {@code rangeCost/8} but below {@code rangeCost} -> points branch. */ + static final int BROAD_LEAD_DF = 250_000; + + @State(Scope.Benchmark) + public static class BenchState { + + @Param({"contains", "intersects", "within", "crosses"}) + public String criteria; + + @Param({"r_ir", "r_ir_dv", "r_mv_ir", "r_mv_ir_dv"}) + public String field; + + @Param({"selective_dv", "broad_points", "none_points"}) + public String scenario; + + // Rotated so repeated queries are distinct top-level queries (each ~SELECTIVE_LEAD_DF docs). + Iterator selectiveTerms; + // Rotated broad-lead terms (each ~BROAD_LEAD_DF docs). + Iterator broadTerms; + + @Setup(Level.Trial) + public void setupTrial(SolrBenchState solrBenchState) throws Exception { + solrBenchState.setUseHttp1(true); + solrBenchState.startSolr(1); + solrBenchState.createCollection(COLLECTION, 1, 1); + + // Controlled lead selectivity: ~numDocs/DF distinct values -> each term matches ~DF docs. + SolrGen selectiveLeadTerms = + integers().between(0, NUM_DOCS / SELECTIVE_LEAD_DF - 1).map(i -> "s" + i, String.class); + SolrGen broadLeadTerms = + integers().between(0, NUM_DOCS / BROAD_LEAD_DF - 1).map(i -> "b" + i, String.class); + // m in [0, NUM_DOCS] so that m + SPAN never overflows int; range string is "[m TO m+SPAN]". + SolrGen rangeValues = + integers() + .between(0, NUM_DOCS) + .map(m -> "[" + m + " TO " + (m + SPAN) + "]", String.class); + // multiValued docs hold 2-3 ranges each, drawn from the same distribution. + var mvRangeValues = lists().of(rangeValues).ofSizeBetween(2, 3); + + Docs docs = + docs() + .field("id", integers().incrementing()) + .field("term_high_s", selectiveLeadTerms) + .field("term_low_s", broadLeadTerms) + .field("r_ir", rangeValues) // single-valued, indexed-only (BKD) + .field("r_ir_dv", rangeValues) // single-valued, indexed + docValues + .field("r_mv_ir", mvRangeValues) // multiValued, indexed-only (BKD) + .field("r_mv_ir_dv", mvRangeValues); // multiValued, indexed + docValues + + solrBenchState.index(COLLECTION, docs, NUM_DOCS, false); + + // Discover the real term values (and their counts) so queries hit existing terms. + String basePath = solrBenchState.nodes.getFirst(); + SolrQuery q = new SolrQuery("*:*"); + q.setParam("facet", "true"); + q.setParam("rows", "0"); + q.setParam("facet.field", "term_high_s", "term_low_s"); + q.setParam("facet.limit", "2000"); + q.setParam("facet.mincount", "1"); + QueryResponse response = + new QueryRequest(q).processWithBaseUrl(solrBenchState.client, basePath, COLLECTION); + selectiveTerms = new CircularIterator<>(facetValues(response, "term_high_s")); + broadTerms = new CircularIterator<>(facetValues(response, "term_low_s")); + + // Measure rangeCost (the range clause's own match count) and derive the branch the same way + // IndexOrDocValuesQuery does: rangeCost >>> 3 <= leadCost -> points, else docValues. leadCost + // is + // the actual doc-frequency of the term this scenario leads with. + String window = criteria.equals("within") ? WITHIN_QUERY : OTHER_QUERY; + SolrQuery rangeOnly = + new SolrQuery( + "q", "{!numericRange criteria=" + criteria + " field=" + field + "}" + window); + rangeOnly.setParam("rows", "0"); + long rangeCost = + new QueryRequest(rangeOnly) + .processWithBaseUrl(solrBenchState.client, basePath, COLLECTION) + .getResults() + .getNumFound(); + long threshold = rangeCost >>> 3; + boolean hasDocValues = field.endsWith("_dv"); + long leadCost; + long dvBranch; // 1 = docValues verify branch, 0 = points/BKD + if (scenario.startsWith("none")) { + leadCost = 0; // standalone: no lead -> points + dvBranch = 0; + } else { + String facetField = scenario.startsWith("selective") ? "term_high_s" : "term_low_s"; + leadCost = firstFacetCount(response, facetField); + // IndexOrDocValuesQuery picks docValues only when the range is >8x the lead (threshold > + // leadCost); otherwise points. A docValues=false field never gets that choice. + dvBranch = (hasDocValues && threshold > leadCost) ? 1 : 0; + } + log( + "[range-bench] criteria=" + + criteria + + " field=" + + field + + " scenario=" + + scenario + + " rangeCost=" + + rangeCost + + " threshold=" + + threshold + + " leadCost=" + + leadCost + + " -> " + + (dvBranch == 1 ? "docValues" : "points")); + } + + private Set facetValues(QueryResponse response, String fieldName) { + return response.getFacetField(fieldName).getValues().stream() + .map(FacetField.Count::getName) + .collect(Collectors.toSet()); + } + + private long firstFacetCount(QueryResponse response, String fieldName) { + return response.getFacetField(fieldName).getValues().stream() + .mapToLong(FacetField.Count::getCount) + .findFirst() + .orElse(0L); + } + + @Setup(Level.Iteration) + public void setupIteration(SolrBenchState solrBenchState) + throws SolrServerException, IOException { + CollectionAdminRequest.Reload reload = CollectionAdminRequest.reloadCollection(COLLECTION); + solrBenchState.client.requestWithBaseUrl(solrBenchState.nodes.getFirst(), reload, null); + } + + /** Builds the query for the current (criteria, field, scenario) parameter combination. */ + QueryRequest query() { + // within needs a window WIDER than the doc ranges (so they can fit inside it); the other + // relations need a narrow window that the doc ranges contain / overlap. + String window = criteria.equals("within") ? WITHIN_QUERY : OTHER_QUERY; + String rangeExpr = "{!numericRange criteria=" + criteria + " field=" + field + "}" + window; + if (scenario.startsWith("none")) { + return new QueryRequest(new SolrQuery("q", rangeExpr)); // standalone: no lead -> points + } + + boolean selective = scenario.startsWith("selective"); + Iterator terms = selective ? selectiveTerms : broadTerms; + String termField = selective ? "term_high_s" : "term_low_s"; + // Nest the {!numericRange} subquery via _query_ so it joins the term clause's BooleanQuery. + String query = "+" + termField + ":\"" + terms.next() + "\" +_query_:\"" + rangeExpr + "\""; + return new QueryRequest(new SolrQuery("q", query)); + } + } + + @Benchmark + public void rangeQuery(Blackhole blackhole, BenchState benchState, SolrBenchState solrBenchState) + throws SolrServerException, IOException { + // Sink the result exactly once, via Blackhole (do NOT also return it): mixing a return-value + // sink and a Blackhole sink can resolve to different JMH blackhole modes, which makes the + // r_ir vs r_ir_dv comparison apples-to-oranges. + blackhole.consume(benchState.query().process(solrBenchState.client, COLLECTION)); + } +} diff --git a/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml b/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml index e3f77a826047..79b26fc9b3cd 100644 --- a/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml +++ b/solr/benchmark/src/resources/configs/cloud-minimal/conf/schema.xml @@ -28,6 +28,7 @@ positionIncrementGap="0"/> + @@ -58,5 +59,11 @@ - id + + + + + + + id diff --git a/solr/core/src/java/org/apache/solr/schema/FieldType.java b/solr/core/src/java/org/apache/solr/schema/FieldType.java index 1452233beee3..d57965d85b41 100644 --- a/solr/core/src/java/org/apache/solr/schema/FieldType.java +++ b/solr/core/src/java/org/apache/solr/schema/FieldType.java @@ -351,6 +351,36 @@ public List createFields(SchemaField field, Object value) { return f == null ? List.of() : List.of(f); } + /** + * Whether this type needs all values of a multiValued field together (rather than one value at a + * time) in order to build its indexable fields - for example to pack several ranges into a single + * {@code BinaryDocValues} blob. When {@code true}, {@link org.apache.solr.update.DocumentBuilder} + * calls {@link #createFieldsFromAllValues(SchemaField, Collection)} once per (multiValued) field + * instead of {@link #createFields(SchemaField, Object)} per value. + */ + public boolean createsFieldsFromAllValues() { + return false; + } + + /** + * Builds the indexable fields for all values of {@code field} in a single document at + * once. Only invoked when {@link #createsFieldsFromAllValues()} returns {@code true}; the default + * simply concatenates {@link #createFields(SchemaField, Object)} over each value, preserving + * per-value behavior. + * + * @param field the schema field + * @param values all (non-null) values for this field in the current document + * @return the indexable fields to add to the document + */ + public List createFieldsFromAllValues( + SchemaField field, Collection values) { + List fields = new ArrayList<>(); + for (Object value : values) { + fields.addAll(createFields(field, value)); + } + return fields; + } + /** * Convert an external value (from XML update command or from query string) into the internal * format for both storing and indexing (which can be modified by any analyzers). diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java index a05a766ac52d..a679356ed18f 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -18,13 +18,20 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import org.apache.lucene.document.BinaryDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; import org.apache.lucene.document.StoredField; import org.apache.lucene.index.IndexableField; +import org.apache.lucene.search.IndexOrDocValuesQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.SortField; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.BytesRefBuilder; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.response.TextResponseWriter; @@ -134,13 +141,20 @@ protected Pattern getSingleBoundPattern() { @Override protected boolean enableDocValuesByDefault() { - return false; // Range fields do not support docValues + // DocValues are supported for both single- and multiValued range fields, but are opt-in: they + // add an extra (binary) docValues field, and range docValues only help queries that combine + // the range clause with a more selective clause, so we don't enable them by default. + return false; } @Override protected void init(IndexSchema schema, Map args) { super.init(schema, args); + // Force useDocValuesAsStored off; it otherwise defaults on for schema, which + // would make fl=* responses fail on a docValues-enabled range field. + properties &= ~USE_DOCVALUES_AS_STORED; + String numDimensionsStr = args.remove("numDimensions"); if (numDimensionsStr != null) { numDimensions = Integer.parseInt(numDimensionsStr); @@ -153,27 +167,33 @@ protected void init(IndexSchema schema, Map args) { + typeName); } } + } - // Range fields do not support docValues - validate this wasn't explicitly enabled - if (hasProperty(DOC_VALUES)) { - throw new SolrException( - ErrorCode.SERVER_ERROR, - "docValues=true enabled but " - + getClass().getSimpleName() - + " does not support docValues for field type " - + typeName); - } + @Override + protected void checkSupportsDocValues() { + // DocValues are supported for both single- and multiValued range fields (backed by binary + // docValues). } @Override public List createFields(SchemaField field, Object value) { - IndexableField indexedField = createField(field, value); List fields = new ArrayList<>(); + IndexableField indexedField = createField(field, value); if (indexedField != null) { fields.add(indexedField); } + if (field.hasDocValues() && !field.multiValued()) { + // Single-valued: one flat BinaryDocValues blob (read directly, no dictionary). multiValued + // docValues are built from all values at once in createFieldsFromAllValues (one blob holding + // every range), since BinaryDocValues holds only one value per document. + NumericRangeValue rv = parseRangeValue(value.toString()); + fields.add( + new BinaryDocValuesField( + field.getName(), BytesRef.deepCopyOf(encodePackedValue(field.getName(), rv)))); + } + if (field.stored()) { fields.add(getStoredField(field, value.toString())); } @@ -181,6 +201,86 @@ public List createFields(SchemaField field, Object value) { return fields; } + @Override + public boolean createsFieldsFromAllValues() { + // multiValued docValues range fields pack every range of a document into ONE BinaryDocValues + // blob (flat, no dictionary), so the type needs all values together to build it. + return true; + } + + @Override + public List createFieldsFromAllValues( + SchemaField field, Collection values) { + List fields = new ArrayList<>(); + // Indexed (BKD) and stored fields: one per value (same as the per-value path). + for (Object value : values) { + IndexableField indexedField = createField(field, value); + if (indexedField != null) { + fields.add(indexedField); + } + if (field.stored()) { + fields.add(getStoredField(field, value.toString())); + } + } + // docValues: a single BinaryDocValues value holding all of the document's ranges. + if (field.hasDocValues()) { + fields.add( + new BinaryDocValuesField(field.getName(), encodePackedValues(field.getName(), values))); + } + return fields; + } + + /** + * Encodes several ranges into one {@code BinaryDocValues} blob: each range's fixed-width {@code + * [min... | max...]} bytes concatenated. All ranges of a field share the same width, so the query + * recovers the count as {@code blob.length / stride}. + */ + protected BytesRef encodePackedValues(String field, Collection values) { + BytesRefBuilder builder = new BytesRefBuilder(); + for (Object value : values) { + builder.append(encodePackedValue(field, parseRangeValue(value.toString()))); + } + return builder.toBytesRef(); + } + + /** + * Encodes a range value into the packed {@code [min... | max...]} byte representation used by + * both the indexed docValues and the query. Reuses Lucene's own encoder (via the type-specific + * {@code *RangeDocValuesField}) so the docValues and BKD encodings stay identical. + */ + protected abstract BytesRef encodePackedValue(String field, NumericRangeValue rangeValue); + + /** Number of bytes per dimension value for this type (e.g. {@code Integer.BYTES}). */ + protected abstract int bytesPerDimension(); + + /** + * If the field has docValues, wraps the BKD query in an {@link IndexOrDocValuesQuery} whose + * docValues clause ({@link MultiBinaryRangeDocValuesQuery}) can cheaply verify candidates when a + * more selective clause leads iteration; otherwise returns the BKD query unchanged. It reads a + * flat per-doc blob of one (single-valued) or several (multiValued) ranges, avoiding the + * dictionary/ordinal overhead of SortedSet docValues. + */ + protected Query maybeWrapWithDocValues( + SchemaField field, QueryType type, NumericRangeValue rangeValue, Query bkdQuery) { + if (!field.hasDocValues()) { + return bkdQuery; + } + BytesRef packed = encodePackedValue(field.getName(), rangeValue); + byte[] queryPackedValue = + Arrays.copyOfRange(packed.bytes, packed.offset, packed.offset + packed.length); + Query dv = + new MultiBinaryRangeDocValuesQuery( + field.getName(), + queryPackedValue, + rangeValue.getDimensions(), + bytesPerDimension(), + type); + if (!field.indexed()) { + return dv; + } + return new IndexOrDocValuesQuery(bkdQuery, dv); + } + protected StoredField getStoredField(SchemaField sf, Object value) { return new StoredField(sf.getName(), value.toString()); } @@ -278,7 +378,7 @@ public Object toNativeType(Object val) { * @param rangeValue a pre-parsed range value produced by this field type * @return a contains query for the given field and range */ - public abstract Query newContainsQuery(String field, NumericRangeValue rangeValue); + public abstract Query newContainsQuery(SchemaField field, NumericRangeValue rangeValue); /** * Creates a Lucene query that matches indexed documents whose stored range intersects @@ -288,7 +388,7 @@ public Object toNativeType(Object val) { * @param rangeValue a pre-parsed range value produced by this field type * @return an intersects query for the given field and range */ - public abstract Query newIntersectsQuery(String field, NumericRangeValue rangeValue); + public abstract Query newIntersectsQuery(SchemaField field, NumericRangeValue rangeValue); /** * Creates a Lucene query that matches indexed documents whose stored range is within the @@ -298,7 +398,7 @@ public Object toNativeType(Object val) { * @param rangeValue a pre-parsed range value produced by this field type * @return a within query for the given field and range */ - public abstract Query newWithinQuery(String field, NumericRangeValue rangeValue); + public abstract Query newWithinQuery(SchemaField field, NumericRangeValue rangeValue); /** * Creates a Lucene query that matches indexed documents whose stored range crosses the @@ -308,7 +408,7 @@ public Object toNativeType(Object val) { * @param rangeValue a pre-parsed range value produced by this field type * @return a crosses query for the given field and range */ - public abstract Query newCrossesQuery(String field, NumericRangeValue rangeValue); + public abstract Query newCrossesQuery(SchemaField field, NumericRangeValue rangeValue); /** * Creates a query for this field that matches docs where the query-range is fully contained by @@ -336,7 +436,7 @@ public Query getFieldQuery(QParser parser, SchemaField field, String externalVal // Check if it's the full range syntax: [min1,min2 TO max1,max2] if (getRangePattern().matcher(trimmed).matches()) { final var rangeValue = parseRangeValue(trimmed); - return newContainsQuery(field.getName(), rangeValue); + return newContainsQuery(field, rangeValue); } // Syntax sugar: also accept a single-bound (i.e pX,pY,pZ) @@ -353,7 +453,10 @@ public Query getFieldQuery(QParser parser, SchemaField field, String externalVal + ")"); } - return newContainsQuery(field.getName(), singleBoundRange); + // A single bound is the degenerate range [p,p]; "contains p" is equivalent to + // "intersects [p,p]", so route it through the intersects path to pick up the docValues + // optimization for point queries. + return newIntersectsQuery(field, singleBoundRange); } throw new SolrException( diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/DoubleRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/DoubleRangeField.java index c7eaeb5a82b8..5b98d7139fb6 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/DoubleRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/DoubleRangeField.java @@ -19,8 +19,11 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.lucene.document.DoubleRange; +import org.apache.lucene.document.DoubleRangeDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; import org.apache.lucene.index.IndexableField; import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.schema.SchemaField; @@ -101,6 +104,17 @@ public IndexableField createField(SchemaField field, Object value) { return new DoubleRange(field.getName(), rangeValue.mins, rangeValue.maxs); } + @Override + protected BytesRef encodePackedValue(String field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return new DoubleRangeDocValuesField(field, rv.mins, rv.maxs).binaryValue(); + } + + @Override + protected int bytesPerDimension() { + return Double.BYTES; + } + /** * Parse a range value string into a RangeValue object. * @@ -196,27 +210,43 @@ private double[] parseDoubleArray(String str, String description) { } @Override - public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return DoubleRange.newContainsQuery(fieldName, rv.mins, rv.maxs); + public Query newContainsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CONTAINS, + rangeValue, + DoubleRange.newContainsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return DoubleRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + public Query newIntersectsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.INTERSECTS, + rangeValue, + DoubleRange.newIntersectsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return DoubleRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + public Query newWithinQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.WITHIN, + rangeValue, + DoubleRange.newWithinQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return DoubleRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + public Query newCrossesQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CROSSES, + rangeValue, + DoubleRange.newCrossesQuery(field.getName(), rv.mins, rv.maxs)); } @Override @@ -261,7 +291,9 @@ protected Query getSpecializedRangeQuery( if (numDimensions == 1) { mins[0] = min; maxs[0] = max; - return DoubleRange.newContainsQuery(field.getName(), mins, maxs); + // Route through newContainsQuery so the standard field:[X TO Y] syntax also picks up the + // docValues optimization when the field has docValues. + return newContainsQuery(field, new RangeValue(mins, maxs)); } else { throw new SolrException( ErrorCode.BAD_REQUEST, diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java index 0d4e4f31d583..edae9f93dd95 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java @@ -19,8 +19,11 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.lucene.document.FloatRange; +import org.apache.lucene.document.FloatRangeDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; import org.apache.lucene.index.IndexableField; import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.schema.SchemaField; @@ -101,6 +104,17 @@ public IndexableField createField(SchemaField field, Object value) { return new FloatRange(field.getName(), rangeValue.mins, rangeValue.maxs); } + @Override + protected BytesRef encodePackedValue(String field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return new FloatRangeDocValuesField(field, rv.mins, rv.maxs).binaryValue(); + } + + @Override + protected int bytesPerDimension() { + return Float.BYTES; + } + /** * Parse a range value string into a RangeValue object. * @@ -196,27 +210,43 @@ private float[] parseFloatArray(String str, String description) { } @Override - public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return FloatRange.newContainsQuery(fieldName, rv.mins, rv.maxs); + public Query newContainsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CONTAINS, + rangeValue, + FloatRange.newContainsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return FloatRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + public Query newIntersectsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.INTERSECTS, + rangeValue, + FloatRange.newIntersectsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return FloatRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + public Query newWithinQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.WITHIN, + rangeValue, + FloatRange.newWithinQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return FloatRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + public Query newCrossesQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CROSSES, + rangeValue, + FloatRange.newCrossesQuery(field.getName(), rv.mins, rv.maxs)); } @Override @@ -261,7 +291,9 @@ protected Query getSpecializedRangeQuery( if (numDimensions == 1) { mins[0] = min; maxs[0] = max; - return FloatRange.newContainsQuery(field.getName(), mins, maxs); + // Route through newContainsQuery so the standard field:[X TO Y] syntax also picks up the + // docValues optimization when the field has docValues. + return newContainsQuery(field, new RangeValue(mins, maxs)); } else { throw new SolrException( ErrorCode.BAD_REQUEST, diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java index b8e76d0a680b..b2faec3534e9 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java @@ -18,8 +18,11 @@ import java.util.regex.Matcher; import org.apache.lucene.document.IntRange; +import org.apache.lucene.document.IntRangeDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; import org.apache.lucene.index.IndexableField; import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.schema.SchemaField; @@ -89,6 +92,17 @@ public IndexableField createField(SchemaField field, Object value) { return new IntRange(field.getName(), rangeValue.mins, rangeValue.maxs); } + @Override + protected BytesRef encodePackedValue(String field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return new IntRangeDocValuesField(field, rv.mins, rv.maxs).binaryValue(); + } + + @Override + protected int bytesPerDimension() { + return Integer.BYTES; + } + /** * Parse a range value string into a RangeValue object. * @@ -184,27 +198,43 @@ private int[] parseIntArray(String str, String description) { } @Override - public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rangeValueTyped = (RangeValue) rangeValue; - return IntRange.newContainsQuery(fieldName, rangeValueTyped.mins, rangeValueTyped.maxs); + public Query newContainsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CONTAINS, + rangeValue, + IntRange.newContainsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return IntRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + public Query newIntersectsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.INTERSECTS, + rangeValue, + IntRange.newIntersectsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return IntRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + public Query newWithinQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.WITHIN, + rangeValue, + IntRange.newWithinQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return IntRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + public Query newCrossesQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CROSSES, + rangeValue, + IntRange.newCrossesQuery(field.getName(), rv.mins, rv.maxs)); } @Override @@ -248,7 +278,9 @@ protected Query getSpecializedRangeQuery( if (numDimensions == 1) { mins[0] = min; maxs[0] = max; - return IntRange.newContainsQuery(field.getName(), mins, maxs); + // Route through newContainsQuery so the standard field:[X TO Y] syntax also picks up the + // docValues optimization when the field has docValues. + return newContainsQuery(field, new RangeValue(mins, maxs)); } else { throw new SolrException( ErrorCode.BAD_REQUEST, diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/LongRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/LongRangeField.java index 5004f3affb31..6057a9f81ef0 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/LongRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/LongRangeField.java @@ -18,8 +18,11 @@ import java.util.regex.Matcher; import org.apache.lucene.document.LongRange; +import org.apache.lucene.document.LongRangeDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; import org.apache.lucene.index.IndexableField; import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.schema.SchemaField; @@ -90,6 +93,17 @@ public IndexableField createField(SchemaField field, Object value) { return new LongRange(field.getName(), rangeValue.mins, rangeValue.maxs); } + @Override + protected BytesRef encodePackedValue(String field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return new LongRangeDocValuesField(field, rv.mins, rv.maxs).binaryValue(); + } + + @Override + protected int bytesPerDimension() { + return Long.BYTES; + } + /** * Parse a range value string into a RangeValue object. * @@ -185,27 +199,43 @@ private long[] parseLongArray(String str, String description) { } @Override - public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rangeValueTyped = (RangeValue) rangeValue; - return LongRange.newContainsQuery(fieldName, rangeValueTyped.mins, rangeValueTyped.maxs); + public Query newContainsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CONTAINS, + rangeValue, + LongRange.newContainsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return LongRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + public Query newIntersectsQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.INTERSECTS, + rangeValue, + LongRange.newIntersectsQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return LongRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + public Query newWithinQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.WITHIN, + rangeValue, + LongRange.newWithinQuery(field.getName(), rv.mins, rv.maxs)); } @Override - public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { - final var rv = (RangeValue) rangeValue; - return LongRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + public Query newCrossesQuery(SchemaField field, NumericRangeValue rangeValue) { + RangeValue rv = (RangeValue) rangeValue; + return maybeWrapWithDocValues( + field, + QueryType.CROSSES, + rangeValue, + LongRange.newCrossesQuery(field.getName(), rv.mins, rv.maxs)); } @Override @@ -249,7 +279,9 @@ protected Query getSpecializedRangeQuery( if (numDimensions == 1) { mins[0] = min; maxs[0] = max; - return LongRange.newContainsQuery(field.getName(), mins, maxs); + // Route through newContainsQuery so the standard field:[X TO Y] syntax also picks up the + // docValues optimization when the field has docValues. + return newContainsQuery(field, new RangeValue(mins, maxs)); } else { throw new SolrException( ErrorCode.BAD_REQUEST, diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java b/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java new file mode 100644 index 000000000000..f8404ebeba39 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java @@ -0,0 +1,172 @@ +/* + * 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.schema.numericrange; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import org.apache.lucene.document.RangeFieldQuery; +import org.apache.lucene.index.BinaryDocValues; +import org.apache.lucene.index.DocValues; +import org.apache.lucene.index.LeafReader; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.search.ConstantScoreScorer; +import org.apache.lucene.search.ConstantScoreWeight; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.QueryVisitor; +import org.apache.lucene.search.ScoreMode; +import org.apache.lucene.search.Scorer; +import org.apache.lucene.search.ScorerSupplier; +import org.apache.lucene.search.TwoPhaseIterator; +import org.apache.lucene.search.Weight; +import org.apache.lucene.util.ArrayUtil; +import org.apache.lucene.util.ArrayUtil.ByteArrayComparator; +import org.apache.lucene.util.BytesRef; + +/** + * A docValues-backed range query over {@link BinaryDocValues} for numeric range field types + * (single- or multiValued). + * + *

A document's range(s) are packed into a single {@code BinaryDocValues} value as one or more + * concatenated fixed-width {@code [min... | max...]} blobs (see {@code + * AbstractNumericRangeField.encodePackedValues}). Because every range for a field is the same width + * ({@code stride = 2 * numDims * bytesPerDim}), the count is recovered as {@code value.length / + * stride}. + * + *

Reading the flat per-doc blob avoids the dictionary/ordinal dereference a SortedSet-backed + * docValues range query would pay, so it stays cheap even for high-cardinality range data. It is a + * candidate to push down to Lucene, which currently lacks a multi-valued binary range docValues + * field. + */ +final class MultiBinaryRangeDocValuesQuery extends Query { + + private final String field; + private final byte[] queryPackedValue; + private final int numDims; + private final int bytesPerDim; + private final RangeFieldQuery.QueryType queryType; + private final ByteArrayComparator comparator; + + /** + * @param field the field name + * @param queryPackedValue the query range, encoded as {@code [min... | max...]} the same way the + * indexed ranges are + * @param numDims number of dimensions (1-4) + * @param bytesPerDim bytes per dimension value (e.g. {@code Integer.BYTES}) + * @param queryType the relation to test ({@code INTERSECTS}, {@code CONTAINS}, {@code WITHIN}, + * {@code CROSSES}) + */ + MultiBinaryRangeDocValuesQuery( + String field, + byte[] queryPackedValue, + int numDims, + int bytesPerDim, + RangeFieldQuery.QueryType queryType) { + this.field = Objects.requireNonNull(field, "field must not be null"); + this.queryPackedValue = + Objects.requireNonNull(queryPackedValue, "queryPackedValue must not be null"); + this.numDims = numDims; + this.bytesPerDim = bytesPerDim; + this.queryType = Objects.requireNonNull(queryType, "queryType must not be null"); + this.comparator = ArrayUtil.getUnsignedComparator(bytesPerDim); + } + + @Override + public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) { + return new ConstantScoreWeight(this, boost) { + @Override + public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException { + LeafReader reader = context.reader(); + if (reader.getFieldInfos().fieldInfo(field) == null) { + return null; + } + final BinaryDocValues values = DocValues.getBinary(reader, field); + final int stride = 2 * numDims * bytesPerDim; + final byte[] docPacked = new byte[stride]; + final TwoPhaseIterator iterator = + new TwoPhaseIterator(values) { + @Override + public boolean matches() throws IOException { + // The doc's value is N concatenated stride-byte ranges; match if ANY satisfies. + // Copy each range to a 0-based buffer because QueryType.matches indexes from 0. + BytesRef b = values.binaryValue(); + for (int off = b.offset; off < b.offset + b.length; off += stride) { + System.arraycopy(b.bytes, off, docPacked, 0, stride); + if (queryType.matches( + queryPackedValue, docPacked, numDims, bytesPerDim, comparator)) { + return true; + } + } + return false; + } + + @Override + public float matchCost() { + return stride; // ~ one packed-value comparison per range + } + }; + + Scorer scorer = new ConstantScoreScorer(score(), scoreMode, iterator); + return new DefaultScorerSupplier(scorer); + } + + @Override + public boolean isCacheable(LeafReaderContext ctx) { + return DocValues.isCacheable(ctx, field); + } + }; + } + + @Override + public void visit(QueryVisitor visitor) { + if (visitor.acceptField(field)) { + visitor.visitLeaf(this); + } + } + + @Override + public String toString(String f) { + return getClass().getSimpleName() + "(field=" + field + ", type=" + queryType + ")"; + } + + @Override + public boolean equals(Object obj) { + if (!sameClassAs(obj)) { + return false; + } + MultiBinaryRangeDocValuesQuery that = (MultiBinaryRangeDocValuesQuery) obj; + // queryType MUST be part of identity: the same field/range under different relations are + // different queries and must not collide in the query cache. + return numDims == that.numDims + && bytesPerDim == that.bytesPerDim + && queryType == that.queryType + && field.equals(that.field) + && Arrays.equals(queryPackedValue, that.queryPackedValue); + } + + @Override + public int hashCode() { + int h = classHash(); + h = 31 * h + field.hashCode(); + h = 31 * h + Arrays.hashCode(queryPackedValue); + h = 31 * h + numDims; + h = 31 * h + bytesPerDim; + h = 31 * h + queryType.hashCode(); + return h; + } +} diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java index 94e113d805e4..fce0cb8ee6dc 100644 --- a/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java @@ -194,10 +194,10 @@ public Query parse() throws SyntaxError { throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid range value: " + rangeValue, e); } return switch (criteria) { - case INTERSECTS -> rangeField.newIntersectsQuery(fieldName, range); - case WITHIN -> rangeField.newWithinQuery(fieldName, range); - case CONTAINS -> rangeField.newContainsQuery(fieldName, range); - case CROSSES -> rangeField.newCrossesQuery(fieldName, range); + case INTERSECTS -> rangeField.newIntersectsQuery(schemaField, range); + case WITHIN -> rangeField.newWithinQuery(schemaField, range); + case CONTAINS -> rangeField.newContainsQuery(schemaField, range); + case CROSSES -> rangeField.newCrossesQuery(schemaField, range); }; } else { throw new SolrException( diff --git a/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java b/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java index 0a13b967989d..f2ec9bfc2dad 100644 --- a/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java +++ b/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java @@ -207,6 +207,42 @@ public static Document toDocument( usedFields); } } + } else if (sfield != null + && sfield.multiValued() + && !forInPlaceUpdate + && sfield.getType().createsFieldsFromAllValues()) { + // Types that must combine a field's values into a single indexable field (e.g. all of a + // doc's ranges into one BinaryDocValues blob) receive them together. copyFields stay + // per-value. + List values = new ArrayList<>(field.getValueCount()); + for (Object v : field) { + if (v != null) { + values.add(v); + } + } + if (!values.isEmpty()) { + hasField = true; + for (IndexableField f : sfield.getType().createFieldsFromAllValues(sfield, values)) { + if (f != null) { + out.add(f); + } + } + usedFields.add(sfield.getName()); + used = true; + if (copyFields != null) { + for (Object v : values) { + addCopyFields( + schema, + v, + sfield.getType(), + copyFields, + forInPlaceUpdate, + uniqueKeyFieldName, + out, + usedFields); + } + } + } } else { Iterator it = field.iterator(); while (it.hasNext()) { diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml index 660acc1bfb1a..dcab0099f63b 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml @@ -105,6 +105,24 @@ + + + + + + + + + + + + + + + + + + id diff --git a/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java new file mode 100644 index 000000000000..1af2f79fa745 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java @@ -0,0 +1,217 @@ +/* + * 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.schema.numericrange; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import org.apache.lucene.document.BinaryDocValuesField; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.IntRange; +import org.apache.lucene.document.IntRangeDocValuesField; +import org.apache.lucene.document.RangeFieldQuery.QueryType; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.search.CollectorManager; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreMode; +import org.apache.lucene.search.SimpleCollector; +import org.apache.lucene.store.Directory; +import org.apache.lucene.tests.index.RandomIndexWriter; +import org.apache.lucene.tests.util.TestUtil; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.BytesRefBuilder; +import org.apache.lucene.util.FixedBitSet; +import org.apache.solr.SolrTestCase; +import org.junit.Test; + +/** + * Unit tests for {@link MultiBinaryRangeDocValuesQuery}, the binary docValues-backed range + * verifier. + * + *

Each doc indexes its range(s) both as BKD ({@link IntRange}) and as a single {@code + * BinaryDocValues} blob (all ranges concatenated, as {@code AbstractNumericRangeField} does), + * letting us assert the docValues verifier returns exactly the same documents as the trusted BKD + * query across all four relations - for both single-valued docs (one range) and multiValued docs + * (several ranges in one blob), where "any range matches" (not the union of ranges) is correct. + */ +public class MultiBinaryRangeDocValuesQueryTest extends SolrTestCase { + + private static final String FIELD = "r"; + + /** Encodes a 1-D query range the same way indexed ranges are encoded. */ + private static byte[] encode(int min, int max) { + BytesRef b = new IntRangeDocValuesField(FIELD, new int[] {min}, new int[] {max}).binaryValue(); + return Arrays.copyOfRange(b.bytes, b.offset, b.offset + b.length); + } + + private static Query dv(QueryType type, int min, int max) { + return new MultiBinaryRangeDocValuesQuery(FIELD, encode(min, max), 1, IntRange.BYTES, type); + } + + private static Query bkd(QueryType type, int min, int max) { + int[] mn = {min}; + int[] mx = {max}; + return switch (type) { + case INTERSECTS -> IntRange.newIntersectsQuery(FIELD, mn, mx); + case CONTAINS -> IntRange.newContainsQuery(FIELD, mn, mx); + case WITHIN -> IntRange.newWithinQuery(FIELD, mn, mx); + case CROSSES -> IntRange.newCrossesQuery(FIELD, mn, mx); + }; + } + + /** + * Adds a document whose (possibly multiple) ranges are indexed as BKD and as one BinaryDocValues + * blob holding every range concatenated (a single-range doc is just the one-range case). + */ + private static void addDoc(RandomIndexWriter w, int[]... ranges) throws IOException { + Document doc = new Document(); + BytesRefBuilder blob = new BytesRefBuilder(); + for (int[] r : ranges) { + int[] mn = {r[0]}; + int[] mx = {r[1]}; + doc.add(new IntRange(FIELD, mn, mx)); // BKD (ground truth) + blob.append(new IntRangeDocValuesField(FIELD, mn, mx).binaryValue()); // concat into one blob + } + doc.add(new BinaryDocValuesField(FIELD, blob.toBytesRef())); + w.addDocument(doc); + } + + private static FixedBitSet matches(IndexSearcher s, Query q) throws IOException { + int maxDoc = Math.max(1, s.getIndexReader().maxDoc()); + return s.search( + q, + new CollectorManager() { + @Override + public BitSetCollector newCollector() { + return new BitSetCollector(maxDoc); + } + + @Override + public FixedBitSet reduce(Collection collectors) { + FixedBitSet merged = new FixedBitSet(maxDoc); + for (BitSetCollector c : collectors) { + merged.or(c.bits); + } + return merged; + } + }); + } + + /** + * Collects matching doc ids (segment-local -> absolute) into a bitset, per CollectorManager. + */ + private static final class BitSetCollector extends SimpleCollector { + final FixedBitSet bits; + int base; + + BitSetCollector(int maxDoc) { + this.bits = new FixedBitSet(maxDoc); + } + + @Override + public void collect(int doc) { + bits.set(base + doc); + } + + @Override + protected void doSetNextReader(LeafReaderContext ctx) { + base = ctx.docBase; + } + + @Override + public ScoreMode scoreMode() { + return ScoreMode.COMPLETE_NO_SCORES; + } + } + + @Test + public void testMultiValuedPerRangeSemantics() throws Exception { + try (Directory dir = newDirectory()) { + RandomIndexWriter w = new RandomIndexWriter(random(), dir); + addDoc(w, new int[] {1, 10}, new int[] {5, 20}); // doc: two overlapping ranges + addDoc(w, new int[] {0, 5}, new int[] {100, 200}); // doc: two disjoint ranges + addDoc(w, new int[] {130, 160}); // doc: single range + try (IndexReader reader = w.getReader()) { + w.close(); + IndexSearcher searcher = newSearcher(reader); + + // CONTAINS [1,15]: neither individual range of the first doc contains it; the *union* + // [1,20] would, but ranges are never unioned -> zero matches. + assertEquals(0, searcher.count(dv(QueryType.CONTAINS, 1, 15))); + assertEquals(0, searcher.count(bkd(QueryType.CONTAINS, 1, 15))); + + // CONTAINS [3,150]: the second doc's [0,5] and [100,200] each satisfy one half-open bound, + // so a decomposition would falsely match -- native per-range matching must not. + assertEquals(0, searcher.count(dv(QueryType.CONTAINS, 3, 150))); + + // CONTAINS [1,10]: first doc's [1,10] contains it (any range suffices). + assertEquals(1, searcher.count(dv(QueryType.CONTAINS, 1, 10))); + + // INTERSECTS [12,14]: first doc's [5,20] overlaps it. + assertEquals(1, searcher.count(dv(QueryType.INTERSECTS, 12, 14))); + + // WITHIN [0,25]: first doc ([1,10]) and second doc ([0,5]) each have a range within it. + assertEquals(2, searcher.count(dv(QueryType.WITHIN, 0, 25))); + + // Every case must agree with the BKD ground truth. + for (QueryType t : QueryType.values()) { + for (int[] q : new int[][] {{1, 15}, {3, 150}, {1, 10}, {12, 14}, {0, 25}}) { + assertEquals( + "relation " + t + " query [" + q[0] + " TO " + q[1] + "]", + matches(searcher, bkd(t, q[0], q[1])), + matches(searcher, dv(t, q[0], q[1]))); + } + } + } + } + } + + @Test + public void testParityWithBkdRandom() throws Exception { + try (Directory dir = newDirectory()) { + RandomIndexWriter w = new RandomIndexWriter(random(), dir); + int numDocs = atLeast(200); + for (int i = 0; i < numDocs; i++) { + int numRanges = TestUtil.nextInt(random(), 1, 3); // single- and multi-valued docs + int[][] ranges = new int[numRanges][2]; + for (int r = 0; r < numRanges; r++) { + int a = TestUtil.nextInt(random(), -50, 50); + int b = TestUtil.nextInt(random(), a, 50); // a <= b + ranges[r] = new int[] {a, b}; + } + addDoc(w, ranges); + } + try (IndexReader reader = w.getReader()) { + w.close(); + IndexSearcher searcher = newSearcher(reader); + + for (int iter = 0; iter < 200; iter++) { + int qa = TestUtil.nextInt(random(), -55, 55); + int qb = TestUtil.nextInt(random(), qa, 55); + for (QueryType t : QueryType.values()) { + assertEquals( + "relation " + t + " query [" + qa + " TO " + qb + "]", + matches(searcher, bkd(t, qa, qb)), + matches(searcher, dv(t, qa, qb))); + } + } + } + } + } +} diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java new file mode 100644 index 000000000000..b176faad6e6c --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java @@ -0,0 +1,274 @@ +/* + * 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.search.numericrange; + +import java.util.List; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Integration tests over the {@code schema-numericrange.xml} verifying that the docValues-enabled + * range fields return exactly the same results as their indexed-only counterparts. + * + *

The docValues-enabled fields wrap their BKD query in a Lucene {@link + * org.apache.lucene.search.IndexOrDocValuesQuery}. These tests assert that this produces identical + * matches to the direct BKD query across intersects / contains / single-bound / multi-dimensional + * queries, inside a selective conjunction, and for the docValues-only (non-indexed) field. + */ +public class NumericRangeDocValuesTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema-numericrange.xml"); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + clearIndex(); + assertU(commit()); + } + + /** + * Index the same int range into the indexed-only, indexed+docValues, and docValues-only fields. + */ + private void addIntDoc(String id, String range) { + assertU( + adoc( + "id", id, + "price_range", range, + "price_range_dv", range, + "price_range_dvonly", range)); + } + + private List get1dIntRangeFieldNames() { + return List.of("price_range", "price_range_dv", "price_range_dvonly"); + } + + @Test + public void testIntersectsParity() { + addIntDoc("1", "[100 TO 200]"); + addIntDoc("2", "[150 TO 250]"); + addIntDoc("3", "[50 TO 80]"); + assertU(commit()); + + // intersects [120 TO 180] -> docs 1 and 2, for every field variant + for (String field : get1dIntRangeFieldNames()) { + assertQ( + "intersects on " + field, + req("q", "{!numericRange criteria=intersects field=" + field + "}[120 TO 180]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + } + + @Test + public void testContainsParity() { + addIntDoc("1", "[100 TO 200]"); // fully contains [120 TO 180] + addIntDoc("2", "[130 TO 160]"); // within, does NOT contain + addIntDoc("3", "[150 TO 250]"); // partial overlap, does NOT contain + assertU(commit()); + + for (String field : get1dIntRangeFieldNames()) { + assertQ( + "contains on " + field, + req("q", "{!numericRange criteria=contains field=" + field + "}[120 TO 180]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + } + + @Test + public void testStandardSyntaxContainsParity() { + addIntDoc("1", "[100 TO 200]"); // contains [120 TO 180] + addIntDoc("2", "[130 TO 160]"); // no + assertU(commit()); + + for (String field : get1dIntRangeFieldNames()) { + assertQ( + "standard-syntax contains on " + field, + req("q", field + ":[120 TO 180]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + } + + @Test + public void testSingleBoundParity() { + // A single bound (field:150) is a degenerate range [150,150]; contains == intersects for a + // point. + addIntDoc("1", "[100 TO 200]"); // contains 150 + addIntDoc("2", "[150 TO 150]"); // contains 150 + addIntDoc("3", "[100 TO 149]"); // no (max below 150) + addIntDoc("4", "[151 TO 300]"); // no (min above 150) + assertU(commit()); + + for (String f : get1dIntRangeFieldNames()) { + assertQ( + "single-bound on " + f, + req("q", f + ":150"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + } + + @Test + public void test2DContainsParity() { + assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]", "bbox_dv", "[0,0 TO 10,10]")); // contains + assertU(adoc("id", "2", "bbox", "[4,4 TO 6,6]", "bbox_dv", "[4,4 TO 6,6]")); // does not contain + assertU(commit()); + + for (String f : new String[] {"bbox", "bbox_dv"}) { + assertQ( + "2D contains on " + f, + req("q", "{!numericRange criteria=contains field=" + f + "}[3,3 TO 7,7]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + } + + @Test + public void testSelectiveConjunction() { + // Exercises IndexOrDocValuesQuery inside a conjunction: the selective title clause leads and + // the + // docValues range clause verifies. Must match the equivalent non-docValues query exactly. + assertU( + adoc( + "id", + "1", + "title", + "match", + "price_range", + "[100 TO 200]", + "price_range_dv", + "[100 TO 200]")); + assertU( + adoc( + "id", + "2", + "title", + "match", + "price_range", + "[300 TO 400]", + "price_range_dv", + "[300 TO 400]")); + assertU( + adoc( + "id", + "3", + "title", + "other", + "price_range", + "[100 TO 200]", + "price_range_dv", + "[100 TO 200]")); + assertU(commit()); + + for (String f : new String[] {"price_range", "price_range_dv"}) { + assertQ( + "selective AND contains on " + f, + req("q", "+title:match +" + f + ":[120 TO 180]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + } + + @Test + public void testLongParity() { + assertU(adoc("id", "1", "long_range", "[100 TO 200]", "long_range_dv", "[100 TO 200]")); + assertU(adoc("id", "2", "long_range", "[150 TO 250]", "long_range_dv", "[150 TO 250]")); + assertU(adoc("id", "3", "long_range", "[50 TO 80]", "long_range_dv", "[50 TO 80]")); + assertU(commit()); + + for (String f : new String[] {"long_range", "long_range_dv"}) { + assertQ( + "long intersects on " + f, + req("q", "{!numericRange criteria=intersects field=" + f + "}[120 TO 180]"), + "//result[@numFound='2']"); + } + } + + @Test + public void testFloatIntersectsParity() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]", "float_range_dv", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range", "[1.5 TO 3.0]", "float_range_dv", "[1.5 TO 3.0]")); + assertU(adoc("id", "3", "float_range", "[5.0 TO 6.0]", "float_range_dv", "[5.0 TO 6.0]")); + assertU(commit()); + + for (String f : new String[] {"float_range", "float_range_dv"}) { + assertQ( + "float intersects on " + f, + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.2 TO 1.8]"), + "//result[@numFound='2']"); + } + } + + @Test + public void testDoubleContainsParity() { + // doc 1 fully contains [2.0 TO 4.0]; doc 2 is within it (does not contain) + assertU(adoc("id", "1", "double_range", "[1.0 TO 5.0]", "double_range_dv", "[1.0 TO 5.0]")); + assertU(adoc("id", "2", "double_range", "[2.5 TO 3.5]", "double_range_dv", "[2.5 TO 3.5]")); + assertU(commit()); + + for (String f : new String[] {"double_range", "double_range_dv"}) { + assertQ( + "double contains on " + f, + req("q", "{!numericRange criteria=contains field=" + f + "}[2.0 TO 4.0]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + } + + @Test + public void testSortingUnsupportedEvenWithDocValues() { + addIntDoc("1", "[100 TO 200]"); + assertU(commit()); + // Range fields don't implement sorting: there's no canonical key for ordering intervals (by + // min? max? width? midpoint?), multi-dimensional ranges (bbox/cube/tesseract) have no sensible + // order at all, and the packed BINARY docValues isn't a sortable scalar. Enabling docValues + // does + // not change that -- getSortField still throws. + assertQEx( + "sorting on a range field must fail even with docValues", + "Cannot sort on", + req("q", "*:*", "sort", "price_range_dv asc"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testQueryFacetingWithDocValues() { + // Range fields support facet.query (count docs matching a range query) -- the realistic way to + // facet ranges in Solr. (facet.field / value faceting is NOT supported: range docValues are + // BINARY, not the SortedSet docValues that term-faceting needs) + addIntDoc("1", "[100 TO 200]"); + addIntDoc("2", "[150 TO 250]"); + addIntDoc("3", "[50 TO 80]"); + assertU(commit()); + + assertQ( + req( + "q", "*:*", + "facet", "true", + "facet.query", + "{!numericRange criteria=intersects field=price_range_dv key=pr}[120 TO 180]"), + "//lst[@name='facet_queries']/int[@name='pr'][.='2']"); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java new file mode 100644 index 000000000000..084e5c12c031 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java @@ -0,0 +1,187 @@ +/* + * 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.search.numericrange; + +import java.util.ArrayList; +import java.util.List; +import org.apache.solr.SolrTestCaseJ4; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Integration tests for multiValued range fields with docValues (backed by SortedSet docValues). + * + *

A document may hold several ranges. The docValues verifier ({@code + * MultiBinaryRangeDocValuesQuery}) matches a document when any of its ranges satisfies the + * relation -- the same "any value matches" semantics as multiValued Points (BKD). Each test indexes + * the same ranges into a multiValued BKD-only field ({@code *_multi}) and a multiValued docValues + * field ({@code *_mv_dv}) and asserts they return identical counts, so the docValues path is + * checked against the trusted BKD path. + * + *

That these fields load at all also verifies that {@code multiValued=true docValues=true} is + * now accepted for range field types. + */ +public class NumericRangeMultiValuedDocValuesTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema-numericrange.xml"); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + clearIndex(); + assertU(commit()); + } + + /** Indexes the given ranges into both a BKD-only and a docValues multiValued field. */ + private void addMvDoc(String id, String bkdField, String dvField, String... ranges) { + List fieldsAndValues = new ArrayList<>(); + fieldsAndValues.add("id"); + fieldsAndValues.add(id); + for (String r : ranges) { + fieldsAndValues.add(bkdField); + fieldsAndValues.add(r); + fieldsAndValues.add(dvField); + fieldsAndValues.add(r); + } + assertU(adoc(fieldsAndValues.toArray(new String[0]))); + } + + /** Asserts the BKD field and the docValues field return the same (expected) count. */ + private void assertSameCount( + String criteria, String range, String bkdField, String dvField, int expected) { + for (String f : new String[] {bkdField, dvField}) { + assertQ( + "criteria=" + criteria + " field=" + f + " " + range, + req("q", "{!numericRange criteria=" + criteria + " field=" + f + "}" + range), + "//result[@numFound='" + expected + "']"); + } + } + + @Test + public void testContainsPerRangeNotUnion() { + // doc 1: two overlapping ranges whose *union* is [1,20] + addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); + // doc 2: two disjoint ranges + addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); + assertU(commit()); + + // Neither individual range of doc 1 contains [1,15] + assertSameCount("contains", "[1 TO 15]", "price_range_multi", "price_range_mv_dv", 0); + + // doc 2's [0,5] and [100,200] each satisfy one half-open bound of a CONTAINS([3,150]) split, so + // a decomposition would falsely match -- native per-range matching must not. + assertSameCount("contains", "[3 TO 150]", "price_range_multi", "price_range_mv_dv", 0); + + // doc 1's [1,10] actually contains [1,10] (any range suffices). + assertSameCount("contains", "[1 TO 10]", "price_range_multi", "price_range_mv_dv", 1); + } + + @Test + public void testAllRelationsMultiValued() { + addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); + addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); + addMvDoc("3", "price_range_multi", "price_range_mv_dv", "[130 TO 160]"); + assertU(commit()); + + // INTERSECTS [12,14]: only doc 1 (via its [5,20] range). + assertSameCount("intersects", "[12 TO 14]", "price_range_multi", "price_range_mv_dv", 1); + // WITHIN [0,25]: doc 1 ([1,10]) and doc 2 ([0,5]) each have a range within it. + assertSameCount("within", "[0 TO 25]", "price_range_multi", "price_range_mv_dv", 2); + // CROSSES [8,50]: doc 1 crosses (its ranges overlap but aren't within); docs 2,3 are disjoint. + assertSameCount("crosses", "[8 TO 50]", "price_range_multi", "price_range_mv_dv", 1); + } + + @Test + public void test2dLongMultiValued() { + addMvDoc( + "1", + "long_range_multi_2d", + "long_range_mv_dv_2d", + "[100,100 TO 200,200]", + "[500,500 TO 600,600]"); + addMvDoc("2", "long_range_multi_2d", "long_range_mv_dv_2d", "[1,3000000000 TO 5,4000000000]"); + assertU(commit()); + + assertSameCount( + "intersects", "[150,100 TO 550,150]", "long_range_multi_2d", "long_range_mv_dv_2d", 1); + assertSameCount( + "intersects", + "[2,3500000000 TO 5,3600000000]", + "long_range_multi_2d", + "long_range_mv_dv_2d", + 1); + } + + @Test + public void testFloatMultiValued() { + addMvDoc("1", "float_range_multi", "float_range_mv_dv", "[1.0 TO 2.0]", "[5.0 TO 6.0]"); + addMvDoc("2", "float_range_multi", "float_range_mv_dv", "[10.0 TO 20.0]"); + assertU(commit()); + + assertSameCount("intersects", "[1.5 TO 1.8]", "float_range_multi", "float_range_mv_dv", 1); + assertSameCount("within", "[0.0 TO 100.0]", "float_range_multi", "float_range_mv_dv", 2); + } + + @Test + public void test2dDoubleMultiValued() { + addMvDoc( + "1", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "[1.0,-3.1 TO 5.0,-1.1]", + "[-5.5,10.0 TO 1.5,15.0]"); + addMvDoc("2", "double_range_multi_2d", "double_range_mv_dv_2d", "[-10,2.5 TO 0,3.5]"); + assertU(commit()); + + // Ranges per doc (each a 2D bounding box): + // doc1 A: D1 [1.0, 5.0] D2 [-3.1, -1.1] + // doc1 B: D1 [-5.5, 1.5] D2 [10.0, 15.0] + // doc2 C: D1 [-10.0, 0.0] D2 [2.5, 3.5] + + // CONTAINS: a doc range must wrap the query box in *both* dims. doc1's A wraps + // [2,4]x[-3.0,-1.15] + // (1<=2, 5>=4 and -3.1<=-3.0, -1.1>=-1.15); doc2's C does not (its D1 max 0 < 4). -> only doc + // 1. + assertSameCount( + "contains", "[2.0,-3.0 TO 4.0,-1.15]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); + + // WITHIN: a doc range must fit *inside* the query box in both dims. doc1's A fits inside + // [0,6]x[-4,-1]; doc2's C sticks out on D1 (min -10 < 0). -> only doc 1. + assertSameCount( + "within", "[0.0,-4.0 TO 6.0,-1.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); + + // INTERSECTS: overlap in both dims is enough. The central box [-2,3]x[-2,3] overlaps doc1's A + // and + // doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. + assertSameCount( + "intersects", + "[-2.0,-2.0 TO 3.0,3.0]", + "double_range_multi_2d", + "double_range_mv_dv_2d", + 2); + + // CROSSES = intersects AND NOT within. For [-1,6]x[-4,3], doc1's A is fully *within* it (so + // does + // not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on D1 + // (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. + assertSameCount( + "crosses", "[-1.0,-4.0 TO 6.0,3.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); + } +} diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc index 7a9508e083f8..d28820458aba 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc @@ -51,17 +51,17 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |IntPointField |Integer field (32-bit signed integer). This class encodes int values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. -|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. |LatLonPointSpatialField |A latitude/longitude coordinate pair; possibly multi-valued for multiple points. Usually it's specified as "lat,lon" order with a comma. See the section xref:query-guide:spatial-search.adoc[] for more information. |LongPointField |Long field (64-bit signed integer). This class encodes foo values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. -|LongRangeField |Stores single or multi-dimensional ranges of long integers, using syntax like `[1000000000 TO 4000000000]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|LongRangeField |Stores single or multi-dimensional ranges of long integers, using syntax like `[1000000000 TO 4000000000]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. -|DoubleRangeField |Stores single or multi-dimensional ranges of double-precision floating-point numbers, using syntax like `[1.5 TO 2.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|DoubleRangeField |Stores single or multi-dimensional ranges of double-precision floating-point numbers, using syntax like `[1.5 TO 2.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. -|FloatRangeField |Stores single or multi-dimensional ranges of floating-point numbers, using syntax like `[1.5 TO 4.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|FloatRangeField |Stores single or multi-dimensional ranges of floating-point numbers, using syntax like `[1.5 TO 4.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. |NestPathField | Specialized field type storing enhanced information, when xref:indexing-nested-documents.adoc#schema-configuration[working with nested documents]. From f78beaa703481b49406b4836b12e086546d57efe Mon Sep 17 00:00:00 2001 From: Sonu Sharma Date: Sat, 18 Jul 2026 22:29:02 +0530 Subject: [PATCH 2/4] SOLR-18201: docValues based optimization for range fields --- .../apache/solr/bench/search/RangeSearch.java | 16 +++++++--------- .../numericrange/AbstractNumericRangeField.java | 4 ++-- .../numericrange/NumericRangeDocValuesTest.java | 8 +++----- .../NumericRangeMultiValuedDocValuesTest.java | 15 ++++++--------- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java index b52a59cca00a..95456badb8c7 100644 --- a/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java +++ b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java @@ -54,14 +54,14 @@ * single-valued ({@code r_ir}/{@code r_ir_dv}) and multiValued ({@code r_mv_ir}/{@code * r_mv_ir_dv}). A central window matches ~half the docs, so the range clause is costly to * materialize but cheap to verify per-doc. It is AND-ed with a selective term filter whose - * doc-frequency is controlled, so we know -- and the {@code [range-bench]} setup log confirms -- - * which {@link org.apache.lucene.search.IndexOrDocValuesQuery} branch runs. + * doc-frequency is controlled, so we know and the {@code [range-bench]} setup log confirms, which + * {@link org.apache.lucene.search.IndexOrDocValuesQuery} branch runs. * *

Params: {@code criteria} (the {@code {!numericRange criteria=...}} relation), {@code field} - * (the DV / non-DV pairs), and {@code scenario} -- the lead's selectivity vs the {@code - * rangeCost/8} cutover: {@code selective_dv} (~{@value #SELECTIVE_LEAD_DF} docs -> docValues - * branch), {@code broad_points} (~{@value #BROAD_LEAD_DF} docs -> points branch), {@code - * none_points} (standalone -> points). + * (the DV / non-DV pairs), and {@code scenario} - the lead's selectivity vs the {@code rangeCost/8} + * cutover: {@code selective_dv} (~{@value #SELECTIVE_LEAD_DF} docs -> docValues branch), {@code + * broad_points} (~{@value #BROAD_LEAD_DF} docs -> points branch), {@code none_points} + * (standalone -> points). */ @Fork(value = 1) @Warmup(time = 5, iterations = 5) @@ -241,9 +241,7 @@ QueryRequest query() { @Benchmark public void rangeQuery(Blackhole blackhole, BenchState benchState, SolrBenchState solrBenchState) throws SolrServerException, IOException { - // Sink the result exactly once, via Blackhole (do NOT also return it): mixing a return-value - // sink and a Blackhole sink can resolve to different JMH blackhole modes, which makes the - // r_ir vs r_ir_dv comparison apples-to-oranges. + // Sink the result exactly once, via Blackhole blackhole.consume(benchState.query().process(solrBenchState.client, COLLECTION)); } } diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java index a679356ed18f..9bc3f6d8eea7 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -141,7 +141,7 @@ protected Pattern getSingleBoundPattern() { @Override protected boolean enableDocValuesByDefault() { - // DocValues are supported for both single- and multiValued range fields, but are opt-in: they + // DocValues are supported for both single and multiValued range fields, but are opt-in: they // add an extra (binary) docValues field, and range docValues only help queries that combine // the range clause with a more selective clause, so we don't enable them by default. return false; @@ -171,7 +171,7 @@ protected void init(IndexSchema schema, Map args) { @Override protected void checkSupportsDocValues() { - // DocValues are supported for both single- and multiValued range fields (backed by binary + // DocValues are supported for both single and multiValued range fields (backed by binary // docValues). } diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java index b176faad6e6c..784c842be208 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java @@ -148,8 +148,7 @@ public void test2DContainsParity() { @Test public void testSelectiveConjunction() { // Exercises IndexOrDocValuesQuery inside a conjunction: the selective title clause leads and - // the - // docValues range clause verifies. Must match the equivalent non-docValues query exactly. + // the docValues range clause verifies. Must match the equivalent non-docValues query exactly. assertU( adoc( "id", @@ -244,8 +243,7 @@ public void testSortingUnsupportedEvenWithDocValues() { // Range fields don't implement sorting: there's no canonical key for ordering intervals (by // min? max? width? midpoint?), multi-dimensional ranges (bbox/cube/tesseract) have no sensible // order at all, and the packed BINARY docValues isn't a sortable scalar. Enabling docValues - // does - // not change that -- getSortField still throws. + // does not change that, getSortField still throws. assertQEx( "sorting on a range field must fail even with docValues", "Cannot sort on", @@ -255,7 +253,7 @@ public void testSortingUnsupportedEvenWithDocValues() { @Test public void testQueryFacetingWithDocValues() { - // Range fields support facet.query (count docs matching a range query) -- the realistic way to + // Range fields support facet.query (count docs matching a range query), the realistic way to // facet ranges in Solr. (facet.field / value faceting is NOT supported: range docValues are // BINARY, not the SortedSet docValues that term-faceting needs) addIntDoc("1", "[100 TO 200]"); diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java index 084e5c12c031..7222318c5808 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java @@ -27,7 +27,7 @@ * *

A document may hold several ranges. The docValues verifier ({@code * MultiBinaryRangeDocValuesQuery}) matches a document when any of its ranges satisfies the - * relation -- the same "any value matches" semantics as multiValued Points (BKD). Each test indexes + * relation, the same "any value matches" semantics as multiValued Points (BKD). Each test indexes * the same ranges into a multiValued BKD-only field ({@code *_multi}) and a multiValued docValues * field ({@code *_mv_dv}) and asserts they return identical counts, so the docValues path is * checked against the trusted BKD path. @@ -156,9 +156,8 @@ public void test2dDoubleMultiValued() { // doc2 C: D1 [-10.0, 0.0] D2 [2.5, 3.5] // CONTAINS: a doc range must wrap the query box in *both* dims. doc1's A wraps - // [2,4]x[-3.0,-1.15] - // (1<=2, 5>=4 and -3.1<=-3.0, -1.1>=-1.15); doc2's C does not (its D1 max 0 < 4). -> only doc - // 1. + // [2,4]x[-3.0,-1.15] (1<=2, 5>=4 and -3.1<=-3.0, -1.1>=-1.15). + // doc2's C does not (its D1 max 0 < 4). -> only doc 1. assertSameCount( "contains", "[2.0,-3.0 TO 4.0,-1.15]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); @@ -168,8 +167,7 @@ public void test2dDoubleMultiValued() { "within", "[0.0,-4.0 TO 6.0,-1.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); // INTERSECTS: overlap in both dims is enough. The central box [-2,3]x[-2,3] overlaps doc1's A - // and - // doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. + // and doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. assertSameCount( "intersects", "[-2.0,-2.0 TO 3.0,3.0]", @@ -178,9 +176,8 @@ public void test2dDoubleMultiValued() { 2); // CROSSES = intersects AND NOT within. For [-1,6]x[-4,3], doc1's A is fully *within* it (so - // does - // not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on D1 - // (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. + // does not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on + // D1 (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. assertSameCount( "crosses", "[-1.0,-4.0 TO 6.0,3.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); } From 54a9c24cd01422d9209cdc6378f03b2dc4bb6603 Mon Sep 17 00:00:00 2001 From: Sonu Sharma Date: Wed, 22 Jul 2026 19:14:16 +0530 Subject: [PATCH 3/4] review comments resolution --- ...RangeSearch.java => RangeFieldSearch.java} | 2 +- .../org/apache/solr/schema/FieldType.java | 8 +- .../AbstractNumericRangeField.java | 29 +- .../MultiBinaryRangeDocValuesQuery.java | 9 +- .../apache/solr/update/DocumentBuilder.java | 2 +- .../collection1/conf/schema-numericrange.xml | 6 +- .../MultiBinaryRangeDocValuesQueryTest.java | 217 ------------- .../NumericRangeDocValuesTest.java | 301 ++++++++++-------- .../NumericRangeMultiValuedDocValuesTest.java | 184 ----------- .../NumericRangeQParserPluginDoubleTest.java | 118 ++++--- .../NumericRangeQParserPluginFloatTest.java | 118 ++++--- .../NumericRangeQParserPluginIntTest.java | 191 ++++++----- .../NumericRangeQParserPluginLongTest.java | 133 ++++---- .../pages/field-types-included-with-solr.adoc | 8 +- 14 files changed, 548 insertions(+), 778 deletions(-) rename solr/benchmark/src/java/org/apache/solr/bench/search/{RangeSearch.java => RangeFieldSearch.java} (99%) delete mode 100644 solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java delete mode 100644 solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java diff --git a/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeFieldSearch.java similarity index 99% rename from solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java rename to solr/benchmark/src/java/org/apache/solr/bench/search/RangeFieldSearch.java index 95456badb8c7..078f3570edb6 100644 --- a/solr/benchmark/src/java/org/apache/solr/bench/search/RangeSearch.java +++ b/solr/benchmark/src/java/org/apache/solr/bench/search/RangeFieldSearch.java @@ -67,7 +67,7 @@ @Warmup(time = 5, iterations = 5) @Measurement(time = 5, iterations = 5) @Threads(value = 1) -public class RangeSearch { +public class RangeFieldSearch { static final String COLLECTION = "c1"; diff --git a/solr/core/src/java/org/apache/solr/schema/FieldType.java b/solr/core/src/java/org/apache/solr/schema/FieldType.java index d57965d85b41..791ebe559438 100644 --- a/solr/core/src/java/org/apache/solr/schema/FieldType.java +++ b/solr/core/src/java/org/apache/solr/schema/FieldType.java @@ -358,15 +358,15 @@ public List createFields(SchemaField field, Object value) { * calls {@link #createFieldsFromAllValues(SchemaField, Collection)} once per (multiValued) field * instead of {@link #createFields(SchemaField, Object)} per value. */ - public boolean createsFieldsFromAllValues() { + public boolean shouldCreateFieldsFromAllValues() { return false; } /** * Builds the indexable fields for all values of {@code field} in a single document at - * once. Only invoked when {@link #createsFieldsFromAllValues()} returns {@code true}; the default - * simply concatenates {@link #createFields(SchemaField, Object)} over each value, preserving - * per-value behavior. + * once. Only invoked when {@link #shouldCreateFieldsFromAllValues()} returns {@code true}; the + * default simply concatenates {@link #createFields(SchemaField, Object)} over each value, + * preserving per-value behavior. * * @param field the schema field * @param values all (non-null) values for this field in the current document diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java index 9bc3f6d8eea7..f13628c3882d 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -141,20 +141,25 @@ protected Pattern getSingleBoundPattern() { @Override protected boolean enableDocValuesByDefault() { - // DocValues are supported for both single and multiValued range fields, but are opt-in: they - // add an extra (binary) docValues field, and range docValues only help queries that combine - // the range clause with a more selective clause, so we don't enable them by default. - return false; + // DocValues are supported for both single and multiValued range fields, enabled by default. + return true; + } + + @Override + protected void setArgs(IndexSchema schema, Map args) { + // A range field's docValues are an opaque packed BINARY blob not the external "[min TO max]" form, so + // they can't stand in for the stored value: useDocValuesAsStored would make fl=* return that + // raw blob. It otherwise defaults to off here and reject an explicit + // useDocValuesAsStored="true" rather than silently ignoring what was requested. + args.putIfAbsent("useDocValuesAsStored", "false"); + super.setArgs(schema, args); + restrictProps(USE_DOCVALUES_AS_STORED); } @Override protected void init(IndexSchema schema, Map args) { super.init(schema, args); - // Force useDocValuesAsStored off; it otherwise defaults on for schema, which - // would make fl=* responses fail on a docValues-enabled range field. - properties &= ~USE_DOCVALUES_AS_STORED; - String numDimensionsStr = args.remove("numDimensions"); if (numDimensionsStr != null) { numDimensions = Integer.parseInt(numDimensionsStr); @@ -202,7 +207,7 @@ public List createFields(SchemaField field, Object value) { } @Override - public boolean createsFieldsFromAllValues() { + public boolean shouldCreateFieldsFromAllValues() { // multiValued docValues range fields pack every range of a document into ONE BinaryDocValues // blob (flat, no dictionary), so the type needs all values together to build it. return true; @@ -453,10 +458,8 @@ public Query getFieldQuery(QParser parser, SchemaField field, String externalVal + ")"); } - // A single bound is the degenerate range [p,p]; "contains p" is equivalent to - // "intersects [p,p]", so route it through the intersects path to pick up the docValues - // optimization for point queries. - return newIntersectsQuery(field, singleBoundRange); + // A single bound is the degenerate range [p,p]. + return newContainsQuery(field, singleBoundRange); } throw new SolrException( diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java b/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java index f8404ebeba39..74608b011128 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQuery.java @@ -141,7 +141,14 @@ public void visit(QueryVisitor visitor) { @Override public String toString(String f) { - return getClass().getSimpleName() + "(field=" + field + ", type=" + queryType + ")"; + return getClass().getSimpleName() + + "(field=" + + field + + ", type=" + + queryType + + ", value=" + + Arrays.toString(queryPackedValue) + + ")"; } @Override diff --git a/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java b/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java index f2ec9bfc2dad..390d86161812 100644 --- a/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java +++ b/solr/core/src/java/org/apache/solr/update/DocumentBuilder.java @@ -210,7 +210,7 @@ public static Document toDocument( } else if (sfield != null && sfield.multiValued() && !forInPlaceUpdate - && sfield.getType().createsFieldsFromAllValues()) { + && sfield.getType().shouldCreateFieldsFromAllValues()) { // Types that must combine a field's values into a single indexable field (e.g. all of a // doc's ranges into one BinaryDocValues blob) receive them together. copyFields stay // per-value. diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml index dcab0099f63b..43ff7db2fbd5 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml @@ -112,10 +112,10 @@ - - + + - + diff --git a/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java deleted file mode 100644 index 1af2f79fa745..000000000000 --- a/solr/core/src/test/org/apache/solr/schema/numericrange/MultiBinaryRangeDocValuesQueryTest.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * 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.schema.numericrange; - -import java.io.IOException; -import java.util.Arrays; -import java.util.Collection; -import org.apache.lucene.document.BinaryDocValuesField; -import org.apache.lucene.document.Document; -import org.apache.lucene.document.IntRange; -import org.apache.lucene.document.IntRangeDocValuesField; -import org.apache.lucene.document.RangeFieldQuery.QueryType; -import org.apache.lucene.index.IndexReader; -import org.apache.lucene.index.LeafReaderContext; -import org.apache.lucene.search.CollectorManager; -import org.apache.lucene.search.IndexSearcher; -import org.apache.lucene.search.Query; -import org.apache.lucene.search.ScoreMode; -import org.apache.lucene.search.SimpleCollector; -import org.apache.lucene.store.Directory; -import org.apache.lucene.tests.index.RandomIndexWriter; -import org.apache.lucene.tests.util.TestUtil; -import org.apache.lucene.util.BytesRef; -import org.apache.lucene.util.BytesRefBuilder; -import org.apache.lucene.util.FixedBitSet; -import org.apache.solr.SolrTestCase; -import org.junit.Test; - -/** - * Unit tests for {@link MultiBinaryRangeDocValuesQuery}, the binary docValues-backed range - * verifier. - * - *

Each doc indexes its range(s) both as BKD ({@link IntRange}) and as a single {@code - * BinaryDocValues} blob (all ranges concatenated, as {@code AbstractNumericRangeField} does), - * letting us assert the docValues verifier returns exactly the same documents as the trusted BKD - * query across all four relations - for both single-valued docs (one range) and multiValued docs - * (several ranges in one blob), where "any range matches" (not the union of ranges) is correct. - */ -public class MultiBinaryRangeDocValuesQueryTest extends SolrTestCase { - - private static final String FIELD = "r"; - - /** Encodes a 1-D query range the same way indexed ranges are encoded. */ - private static byte[] encode(int min, int max) { - BytesRef b = new IntRangeDocValuesField(FIELD, new int[] {min}, new int[] {max}).binaryValue(); - return Arrays.copyOfRange(b.bytes, b.offset, b.offset + b.length); - } - - private static Query dv(QueryType type, int min, int max) { - return new MultiBinaryRangeDocValuesQuery(FIELD, encode(min, max), 1, IntRange.BYTES, type); - } - - private static Query bkd(QueryType type, int min, int max) { - int[] mn = {min}; - int[] mx = {max}; - return switch (type) { - case INTERSECTS -> IntRange.newIntersectsQuery(FIELD, mn, mx); - case CONTAINS -> IntRange.newContainsQuery(FIELD, mn, mx); - case WITHIN -> IntRange.newWithinQuery(FIELD, mn, mx); - case CROSSES -> IntRange.newCrossesQuery(FIELD, mn, mx); - }; - } - - /** - * Adds a document whose (possibly multiple) ranges are indexed as BKD and as one BinaryDocValues - * blob holding every range concatenated (a single-range doc is just the one-range case). - */ - private static void addDoc(RandomIndexWriter w, int[]... ranges) throws IOException { - Document doc = new Document(); - BytesRefBuilder blob = new BytesRefBuilder(); - for (int[] r : ranges) { - int[] mn = {r[0]}; - int[] mx = {r[1]}; - doc.add(new IntRange(FIELD, mn, mx)); // BKD (ground truth) - blob.append(new IntRangeDocValuesField(FIELD, mn, mx).binaryValue()); // concat into one blob - } - doc.add(new BinaryDocValuesField(FIELD, blob.toBytesRef())); - w.addDocument(doc); - } - - private static FixedBitSet matches(IndexSearcher s, Query q) throws IOException { - int maxDoc = Math.max(1, s.getIndexReader().maxDoc()); - return s.search( - q, - new CollectorManager() { - @Override - public BitSetCollector newCollector() { - return new BitSetCollector(maxDoc); - } - - @Override - public FixedBitSet reduce(Collection collectors) { - FixedBitSet merged = new FixedBitSet(maxDoc); - for (BitSetCollector c : collectors) { - merged.or(c.bits); - } - return merged; - } - }); - } - - /** - * Collects matching doc ids (segment-local -> absolute) into a bitset, per CollectorManager. - */ - private static final class BitSetCollector extends SimpleCollector { - final FixedBitSet bits; - int base; - - BitSetCollector(int maxDoc) { - this.bits = new FixedBitSet(maxDoc); - } - - @Override - public void collect(int doc) { - bits.set(base + doc); - } - - @Override - protected void doSetNextReader(LeafReaderContext ctx) { - base = ctx.docBase; - } - - @Override - public ScoreMode scoreMode() { - return ScoreMode.COMPLETE_NO_SCORES; - } - } - - @Test - public void testMultiValuedPerRangeSemantics() throws Exception { - try (Directory dir = newDirectory()) { - RandomIndexWriter w = new RandomIndexWriter(random(), dir); - addDoc(w, new int[] {1, 10}, new int[] {5, 20}); // doc: two overlapping ranges - addDoc(w, new int[] {0, 5}, new int[] {100, 200}); // doc: two disjoint ranges - addDoc(w, new int[] {130, 160}); // doc: single range - try (IndexReader reader = w.getReader()) { - w.close(); - IndexSearcher searcher = newSearcher(reader); - - // CONTAINS [1,15]: neither individual range of the first doc contains it; the *union* - // [1,20] would, but ranges are never unioned -> zero matches. - assertEquals(0, searcher.count(dv(QueryType.CONTAINS, 1, 15))); - assertEquals(0, searcher.count(bkd(QueryType.CONTAINS, 1, 15))); - - // CONTAINS [3,150]: the second doc's [0,5] and [100,200] each satisfy one half-open bound, - // so a decomposition would falsely match -- native per-range matching must not. - assertEquals(0, searcher.count(dv(QueryType.CONTAINS, 3, 150))); - - // CONTAINS [1,10]: first doc's [1,10] contains it (any range suffices). - assertEquals(1, searcher.count(dv(QueryType.CONTAINS, 1, 10))); - - // INTERSECTS [12,14]: first doc's [5,20] overlaps it. - assertEquals(1, searcher.count(dv(QueryType.INTERSECTS, 12, 14))); - - // WITHIN [0,25]: first doc ([1,10]) and second doc ([0,5]) each have a range within it. - assertEquals(2, searcher.count(dv(QueryType.WITHIN, 0, 25))); - - // Every case must agree with the BKD ground truth. - for (QueryType t : QueryType.values()) { - for (int[] q : new int[][] {{1, 15}, {3, 150}, {1, 10}, {12, 14}, {0, 25}}) { - assertEquals( - "relation " + t + " query [" + q[0] + " TO " + q[1] + "]", - matches(searcher, bkd(t, q[0], q[1])), - matches(searcher, dv(t, q[0], q[1]))); - } - } - } - } - } - - @Test - public void testParityWithBkdRandom() throws Exception { - try (Directory dir = newDirectory()) { - RandomIndexWriter w = new RandomIndexWriter(random(), dir); - int numDocs = atLeast(200); - for (int i = 0; i < numDocs; i++) { - int numRanges = TestUtil.nextInt(random(), 1, 3); // single- and multi-valued docs - int[][] ranges = new int[numRanges][2]; - for (int r = 0; r < numRanges; r++) { - int a = TestUtil.nextInt(random(), -50, 50); - int b = TestUtil.nextInt(random(), a, 50); // a <= b - ranges[r] = new int[] {a, b}; - } - addDoc(w, ranges); - } - try (IndexReader reader = w.getReader()) { - w.close(); - IndexSearcher searcher = newSearcher(reader); - - for (int iter = 0; iter < 200; iter++) { - int qa = TestUtil.nextInt(random(), -55, 55); - int qb = TestUtil.nextInt(random(), qa, 55); - for (QueryType t : QueryType.values()) { - assertEquals( - "relation " + t + " query [" + qa + " TO " + qb + "]", - matches(searcher, bkd(t, qa, qb)), - matches(searcher, dv(t, qa, qb))); - } - } - } - } - } -} diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java index 784c842be208..33f85b4f6e4a 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java @@ -16,6 +16,7 @@ */ package org.apache.solr.search.numericrange; +import java.util.ArrayList; import java.util.List; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; @@ -23,13 +24,19 @@ import org.junit.Test; /** - * Integration tests over the {@code schema-numericrange.xml} verifying that the docValues-enabled - * range fields return exactly the same results as their indexed-only counterparts. + * Tests for docValues-specific behaviors (specially multiValued) of the range field types. * - *

The docValues-enabled fields wrap their BKD query in a Lucene {@link - * org.apache.lucene.search.IndexOrDocValuesQuery}. These tests assert that this produces identical - * matches to the direct BKD query across intersects / contains / single-bound / multi-dimensional - * queries, inside a selective conjunction, and for the docValues-only (non-indexed) field. + *

Plain query parity between the docValues and non-docValues fields (intersects / contains / + * within / crosses / single-bound, across int/long/float/double and the docValues-only field) is + * covered there instead: each of those tests randomizes its field over the indexed-only, + * indexed+docValues, and (for int) docValues-only variants, so choosing the non-indexed + * docValues-only field also exercises the pure {@code MultiBinaryRangeDocValuesQuery} path. + * + *

This also adds multiValued parity (several ranges packed into one docValues blob), + * the {@link org.apache.lucene.search.IndexOrDocValuesQuery} clause + * inside a selective conjunction (where the docValues clause leads iteration), and the fact that + * enabling docValues does not enable sorting or value ({@code facet.field}) faceting even + * though {@code facet.query} still works. */ public class NumericRangeDocValuesTest extends SolrTestCaseJ4 { @@ -45,9 +52,7 @@ public void setUp() throws Exception { assertU(commit()); } - /** - * Index the same int range into the indexed-only, indexed+docValues, and docValues-only fields. - */ + /** Index the same int range into the indexed-only, indexed+docValues, and docValues-only fields. */ private void addIntDoc(String id, String range) { assertU( adoc( @@ -57,94 +62,6 @@ private void addIntDoc(String id, String range) { "price_range_dvonly", range)); } - private List get1dIntRangeFieldNames() { - return List.of("price_range", "price_range_dv", "price_range_dvonly"); - } - - @Test - public void testIntersectsParity() { - addIntDoc("1", "[100 TO 200]"); - addIntDoc("2", "[150 TO 250]"); - addIntDoc("3", "[50 TO 80]"); - assertU(commit()); - - // intersects [120 TO 180] -> docs 1 and 2, for every field variant - for (String field : get1dIntRangeFieldNames()) { - assertQ( - "intersects on " + field, - req("q", "{!numericRange criteria=intersects field=" + field + "}[120 TO 180]"), - "//result[@numFound='2']", - "//result/doc/str[@name='id'][.='1']", - "//result/doc/str[@name='id'][.='2']"); - } - } - - @Test - public void testContainsParity() { - addIntDoc("1", "[100 TO 200]"); // fully contains [120 TO 180] - addIntDoc("2", "[130 TO 160]"); // within, does NOT contain - addIntDoc("3", "[150 TO 250]"); // partial overlap, does NOT contain - assertU(commit()); - - for (String field : get1dIntRangeFieldNames()) { - assertQ( - "contains on " + field, - req("q", "{!numericRange criteria=contains field=" + field + "}[120 TO 180]"), - "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); - } - } - - @Test - public void testStandardSyntaxContainsParity() { - addIntDoc("1", "[100 TO 200]"); // contains [120 TO 180] - addIntDoc("2", "[130 TO 160]"); // no - assertU(commit()); - - for (String field : get1dIntRangeFieldNames()) { - assertQ( - "standard-syntax contains on " + field, - req("q", field + ":[120 TO 180]"), - "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); - } - } - - @Test - public void testSingleBoundParity() { - // A single bound (field:150) is a degenerate range [150,150]; contains == intersects for a - // point. - addIntDoc("1", "[100 TO 200]"); // contains 150 - addIntDoc("2", "[150 TO 150]"); // contains 150 - addIntDoc("3", "[100 TO 149]"); // no (max below 150) - addIntDoc("4", "[151 TO 300]"); // no (min above 150) - assertU(commit()); - - for (String f : get1dIntRangeFieldNames()) { - assertQ( - "single-bound on " + f, - req("q", f + ":150"), - "//result[@numFound='2']", - "//result/doc/str[@name='id'][.='1']", - "//result/doc/str[@name='id'][.='2']"); - } - } - - @Test - public void test2DContainsParity() { - assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]", "bbox_dv", "[0,0 TO 10,10]")); // contains - assertU(adoc("id", "2", "bbox", "[4,4 TO 6,6]", "bbox_dv", "[4,4 TO 6,6]")); // does not contain - assertU(commit()); - - for (String f : new String[] {"bbox", "bbox_dv"}) { - assertQ( - "2D contains on " + f, - req("q", "{!numericRange criteria=contains field=" + f + "}[3,3 TO 7,7]"), - "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); - } - } - @Test public void testSelectiveConjunction() { // Exercises IndexOrDocValuesQuery inside a conjunction: the selective title clause leads and @@ -190,50 +107,164 @@ public void testSelectiveConjunction() { } } - @Test - public void testLongParity() { - assertU(adoc("id", "1", "long_range", "[100 TO 200]", "long_range_dv", "[100 TO 200]")); - assertU(adoc("id", "2", "long_range", "[150 TO 250]", "long_range_dv", "[150 TO 250]")); - assertU(adoc("id", "3", "long_range", "[50 TO 80]", "long_range_dv", "[50 TO 80]")); - assertU(commit()); + /** Indexes the given ranges into both a BKD-only and a docValues multiValued field. */ + private void addMvDoc(String id, String bkdField, String dvField, String... ranges) { + List fieldsAndValues = new ArrayList<>(); + fieldsAndValues.add("id"); + fieldsAndValues.add(id); + for (String r : ranges) { + fieldsAndValues.add(bkdField); + fieldsAndValues.add(r); + fieldsAndValues.add(dvField); + fieldsAndValues.add(r); + } + assertU(adoc(fieldsAndValues.toArray(new String[0]))); + } - for (String f : new String[] {"long_range", "long_range_dv"}) { + /** + * Asserts the BKD field and its docValues sibling both match exactly {@code expectedIds} (and only + * those) for the query. Checks the matched ids, not just the count. + */ + private void assertSameMatches( + String criteria, String range, String bkdField, String dvField, String... expectedIds) { + for (String f : new String[] {bkdField, dvField}) { + List tests = new ArrayList<>(); + // numFound + presence of each (unique) id together pin the exact match set. + tests.add("//result[@numFound='" + expectedIds.length + "']"); + for (String id : expectedIds) { + tests.add("//result/doc/str[@name='id'][.='" + id + "']"); + } assertQ( - "long intersects on " + f, - req("q", "{!numericRange criteria=intersects field=" + f + "}[120 TO 180]"), - "//result[@numFound='2']"); + "criteria=" + criteria + " field=" + f + " " + range, + req("q", "{!numericRange criteria=" + criteria + " field=" + f + "}" + range), + tests.toArray(new String[0])); } } @Test - public void testFloatIntersectsParity() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]", "float_range_dv", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "float_range", "[1.5 TO 3.0]", "float_range_dv", "[1.5 TO 3.0]")); - assertU(adoc("id", "3", "float_range", "[5.0 TO 6.0]", "float_range_dv", "[5.0 TO 6.0]")); + public void testContainsPerRangeNotUnion() { + // doc 1: two overlapping ranges whose *union* is [1,20] + addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); + // doc 2: two disjoint ranges + addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); assertU(commit()); - for (String f : new String[] {"float_range", "float_range_dv"}) { - assertQ( - "float intersects on " + f, - req("q", "{!numericRange criteria=intersects field=" + f + "}[1.2 TO 1.8]"), - "//result[@numFound='2']"); - } + // Neither individual range of doc 1 contains [1,15] + assertSameMatches("contains", "[1 TO 15]", "price_range_multi", "price_range_mv_dv"); + + // doc 2's [0,5] and [100,200] each satisfy one half-open bound of a CONTAINS([3,150]) split, so + // a decomposition would falsely match -- native per-range matching must not. + assertSameMatches("contains", "[3 TO 150]", "price_range_multi", "price_range_mv_dv"); + + // doc 1's [1,10] actually contains [1,10] (any range suffices). + assertSameMatches("contains", "[1 TO 10]", "price_range_multi", "price_range_mv_dv", "1"); } @Test - public void testDoubleContainsParity() { - // doc 1 fully contains [2.0 TO 4.0]; doc 2 is within it (does not contain) - assertU(adoc("id", "1", "double_range", "[1.0 TO 5.0]", "double_range_dv", "[1.0 TO 5.0]")); - assertU(adoc("id", "2", "double_range", "[2.5 TO 3.5]", "double_range_dv", "[2.5 TO 3.5]")); + public void testAllRelationsMultiValued() { + addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); + addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); + addMvDoc("3", "price_range_multi", "price_range_mv_dv", "[130 TO 160]"); assertU(commit()); - for (String f : new String[] {"double_range", "double_range_dv"}) { - assertQ( - "double contains on " + f, - req("q", "{!numericRange criteria=contains field=" + f + "}[2.0 TO 4.0]"), - "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); - } + // INTERSECTS [12,14]: only doc 1 (via its [5,20] range). + assertSameMatches("intersects", "[12 TO 14]", "price_range_multi", "price_range_mv_dv", "1"); + // WITHIN [0,25]: doc 1 ([1,10]) and doc 2 ([0,5]) each have a range within it. + assertSameMatches("within", "[0 TO 25]", "price_range_multi", "price_range_mv_dv", "1", "2"); + // CROSSES [8,50]: doc 1 crosses (its ranges overlap but aren't within); docs 2,3 are disjoint. + assertSameMatches("crosses", "[8 TO 50]", "price_range_multi", "price_range_mv_dv", "1"); + } + + @Test + public void test2dLongMultiValued() { + addMvDoc( + "1", + "long_range_multi_2d", + "long_range_mv_dv_2d", + "[100,100 TO 200,200]", + "[500,500 TO 600,600]"); + addMvDoc("2", "long_range_multi_2d", "long_range_mv_dv_2d", "[1,3000000000 TO 5,4000000000]"); + assertU(commit()); + + // Overlaps doc 1's [100,100 TO 200,200] box in both dims. + assertSameMatches( + "intersects", "[150,100 TO 550,150]", "long_range_multi_2d", "long_range_mv_dv_2d", "1"); + // Overlaps doc 2's box (whose D2 spans 3e9..4e9). + assertSameMatches( + "intersects", + "[2,3500000000 TO 5,3600000000]", + "long_range_multi_2d", + "long_range_mv_dv_2d", + "2"); + } + + @Test + public void testFloatMultiValued() { + addMvDoc("1", "float_range_multi", "float_range_mv_dv", "[1.0 TO 2.0]", "[5.0 TO 6.0]"); + addMvDoc("2", "float_range_multi", "float_range_mv_dv", "[10.0 TO 20.0]"); + assertU(commit()); + + // INTERSECTS [1.5,1.8]: only doc 1 (via its [1.0,2.0] range). + assertSameMatches("intersects", "[1.5 TO 1.8]", "float_range_multi", "float_range_mv_dv", "1"); + // WITHIN [0.0,100.0]: both docs' ranges fit inside. + assertSameMatches( + "within", "[0.0 TO 100.0]", "float_range_multi", "float_range_mv_dv", "1", "2"); + } + + @Test + public void test2dDoubleMultiValued() { + addMvDoc( + "1", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "[1.0,-3.1 TO 5.0,-1.1]", + "[-5.5,10.0 TO 1.5,15.0]"); + addMvDoc("2", "double_range_multi_2d", "double_range_mv_dv_2d", "[-10,2.5 TO 0,3.5]"); + assertU(commit()); + + // Ranges per doc (each a 2D bounding box): + // doc1 A: D1 [1.0, 5.0] D2 [-3.1, -1.1] + // doc1 B: D1 [-5.5, 1.5] D2 [10.0, 15.0] + // doc2 C: D1 [-10.0, 0.0] D2 [2.5, 3.5] + + // CONTAINS: a doc range must wrap the query box in *both* dims. doc1's A wraps + // [2,4]x[-3.0,-1.15] (1<=2, 5>=4 and -3.1<=-3.0, -1.1>=-1.15). + // doc2's C does not (its D1 max 0 < 4). -> only doc 1. + assertSameMatches( + "contains", + "[2.0,-3.0 TO 4.0,-1.15]", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "1"); + + // WITHIN: a doc range must fit *inside* the query box in both dims. doc1's A fits inside + // [0,6]x[-4,-1]; doc2's C sticks out on D1 (min -10 < 0). -> only doc 1. + assertSameMatches( + "within", + "[0.0,-4.0 TO 6.0,-1.0]", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "1"); + + // INTERSECTS: overlap in both dims is enough. The central box [-2,3]x[-2,3] overlaps doc1's A + // and doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. + assertSameMatches( + "intersects", + "[-2.0,-2.0 TO 3.0,3.0]", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "1", + "2"); + + // CROSSES = intersects AND NOT within. For [-1,6]x[-4,3], doc1's A is fully *within* it (so + // does not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on + // D1 (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. + assertSameMatches( + "crosses", + "[-1.0,-4.0 TO 6.0,3.0]", + "double_range_multi_2d", + "double_range_mv_dv_2d", + "2"); } @Test @@ -251,6 +282,20 @@ public void testSortingUnsupportedEvenWithDocValues() { SolrException.ErrorCode.BAD_REQUEST); } + @Test + public void testFieldFacetingUnsupportedEvenWithDocValues() { + addIntDoc("1", "[100 TO 200]"); + addIntDoc("2", "[150 TO 250]"); + assertU(commit()); + // Value faceting (facet.field) isn't supported on range fields: Use facet.query + // for ranges instead (see testQueryFacetingWithDocValues). + assertQ( + "value faceting on a range field yields no buckets even with docValues", + req("q", "*:*", "rows", "0", "facet", "true", "facet.field", "price_range_dv"), + "//lst[@name='facet_fields']/lst[@name='price_range_dv']", + "count(//lst[@name='facet_fields']/lst[@name='price_range_dv']/int)=0"); + } + @Test public void testQueryFacetingWithDocValues() { // Range fields support facet.query (count docs matching a range query), the realistic way to diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java deleted file mode 100644 index 7222318c5808..000000000000 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeMultiValuedDocValuesTest.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * 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.search.numericrange; - -import java.util.ArrayList; -import java.util.List; -import org.apache.solr.SolrTestCaseJ4; -import org.junit.BeforeClass; -import org.junit.Test; - -/** - * Integration tests for multiValued range fields with docValues (backed by SortedSet docValues). - * - *

A document may hold several ranges. The docValues verifier ({@code - * MultiBinaryRangeDocValuesQuery}) matches a document when any of its ranges satisfies the - * relation, the same "any value matches" semantics as multiValued Points (BKD). Each test indexes - * the same ranges into a multiValued BKD-only field ({@code *_multi}) and a multiValued docValues - * field ({@code *_mv_dv}) and asserts they return identical counts, so the docValues path is - * checked against the trusted BKD path. - * - *

That these fields load at all also verifies that {@code multiValued=true docValues=true} is - * now accepted for range field types. - */ -public class NumericRangeMultiValuedDocValuesTest extends SolrTestCaseJ4 { - - @BeforeClass - public static void beforeClass() throws Exception { - initCore("solrconfig.xml", "schema-numericrange.xml"); - } - - @Override - public void setUp() throws Exception { - super.setUp(); - clearIndex(); - assertU(commit()); - } - - /** Indexes the given ranges into both a BKD-only and a docValues multiValued field. */ - private void addMvDoc(String id, String bkdField, String dvField, String... ranges) { - List fieldsAndValues = new ArrayList<>(); - fieldsAndValues.add("id"); - fieldsAndValues.add(id); - for (String r : ranges) { - fieldsAndValues.add(bkdField); - fieldsAndValues.add(r); - fieldsAndValues.add(dvField); - fieldsAndValues.add(r); - } - assertU(adoc(fieldsAndValues.toArray(new String[0]))); - } - - /** Asserts the BKD field and the docValues field return the same (expected) count. */ - private void assertSameCount( - String criteria, String range, String bkdField, String dvField, int expected) { - for (String f : new String[] {bkdField, dvField}) { - assertQ( - "criteria=" + criteria + " field=" + f + " " + range, - req("q", "{!numericRange criteria=" + criteria + " field=" + f + "}" + range), - "//result[@numFound='" + expected + "']"); - } - } - - @Test - public void testContainsPerRangeNotUnion() { - // doc 1: two overlapping ranges whose *union* is [1,20] - addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); - // doc 2: two disjoint ranges - addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); - assertU(commit()); - - // Neither individual range of doc 1 contains [1,15] - assertSameCount("contains", "[1 TO 15]", "price_range_multi", "price_range_mv_dv", 0); - - // doc 2's [0,5] and [100,200] each satisfy one half-open bound of a CONTAINS([3,150]) split, so - // a decomposition would falsely match -- native per-range matching must not. - assertSameCount("contains", "[3 TO 150]", "price_range_multi", "price_range_mv_dv", 0); - - // doc 1's [1,10] actually contains [1,10] (any range suffices). - assertSameCount("contains", "[1 TO 10]", "price_range_multi", "price_range_mv_dv", 1); - } - - @Test - public void testAllRelationsMultiValued() { - addMvDoc("1", "price_range_multi", "price_range_mv_dv", "[1 TO 10]", "[5 TO 20]"); - addMvDoc("2", "price_range_multi", "price_range_mv_dv", "[0 TO 5]", "[100 TO 200]"); - addMvDoc("3", "price_range_multi", "price_range_mv_dv", "[130 TO 160]"); - assertU(commit()); - - // INTERSECTS [12,14]: only doc 1 (via its [5,20] range). - assertSameCount("intersects", "[12 TO 14]", "price_range_multi", "price_range_mv_dv", 1); - // WITHIN [0,25]: doc 1 ([1,10]) and doc 2 ([0,5]) each have a range within it. - assertSameCount("within", "[0 TO 25]", "price_range_multi", "price_range_mv_dv", 2); - // CROSSES [8,50]: doc 1 crosses (its ranges overlap but aren't within); docs 2,3 are disjoint. - assertSameCount("crosses", "[8 TO 50]", "price_range_multi", "price_range_mv_dv", 1); - } - - @Test - public void test2dLongMultiValued() { - addMvDoc( - "1", - "long_range_multi_2d", - "long_range_mv_dv_2d", - "[100,100 TO 200,200]", - "[500,500 TO 600,600]"); - addMvDoc("2", "long_range_multi_2d", "long_range_mv_dv_2d", "[1,3000000000 TO 5,4000000000]"); - assertU(commit()); - - assertSameCount( - "intersects", "[150,100 TO 550,150]", "long_range_multi_2d", "long_range_mv_dv_2d", 1); - assertSameCount( - "intersects", - "[2,3500000000 TO 5,3600000000]", - "long_range_multi_2d", - "long_range_mv_dv_2d", - 1); - } - - @Test - public void testFloatMultiValued() { - addMvDoc("1", "float_range_multi", "float_range_mv_dv", "[1.0 TO 2.0]", "[5.0 TO 6.0]"); - addMvDoc("2", "float_range_multi", "float_range_mv_dv", "[10.0 TO 20.0]"); - assertU(commit()); - - assertSameCount("intersects", "[1.5 TO 1.8]", "float_range_multi", "float_range_mv_dv", 1); - assertSameCount("within", "[0.0 TO 100.0]", "float_range_multi", "float_range_mv_dv", 2); - } - - @Test - public void test2dDoubleMultiValued() { - addMvDoc( - "1", - "double_range_multi_2d", - "double_range_mv_dv_2d", - "[1.0,-3.1 TO 5.0,-1.1]", - "[-5.5,10.0 TO 1.5,15.0]"); - addMvDoc("2", "double_range_multi_2d", "double_range_mv_dv_2d", "[-10,2.5 TO 0,3.5]"); - assertU(commit()); - - // Ranges per doc (each a 2D bounding box): - // doc1 A: D1 [1.0, 5.0] D2 [-3.1, -1.1] - // doc1 B: D1 [-5.5, 1.5] D2 [10.0, 15.0] - // doc2 C: D1 [-10.0, 0.0] D2 [2.5, 3.5] - - // CONTAINS: a doc range must wrap the query box in *both* dims. doc1's A wraps - // [2,4]x[-3.0,-1.15] (1<=2, 5>=4 and -3.1<=-3.0, -1.1>=-1.15). - // doc2's C does not (its D1 max 0 < 4). -> only doc 1. - assertSameCount( - "contains", "[2.0,-3.0 TO 4.0,-1.15]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); - - // WITHIN: a doc range must fit *inside* the query box in both dims. doc1's A fits inside - // [0,6]x[-4,-1]; doc2's C sticks out on D1 (min -10 < 0). -> only doc 1. - assertSameCount( - "within", "[0.0,-4.0 TO 6.0,-1.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); - - // INTERSECTS: overlap in both dims is enough. The central box [-2,3]x[-2,3] overlaps doc1's A - // and doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. - assertSameCount( - "intersects", - "[-2.0,-2.0 TO 3.0,3.0]", - "double_range_multi_2d", - "double_range_mv_dv_2d", - 2); - - // CROSSES = intersects AND NOT within. For [-1,6]x[-4,3], doc1's A is fully *within* it (so - // does not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on - // D1 (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. - assertSameCount( - "crosses", "[-1.0,-4.0 TO 6.0,3.0]", "double_range_multi_2d", "double_range_mv_dv_2d", 1); - } -} diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginDoubleTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginDoubleTest.java index e57334779c8e..06bf94895d31 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginDoubleTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginDoubleTest.java @@ -24,9 +24,21 @@ /** * Tests for {@link NumericRangeQParserPlugin} using {@link * org.apache.solr.schema.numericrange.DoubleRangeField} fields. + * + *

Each 1D scenario picks its field at random (via {@link #randomField}) from the equivalent + * indexed-only and indexed+docValues variants, so both the non-docValues and docValues code paths + * get incidental coverage across seeds while the assertions stay identical. */ public class NumericRangeQParserPluginDoubleTest extends SolrTestCaseJ4 { + /** 1D double range: indexed-only field and its indexed+docValues sibling (both stored). */ + private static final String[] ONE_D = {"double_range", "double_range_dv"}; + + /** Randomly returns one of the given field variants so DV / non-DV both get covered. */ + private static String randomField(String... variants) { + return variants[random().nextInt(variants.length)]; + } + @BeforeClass public static void beforeClass() throws Exception { initCore("solrconfig.xml", "schema-numericrange.xml"); @@ -41,28 +53,29 @@ public void setUp() throws Exception { @Test public void test1DIntersectsQuery() { - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "double_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "double_range", "[0.5 TO 0.8]")); - assertU(adoc("id", "4", "double_range", "[2.0 TO 3.0]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); + assertU(adoc("id", "4", f, "[2.0 TO 3.0]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[1.2 TO 1.8]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.2 TO 1.8]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", - "//result/doc/str[@name='double_range'][.='[1.0 TO 2.0]']", - "//result/doc/str[@name='double_range'][.='[1.5 TO 2.5]']"); + "//result/doc/str[@name='" + f + "'][.='[1.0 TO 2.0]']", + "//result/doc/str[@name='" + f + "'][.='[1.5 TO 2.5]']"); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[0.0 TO 1.0]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0.0 TO 1.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[1.75 TO 2.25]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.75 TO 2.25]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -71,46 +84,48 @@ public void test1DIntersectsQuery() { @Test public void test1DWithinQuery() { - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "double_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "double_range", "[0.5 TO 0.8]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"within\" field=double_range}[0.0 TO 3.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0.0 TO 3.0]"), "//result[@numFound='3']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=double_range}[1.0 TO 2.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[1.0 TO 2.0]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=double_range}[0.0 TO 1.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0.0 TO 1.0]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); } @Test public void test1DContainsQuery() { - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "double_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "double_range", "[0.5 TO 3.0]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 3.0]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=double_range}[1.6 TO 1.7]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[1.6 TO 1.7]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=double_range}[0.0 TO 4.0]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[0.0 TO 4.0]"), "//result[@numFound='0']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=double_range}[1.0 TO 2.0]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[1.0 TO 2.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); @@ -118,14 +133,15 @@ public void test1DContainsQuery() { @Test public void test1DCrossesQuery() { - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "double_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "double_range", "[0.5 TO 0.8]")); - assertU(adoc("id", "4", "double_range", "[1.2 TO 1.8]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); + assertU(adoc("id", "4", f, "[1.2 TO 1.8]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"crosses\" field=double_range}[1.5 TO 2.5]"), + req("q", "{!numericRange criteria=\"crosses\" field=" + f + "}[1.5 TO 2.5]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='4']"); @@ -213,27 +229,29 @@ public void testMultiValuedField() { @Test public void testNegativeValues() { - assertU(adoc("id", "1", "double_range", "[-1.0 TO -0.5]")); - assertU(adoc("id", "2", "double_range", "[-0.75 TO -0.25]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[-1.0 TO -0.5]")); + assertU(adoc("id", "2", f, "[-0.75 TO -0.25]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[-0.8 TO -0.6]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[-0.8 TO -0.6]"), "//result[@numFound='2']"); } @Test public void testPointRange() { - assertU(adoc("id", "1", "double_range", "[1.5 TO 1.5]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.5 TO 1.5]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[1.5 TO 1.5]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.5 TO 1.5]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=intersects field=double_range}[0.5 TO 1.75]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0.5 TO 1.75]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -307,31 +325,33 @@ public void testEmptyRangeValue() { @Test public void testGetFieldQueryFullRange() { + String f = randomField(ONE_D); // doc 1: narrow range, fully inside the query range → should NOT match (doc contains query) // doc 2: wide range that fully contains the query range → should match // doc 3: range that only partially overlaps → should NOT match - assertU(adoc("id", "1", "double_range", "[1.3 TO 1.6]")); // No match - assertU(adoc("id", "2", "double_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "3", "double_range", "[1.5 TO 2.5]")); // No match + assertU(adoc("id", "1", f, "[1.3 TO 1.6]")); // No match + assertU(adoc("id", "2", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", f, "[1.5 TO 2.5]")); // No match assertU(commit()); // Contains semantics: find indexed ranges that fully contain [1.2 TO 1.8] assertQ( - req("q", "double_range:[1.2 TO 1.8]"), + req("q", f + ":[1.2 TO 1.8]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='2']"); } @Test public void testGetFieldQueryFullRangeMultipleMatches() { - assertU(adoc("id", "1", "double_range", "[0.0 TO 10.0]")); // Match! - assertU(adoc("id", "2", "double_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "3", "double_range", "[1.0 TO 1.99]")); // No match - max too low - assertU(adoc("id", "4", "double_range", "[1.01 TO 2.0]")); // No match - min too high + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[0.0 TO 10.0]")); // Match! + assertU(adoc("id", "2", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", f, "[1.0 TO 1.99]")); // No match - max too low + assertU(adoc("id", "4", f, "[1.01 TO 2.0]")); // No match - min too high assertU(commit()); assertQ( - req("q", "double_range:[1.0 TO 2.0]"), + req("q", f + ":[1.0 TO 2.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -339,15 +359,16 @@ public void testGetFieldQueryFullRangeMultipleMatches() { @Test public void testGetFieldQuerySingleBound() { + String f = randomField(ONE_D); // Single-bound syntax: double_range:1.5 is sugar for contains([1.5 TO 1.5]) - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "2", "double_range", "[1.5 TO 1.5]")); // Match! - assertU(adoc("id", "3", "double_range", "[1.0 TO 1.49]")); // No match - max below 1.5 - assertU(adoc("id", "4", "double_range", "[1.51 TO 3.0]")); // No match - min above 1.5 + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "2", f, "[1.5 TO 1.5]")); // Match! + assertU(adoc("id", "3", f, "[1.0 TO 1.49]")); // No match - max below 1.5 + assertU(adoc("id", "4", f, "[1.51 TO 3.0]")); // No match - min above 1.5 assertU(commit()); assertQ( - req("q", "double_range:1.5"), + req("q", f + ":1.5"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -371,7 +392,8 @@ public void testGetFieldQuerySingleBound2D() { @Test public void testGetFieldQueryFieldFormatting() { - assertU(adoc("id", "1", "double_range", "[1.0 TO 2.0]")); + String f1 = randomField(ONE_D); + assertU(adoc("id", "1", f1, "[1.0 TO 2.0]")); assertU(adoc("id", "2", "double_range_2d", "[1.0,2.0 TO 3.0,4.0]")); assertU(adoc("id", "3", "double_range_3d", "[0.5,1.0,1.5 TO 2.5,3.0,3.5]")); assertU(adoc("id", "4", "double_range_4d", "[0.1,0.2,0.3,0.4 TO 1.1,1.2,1.3,1.4]")); @@ -391,7 +413,7 @@ public void testGetFieldQueryFieldFormatting() { assertQ( req("q", "id:1"), "//result[@numFound='1']", - "//result/doc/str[@name='double_range'][.='[1.0 TO 2.0]']"); + "//result/doc/str[@name='" + f1 + "'][.='[1.0 TO 2.0]']"); // Verify 2D field returns correctly formatted value assertQ( diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java index 32b1e1200bdd..031396c811f0 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java @@ -24,9 +24,21 @@ /** * Tests for {@link NumericRangeQParserPlugin} using {@link * org.apache.solr.schema.numericrange.FloatRangeField} fields. + * + *

Each 1D scenario picks its field at random (via {@link #randomField}) from the equivalent + * indexed-only and indexed+docValues variants, so both the non-docValues and docValues code paths + * get incidental coverage across seeds while the assertions stay identical. */ public class NumericRangeQParserPluginFloatTest extends SolrTestCaseJ4 { + /** 1D float range: indexed-only field and its indexed+docValues sibling (both stored). */ + private static final String[] ONE_D = {"float_range", "float_range_dv"}; + + /** Randomly returns one of the given field variants so DV / non-DV both get covered. */ + private static String randomField(String... variants) { + return variants[random().nextInt(variants.length)]; + } + @BeforeClass public static void beforeClass() throws Exception { initCore("solrconfig.xml", "schema-numericrange.xml"); @@ -41,28 +53,29 @@ public void setUp() throws Exception { @Test public void test1DIntersectsQuery() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); - assertU(adoc("id", "4", "float_range", "[2.0 TO 3.0]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); + assertU(adoc("id", "4", f, "[2.0 TO 3.0]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[1.2 TO 1.8]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.2 TO 1.8]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", - "//result/doc/str[@name='float_range'][.='[1.0 TO 2.0]']", - "//result/doc/str[@name='float_range'][.='[1.5 TO 2.5]']"); + "//result/doc/str[@name='" + f + "'][.='[1.0 TO 2.0]']", + "//result/doc/str[@name='" + f + "'][.='[1.5 TO 2.5]']"); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[0.0 TO 1.0]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0.0 TO 1.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[1.75 TO 2.25]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.75 TO 2.25]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -71,46 +84,48 @@ public void test1DIntersectsQuery() { @Test public void test1DWithinQuery() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"within\" field=float_range}[0.0 TO 3.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0.0 TO 3.0]"), "//result[@numFound='3']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=float_range}[1.0 TO 2.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[1.0 TO 2.0]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=float_range}[0.0 TO 1.0]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0.0 TO 1.0]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); } @Test public void test1DContainsQuery() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "float_range", "[0.5 TO 3.0]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 3.0]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=float_range}[1.6 TO 1.7]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[1.6 TO 1.7]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=float_range}[0.0 TO 4.0]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[0.0 TO 4.0]"), "//result[@numFound='0']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=float_range}[1.0 TO 2.0]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[1.0 TO 2.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); @@ -118,14 +133,15 @@ public void test1DContainsQuery() { @Test public void test1DCrossesQuery() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); - assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); - assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); - assertU(adoc("id", "4", "float_range", "[1.2 TO 1.8]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); + assertU(adoc("id", "2", f, "[1.5 TO 2.5]")); + assertU(adoc("id", "3", f, "[0.5 TO 0.8]")); + assertU(adoc("id", "4", f, "[1.2 TO 1.8]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"crosses\" field=float_range}[1.5 TO 2.5]"), + req("q", "{!numericRange criteria=\"crosses\" field=" + f + "}[1.5 TO 2.5]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='4']"); @@ -212,27 +228,29 @@ public void testMultiValuedField() { @Test public void testNegativeValues() { - assertU(adoc("id", "1", "float_range", "[-1.0 TO -0.5]")); - assertU(adoc("id", "2", "float_range", "[-0.75 TO -0.25]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[-1.0 TO -0.5]")); + assertU(adoc("id", "2", f, "[-0.75 TO -0.25]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[-0.8 TO -0.6]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[-0.8 TO -0.6]"), "//result[@numFound='2']"); } @Test public void testPointRange() { - assertU(adoc("id", "1", "float_range", "[1.5 TO 1.5]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[1.5 TO 1.5]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[1.5 TO 1.5]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[1.5 TO 1.5]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=intersects field=float_range}[0.5 TO 1.75]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0.5 TO 1.75]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -306,31 +324,33 @@ public void testEmptyRangeValue() { @Test public void testGetFieldQueryFullRange() { + String f = randomField(ONE_D); // doc 1: narrow range, fully inside the query range → should NOT match (doc contains query) // doc 2: wide range that fully contains the query range → should match // doc 3: range that only partially overlaps → should NOT match - assertU(adoc("id", "1", "float_range", "[1.3 TO 1.6]")); // No match - assertU(adoc("id", "2", "float_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "3", "float_range", "[1.5 TO 2.5]")); // No match + assertU(adoc("id", "1", f, "[1.3 TO 1.6]")); // No match + assertU(adoc("id", "2", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", f, "[1.5 TO 2.5]")); // No match assertU(commit()); // Contains semantics: find indexed ranges that fully contain [1.2 TO 1.8] assertQ( - req("q", "float_range:[1.2 TO 1.8]"), + req("q", f + ":[1.2 TO 1.8]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='2']"); } @Test public void testGetFieldQueryFullRangeMultipleMatches() { - assertU(adoc("id", "1", "float_range", "[0.0 TO 10.0]")); // Match! - assertU(adoc("id", "2", "float_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "3", "float_range", "[1.0 TO 1.99]")); // No match - max too low - assertU(adoc("id", "4", "float_range", "[1.01 TO 2.0]")); // No match - min too high + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[0.0 TO 10.0]")); // Match! + assertU(adoc("id", "2", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", f, "[1.0 TO 1.99]")); // No match - max too low + assertU(adoc("id", "4", f, "[1.01 TO 2.0]")); // No match - min too high assertU(commit()); assertQ( - req("q", "float_range:[1.0 TO 2.0]"), + req("q", f + ":[1.0 TO 2.0]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -338,15 +358,16 @@ public void testGetFieldQueryFullRangeMultipleMatches() { @Test public void testGetFieldQuerySingleBound() { + String f = randomField(ONE_D); // Single-bound syntax: float_range:1.5 is sugar for contains([1.5 TO 1.5]) - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); // Match! - assertU(adoc("id", "2", "float_range", "[1.5 TO 1.5]")); // Match! - assertU(adoc("id", "3", "float_range", "[1.0 TO 1.49]")); // No match - max below 1.5 - assertU(adoc("id", "4", "float_range", "[1.51 TO 3.0]")); // No match - min above 1.5 + assertU(adoc("id", "1", f, "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "2", f, "[1.5 TO 1.5]")); // Match! + assertU(adoc("id", "3", f, "[1.0 TO 1.49]")); // No match - max below 1.5 + assertU(adoc("id", "4", f, "[1.51 TO 3.0]")); // No match - min above 1.5 assertU(commit()); assertQ( - req("q", "float_range:1.5"), + req("q", f + ":1.5"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -370,7 +391,8 @@ public void testGetFieldQuerySingleBound2D() { @Test public void testGetFieldQueryFieldFormatting() { - assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + String f1 = randomField(ONE_D); + assertU(adoc("id", "1", f1, "[1.0 TO 2.0]")); assertU(adoc("id", "2", "float_range_2d", "[1.0,2.0 TO 3.0,4.0]")); assertU(adoc("id", "3", "float_range_3d", "[0.5,1.0,1.5 TO 2.5,3.0,3.5]")); assertU(adoc("id", "4", "float_range_4d", "[0.1,0.2,0.3,0.4 TO 1.1,1.2,1.3,1.4]")); @@ -390,7 +412,7 @@ public void testGetFieldQueryFieldFormatting() { assertQ( req("q", "id:1"), "//result[@numFound='1']", - "//result/doc/str[@name='float_range'][.='[1.0 TO 2.0]']"); + "//result/doc/str[@name='" + f1 + "'][.='[1.0 TO 2.0]']"); // Verify 2D field returns correctly formatted value assertQ( diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java index b76db8acf82c..f89e53add374 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java @@ -24,9 +24,27 @@ /** * Tests for {@link NumericRangeQParserPlugin} using {@link * org.apache.solr.schema.numericrange.IntRangeField} fields. + * + *

Each scenario picks its field at random (via {@link #randomField}) from the equivalent field + * variants for 1D: indexed-only, indexed+docValues, and the (non-indexed) docValues-only field + * so all of those code paths, including the pure docValues query, get incidental coverage across + * seeds while the assertions stay identical. */ public class NumericRangeQParserPluginIntTest extends SolrTestCaseJ4 { + /** + * 1D int range variants: indexed-only, indexed+docValues, and (non-indexed) docValues-only. + */ + private static final String[] ONE_D = {"price_range", "price_range_dv", "price_range_dvonly"}; + + /** 2D int range (bounding box): indexed-only field and its indexed+docValues sibling. */ + private static final String[] TWO_D = {"bbox", "bbox_dv"}; + + /** Randomly returns one of the given field variants so DV / non-DV both get covered. */ + private static String randomField(String... variants) { + return variants[random().nextInt(variants.length)]; + } + @BeforeClass public static void beforeClass() throws Exception { initCore("solrconfig.xml", "schema-numericrange.xml"); @@ -41,32 +59,33 @@ public void setUp() throws Exception { @Test public void test1DIntersectsQuery() { + String f = randomField(ONE_D); // Index documents with 1D ranges - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); - assertU(adoc("id", "2", "price_range", "[150 TO 250]")); - assertU(adoc("id", "3", "price_range", "[50 TO 80]")); - assertU(adoc("id", "4", "price_range", "[200 TO 300]")); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); + assertU(adoc("id", "4", f, "[200 TO 300]")); assertU(commit()); // Query: find ranges intersecting [120 TO 180] assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[120 TO 180]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[120 TO 180]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", - "//result/doc/str[@name='price_range'][.='[100 TO 200]']", - "//result/doc/str[@name='price_range'][.='[150 TO 250]']"); + "//result/doc/str[@name='" + f + "'][.='[100 TO 200]']", + "//result/doc/str[@name='" + f + "'][.='[150 TO 250]']"); // Query: find ranges intersecting [0 TO 100] assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0 TO 100]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); // Query: find ranges intersecting [175 TO 225] assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[175 TO 225]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[175 TO 225]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -75,39 +94,41 @@ public void test1DIntersectsQuery() { @Test public void test1DWithinQuery() { - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); - assertU(adoc("id", "2", "price_range", "[150 TO 250]")); - assertU(adoc("id", "3", "price_range", "[50 TO 80]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); assertU(commit()); // Query: find ranges within [0 TO 300] assertQ( - req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 300]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0 TO 300]"), "//result[@numFound='3']"); // Query: find ranges within [100 TO 200] assertQ( - req("q", "{!numericRange criteria=\"within\" field=price_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[100 TO 200]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query: find ranges within [0 TO 100] assertQ( - req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); } @Test public void test1DContainsQuery() { - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); - assertU(adoc("id", "2", "price_range", "[150 TO 250]")); - assertU(adoc("id", "3", "price_range", "[50 TO 300]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 300]")); assertU(commit()); // Query: find ranges containing [160 TO 170] assertQ( - req("q", "{!numericRange criteria=\"contains\" field=price_range}[160 TO 170]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[160 TO 170]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -115,12 +136,12 @@ public void test1DContainsQuery() { // Query: find ranges containing [0 TO 400] assertQ( - req("q", "{!numericRange criteria=\"contains\" field=price_range}[0 TO 400]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[0 TO 400]"), "//result[@numFound='0']"); // Query: find ranges containing [100 TO 200] assertQ( - req("q", "{!numericRange criteria=\"contains\" field=price_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[100 TO 200]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); @@ -128,16 +149,17 @@ public void test1DContainsQuery() { @Test public void test1DCrossesQuery() { - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); - assertU(adoc("id", "2", "price_range", "[150 TO 250]")); - assertU(adoc("id", "3", "price_range", "[50 TO 80]")); - assertU(adoc("id", "4", "price_range", "[120 TO 180]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); + assertU(adoc("id", "4", f, "[120 TO 180]")); assertU(commit()); // Query: find ranges crossing [150 TO 250] // Should match ranges that intersect but are not within assertQ( - req("q", "{!numericRange criteria=\"crosses\" field=price_range}[150 TO 250]"), + req("q", "{!numericRange criteria=\"crosses\" field=" + f + "}[150 TO 250]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='4']"); @@ -145,53 +167,69 @@ public void test1DCrossesQuery() { @Test public void test2DIntersectsQuery() { + String f = randomField(TWO_D); // Index documents with 2D ranges (bounding boxes) - assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]")); - assertU(adoc("id", "2", "bbox", "[5,5 TO 15,15]")); - assertU(adoc("id", "3", "bbox", "[20,20 TO 30,30]")); + assertU(adoc("id", "1", f, "[0,0 TO 10,10]")); + assertU(adoc("id", "2", f, "[5,5 TO 15,15]")); + assertU(adoc("id", "3", f, "[20,20 TO 30,30]")); assertU(commit()); // Query: find bboxes intersecting [8,8 TO 12,12] assertQ( - req("q", "{!numericRange criteria=intersects field=bbox}[8,8 TO 12,12]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[8,8 TO 12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", - "//result/doc/str[@name='bbox'][.='[0,0 TO 10,10]']", - "//result/doc/str[@name='bbox'][.='[5,5 TO 15,15]']"); + "//result/doc/str[@name='" + f + "'][.='[0,0 TO 10,10]']", + "//result/doc/str[@name='" + f + "'][.='[5,5 TO 15,15]']"); // Query: find bboxes intersecting [25,25 TO 35,35] assertQ( - req("q", "{!numericRange criteria=intersects field=bbox}[25,25 TO 35,35]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[25,25 TO 35,35]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); // Query: find bboxes intersecting [100,100 TO 200,200] assertQ( - req("q", "{!numericRange criteria=intersects field=bbox}[100,100 TO 200,200]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[100,100 TO 200,200]"), "//result[@numFound='0']"); } @Test public void test2DWithinQuery() { - assertU(adoc("id", "1", "bbox", "[5,5 TO 10,10]")); - assertU(adoc("id", "2", "bbox", "[0,0 TO 20,20]")); - assertU(adoc("id", "3", "bbox", "[15,15 TO 25,25]")); + String f = randomField(TWO_D); + assertU(adoc("id", "1", f, "[5,5 TO 10,10]")); + assertU(adoc("id", "2", f, "[0,0 TO 20,20]")); + assertU(adoc("id", "3", f, "[15,15 TO 25,25]")); assertU(commit()); // Query: find bboxes within [0,0 TO 20,20] assertQ( - req("q", "{!numericRange criteria=\"within\" field=bbox}[0,0 TO 20,20]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0,0 TO 20,20]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); // Query: find bboxes within [0,0 TO 30,30] assertQ( - req("q", "{!numericRange criteria=\"within\" field=bbox}[0,0 TO 30,30]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0,0 TO 30,30]"), "//result[@numFound='3']"); } + @Test + public void test2DContainsQuery() { + String f = randomField(TWO_D); + assertU(adoc("id", "1", f, "[0,0 TO 10,10]")); // contains [3,3 TO 7,7] + assertU(adoc("id", "2", f, "[4,4 TO 6,6]")); // within it, does NOT contain + assertU(commit()); + + // Query: find bboxes containing [3,3 TO 7,7] + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[3,3 TO 7,7]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + @Test public void test3DQuery() { // Index documents with 3D ranges (bounding cubes) @@ -329,46 +367,49 @@ public void testEmptyRangeValue() { @Test public void testNegativeValues() { - assertU(adoc("id", "1", "price_range", "[-100 TO -50]")); - assertU(adoc("id", "2", "price_range", "[-75 TO -25]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[-100 TO -50]")); + assertU(adoc("id", "2", f, "[-75 TO -25]")); assertU(commit()); // Query with negative range assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[-80 TO -60]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[-80 TO -60]"), "//result[@numFound='2']"); } @Test public void testExtremeValues() { + String f = randomField(ONE_D); int min = Integer.MIN_VALUE; int max = Integer.MAX_VALUE; - assertU(adoc("id", "1", "price_range", "[" + min + " TO " + max + "]")); + assertU(adoc("id", "1", f, "[" + min + " TO " + max + "]")); assertU(commit()); // Query should match the extreme range assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @Test public void testPointRange() { + String f = randomField(ONE_D); // Point range where min == max - assertU(adoc("id", "1", "price_range", "[100 TO 100]")); + assertU(adoc("id", "1", f, "[100 TO 100]")); assertU(commit()); // Intersects query with point assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[100 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[100 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Intersects query containing point assertQ( - req("q", "{!numericRange criteria=intersects field=price_range}[50 TO 150]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[50 TO 150]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -382,31 +423,33 @@ public void testPointRange() { @Test public void testGetFieldQueryFullRange() { + String f = randomField(ONE_D); // doc 1: narrow range, fully inside the query range → should NOT match (doc contains query) // doc 2: wide range that fully contains the query range → should match // doc 3: range that only partially overlaps → should NOT match - assertU(adoc("id", "1", "price_range", "[130 TO 160]")); // No match - assertU(adoc("id", "2", "price_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "3", "price_range", "[150 TO 250]")); // No match + assertU(adoc("id", "1", f, "[130 TO 160]")); // No match + assertU(adoc("id", "2", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "3", f, "[150 TO 250]")); // No match assertU(commit()); // Contains semantics: find indexed ranges that fully contain [120 TO 180] assertQ( - req("q", "price_range:[120 TO 180]"), + req("q", f + ":[120 TO 180]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='2']"); } @Test public void testGetFieldQueryFullRangeMultipleMatches() { - assertU(adoc("id", "1", "price_range", "[0 TO 1000]")); // Match! - assertU(adoc("id", "2", "price_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "3", "price_range", "[100 TO 199]")); // No match - max too low - assertU(adoc("id", "4", "price_range", "[101 TO 200]")); // No match - min too high + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[0 TO 1000]")); // Match! + assertU(adoc("id", "2", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "3", f, "[100 TO 199]")); // No match - max too low + assertU(adoc("id", "4", f, "[101 TO 200]")); // No match - min too high assertU(commit()); assertQ( - req("q", "price_range:[100 TO 200]"), + req("q", f + ":[100 TO 200]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -414,15 +457,16 @@ public void testGetFieldQueryFullRangeMultipleMatches() { @Test public void testGetFieldQuerySingleBound() { - // Single-bound syntax: price_range:150 is sugar for contains([150 TO 150]) - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "2", "price_range", "[150 TO 150]")); // Match! - assertU(adoc("id", "3", "price_range", "[100 TO 149]")); // No match - max below 150 - assertU(adoc("id", "4", "price_range", "[151 TO 300]")); // No match - min above 150 + String f = randomField(ONE_D); + // Single-bound syntax: :150 is sugar for contains([150 TO 150]) + assertU(adoc("id", "1", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "2", f, "[150 TO 150]")); // Match! + assertU(adoc("id", "3", f, "[100 TO 149]")); // No match - max below 150 + assertU(adoc("id", "4", f, "[151 TO 300]")); // No match - min above 150 assertU(commit()); assertQ( - req("q", "price_range:150"), + req("q", f + ":150"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -430,15 +474,16 @@ public void testGetFieldQuerySingleBound() { @Test public void testGetFieldQuerySingleBound2D() { - // 2D single-bound: bbox:5,5 is sugar for contains([5,5 TO 5,5]) - assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]")); // Match! - assertU(adoc("id", "2", "bbox", "[5,5 TO 5,5]")); // Match! - assertU(adoc("id", "3", "bbox", "[0,0 TO 4,10]")); // No match - X dimension ends too low - assertU(adoc("id", "4", "bbox", "[6,0 TO 10,10]")); // No match - X dimension starts too high + String f = randomField(TWO_D); + // 2D single-bound: :5,5 is sugar for contains([5,5 TO 5,5]) + assertU(adoc("id", "1", f, "[0,0 TO 10,10]")); // Match! + assertU(adoc("id", "2", f, "[5,5 TO 5,5]")); // Match! + assertU(adoc("id", "3", f, "[0,0 TO 4,10]")); // No match - X dimension ends too low + assertU(adoc("id", "4", f, "[6,0 TO 10,10]")); // No match - X dimension starts too high assertU(commit()); assertQ( - req("q", "bbox:5,5"), + req("q", f + ":5,5"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -446,10 +491,12 @@ public void testGetFieldQuerySingleBound2D() { @Test public void testGetFieldQueryFieldFormatting() { + String f1 = randomField(ONE_D); + String f2 = randomField(TWO_D); // Test 1D field formatting - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "1", f1, "[100 TO 200]")); // Test 2D field formatting - assertU(adoc("id", "2", "bbox", "[10,20 TO 30,40]")); + assertU(adoc("id", "2", f2, "[10,20 TO 30,40]")); // Test 3D field formatting assertU(adoc("id", "3", "cube", "[5,10,15 TO 25,30,35]")); // Test 4D field formatting @@ -471,13 +518,13 @@ public void testGetFieldQueryFieldFormatting() { assertQ( req("q", "id:1"), "//result[@numFound='1']", - "//result/doc/str[@name='price_range'][.='[100 TO 200]']"); + "//result/doc/str[@name='" + f1 + "'][.='[100 TO 200]']"); // Verify 2D field returns correctly formatted value assertQ( req("q", "id:2"), "//result[@numFound='1']", - "//result/doc/str[@name='bbox'][.='[10,20 TO 30,40]']"); + "//result/doc/str[@name='" + f2 + "'][.='[10,20 TO 30,40]']"); // Verify 3D field returns correctly formatted value assertQ( diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginLongTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginLongTest.java index 1b774e88e90f..3953920968bd 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginLongTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginLongTest.java @@ -24,9 +24,21 @@ /** * Tests for {@link NumericRangeQParserPlugin} using {@link * org.apache.solr.schema.numericrange.LongRangeField} fields. + * + *

Each 1D scenario picks its field at random (via {@link #randomField}) from the equivalent + * indexed-only and indexed+docValues variants, so both the non-docValues and docValues code paths + * get incidental coverage across seeds while the assertions stay identical. */ public class NumericRangeQParserPluginLongTest extends SolrTestCaseJ4 { + /** 1D long range: indexed-only field and its indexed+docValues sibling (both stored). */ + private static final String[] ONE_D = {"long_range", "long_range_dv"}; + + /** Randomly returns one of the given field variants so DV / non-DV both get covered. */ + private static String randomField(String... variants) { + return variants[random().nextInt(variants.length)]; + } + @BeforeClass public static void beforeClass() throws Exception { initCore("solrconfig.xml", "schema-numericrange.xml"); @@ -41,28 +53,29 @@ public void setUp() throws Exception { @Test public void test1DIntersectsQuery() { - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); - assertU(adoc("id", "2", "long_range", "[150 TO 250]")); - assertU(adoc("id", "3", "long_range", "[50 TO 80]")); - assertU(adoc("id", "4", "long_range", "[200 TO 300]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); + assertU(adoc("id", "4", f, "[200 TO 300]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[120 TO 180]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[120 TO 180]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", - "//result/doc/str[@name='long_range'][.='[100 TO 200]']", - "//result/doc/str[@name='long_range'][.='[150 TO 250]']"); + "//result/doc/str[@name='" + f + "'][.='[100 TO 200]']", + "//result/doc/str[@name='" + f + "'][.='[150 TO 250]']"); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0 TO 100]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[175 TO 225]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[175 TO 225]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -71,46 +84,48 @@ public void test1DIntersectsQuery() { @Test public void test1DWithinQuery() { - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); - assertU(adoc("id", "2", "long_range", "[150 TO 250]")); - assertU(adoc("id", "3", "long_range", "[50 TO 80]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"within\" field=long_range}[0 TO 300]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0 TO 300]"), "//result[@numFound='3']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=long_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[100 TO 200]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=\"within\" field=long_range}[0 TO 100]"), + req("q", "{!numericRange criteria=\"within\" field=" + f + "}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); } @Test public void test1DContainsQuery() { - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); - assertU(adoc("id", "2", "long_range", "[150 TO 250]")); - assertU(adoc("id", "3", "long_range", "[50 TO 300]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 300]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=long_range}[160 TO 170]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[160 TO 170]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", "//result/doc/str[@name='id'][.='3']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=long_range}[0 TO 400]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[0 TO 400]"), "//result[@numFound='0']"); assertQ( - req("q", "{!numericRange criteria=\"contains\" field=long_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"contains\" field=" + f + "}[100 TO 200]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); @@ -118,14 +133,15 @@ public void test1DContainsQuery() { @Test public void test1DCrossesQuery() { - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); - assertU(adoc("id", "2", "long_range", "[150 TO 250]")); - assertU(adoc("id", "3", "long_range", "[50 TO 80]")); - assertU(adoc("id", "4", "long_range", "[120 TO 180]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 200]")); + assertU(adoc("id", "2", f, "[150 TO 250]")); + assertU(adoc("id", "3", f, "[50 TO 80]")); + assertU(adoc("id", "4", f, "[120 TO 180]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=\"crosses\" field=long_range}[150 TO 250]"), + req("q", "{!numericRange criteria=\"crosses\" field=" + f + "}[150 TO 250]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='4']"); @@ -269,56 +285,60 @@ public void testEmptyRangeValue() { @Test public void testNegativeValues() { - assertU(adoc("id", "1", "long_range", "[-100 TO -50]")); - assertU(adoc("id", "2", "long_range", "[-75 TO -25]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[-100 TO -50]")); + assertU(adoc("id", "2", f, "[-75 TO -25]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[-80 TO -60]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[-80 TO -60]"), "//result[@numFound='2']"); } @Test public void testValuesOutsideIntRange() { + String f = randomField(ONE_D); // Values that cannot be stored in an int but are valid longs long min = 3_000_000_000L; long max = 4_000_000_000L; - assertU(adoc("id", "1", "long_range", "[" + min + " TO " + max + "]")); + assertU(adoc("id", "1", f, "[" + min + " TO " + max + "]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[3500000000 TO 3600000000]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[3500000000 TO 3600000000]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @Test public void testExtremeValues() { + String f = randomField(ONE_D); long min = Long.MIN_VALUE; long max = Long.MAX_VALUE; - assertU(adoc("id", "1", "long_range", "[" + min + " TO " + max + "]")); + assertU(adoc("id", "1", f, "[" + min + " TO " + max + "]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @Test public void testPointRange() { - assertU(adoc("id", "1", "long_range", "[100 TO 100]")); + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[100 TO 100]")); assertU(commit()); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[100 TO 100]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[100 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); assertQ( - req("q", "{!numericRange criteria=intersects field=long_range}[50 TO 150]"), + req("q", "{!numericRange criteria=intersects field=" + f + "}[50 TO 150]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -329,31 +349,33 @@ public void testPointRange() { @Test public void testGetFieldQueryFullRange() { + String f = randomField(ONE_D); // doc 1: narrow range, fully inside the query range → should NOT match (doc contains query) // doc 2: wide range that fully contains the query range → should match // doc 3: range that only partially overlaps → should NOT match - assertU(adoc("id", "1", "long_range", "[130 TO 160]")); // No match - assertU(adoc("id", "2", "long_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "3", "long_range", "[150 TO 250]")); // No match + assertU(adoc("id", "1", f, "[130 TO 160]")); // No match + assertU(adoc("id", "2", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "3", f, "[150 TO 250]")); // No match assertU(commit()); // Contains semantics: find indexed ranges that fully contain [120 TO 180] assertQ( - req("q", "long_range:[120 TO 180]"), + req("q", f + ":[120 TO 180]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='2']"); } @Test public void testGetFieldQueryFullRangeMultipleMatches() { - assertU(adoc("id", "1", "long_range", "[0 TO 1000]")); // Match! - assertU(adoc("id", "2", "long_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "3", "long_range", "[100 TO 199]")); // No match - max too low - assertU(adoc("id", "4", "long_range", "[101 TO 200]")); // No match - min too high + String f = randomField(ONE_D); + assertU(adoc("id", "1", f, "[0 TO 1000]")); // Match! + assertU(adoc("id", "2", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "3", f, "[100 TO 199]")); // No match - max too low + assertU(adoc("id", "4", f, "[101 TO 200]")); // No match - min too high assertU(commit()); assertQ( - req("q", "long_range:[100 TO 200]"), + req("q", f + ":[100 TO 200]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -361,15 +383,16 @@ public void testGetFieldQueryFullRangeMultipleMatches() { @Test public void testGetFieldQuerySingleBound() { - // Single-bound syntax: long_range:150 is sugar for contains([150 TO 150]) - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); // Match! - assertU(adoc("id", "2", "long_range", "[150 TO 150]")); // Match! - assertU(adoc("id", "3", "long_range", "[100 TO 149]")); // No match - max below 150 - assertU(adoc("id", "4", "long_range", "[151 TO 300]")); // No match - min above 150 + String f = randomField(ONE_D); + // Single-bound syntax: :150 is sugar for contains([150 TO 150]) + assertU(adoc("id", "1", f, "[100 TO 200]")); // Match! + assertU(adoc("id", "2", f, "[150 TO 150]")); // Match! + assertU(adoc("id", "3", f, "[100 TO 149]")); // No match - max below 150 + assertU(adoc("id", "4", f, "[151 TO 300]")); // No match - min above 150 assertU(commit()); assertQ( - req("q", "long_range:150"), + req("q", f + ":150"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -377,7 +400,8 @@ public void testGetFieldQuerySingleBound() { @Test public void testGetFieldQuerySingleBound2D() { - // 2D single-bound: bbox:5,5 is sugar for contains([5,5 TO 5,5]) + // 2D single-bound: bbox:5,5 is sugar for contains([5,5 TO 5,5]). Uses the int bbox field (no + // long 2D docValues variant exists), so this is left on the indexed-only field. assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]")); // Match! assertU(adoc("id", "2", "bbox", "[5,5 TO 5,5]")); // Match! assertU(adoc("id", "3", "bbox", "[0,0 TO 4,10]")); // No match - X dimension ends too low @@ -393,8 +417,9 @@ public void testGetFieldQuerySingleBound2D() { @Test public void testGetFieldQueryFieldFormatting() { + String f1 = randomField(ONE_D); // Test 1D field formatting - assertU(adoc("id", "1", "long_range", "[100 TO 200]")); + assertU(adoc("id", "1", f1, "[100 TO 200]")); // Test 2D field formatting assertU(adoc("id", "2", "bbox", "[10,20 TO 30,40]")); // Test 3D field formatting @@ -418,7 +443,7 @@ public void testGetFieldQueryFieldFormatting() { assertQ( req("q", "id:1"), "//result[@numFound='1']", - "//result/doc/str[@name='long_range'][.='[100 TO 200]']"); + "//result/doc/str[@name='" + f1 + "'][.='[100 TO 200]']"); // Verify 2D field returns correctly formatted value assertQ( diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc index d28820458aba..25120c056132 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc @@ -51,17 +51,17 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |IntPointField |Integer field (32-bit signed integer). This class encodes int values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. -|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Note that, unlike most docValues-enabled types, this does _not_ enable sorting or value (`facet.field`) faceting. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. |LatLonPointSpatialField |A latitude/longitude coordinate pair; possibly multi-valued for multiple points. Usually it's specified as "lat,lon" order with a comma. See the section xref:query-guide:spatial-search.adoc[] for more information. |LongPointField |Long field (64-bit signed integer). This class encodes foo values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. -|LongRangeField |Stores single or multi-dimensional ranges of long integers, using syntax like `[1000000000 TO 4000000000]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|LongRangeField |Stores single or multi-dimensional ranges of long integers, using syntax like `[1000000000 TO 4000000000]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Note that, unlike most docValues-enabled types, this does _not_ enable sorting or value (`facet.field`) faceting. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. -|DoubleRangeField |Stores single or multi-dimensional ranges of double-precision floating-point numbers, using syntax like `[1.5 TO 2.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|DoubleRangeField |Stores single or multi-dimensional ranges of double-precision floating-point numbers, using syntax like `[1.5 TO 2.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Note that, unlike most docValues-enabled types, this does _not_ enable sorting or value (`facet.field`) faceting. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. -|FloatRangeField |Stores single or multi-dimensional ranges of floating-point numbers, using syntax like `[1.5 TO 4.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|FloatRangeField |Stores single or multi-dimensional ranges of floating-point numbers, using syntax like `[1.5 TO 4.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type supports `docValues="true"` (with `multiValued="true"` or `"false"`) as a query-time filter optimization. Note that, unlike most docValues-enabled types, this does _not_ enable sorting or value (`facet.field`) faceting. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. |NestPathField | Specialized field type storing enhanced information, when xref:indexing-nested-documents.adoc#schema-configuration[working with nested documents]. From daa7c0f531ecb8dabecacf5773b0cb69bb015082 Mon Sep 17 00:00:00 2001 From: Sonu Sharma Date: Wed, 22 Jul 2026 19:19:45 +0530 Subject: [PATCH 4/4] tidy fix --- .../AbstractNumericRangeField.java | 6 ++-- .../NumericRangeDocValuesTest.java | 29 +++++++------------ .../NumericRangeQParserPluginIntTest.java | 10 +++---- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java index f13628c3882d..5dd9f140c32f 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -147,9 +147,9 @@ protected boolean enableDocValuesByDefault() { @Override protected void setArgs(IndexSchema schema, Map args) { - // A range field's docValues are an opaque packed BINARY blob not the external "[min TO max]" form, so - // they can't stand in for the stored value: useDocValuesAsStored would make fl=* return that - // raw blob. It otherwise defaults to off here and reject an explicit + // A range field's docValues are an opaque packed BINARY blob not the external "[min TO max]" + // form, so they can't stand in for the stored value: useDocValuesAsStored would make fl=* + // return that raw blob. It otherwise defaults to off here and reject an explicit // useDocValuesAsStored="true" rather than silently ignoring what was requested. args.putIfAbsent("useDocValuesAsStored", "false"); super.setArgs(schema, args); diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java index 33f85b4f6e4a..c9250ac2d8f9 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeDocValuesTest.java @@ -32,11 +32,10 @@ * indexed+docValues, and (for int) docValues-only variants, so choosing the non-indexed * docValues-only field also exercises the pure {@code MultiBinaryRangeDocValuesQuery} path. * - *

This also adds multiValued parity (several ranges packed into one docValues blob), - * the {@link org.apache.lucene.search.IndexOrDocValuesQuery} clause - * inside a selective conjunction (where the docValues clause leads iteration), and the fact that - * enabling docValues does not enable sorting or value ({@code facet.field}) faceting even - * though {@code facet.query} still works. + *

This also adds multiValued parity (several ranges packed into one docValues blob), the {@link + * org.apache.lucene.search.IndexOrDocValuesQuery} clause inside a selective conjunction (where the + * docValues clause leads iteration), and the fact that enabling docValues does not enable + * sorting or value ({@code facet.field}) faceting even though {@code facet.query} still works. */ public class NumericRangeDocValuesTest extends SolrTestCaseJ4 { @@ -52,7 +51,9 @@ public void setUp() throws Exception { assertU(commit()); } - /** Index the same int range into the indexed-only, indexed+docValues, and docValues-only fields. */ + /** + * Index the same int range into the indexed-only, indexed+docValues, and docValues-only fields. + */ private void addIntDoc(String id, String range) { assertU( adoc( @@ -122,8 +123,8 @@ private void addMvDoc(String id, String bkdField, String dvField, String... rang } /** - * Asserts the BKD field and its docValues sibling both match exactly {@code expectedIds} (and only - * those) for the query. Checks the matched ids, not just the count. + * Asserts the BKD field and its docValues sibling both match exactly {@code expectedIds} (and + * only those) for the query. Checks the matched ids, not just the count. */ private void assertSameMatches( String criteria, String range, String bkdField, String dvField, String... expectedIds) { @@ -240,11 +241,7 @@ public void test2dDoubleMultiValued() { // WITHIN: a doc range must fit *inside* the query box in both dims. doc1's A fits inside // [0,6]x[-4,-1]; doc2's C sticks out on D1 (min -10 < 0). -> only doc 1. assertSameMatches( - "within", - "[0.0,-4.0 TO 6.0,-1.0]", - "double_range_multi_2d", - "double_range_mv_dv_2d", - "1"); + "within", "[0.0,-4.0 TO 6.0,-1.0]", "double_range_multi_2d", "double_range_mv_dv_2d", "1"); // INTERSECTS: overlap in both dims is enough. The central box [-2,3]x[-2,3] overlaps doc1's A // and doc2's C, so both match (any-range / any-doc "OR" semantics). -> 2 docs. @@ -260,11 +257,7 @@ public void test2dDoubleMultiValued() { // does not cross) and its B is disjoint -> doc 1 excluded; doc2's C overlaps but pokes out on // D1 (min -10) -> it crosses. -> only doc 2, which distinguishes crosses from within. assertSameMatches( - "crosses", - "[-1.0,-4.0 TO 6.0,3.0]", - "double_range_multi_2d", - "double_range_mv_dv_2d", - "2"); + "crosses", "[-1.0,-4.0 TO 6.0,3.0]", "double_range_multi_2d", "double_range_mv_dv_2d", "2"); } @Test diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java index f89e53add374..b93c0993edb4 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java @@ -26,15 +26,13 @@ * org.apache.solr.schema.numericrange.IntRangeField} fields. * *

Each scenario picks its field at random (via {@link #randomField}) from the equivalent field - * variants for 1D: indexed-only, indexed+docValues, and the (non-indexed) docValues-only field - * so all of those code paths, including the pure docValues query, get incidental coverage across - * seeds while the assertions stay identical. + * variants for 1D: indexed-only, indexed+docValues, and the (non-indexed) docValues-only field so + * all of those code paths, including the pure docValues query, get incidental coverage across seeds + * while the assertions stay identical. */ public class NumericRangeQParserPluginIntTest extends SolrTestCaseJ4 { - /** - * 1D int range variants: indexed-only, indexed+docValues, and (non-indexed) docValues-only. - */ + /** 1D int range variants: indexed-only, indexed+docValues, and (non-indexed) docValues-only. */ private static final String[] ONE_D = {"price_range", "price_range_dv", "price_range_dvonly"}; /** 2D int range (bounding box): indexed-only field and its indexed+docValues sibling. */