This is an automated email from the ASF dual-hosted git repository. gerlowskija pushed a commit to branch branch_9x in repository https://gitbox.apache.org/repos/asf/solr.git
commit 6d016e3c890fc1605d2567796f812bfdec0461c2 Author: Jason Gerlowski <[email protected]> AuthorDate: Wed Mar 11 13:08:51 2026 -0400 SOLR-13309: Introduce LongRangeField to expose Lucene 'LongRange' (#4192) This commit adds a new field type, LongRangeField, that can be used to hold singular or multi-dimensional (up to 4) ranges of longs. LongRangeField is compatible with the previously added `{!numericRange}` and supports similar syntax. Co-authored-by: Claude Sonnet 4.6 <[email protected]> (cherry picked from commit 51b45f712794d2e59c76cd303d46ece63feacbf8) --- .../unreleased/SOLR-13309-long-range-field.yml | 7 + .../numericrange/AbstractNumericRangeField.java | 317 +++++++++++++++ .../solr/schema/numericrange/IntRangeField.java | 185 ++------- .../solr/schema/numericrange/LongRangeField.java | 292 ++++++++++++++ .../java/org/apache/solr/search/QParserPlugin.java | 4 +- ...rPlugin.java => NumericRangeQParserPlugin.java} | 99 ++--- ...schema-intrange.xml => schema-numericrange.xml} | 23 +- .../schema/numericrange/LongRangeFieldTest.java | 357 ++++++++++++++++ .../org/apache/solr/search/QueryEqualityTest.java | 6 +- ....java => NumericRangeQParserPluginIntTest.java} | 11 +- .../NumericRangeQParserPluginLongTest.java | 449 +++++++++++++++++++++ .../pages/field-types-included-with-solr.adoc | 2 + .../modules/query-guide/pages/other-parsers.adoc | 11 +- 13 files changed, 1534 insertions(+), 229 deletions(-) diff --git a/changelog/unreleased/SOLR-13309-long-range-field.yml b/changelog/unreleased/SOLR-13309-long-range-field.yml new file mode 100644 index 00000000000..78dab312b6f --- /dev/null +++ b/changelog/unreleased/SOLR-13309-long-range-field.yml @@ -0,0 +1,7 @@ +title: Introduce new `LongRangeField` field type and (experimental) `{!numericRange}` query parser for storing and querying long ranges +type: added +authors: + - name: Jason Gerlowski +links: + - name: SOLR-13309 + url: https://issues.apache.org/jira/browse/SOLR-13309 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 new file mode 100644 index 00000000000..8d61af15df8 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -0,0 +1,317 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.apache.lucene.document.StoredField; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.SortField; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.response.TextResponseWriter; +import org.apache.solr.schema.IndexSchema; +import org.apache.solr.schema.PrimitiveFieldType; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.search.QParser; +import org.apache.solr.uninverting.UninvertingReader.Type; + +/** + * Abstract base class for numeric range field types that wrap Lucene's multi-dimensional range + * fields (e.g., {@link org.apache.lucene.document.IntRange}, {@link + * org.apache.lucene.document.LongRange}). + * + * <p>Provides common infrastructure for range field types including: + * + * <ul> + * <li>Configurable number of dimensions (1–4) via the {@code numDimensions} schema attribute + * <li>Shared regex patterns for parsing range value strings + * <li>Standard field lifecycle methods (init, createFields, write, etc.) + * </ul> + * + * <p>Concrete subclasses must implement {@link #parseRangeValue(String)} to parse the string + * representation into a type-specific range value, and {@link #createField(SchemaField, Object)} to + * produce the underlying Lucene {@link IndexableField}. + * + * @see IntRangeField + * @see LongRangeField + */ +public abstract class AbstractNumericRangeField extends PrimitiveFieldType { + + /** + * Marker interface for parsed range value objects. Implemented by the inner {@code RangeValue} + * classes of concrete subclasses so that {@link #toNativeType(Object)} can identify already- + * parsed values without knowing the concrete type. + * + * <p>Concrete subclasses override {@link #parseRangeValue(String)} with a covariant return type + * so callers within the subclass receive the concrete type directly (e.g. {@code + * IntRangeField.RangeValue}) with no casting required. + */ + public interface NumericRangeValue { + int getDimensions(); + } + + /** Regex fragment matching a comma-separated list of signed integers (no decimal points). */ + protected static final String COMMA_DELIMITED_NUMS = "-?\\d+(?:\\s*,\\s*-?\\d+)*"; + + private static final String RANGE_PATTERN_STR = + "\\[\\s*(" + COMMA_DELIMITED_NUMS + ")\\s+TO\\s+(" + COMMA_DELIMITED_NUMS + ")\\s*\\]"; + + /** Pre-compiled pattern matching {@code [min1,min2,... TO max1,max2,...]} range syntax. */ + protected static final Pattern RANGE_PATTERN_REGEX = Pattern.compile(RANGE_PATTERN_STR); + + /** Pre-compiled pattern matching a single (multi-dimensional) bound, e.g. {@code 1,2,3}. */ + protected static final Pattern SINGLE_BOUND_PATTERN = + Pattern.compile("^" + COMMA_DELIMITED_NUMS + "$"); + + /** Configured number of dimensions for this field type; defaults to 1. */ + protected int numDimensions = 1; + + @Override + protected boolean enableDocValuesByDefault() { + return false; // Range fields do not support docValues + } + + @Override + protected void init(IndexSchema schema, Map<String, String> args) { + super.init(schema, args); + + String numDimensionsStr = args.remove("numDimensions"); + if (numDimensionsStr != null) { + numDimensions = Integer.parseInt(numDimensionsStr); + if (numDimensions < 1 || numDimensions > 4) { + throw new SolrException( + ErrorCode.SERVER_ERROR, + "numDimensions must be between 1 and 4, but was [" + + numDimensions + + "] for field type " + + 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 + public List<IndexableField> createFields(SchemaField field, Object value) { + IndexableField indexedField = createField(field, value); + List<IndexableField> fields = new ArrayList<>(); + + if (indexedField != null) { + fields.add(indexedField); + } + + if (field.stored()) { + fields.add(getStoredField(field, value.toString())); + } + + return fields; + } + + protected StoredField getStoredField(SchemaField sf, Object value) { + return new StoredField(sf.getName(), value.toString()); + } + + @Override + public void write(TextResponseWriter writer, String name, IndexableField f) throws IOException { + writer.writeStr(name, toExternal(f), false); + } + + @Override + public SortField getSortField(SchemaField field, boolean top) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Cannot sort on " + getClass().getSimpleName() + ": " + field.getName()); + } + + @Override + public Type getUninversionType(SchemaField sf) { + return null; // No field cache support + } + + @Override + public String toInternal(String val) { + // Validate format and return as-is + parseRangeValue(val); + return val; + } + + @Override + public String toExternal(IndexableField f) { + return f.stringValue(); + } + + @Override + public Object toNativeType(Object val) { + if (val == null) return null; + if (val instanceof NumericRangeValue) return val; + return parseRangeValue(val.toString()); + } + + /** + * Parse a range value string into a type-specific range value object. + * + * <p>Implementations should accept the {@code [min1,min2,... TO max1,max2,...]} bracket notation + * (using {@link #RANGE_PATTERN_REGEX}) and validate that: + * + * <ul> + * <li>The format matches the expected pattern + * <li>The number of dimensions in the value matches {@link #numDimensions} + * <li>Each min value is less than or equal to the corresponding max value + * </ul> + * + * <p>Subclasses should override this with a covariant return type (their concrete inner {@code + * RangeValue} class) so that internal callers receive the fully-typed value without casting. + * + * @param value the string value in bracket notation + * @return a {@link NumericRangeValue} holding the parsed min/max arrays + * @throws SolrException if value format is invalid + */ + public abstract NumericRangeValue parseRangeValue(String value); + + /** + * Parses a single N-dimensional point expressed as a comma-separated string (e.g. {@code "5"} or + * {@code "5,10"}) into a {@link NumericRangeValue} where both mins and maxs are set to the parsed + * bound. + * + * <p>This is used by {@link #getFieldQuery} to support the "single bound" query shorthand, where + * a bare coordinate is treated as a degenerate range {@code [p TO p]}. Dimension-count validation + * against {@link #numDimensions} is performed by the caller and does not need to be repeated + * here. + * + * <p>Subclasses should override with a covariant return type so that internal callers receive the + * concrete {@code RangeValue} type without casting. + * + * @param value a comma-separated numeric string (e.g. {@code "5,10"} for a 2D point) + * @return a {@link NumericRangeValue} with mins and maxs both equal to the parsed bound + * @throws SolrException if the string contains non-numeric values + */ + public abstract NumericRangeValue parseSingleBound(String value); + + /** + * Creates a Lucene query that matches indexed documents whose stored range <em>contains</em> the + * query range described by {@code rangeValue}. + * + * <p>This is the default query semantics used by {@link #getFieldQuery}. Queries with other match + * semantics (intersects, within, crosses) are available via {@link + * org.apache.solr.search.numericrange.NumericRangeQParserPlugin}. + * + * <p>The {@code rangeValue} argument may originate from either {@link #parseRangeValue} (full + * {@code [min TO max]} syntax) or {@link #parseSingleBound} (point query shorthand). In the point + * case, mins and maxs are equal, so the query finds documents whose range contains that exact + * point. + * + * @param field the name of the field to query + * @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); + + /** + * Creates a Lucene query that matches indexed documents whose stored range <em>intersects</em> + * the query range described by {@code rangeValue}. + * + * @param field the name of the field to query + * @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); + + /** + * Creates a Lucene query that matches indexed documents whose stored range is <em>within</em> the + * query range described by {@code rangeValue}. + * + * @param field the name of the field to query + * @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); + + /** + * Creates a Lucene query that matches indexed documents whose stored range <em>crosses</em> the + * boundaries of the query range described by {@code rangeValue}. + * + * @param field the name of the field to query + * @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); + + /** + * Creates a query for this field that matches docs where the query-range is fully contained by + * the field value. + * + * <p>Queries requiring other match semantics can use {@link + * org.apache.solr.search.numericrange.NumericRangeQParserPlugin} + * + * @param parser The {@link org.apache.solr.search.QParser} calling the method + * @param field The {@link org.apache.solr.schema.SchemaField} of the field to search + * @param externalVal The String representation of the value to search. Supports both a + * (multi-)dimensional range of the form [1,2 TO 3,4], or a single (multi-)dimensional bound + * (e.g. 1,2). In the latter case, the single bound will be used as both the min and max. Both + * formats use "contains" query semantics to find indexed ranges that contain the query range. + * @return Query for this field using contains semantics + */ + @Override + public Query getFieldQuery(QParser parser, SchemaField field, String externalVal) { + if (externalVal == null || externalVal.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Query value cannot be null or empty"); + } + + String trimmed = externalVal.trim(); + + // Check if it's the full range syntax: [min1,min2 TO max1,max2] + if (RANGE_PATTERN_REGEX.matcher(trimmed).matches()) { + final var rangeValue = parseRangeValue(trimmed); + return newContainsQuery(field.getName(), rangeValue); + } + + // Syntax sugar: also accept a single-bound (i.e pX,pY,pZ) + if (SINGLE_BOUND_PATTERN.matcher(trimmed).matches()) { + final var singleBoundRange = parseSingleBound(trimmed); + + if (singleBoundRange.getDimensions() != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Single bound dimensions (" + + singleBoundRange.getDimensions() + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + return newContainsQuery(field.getName(), singleBoundRange); + } + + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid query format. Expected either a range [min TO max] or a single bound to search for, got: " + + externalVal); + } +} 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 0dd63b366bb..55a966c312f 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 @@ -16,25 +16,14 @@ */ package org.apache.solr.schema.numericrange; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.lucene.document.IntRange; -import org.apache.lucene.document.StoredField; import org.apache.lucene.index.IndexableField; import org.apache.lucene.search.Query; -import org.apache.lucene.search.SortField; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; -import org.apache.solr.response.TextResponseWriter; -import org.apache.solr.schema.IndexSchema; -import org.apache.solr.schema.PrimitiveFieldType; import org.apache.solr.schema.SchemaField; import org.apache.solr.search.QParser; -import org.apache.solr.uninverting.UninvertingReader.Type; /** * Field type for integer ranges with support for 1-4 dimensions. @@ -84,49 +73,9 @@ import org.apache.solr.uninverting.UninvertingReader.Type; * therefore can't be used for sorting, faceting, etc. * * @see IntRange - * @see org.apache.solr.search.numericrange.IntRangeQParserPlugin + * @see org.apache.solr.search.numericrange.NumericRangeQParserPlugin */ -public class IntRangeField extends PrimitiveFieldType { - - private static final String COMMA_DELIMITED_INTS = "-?\\d+(?:\\s*,\\s*-?\\d+)*"; - private static final String RANGE_PATTERN = - "\\[\\s*(" + COMMA_DELIMITED_INTS + ")\\s+TO\\s+(" + COMMA_DELIMITED_INTS + ")\\s*\\]"; - private static final Pattern RANGE_PATTERN_REGEX = Pattern.compile(RANGE_PATTERN); - private static final Pattern SINGLE_BOUND_PATTERN = - Pattern.compile("^" + COMMA_DELIMITED_INTS + "$"); - - private int numDimensions = 1; - - @Override - protected boolean enableDocValuesByDefault() { - return false; // IntRange does not support docValues - } - - @Override - protected void init(IndexSchema schema, Map<String, String> args) { - super.init(schema, args); - - String numDimensionsStr = args.remove("numDimensions"); - if (numDimensionsStr != null) { - numDimensions = Integer.parseInt(numDimensionsStr); - if (numDimensions < 1 || numDimensions > 4) { - throw new SolrException( - ErrorCode.SERVER_ERROR, - "numDimensions must be between 1 and 4, but was [" - + numDimensions - + "] for field type " - + typeName); - } - } - - // IntRange does not support docValues - validate this wasn't explicitly set - if (hasProperty(DOC_VALUES)) { - throw new SolrException( - ErrorCode.SERVER_ERROR, - "docValues=true enabled but IntRangeField does not support docValues for field type " - + typeName); - } - } +public class IntRangeField extends AbstractNumericRangeField { @Override public IndexableField createField(SchemaField field, Object value) { @@ -140,58 +89,6 @@ public class IntRangeField extends PrimitiveFieldType { return new IntRange(field.getName(), rangeValue.mins, rangeValue.maxs); } - @Override - public List<IndexableField> createFields(SchemaField field, Object value) { - IndexableField indexedField = createField(field, value); - List<IndexableField> fields = new ArrayList<>(); - - if (indexedField != null) { - fields.add(indexedField); - } - - if (field.stored()) { - String valueStr = value.toString(); - fields.add(getStoredField(field, valueStr)); - } - - return fields; - } - - @Override - public void write(TextResponseWriter writer, String name, IndexableField f) throws IOException { - writer.writeStr(name, toExternal(f), false); - } - - @Override - public SortField getSortField(SchemaField field, boolean top) { - throw new SolrException( - ErrorCode.BAD_REQUEST, "Cannot sort on IntRangeField: " + field.getName()); - } - - @Override - public Type getUninversionType(SchemaField sf) { - return null; // No field cache support - } - - @Override - public String toInternal(String val) { - // Validate format and return as-is - parseRangeValue(val); - return val; - } - - @Override - public String toExternal(IndexableField f) { - return f.stringValue(); - } - - @Override - public Object toNativeType(Object val) { - if (val == null) return null; - if (val instanceof RangeValue) return val; - return parseRangeValue(val.toString()); - } - /** * Parse a range value string into a RangeValue object. * @@ -199,6 +96,7 @@ public class IntRangeField extends PrimitiveFieldType { * @return parsed RangeValue * @throws SolrException if value format is invalid */ + @Override public RangeValue parseRangeValue(String value) { if (value == null || value.trim().isEmpty()) { throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be null or empty"); @@ -254,6 +152,12 @@ public class IntRangeField extends PrimitiveFieldType { return new RangeValue(mins, maxs); } + @Override + public NumericRangeValue parseSingleBound(String value) { + final var singleBoundTyped = parseIntArray(value, "single bound values"); + return new RangeValue(singleBoundTyped, singleBoundTyped); + } + /** * Parse a comma-separated string of integers into an array. * @@ -279,60 +183,28 @@ public class IntRangeField extends PrimitiveFieldType { return result; } - protected StoredField getStoredField(SchemaField sf, Object value) { - return new StoredField(sf.getName(), value.toString()); + @Override + public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rangeValueTyped = (RangeValue) rangeValue; + return IntRange.newContainsQuery(fieldName, rangeValueTyped.mins, rangeValueTyped.maxs); } - /** - * Creates a query for this field that matches docs where the query-range is fully contained by - * the field value. - * - * <p>Queries requiring other match semantics can use {@link - * org.apache.solr.search.numericrange.IntRangeQParserPlugin} - * - * @param parser The {@link org.apache.solr.search.QParser} calling the method - * @param field The {@link org.apache.solr.schema.SchemaField} of the field to search - * @param externalVal The String representation of the value to search. Supports both a - * (multi-)dimensional range of the form [1,2 TO 3,4], or a single (multi-)dimensional bound - * (e.g. 1,2). In the latter case, the single bound will be used as both the min and max. Both - * formats use "contains" query semantics to find indexed ranges that contain the query range. - * @return Query for this field using contains semantics - */ @Override - public Query getFieldQuery(QParser parser, SchemaField field, String externalVal) { - if (externalVal == null || externalVal.trim().isEmpty()) { - throw new SolrException(ErrorCode.BAD_REQUEST, "Query value cannot be null or empty"); - } - - String trimmed = externalVal.trim(); - - // Check if it's the full range syntax: [min1,min2 TO max1,max2] - if (RANGE_PATTERN_REGEX.matcher(trimmed).matches()) { - RangeValue rangeValue = parseRangeValue(trimmed); - return IntRange.newContainsQuery(field.getName(), rangeValue.mins, rangeValue.maxs); - } - - // Syntax sugar: also accept a single-bound (i.e pX,pY,pZ) - if (SINGLE_BOUND_PATTERN.matcher(trimmed).matches()) { - int[] bound = parseIntArray(trimmed, "single bound values"); - - if (bound.length != numDimensions) { - throw new SolrException( - ErrorCode.BAD_REQUEST, - "Single bound dimensions (" - + bound.length - + ") do not match field type numDimensions (" - + numDimensions - + ")"); - } + public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return IntRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + } - return IntRange.newContainsQuery(field.getName(), bound, bound); - } + @Override + public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return IntRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + } - throw new SolrException( - ErrorCode.BAD_REQUEST, - "Invalid query format. Expected either a range [min TO max] or a single bound to search for, got: " - + externalVal); + @Override + public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return IntRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); } @Override @@ -385,8 +257,8 @@ public class IntRangeField extends PrimitiveFieldType { } } - /** Simple holder class for parsed range values. */ - public static class RangeValue { + /** Simple holder class for parsed integer range values. */ + public static class RangeValue implements AbstractNumericRangeField.NumericRangeValue { public final int[] mins; public final int[] maxs; @@ -395,6 +267,7 @@ public class IntRangeField extends PrimitiveFieldType { this.maxs = maxs; } + @Override public int getDimensions() { return mins.length; } 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 new file mode 100644 index 00000000000..e4eaabb768e --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/LongRangeField.java @@ -0,0 +1,292 @@ +/* + * 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.util.regex.Matcher; +import org.apache.lucene.document.LongRange; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.search.Query; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.search.QParser; + +/** + * Field type for long ranges with support for 1-4 dimensions. + * + * <p>This field type wraps Lucene's {@link LongRange} to provide storage and querying of long range + * values. Ranges can be 1-dimensional (simple ranges), 2-dimensional (bounding boxes), + * 3-dimensional (bounding cubes), or 4-dimensional (tesseracts). + * + * <h2>Value Format</h2> + * + * Values are specified using bracket notation with a TO keyword separator: + * + * <ul> + * <li>1D: {@code [10 TO 20]} + * <li>2D: {@code [10,20 TO 30,40]} + * <li>3D: {@code [10,20,30 TO 40,50,60]} + * <li>4D: {@code [10,20,30,40 TO 50,60,70,80]} + * </ul> + * + * As the name suggests minimum values (those on the left) must always be less than or equal to the + * maximum value for the corresponding dimension. Long values outside the range of {@code int} are + * fully supported (e.g. {@code [3000000000 TO 4000000000]}). + * + * <h2>Schema Configuration</h2> + * + * <pre> + * <fieldType name="longrange" class="org.apache.solr.schema.numericrange.LongRangeField" numDimensions="1"/> + * <fieldType name="longrange2d" class="org.apache.solr.schema.numericrange.LongRangeField" numDimensions="2"/> + * <field name="long_range" type="longrange" indexed="true" stored="true"/> + * <field name="long_range_2d" type="longrange2d" indexed="true" stored="true"/> + * </pre> + * + * <h2>Querying</h2> + * + * Use the {@code numericRange} query parser for range queries with support for different query + * types: + * + * <ul> + * <li>Intersects: {@code {!numericRange criteria="intersects" field=long_range}[100 TO 200]} + * <li>Within: {@code {!numericRange criteria="within" field=long_range}[0 TO 300]} + * <li>Contains: {@code {!numericRange criteria="contains" field=long_range}[150 TO 175]} + * <li>Crosses: {@code {!numericRange criteria="crosses" field=long_range}[150 TO 250]} + * </ul> + * + * <h2>Limitations</h2> + * + * The main limitation of this field type is that it doesn't support docValues or uninversion, and + * therefore can't be used for sorting, faceting, etc. + * + * @see LongRange + * @see org.apache.solr.search.numericrange.NumericRangeQParserPlugin + */ +public class LongRangeField extends AbstractNumericRangeField { + + @Override + public IndexableField createField(SchemaField field, Object value) { + if (!field.indexed() && !field.stored()) { + return null; + } + + String valueStr = value.toString(); + RangeValue rangeValue = parseRangeValue(valueStr); + + return new LongRange(field.getName(), rangeValue.mins, rangeValue.maxs); + } + + /** + * Parse a range value string into a RangeValue object. + * + * @param value the string value in format "[min1,min2,... TO max1,max2,...]" + * @return parsed RangeValue + * @throws SolrException if value format is invalid + */ + @Override + public RangeValue parseRangeValue(String value) { + if (value == null || value.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be null or empty"); + } + + Matcher matcher = RANGE_PATTERN_REGEX.matcher(value.trim()); + if (!matcher.matches()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid range format. Expected: [min1,min2,... TO max1,max2,...] where min and max values are longs, but got: " + + value); + } + + String minPart = matcher.group(1).trim(); + String maxPart = matcher.group(2).trim(); + + long[] mins = parseLongArray(minPart, "min values"); + long[] maxs = parseLongArray(maxPart, "max values"); + + if (mins.length != maxs.length) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min and max dimensions must match. Min dimensions: " + + mins.length + + ", max dimensions: " + + maxs.length); + } + + if (mins.length != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Range dimensions (" + + mins.length + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + // Validate that min <= max for each dimension + for (int i = 0; i < mins.length; i++) { + if (mins[i] > maxs[i]) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min value must be <= max value for dimension " + + i + + ". Min: " + + mins[i] + + ", Max: " + + maxs[i]); + } + } + + return new RangeValue(mins, maxs); + } + + @Override + public NumericRangeValue parseSingleBound(String value) { + final var singleBoundTyped = parseLongArray(value, "single bound values"); + return new RangeValue(singleBoundTyped, singleBoundTyped); + } + + /** + * Parse a comma-separated string of longs into an array. + * + * @param str the string to parse + * @param description description for error messages + * @return array of parsed longs + */ + private long[] parseLongArray(String str, String description) { + String[] parts = str.split(","); + long[] result = new long[parts.length]; + + for (int i = 0; i < parts.length; i++) { + try { + result[i] = Long.parseLong(parts[i].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid long in " + description + ": '" + parts[i].trim() + "'", + e); + } + } + + return result; + } + + @Override + public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rangeValueTyped = (RangeValue) rangeValue; + return LongRange.newContainsQuery(fieldName, rangeValueTyped.mins, rangeValueTyped.maxs); + } + + @Override + public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return LongRange.newIntersectsQuery(fieldName, 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); + } + + @Override + public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return LongRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + } + + @Override + protected Query getSpecializedRangeQuery( + QParser parser, + SchemaField field, + String part1, + String part2, + boolean minInclusive, + boolean maxInclusive) { + // For standard range syntax field:[value TO value], default to contains query + if (part1 == null || part2 == null) { + return super.getSpecializedRangeQuery( + parser, field, part1, part2, minInclusive, maxInclusive); + } + + // Parse the range bounds as single-dimensional values + long min, max; + try { + min = Long.parseLong(part1.trim()); + max = Long.parseLong(part2.trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid long values in range query: [" + part1 + " TO " + part2 + "]", + e); + } + + if (!minInclusive) { + min = (min == Long.MAX_VALUE) ? min : min + 1; + } + if (!maxInclusive) { + max = (max == Long.MIN_VALUE) ? max : max - 1; + } + + // Build arrays for the query based on configured dimensions + long[] mins = new long[numDimensions]; + long[] maxs = new long[numDimensions]; + + // For now, only support 1D range syntax with field:[X TO Y] + if (numDimensions == 1) { + mins[0] = min; + maxs[0] = max; + return LongRange.newContainsQuery(field.getName(), mins, maxs); + } else { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Standard range query syntax only supports 1D ranges. " + + "Use {!numericRange ...} for multi-dimensional queries."); + } + } + + /** Simple holder class for parsed long range values. */ + public static class RangeValue implements AbstractNumericRangeField.NumericRangeValue { + public final long[] mins; + public final long[] maxs; + + public RangeValue(long[] mins, long[] maxs) { + this.mins = mins; + this.maxs = maxs; + } + + @Override + public int getDimensions() { + return mins.length; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < mins.length; i++) { + if (i > 0) sb.append(","); + sb.append(mins[i]); + } + sb.append(" TO "); + for (int i = 0; i < maxs.length; i++) { + if (i > 0) sb.append(","); + sb.append(maxs[i]); + } + sb.append("]"); + return sb.toString(); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java index a424bda109e..b3dee0f0a8b 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -32,7 +32,7 @@ import org.apache.solr.search.mlt.MLTContentQParserPlugin; import org.apache.solr.search.mlt.MLTQParserPlugin; import org.apache.solr.search.neural.KnnQParserPlugin; import org.apache.solr.search.neural.VectorSimilarityQParserPlugin; -import org.apache.solr.search.numericrange.IntRangeQParserPlugin; +import org.apache.solr.search.numericrange.NumericRangeQParserPlugin; import org.apache.solr.util.plugin.NamedListInitializedPlugin; public abstract class QParserPlugin implements NamedListInitializedPlugin, SolrInfoBean { @@ -93,7 +93,7 @@ public abstract class QParserPlugin implements NamedListInitializedPlugin, SolrI map.put(KnnQParserPlugin.NAME, new KnnQParserPlugin()); map.put(VectorSimilarityQParserPlugin.NAME, new VectorSimilarityQParserPlugin()); map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin()); - map.put(IntRangeQParserPlugin.NAME, new IntRangeQParserPlugin()); + map.put(NumericRangeQParserPlugin.NAME, new NumericRangeQParserPlugin()); standardPlugins = Collections.unmodifiableMap(map); } diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java similarity index 72% rename from solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java rename to solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java index 5c1e8a6402a..65939903e9b 100644 --- a/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java @@ -17,30 +17,31 @@ package org.apache.solr.search.numericrange; import java.util.Locale; -import org.apache.lucene.document.IntRange; import org.apache.lucene.search.Query; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.SchemaField; +import org.apache.solr.schema.numericrange.AbstractNumericRangeField; +import org.apache.solr.schema.numericrange.AbstractNumericRangeField.NumericRangeValue; import org.apache.solr.schema.numericrange.IntRangeField; -import org.apache.solr.schema.numericrange.IntRangeField.RangeValue; +import org.apache.solr.schema.numericrange.LongRangeField; import org.apache.solr.search.QParser; import org.apache.solr.search.QParserPlugin; import org.apache.solr.search.QueryParsing; import org.apache.solr.search.SyntaxError; /** - * Query parser for IntRangeField with support for different query relationship types. + * Query parser for numeric range fields with support for different query relationship types. * - * <p>This parser enables queries against {@link IntRangeField} fields with explicit control over - * the query relationship type (intersects, within, contains, crosses). + * <p>This parser enables queries against {@link IntRangeField} and {@link LongRangeField} fields + * with explicit control over the query relationship type (intersects, within, contains, crosses). * * <h2>Parameters</h2> * * <ul> - * <li><b>field</b> (required): The IntRangeField to query + * <li><b>field</b> (required): The numeric range field to query * <li><b>criteria</b> (required): Query relationship criteria. One of: intersects, within, * contains, crosses * </ul> @@ -58,28 +59,26 @@ import org.apache.solr.search.SyntaxError; * <h2>Example Usage</h2> * * <pre> - * // 1D range queries + * // IntRangeField queries * {!numericRange criteria="intersects" field=price_range}[100 TO 200] * {!numericRange criteria="within" field=price_range}[0 TO 300] * {!numericRange criteria="contains" field=price_range}[150 TO 175] * {!numericRange criteria="crosses" field=price_range}[150 TO 250] * - * // 2D range queries (bounding boxes) + * // LongRangeField queries + * {!numericRange criteria="intersects" field=long_range}[1000000000 TO 2000000000] + * {!numericRange criteria="within" field=long_range}[0 TO 9999999999] + * + * // Multi-dimensional queries (bounding boxes, cubes, tesseracts) * {!numericRange criteria="intersects" field=bbox}[0,0 TO 10,10] * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20] - * - * // 3D range queries (bounding cubes) - * {!numericRange criteria="intersects" field=cube}[0,0,0 TO 10,10,10] - * - * // 4D range queries (tesseracts) - * {!numericRange criteria="intersects" field=tesseract}[0,0,0,0 TO 10,10,10,10] * </pre> * * @see IntRangeField - * @see IntRange + * @see LongRangeField * @lucene.experimental */ -public class IntRangeQParserPlugin extends QParserPlugin { +public class NumericRangeQParserPlugin extends QParserPlugin { /** Query relationship criteria for range queries. */ public enum QueryCriteria { @@ -171,7 +170,7 @@ public class IntRangeQParserPlugin extends QParserPlugin { throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be empty"); } - // Validate field exists and is an IntRangeField + // Validate field exists and is a supported numeric range field type SchemaField schemaField; try { schemaField = req.getSchema().getField(fieldName); @@ -179,56 +178,34 @@ public class IntRangeQParserPlugin extends QParserPlugin { throw new SolrException(ErrorCode.BAD_REQUEST, "Field not found: " + fieldName, e); } - if (!(schemaField.getType() instanceof IntRangeField)) { + if (schemaField.getType() instanceof AbstractNumericRangeField) { + AbstractNumericRangeField rangeField = (AbstractNumericRangeField) schemaField.getType(); + NumericRangeValue range; + try { + range = rangeField.parseRangeValue(rangeValue); + } catch (SolrException e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid range value: " + rangeValue, e); + } + switch (criteria) { + case INTERSECTS: + return rangeField.newIntersectsQuery(fieldName, range); + case WITHIN: + return rangeField.newWithinQuery(fieldName, range); + case CONTAINS: + return rangeField.newContainsQuery(fieldName, range); + case CROSSES: + return rangeField.newCrossesQuery(fieldName, range); + default: + throw new AssertionError("Unhandled QueryCriteria: " + criteria); + } + } else { throw new SolrException( ErrorCode.BAD_REQUEST, "Field '" + fieldName - + "' must be of type IntRangeField, but is: " + + "' must be a numeric range field type, but is: " + schemaField.getType().getTypeName()); } - - IntRangeField fieldType = (IntRangeField) schemaField.getType(); - - // Parse the range value - RangeValue range; - try { - range = fieldType.parseRangeValue(rangeValue); - } catch (SolrException e) { - throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid range value: " + rangeValue, e); - } - - // Create appropriate query based on criteria - return createRangeQuery(fieldName, range.mins, range.maxs, criteria); - } - - /** - * Create the appropriate Lucene query based on the query criteria. - * - * @param fieldName the field to query - * @param mins minimum values for each dimension - * @param maxs maximum values for each dimension - * @param criteria the query relationship criteria - * @return the created Lucene Query - */ - private Query createRangeQuery( - String fieldName, int[] mins, int[] maxs, QueryCriteria criteria) { - switch (criteria) { - case INTERSECTS: - return IntRange.newIntersectsQuery(fieldName, mins, maxs); - - case WITHIN: - return IntRange.newWithinQuery(fieldName, mins, maxs); - - case CONTAINS: - return IntRange.newContainsQuery(fieldName, mins, maxs); - - case CROSSES: - return IntRange.newCrossesQuery(fieldName, mins, maxs); - - default: - throw new AssertionError("Unhandled QueryCriteria: " + criteria); - } } }; } diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml similarity index 67% rename from solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml rename to solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml index bcfd1b5dc1a..4d70167a59e 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml @@ -16,8 +16,8 @@ limitations under the License. --> -<!-- Test schema for IntRangeField --> -<schema name="intrange-test" version="1.7"> +<!-- Test schema for IntRangeField and LongRangeField --> +<schema name="numericrange-test" version="1.7"> <!-- Basic field types --> <fieldType name="string" class="solr.StrField" sortMissingLast="true" /> @@ -30,6 +30,12 @@ <fieldType name="intrange3d" class="solr.numericrange.IntRangeField" numDimensions="3"/> <fieldType name="intrange4d" class="solr.numericrange.IntRangeField" numDimensions="4"/> + <!-- LongRangeField types with different dimensions --> + <fieldType name="longrange" class="solr.numericrange.LongRangeField" numDimensions="1"/> + <fieldType name="longrange2d" class="solr.numericrange.LongRangeField" numDimensions="2"/> + <fieldType name="longrange3d" class="solr.numericrange.LongRangeField" numDimensions="3"/> + <fieldType name="longrange4d" class="solr.numericrange.LongRangeField" numDimensions="4"/> + <!-- Field definitions --> <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false"/> <field name="_version_" type="long" indexed="false" stored="false" docValues="true"/> @@ -48,6 +54,19 @@ <!-- 4D IntRangeField (tesseract) --> <field name="tesseract" type="intrange4d" indexed="true" stored="true"/> + <!-- 1D LongRangeField --> + <field name="long_range" type="longrange" indexed="true" stored="true"/> + <field name="long_range_multi" type="longrange" indexed="true" stored="true" multiValued="true"/> + + <!-- 2D LongRangeField (bounding box) --> + <field name="long_range_2d" type="longrange2d" indexed="true" stored="true"/> + + <!-- 3D LongRangeField (bounding cube) --> + <field name="long_range_3d" type="longrange3d" indexed="true" stored="true"/> + + <!-- 4D LongRangeField (tesseract) --> + <field name="long_range_4d" type="longrange4d" indexed="true" stored="true"/> + <!-- Required by Solr --> <uniqueKey>id</uniqueKey> </schema> diff --git a/solr/core/src/test/org/apache/solr/schema/numericrange/LongRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/LongRangeFieldTest.java new file mode 100644 index 00000000000..0597939c12c --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/LongRangeFieldTest.java @@ -0,0 +1,357 @@ +/* + * 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 static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import org.apache.lucene.document.LongRange; +import org.apache.lucene.index.IndexableField; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.schema.IndexSchema; +import org.apache.solr.schema.SchemaField; +import org.junit.BeforeClass; + +/** Tests for {@link LongRangeField} */ +public class LongRangeFieldTest extends SolrTestCase { + + @BeforeClass + public static void ensureAssumptions() { + assumeWorkingMockito(); + } + + public void test1DRangeParsing() { + LongRangeField fieldType = createFieldType(1); + + // Valid 1D range + LongRangeField.RangeValue range = fieldType.parseRangeValue("[10 TO 20]"); + assertEquals(1, range.getDimensions()); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.maxs[0]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 TO 20 ]"); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.maxs[0]); + + // Negative numbers + range = fieldType.parseRangeValue("[-100 TO -50]"); + assertEquals(-100L, range.mins[0]); + assertEquals(-50L, range.maxs[0]); + + // Point range (min == max) + range = fieldType.parseRangeValue("[5 TO 5]"); + assertEquals(5L, range.mins[0]); + assertEquals(5L, range.maxs[0]); + + // Values outside int range + range = fieldType.parseRangeValue("[3000000000 TO 4000000000]"); + assertEquals(3_000_000_000L, range.mins[0]); + assertEquals(4_000_000_000L, range.maxs[0]); + } + + public void test2DRangeParsing() { + LongRangeField fieldType = createFieldType(2); + + // Valid 2D range (bounding box) + LongRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + assertEquals(2, range.getDimensions()); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.mins[1]); + assertEquals(30L, range.maxs[0]); + assertEquals(40L, range.maxs[1]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 , 20 TO 30 , 40 ]"); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.mins[1]); + assertEquals(30L, range.maxs[0]); + assertEquals(40L, range.maxs[1]); + } + + public void test3DRangeParsing() { + LongRangeField fieldType = createFieldType(3); + + // Valid 3D range (bounding cube) + LongRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30 TO 40,50,60]"); + assertEquals(3, range.getDimensions()); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.mins[1]); + assertEquals(30L, range.mins[2]); + assertEquals(40L, range.maxs[0]); + assertEquals(50L, range.maxs[1]); + assertEquals(60L, range.maxs[2]); + } + + public void test4DRangeParsing() { + LongRangeField fieldType = createFieldType(4); + + // Valid 4D range (tesseract) + LongRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30,40 TO 50,60,70,80]"); + assertEquals(4, range.getDimensions()); + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.mins[1]); + assertEquals(30L, range.mins[2]); + assertEquals(40L, range.mins[3]); + assertEquals(50L, range.maxs[0]); + assertEquals(60L, range.maxs[1]); + assertEquals(70L, range.maxs[2]); + assertEquals(80L, range.maxs[3]); + } + + public void testInvalidRangeFormat() { + LongRangeField fieldType = createFieldType(1); + + // Missing brackets + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + assertThat(e1.getMessage(), containsString("Invalid range format")); + assertThat(e1.getMessage(), containsString("Expected: [min1,min2,... TO max1,max2,...]")); + + // Missing TO keyword + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + assertThat(e2.getMessage(), containsString("Invalid range format")); + + // Empty value + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + assertThat(e3.getMessage(), containsString("Range value cannot be null or empty")); + + // Null value + SolrException e4 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + assertThat(e4.getMessage(), containsString("Range value cannot be null or empty")); + } + + public void testInvalidNumbers() { + LongRangeField fieldType = createFieldType(1); + + // Non-numeric values + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + assertThat(e1.getMessage(), containsString("Invalid range")); + assertThat(e1.getMessage(), containsString("where min and max values are longs")); + + // Partially numeric + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + assertThat(e2.getMessage(), containsString("Invalid range")); + assertThat(e2.getMessage(), containsString("where min and max values are longs")); + + // Floating point (should fail for LongRange) + SolrException e3 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + assertThat(e3.getMessage(), containsString("Invalid range")); + assertThat(e3.getMessage(), containsString("where min and max values are longs")); + } + + public void testDimensionMismatch() { + LongRangeField fieldType1D = createFieldType(1); + LongRangeField fieldType2D = createFieldType(2); + + // 2D value on 1D field + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + assertThat(e1.getMessage(), containsString("Range dimensions")); + assertThat(e1.getMessage(), containsString("do not match field type numDimensions")); + + // 1D value on 2D field + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + assertThat(e2.getMessage(), containsString("Range dimensions")); + assertThat(e2.getMessage(), containsString("do not match field type numDimensions")); + + // Min/max dimension mismatch + SolrException e3 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[10,20 TO 30]")); // 2D mins, 1D maxs + assertThat(e3.getMessage(), containsString("Min and max dimensions must match")); + } + + public void testMinGreaterThanMax() { + LongRangeField fieldType = createFieldType(1); + + // Min > max should fail + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + assertThat(e1.getMessage(), containsString("Min value must be <= max value")); + assertThat(e1.getMessage(), containsString("dimension 0")); + + // For 2D + LongRangeField fieldType2D = createFieldType(2); + SolrException e2 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + assertThat(e2.getMessage(), containsString("Min value must be <= max value")); + assertThat(e2.getMessage(), containsString("dimension 0")); + } + + public void testFieldCreation1D() { + LongRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "long_range"); + + IndexableField field = fieldType.createField(schemaField, "[100 TO 200]"); + assertNotNull(field); + assertTrue(field instanceof LongRange); + assertEquals("long_range", field.name()); + } + + public void testFieldCreation2D() { + LongRangeField fieldType = createFieldType(2); + SchemaField schemaField = createSchemaField(fieldType, "long_range_2d"); + + IndexableField field = fieldType.createField(schemaField, "[0,0 TO 10,10]"); + assertNotNull(field); + assertTrue(field instanceof LongRange); + assertEquals("long_range_2d", field.name()); + } + + public void testStoredField() { + LongRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "long_range"); + + String value = "[100 TO 200]"; + IndexableField storedField = fieldType.getStoredField(schemaField, value); + assertNotNull(storedField); + assertEquals("long_range", storedField.name()); + assertEquals(value, storedField.stringValue()); + } + + public void testToInternal() { + LongRangeField fieldType = createFieldType(1); + + // Valid value should pass through after validation + String value = "[10 TO 20]"; + String internal = fieldType.toInternal(value); + assertEquals(value, internal); + + // Invalid value should throw exception + SolrException e = expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + assertThat(e.getMessage(), containsString("Invalid range format")); + } + + public void testToNativeType() { + LongRangeField fieldType = createFieldType(1); + + // String input + Object nativeType = fieldType.toNativeType("[10 TO 20]"); + assertTrue(nativeType instanceof LongRangeField.RangeValue); + LongRangeField.RangeValue range = (LongRangeField.RangeValue) nativeType; + assertEquals(10L, range.mins[0]); + assertEquals(20L, range.maxs[0]); + + // RangeValue input (should pass through) + LongRangeField.RangeValue inputRange = + new LongRangeField.RangeValue(new long[] {5L}, new long[] {15L}); + Object result = fieldType.toNativeType(inputRange); + assertSame(inputRange, result); + + // Null input + assertNull(fieldType.toNativeType(null)); + } + + public void testSortFieldThrowsException() { + LongRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "long_range"); + + // Sorting should not be supported + SolrException e = + expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + assertThat(e.getMessage(), containsString("Cannot sort on LongRangeField")); + assertThat(e.getMessage(), containsString("long_range")); + } + + public void testUninversionType() { + LongRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "long_range"); + + // Should return null (no field cache support) + assertNull(fieldType.getUninversionType(schemaField)); + } + + public void testInvalidNumDimensions() { + LongRangeField field = new LongRangeField(); + Map<String, String> args = new HashMap<>(); + IndexSchema schema = createMockSchema(); + + // Test numDimensions = 0 + args.put("numDimensions", "0"); + SolrException e1 = expectThrows(SolrException.class, () -> field.init(schema, args)); + assertThat(e1.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e1.getMessage(), containsString("but was [0]")); + + // Test numDimensions = 5 (too high) + args.put("numDimensions", "5"); + LongRangeField field2 = new LongRangeField(); + SolrException e2 = expectThrows(SolrException.class, () -> field2.init(schema, args)); + assertThat(e2.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e2.getMessage(), containsString("but was [5]")); + + // Test negative numDimensions + args.put("numDimensions", "-1"); + LongRangeField field3 = new LongRangeField(); + SolrException e3 = expectThrows(SolrException.class, () -> field3.init(schema, args)); + assertThat(e3.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e3.getMessage(), containsString("but was [-1]")); + } + + public void testRangeValueToString() { + LongRangeField fieldType = createFieldType(2); + LongRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + + String str = range.toString(); + assertEquals("[10,20 TO 30,40]", str); + } + + public void testExtremeValues() { + LongRangeField fieldType = createFieldType(1); + + // Test with Long.MIN_VALUE and Long.MAX_VALUE + LongRangeField.RangeValue range = + fieldType.parseRangeValue("[" + Long.MIN_VALUE + " TO " + Long.MAX_VALUE + "]"); + assertEquals(Long.MIN_VALUE, range.mins[0]); + assertEquals(Long.MAX_VALUE, range.maxs[0]); + } + + private IndexSchema createMockSchema() { + final var schema = mock(IndexSchema.class); + when(schema.getVersion()).thenReturn(1.7f); + return schema; + } + + private LongRangeField createFieldType(int numDimensions) { + LongRangeField field = new LongRangeField(); + Map<String, String> args = new HashMap<>(); + args.put("numDimensions", String.valueOf(numDimensions)); + + field.init(createMockSchema(), args); + + return field; + } + + private SchemaField createSchemaField(LongRangeField fieldType, String name) { + final var fieldProperties = + 0b1 | 0b100; // INDEXED | STORED - constants cannot be accessed directly due to visibility. + return new SchemaField(name, fieldType, fieldProperties, null); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java index 6756fabcb47..ffe95a9d78d 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -36,7 +36,7 @@ import org.apache.solr.common.SolrInputDocument; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.request.SolrRequestInfo; import org.apache.solr.response.SolrQueryResponse; -import org.apache.solr.search.numericrange.IntRangeQParserPlugin; +import org.apache.solr.search.numericrange.NumericRangeQParserPlugin; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -67,7 +67,9 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { if (!doAssertParserCoverage) return; final var qParsersToTest = new HashSet<>(QParserPlugin.standardPlugins.keySet()); - qParsersToTest.remove(IntRangeQParserPlugin.NAME); // Tested in IntRangeQParserPluginTest + qParsersToTest.remove( + NumericRangeQParserPlugin.NAME); // Tested in NumericRangeQParserPluginIntTest and + // NumericRangeQParserPluginLongTest for (String name : qParsersToTest) { assertTrue( "testParserCoverage was run w/o any other method explicitly testing qparser: " + name, diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java similarity index 98% rename from solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java rename to solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java index 40fb2e6396d..b76db8acf82 100644 --- a/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginIntTest.java @@ -21,12 +21,15 @@ import org.apache.solr.common.SolrException; import org.junit.BeforeClass; import org.junit.Test; -/** Tests for {@link IntRangeQParserPlugin} */ -public class IntRangeQParserPluginTest extends SolrTestCaseJ4 { +/** + * Tests for {@link NumericRangeQParserPlugin} using {@link + * org.apache.solr.schema.numericrange.IntRangeField} fields. + */ +public class NumericRangeQParserPluginIntTest extends SolrTestCaseJ4 { @BeforeClass public static void beforeClass() throws Exception { - initCore("solrconfig.xml", "schema-intrange.xml"); + initCore("solrconfig.xml", "schema-numericrange.xml"); } @Override @@ -281,7 +284,7 @@ public class IntRangeQParserPluginTest extends SolrTestCaseJ4 { // Query on non-IntRangeField should fail assertQEx( "Query on wrong field type should fail", - "must be of type IntRangeField", + "must be a numeric range field type", req("q", "{!numericRange criteria=intersects field=title}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } 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 new file mode 100644 index 00000000000..1b774e88e90 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginLongTest.java @@ -0,0 +1,449 @@ +/* + * 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 org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Tests for {@link NumericRangeQParserPlugin} using {@link + * org.apache.solr.schema.numericrange.LongRangeField} fields. + */ +public class NumericRangeQParserPluginLongTest 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()); + } + + @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]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[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]']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[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]"), + "//result[@numFound='3']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='id'][.='4']"); + } + + @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]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=long_range}[0 TO 300]"), + "//result[@numFound='3']"); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=long_range}[100 TO 200]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=long_range}[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]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=long_range}[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]"), + "//result[@numFound='0']"); + + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=long_range}[100 TO 200]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='3']"); + } + + @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]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"crosses\" field=long_range}[150 TO 250]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='4']"); + } + + @Test + public void test2DIntersectsQuery() { + assertU(adoc("id", "1", "long_range_2d", "[0,0 TO 10,10]")); + assertU(adoc("id", "2", "long_range_2d", "[5,5 TO 15,15]")); + assertU(adoc("id", "3", "long_range_2d", "[20,20 TO 30,30]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_2d}[8,8 TO 12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_2d}[25,25 TO 35,35]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='3']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_2d}[100,100 TO 200,200]"), + "//result[@numFound='0']"); + } + + @Test + public void test3DQuery() { + assertU(adoc("id", "1", "long_range_3d", "[0,0,0 TO 10,10,10]")); + assertU(adoc("id", "2", "long_range_3d", "[5,5,5 TO 15,15,15]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_3d}[8,8,8 TO 12,12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void test4DQuery() { + assertU(adoc("id", "1", "long_range_4d", "[0,0,0,0 TO 10,10,10,10]")); + assertU(adoc("id", "2", "long_range_4d", "[5,5,5,5 TO 15,15,15,15]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_4d}[8,8,8,8 TO 12,12,12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testMultiValuedField() { + assertU( + adoc("id", "1", "long_range_multi", "[100 TO 200]", "long_range_multi", "[300 TO 400]")); + assertU(adoc("id", "2", "long_range_multi", "[150 TO 250]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_multi}[110 TO 120]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/arr[@name='long_range_multi']/str[1][.='[100 TO 200]']", + "//result/doc/arr[@name='long_range_multi']/str[2][.='[300 TO 400]']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_multi}[310 TO 320]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range_multi}[150 TO 250]"), + "//result[@numFound='2']"); + } + + @Test + public void testMissingFieldParameter() { + assertQEx( + "Missing field parameter should fail", + "Missing required parameter: field", + req("q", "{!numericRange criteria=intersects}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testMissingCriteriaParameter() { + assertQEx( + "Missing criteria parameter should fail", + "Missing required parameter: criteria", + req("q", "{!numericRange field=long_range}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidFieldType() { + // Query on a plain string field should fail + assertQEx( + "Query on wrong field type should fail", + "must be a numeric range field type", + req("q", "{!numericRange criteria=intersects field=title}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidQueryType() { + assertU(adoc("id", "1", "long_range", "[100 TO 200]")); + assertU(commit()); + + assertQEx( + "Invalid query criteria should fail", + "Unknown query criteria", + req("q", "{!numericRange criteria=\"invalid\" field=long_range}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidRangeValue() { + assertU(adoc("id", "1", "long_range", "[100 TO 200]")); + assertU(commit()); + + assertQEx( + "Invalid range format should fail", + "Invalid range", + req("q", "{!numericRange criteria=intersects field=long_range}invalid"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testEmptyRangeValue() { + assertU(adoc("id", "1", "long_range", "[100 TO 200]")); + assertU(commit()); + + assertQEx( + "Empty range value should fail", + req("q", "{!numericRange criteria=intersects field=long_range}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testNegativeValues() { + assertU(adoc("id", "1", "long_range", "[-100 TO -50]")); + assertU(adoc("id", "2", "long_range", "[-75 TO -25]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[-80 TO -60]"), + "//result[@numFound='2']"); + } + + @Test + public void testValuesOutsideIntRange() { + // 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(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[3500000000 TO 3600000000]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + @Test + public void testExtremeValues() { + long min = Long.MIN_VALUE; + long max = Long.MAX_VALUE; + + assertU(adoc("id", "1", "long_range", "[" + min + " TO " + max + "]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[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]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[100 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=long_range}[50 TO 150]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + // ------------------------------------------ + // Tests for getFieldQuery and getSpecializedRangeQuery via the standard query parser. + // These default to "contains" semantics. + + @Test + public void testGetFieldQueryFullRange() { + // 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(commit()); + + // Contains semantics: find indexed ranges that fully contain [120 TO 180] + assertQ( + req("q", "long_range:[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 + assertU(commit()); + + assertQ( + req("q", "long_range:[100 TO 200]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @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 + assertU(commit()); + + assertQ( + req("q", "long_range:150"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @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 + assertU(commit()); + + assertQ( + req("q", "bbox:5,5"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQueryFieldFormatting() { + // Test 1D field formatting + assertU(adoc("id", "1", "long_range", "[100 TO 200]")); + // Test 2D field formatting + assertU(adoc("id", "2", "bbox", "[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 + assertU(adoc("id", "4", "tesseract", "[1,2,3,4 TO 11,12,13,14]")); + // Test multi-valued field formatting + assertU( + adoc( + "id", + "5", + "long_range_multi", + "[50 TO 100]", + "long_range_multi", + "[200 TO 300]", + "long_range_multi", + "[400 TO 500]")); + assertU(commit()); + + // Verify 1D field returns correctly formatted value + assertQ( + req("q", "id:1"), + "//result[@numFound='1']", + "//result/doc/str[@name='long_range'][.='[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]']"); + + // Verify 3D field returns correctly formatted value + assertQ( + req("q", "id:3"), + "//result[@numFound='1']", + "//result/doc/str[@name='cube'][.='[5,10,15 TO 25,30,35]']"); + + // Verify 4D field returns correctly formatted value + assertQ( + req("q", "id:4"), + "//result[@numFound='1']", + "//result/doc/str[@name='tesseract'][.='[1,2,3,4 TO 11,12,13,14]']"); + + // Verify multi-valued field returns all values correctly formatted + assertQ( + req("q", "id:5"), + "//result[@numFound='1']", + "//result/doc/arr[@name='long_range_multi']/str[1][.='[50 TO 100]']", + "//result/doc/arr[@name='long_range_multi']/str[2][.='[200 TO 300]']", + "//result/doc/arr[@name='long_range_multi']/str[3][.='[400 TO 500]']"); + } +} 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 60d6b377284..2b176e77786 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 @@ -59,6 +59,8 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |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 is defined in the `org.apache.solr.schema.numericrange` package; fieldType definitions typically reference this as: `<fieldType name="long [...] + |NestPathField | Specialized field type storing enhanced information, when xref:indexing-nested-documents.adoc#schema-configuration[working with nested documents]. |PointType |A single-valued n-dimensional point. It's both for sorting spatial data that is _not_ lat-lon, and for some more rare use-cases. (NOTE: this is _not_ related to the "Point" based numeric fields). See xref:query-guide:spatial-search.adoc[] for more information. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc index e5358937478..45e32c61c64 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc @@ -1006,7 +1006,7 @@ For more information about the possibilities of nested queries, see Yonik Seeley NOTE: Syntax specifics of the `{!numericRange}` query parser are considered experimental and may change in the future. -Allows users to search range fields (e.g. `IntRangeField`) using a specified query-range. +Allows users to search range fields (e.g. `IntRangeField`, `LongRangeField`) using a specified query-range. Multiple match semantics supported, see the `criteria` parameter below for more details. === Numeric Range Parameters @@ -1019,7 +1019,7 @@ Multiple match semantics supported, see the `criteria` parameter below for more |=== + The field name to operate on. -Must be a "range" field (e.g. `IntRangeField`) +Must be a numeric range field type (e.g. `IntRangeField`, `LongRangeField`) `criteria`:: + [%autowidth,frame=none] @@ -1068,6 +1068,13 @@ Find products whose `price_range` overlaps at all with a user's desired price ra {!numericRange field="price_range" criteria="intersects" v="[100 TO 120]"} ---- +Find events whose `event_window` (a `LongRangeField` storing Unix epoch milliseconds) overlaps a time range. + +[source,text] +---- +{!numericRange field="event_window" criteria="intersects"}[1700000000000 TO 1800000000000] +---- + == Neural Query Parsers There is currently one Query Parser in Solr to provide Neural Search: `knn`.
