From f4603574e50914416efb520d9891d8866393fd8b Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 08:32:37 -0500 Subject: [PATCH 01/13] SOLR-13309: Add IntRangeField for Lucenes IntRange This commit adds a new field type, IntRangeField, that can be used to hold singular or multi-dimensional (up to 4) ranges of integers. Field values are represented using brackets and the "TO" operator, with commas used to delimit dimensions (when a particular field is defined as having more than 1 dimension), e.g. - [-1 TO 5] - [1,2 TO 5,10] - [1 TO 1] IntRangeField does not support docValues or uninversion, meaning it's primarily only used for querying. The field can be stored and returned in search-results. Searches on these range-fields mostly rely on a QParser, {!myRange}, which supports "intersects", "crosses", "within", and "contains" semantics via a "criteria" local param. e.g. - {!myRange field=price_range criteria=within}[1 TO 5] Matches docs whose 'price_range' field falls fully within [1 TO 5]. A doc with [2 TO 3] would match; [3 TO 6] or [8 TO 10] would not. - {!myRange field=price_range criteria=crosses}[1,10 TO 5,20] Matches docs whose 'price_range' field is partially but not fully contained within [1,10 TO 5,20]. A doc with [2,11 TO 6,21] would match, but [3,11 TO 5,19] would not. TODO - renaming of QParser, 'myRange' stinks - general cleanup - switch around 'external', 'internal', 'native' representations. --- .../org/apache/solr/schema/IntRangeField.java | 352 +++++++++++++++++ .../solr/search/IntRangeQParserPlugin.java | 178 +++++++++ .../org/apache/solr/search/QParserPlugin.java | 1 + .../solr/collection1/conf/schema-intrange.xml | 53 +++ .../apache/solr/schema/IntRangeFieldTest.java | 319 ++++++++++++++++ .../search/IntRangeQParserPluginTest.java | 360 ++++++++++++++++++ 6 files changed, 1263 insertions(+) create mode 100644 solr/core/src/java/org/apache/solr/schema/IntRangeField.java create mode 100644 solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java create mode 100644 solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml create mode 100644 solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java create mode 100644 solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java new file mode 100644 index 000000000000..5968a4d03b75 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -0,0 +1,352 @@ +/* + * 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; + +import java.io.IOException; +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.search.QParser; +import org.apache.solr.uninverting.UninvertingReader.Type; + +/** + * Field type for integer ranges with support for 1-4 dimensions. + * + *

This field type wraps Lucene's {@link IntRange} to provide storage and querying of integer + * range values. Ranges can be 1-dimensional (simple ranges), 2-dimensional (bounding boxes), + * 3-dimensional (bounding cubes), or 4-dimensional (tesseracts). + * + *

Value Format

+ * + * Values are specified using bracket notation with a TO keyword separator: + * + * + * + * 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. + * + *

Schema Configuration

+ * + *
+ * <fieldType name="intrange" class="org.apache.solr.schema.IntRangeField" numDimensions="1"/>
+ * <fieldType name="intrange2d" class="org.apache.solr.schema.IntRangeField" numDimensions="2"/>
+ * <field name="price_range" type="intrange" indexed="true" stored="true"/>
+ * <field name="bbox" type="intrange2d" indexed="true" stored="true"/>
+ * 
+ * + *

Querying

+ * + * Use the {@code myRange} query parser for range queries with support for different query types: + * + * + * + *

Limitations

+ * + * 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 IntRange + * @see org.apache.solr.search.IntRangeQParserPlugin + */ +public class IntRangeField extends PrimitiveFieldType implements IntValueFieldType { + + private static final Pattern RANGE_PATTERN = + Pattern.compile("\\[\\s*([^\\]]+?)\\s+TO\\s+([^\\]]+?)\\s*\\]"); + + private int numDimensions = 1; + + @Override + protected boolean enableDocValuesByDefault() { + return false; // IntRange does not support docValues + } + + @Override + protected void init(IndexSchema schema, Map 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, got: " + numDimensions); + } + } + + // IntRange does not support docValues - validate this wasn't explicitly set + if (hasProperty(DOC_VALUES)) { + throw new SolrException( + ErrorCode.SERVER_ERROR, "IntRangeField does not support docValues: " + typeName); + } + } + + @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 IntRange(field.getName(), rangeValue.mins, rangeValue.maxs); + } + + @Override + public List createFields(SchemaField field, Object value) { + IndexableField indexedField = createField(field, value); + List fields = new java.util.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, f.stringValue(), 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. + * + * @param value the string value in format "[min1,min2,... TO max1,max2,...]" + * @return parsed RangeValue + * @throws SolrException if value format is invalid + */ + 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.matcher(value.trim()); + if (!matcher.matches()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid range format. Expected: [min1,min2,... TO max1,max2,...], got: " + value); + } + + String minPart = matcher.group(1).trim(); + String maxPart = matcher.group(2).trim(); + + int[] mins = parseIntArray(minPart, "min values"); + int[] maxs = parseIntArray(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); + } + + /** + * Parse a comma-separated string of integers into an array. + * + * @param str the string to parse + * @param description description for error messages + * @return array of parsed integers + */ + private int[] parseIntArray(String str, String description) { + String[] parts = str.split(","); + int[] result = new int[parts.length]; + + for (int i = 0; i < parts.length; i++) { + try { + result[i] = Integer.parseInt(parts[i].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer in " + description + ": '" + parts[i].trim() + "'", + e); + } + } + + return result; + } + + protected StoredField getStoredField(SchemaField sf, Object value) { + return new StoredField(sf.getName(), value.toString()); + } + + @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 intersects query + if (part1 == null || part2 == null) { + return super.getSpecializedRangeQuery( + parser, field, part1, part2, minInclusive, maxInclusive); + } + + // Parse the range bounds as single-dimensional values + int min, max; + try { + min = Integer.parseInt(part1.trim()); + max = Integer.parseInt(part2.trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer values in range query: [" + part1 + " TO " + part2 + "]", + e); + } + + if (!minInclusive) { + min = (min == Integer.MAX_VALUE) ? min : min + 1; + } + if (!maxInclusive) { + max = (max == Integer.MIN_VALUE) ? max : max - 1; + } + + // Build arrays for the query based on configured dimensions + int[] mins = new int[numDimensions]; + int[] maxs = new int[numDimensions]; + + // For now, only support 1D range syntax with field:[X TO Y] + if (numDimensions == 1) { + mins[0] = min; + maxs[0] = max; + return IntRange.newIntersectsQuery(field.getName(), mins, maxs); + } else { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Standard range query syntax only supports 1D ranges. " + + "Use {!myRange field=" + + field.getName() + + "} for multi-dimensional queries."); + } + } + + /** Simple holder class for parsed range values. */ + public static class RangeValue { + public final int[] mins; + public final int[] maxs; + + public RangeValue(int[] mins, int[] maxs) { + this.mins = mins; + this.maxs = maxs; + } + + 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/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java new file mode 100644 index 000000000000..25fb44bbca30 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java @@ -0,0 +1,178 @@ +/* + * 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; + +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.IntRangeField; +import org.apache.solr.schema.IntRangeField.RangeValue; +import org.apache.solr.schema.SchemaField; + +/** + * Query parser for IntRangeField with support for different query relationship types. + * + *

This parser enables queries against {@link IntRangeField} fields with explicit control over + * the query relationship type (intersects, within, contains, crosses). + * + *

Parameters

+ * + *
    + *
  • field (required): The IntRangeField to query + *
  • criteria (optional, default=intersects): Query relationship criteria. One of: + * intersects, within, contains, crosses + *
+ * + *

Query Types

+ * + *
    + *
  • intersects: Matches ranges that overlap with the query range (most permissive) + *
  • within: Matches ranges completely contained by the query range + *
  • contains: Matches ranges that completely contain the query range + *
  • crosses: Matches ranges that cross the query range boundaries (not disjoint, not + * wholly contained) + *
+ * + *

Example Usage

+ * + *
+ * // 1D range queries
+ * {!myRange field=price_range}[100 TO 200]                           // intersects (default)
+ * {!myRange criteria="within" field=price_range}[0 TO 300]           // within
+ * {!myRange criteria="contains" field=price_range}[150 TO 175]       // contains
+ * {!myRange criteria="crosses" field=price_range}[150 TO 250]        // crosses
+ *
+ * // 2D range queries (bounding boxes)
+ * {!myRange field=bbox}[0,0 TO 10,10]                                // intersects
+ * {!myRange criteria="within" field=bbox}[-10,-10 TO 20,20]          // within
+ *
+ * // 3D range queries (bounding cubes)
+ * {!myRange field=cube}[0,0,0 TO 10,10,10]                           // intersects
+ *
+ * // 4D range queries (tesseracts)
+ * {!myRange field=tesseract}[0,0,0,0 TO 10,10,10,10]                 // intersects
+ * 
+ * + * @see IntRangeField + * @see IntRange + */ +public class IntRangeQParserPlugin extends QParserPlugin { + + /** Parser name used in local params syntax: {@code {!myRange ...}} */ + public static final String NAME = "myRange"; + + /** Parameter name for the field to query */ + public static final String FIELD_PARAM = "field"; + + /** Parameter name for the query criteria (intersects, within, contains, crosses) */ + public static final String CRITERIA_PARAM = "criteria"; + + /** Default query criteria if not specified */ + public static final String DEFAULT_CRITERIA = "intersects"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() throws SyntaxError { + // Get required field parameter + String fieldName = localParams.get(FIELD_PARAM); + if (fieldName == null || fieldName.trim().isEmpty()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, "Missing required parameter: " + FIELD_PARAM); + } + + // Get query criteria parameter (default to intersects) + String queryCriteria = localParams.get(CRITERIA_PARAM, DEFAULT_CRITERIA).toLowerCase(); + + // Get the range value from the query string or 'v' param + String rangeValue = localParams.get(QueryParsing.V, qstr); + if (rangeValue == null || rangeValue.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be empty"); + } + + // Validate field exists and is an IntRangeField + SchemaField schemaField; + try { + schemaField = req.getSchema().getField(fieldName); + } catch (SolrException e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Field not found: " + fieldName, e); + } + + if (!(schemaField.getType() instanceof IntRangeField)) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Field '" + + fieldName + + "' must be of type IntRangeField, 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, queryCriteria); + } + + /** + * 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 queryCriteria the query relationship criteria + * @return the created Lucene Query + * @throws SolrException if query criteria is invalid + */ + private Query createRangeQuery(String fieldName, int[] mins, int[] maxs, String queryCriteria) + throws SolrException { + switch (queryCriteria) { + 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 SolrException( + ErrorCode.BAD_REQUEST, + "Unknown query criteria: '" + + queryCriteria + + "'. Valid criteria are: intersects, within, contains, crosses"); + } + } + }; + } +} 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 45409cb8982c..3da5259d5a28 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -90,6 +90,7 @@ public abstract class QParserPlugin implements NamedListInitializedPlugin { map.put(KnnQParserPlugin.NAME, new KnnQParserPlugin()); map.put(VectorSimilarityQParserPlugin.NAME, new VectorSimilarityQParserPlugin()); map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin()); + map.put(IntRangeQParserPlugin.NAME, new IntRangeQParserPlugin()); standardPlugins = Collections.unmodifiableMap(map); } diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml new file mode 100644 index 000000000000..f3c1fb41b638 --- /dev/null +++ b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + diff --git a/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java new file mode 100644 index 000000000000..43ab7f7a6b64 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java @@ -0,0 +1,319 @@ +/* + * 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; + +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +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.IntRange; +import org.apache.lucene.index.IndexableField; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; + +/** Tests for {@link IntRangeField} */ +public class IntRangeFieldTest extends SolrTestCase { + + @BeforeClass + public static void ensureAssumptions() { + assumeWorkingMockito(); + } + + public void test1DRangeParsing() { + IntRangeField fieldType = createFieldType(1); + + // Valid 1D range + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10 TO 20]"); + assertEquals(1, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 TO 20 ]"); + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // Negative numbers + range = fieldType.parseRangeValue("[-100 TO -50]"); + assertEquals(-100, range.mins[0]); + assertEquals(-50, range.maxs[0]); + + // Point range (min == max) + range = fieldType.parseRangeValue("[5 TO 5]"); + assertEquals(5, range.mins[0]); + assertEquals(5, range.maxs[0]); + } + + public void test2DRangeParsing() { + IntRangeField fieldType = createFieldType(2); + + // Valid 2D range (bounding box) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + assertEquals(2, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.maxs[0]); + assertEquals(40, range.maxs[1]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 , 20 TO 30 , 40 ]"); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.maxs[0]); + assertEquals(40, range.maxs[1]); + } + + public void test3DRangeParsing() { + IntRangeField fieldType = createFieldType(3); + + // Valid 3D range (bounding cube) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30 TO 40,50,60]"); + assertEquals(3, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.mins[2]); + assertEquals(40, range.maxs[0]); + assertEquals(50, range.maxs[1]); + assertEquals(60, range.maxs[2]); + } + + public void test4DRangeParsing() { + IntRangeField fieldType = createFieldType(4); + + // Valid 4D range (tesseract) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30,40 TO 50,60,70,80]"); + assertEquals(4, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.mins[2]); + assertEquals(40, range.mins[3]); + assertEquals(50, range.maxs[0]); + assertEquals(60, range.maxs[1]); + assertEquals(70, range.maxs[2]); + assertEquals(80, range.maxs[3]); + } + + public void testInvalidRangeFormat() { + IntRangeField fieldType = createFieldType(1); + + // Missing brackets + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + + // Missing TO keyword + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + + // Empty value + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + + // Null value + expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + } + + public void testInvalidNumbers() { + IntRangeField fieldType = createFieldType(1); + + // Non-numeric values + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + + // Partially numeric + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + + // Floating point (should fail for IntRange) + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + } + + public void testDimensionMismatch() { + IntRangeField fieldType1D = createFieldType(1); + IntRangeField fieldType2D = createFieldType(2); + + // 2D value on 1D field + expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + + // 1D value on 2D field + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + + // Min/max dimension mismatch + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[10,20 TO 30]")); // 2D mins, 1D maxs + } + + public void testMinGreaterThanMax() { + IntRangeField fieldType = createFieldType(1); + + // Min > max should fail + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + + // For 2D + IntRangeField fieldType2D = createFieldType(2); + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + } + + public void testFieldCreation1D() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + IndexableField field = fieldType.createField(schemaField, "[100 TO 200]"); + assertNotNull(field); + assertTrue(field instanceof IntRange); + assertEquals("price_range", field.name()); + } + + public void testFieldCreation2D() { + IntRangeField fieldType = createFieldType(2); + SchemaField schemaField = createSchemaField(fieldType, "bbox"); + + IndexableField field = fieldType.createField(schemaField, "[0,0 TO 10,10]"); + assertNotNull(field); + assertTrue(field instanceof IntRange); + assertEquals("bbox", field.name()); + } + + public void testStoredField() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + String value = "[100 TO 200]"; + IndexableField storedField = fieldType.getStoredField(schemaField, value); + assertNotNull(storedField); + assertEquals("price_range", storedField.name()); + assertEquals(value, storedField.stringValue()); + } + + public void testToInternal() { + IntRangeField 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 + expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + } + + public void testToNativeType() { + IntRangeField fieldType = createFieldType(1); + + // String input + Object nativeType = fieldType.toNativeType("[10 TO 20]"); + assertTrue(nativeType instanceof IntRangeField.RangeValue); + IntRangeField.RangeValue range = (IntRangeField.RangeValue) nativeType; + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // RangeValue input (should pass through) + IntRangeField.RangeValue inputRange = + new IntRangeField.RangeValue(new int[] {5}, new int[] {15}); + Object result = fieldType.toNativeType(inputRange); + assertSame(inputRange, result); + + // Null input + assertNull(fieldType.toNativeType(null)); + } + + public void testSortFieldThrowsException() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + // Sorting should not be supported + expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + } + + public void testUninversionType() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + // Should return null (no field cache support) + assertNull(fieldType.getUninversionType(schemaField)); + } + + public void testDocValuesNotSupported() { + IndexSchema schema = createMockSchema(); + IntRangeField field = new IntRangeField(); + + Map args = new HashMap<>(); + args.put("numDimensions", "1"); + args.put("docValues", "true"); + + // Should throw exception when docValues is enabled + expectThrows(SolrException.class, () -> field.setArgs(schema, args)); + } + + public void testInvalidNumDimensions() { + IntRangeField field = new IntRangeField(); + Map args = new HashMap<>(); + IndexSchema schema = createMockSchema(); + + // Test numDimensions = 0 + args.put("numDimensions", "0"); + expectThrows(SolrException.class, () -> field.init(schema, args)); + + // Test numDimensions = 5 (too high) + args.put("numDimensions", "5"); + IntRangeField field2 = new IntRangeField(); + expectThrows(SolrException.class, () -> field2.init(schema, args)); + + // Test negative numDimensions + args.put("numDimensions", "-1"); + IntRangeField field3 = new IntRangeField(); + expectThrows(SolrException.class, () -> field3.init(schema, args)); + } + + public void testRangeValueToString() { + IntRangeField fieldType = createFieldType(2); + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + + String str = range.toString(); + assertEquals("[10,20 TO 30,40]", str); + } + + public void testExtremeValues() { + IntRangeField fieldType = createFieldType(1); + + // Test with Integer.MIN_VALUE and Integer.MAX_VALUE + IntRangeField.RangeValue range = + fieldType.parseRangeValue("[" + Integer.MIN_VALUE + " TO " + Integer.MAX_VALUE + "]"); + assertEquals(Integer.MIN_VALUE, range.mins[0]); + assertEquals(Integer.MAX_VALUE, range.maxs[0]); + } + + private IndexSchema createMockSchema() { + final var schema = mock(IndexSchema.class); + when(schema.getVersion()).thenReturn(1.7f); + return schema; + } + + private IntRangeField createFieldType(int numDimensions) { + IntRangeField field = new IntRangeField(); + Map args = new HashMap<>(); + args.put("numDimensions", String.valueOf(numDimensions)); + + field.init(createMockSchema(), args); + + return field; + } + + private SchemaField createSchemaField(IntRangeField fieldType, String name) { + return new SchemaField(name, fieldType, SchemaField.INDEXED | SchemaField.STORED, null); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java new file mode 100644 index 000000000000..86d6ebd32660 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java @@ -0,0 +1,360 @@ +/* + * 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; + +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests for {@link IntRangeQParserPlugin} */ +public class IntRangeQParserPluginTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema-intrange.xml"); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + clearIndex(); + assertU(commit()); + } + + @Test + public void test1DIntersectsQuery() { + // 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(commit()); + + // Query: find ranges intersecting [120 TO 180] + assertQ( + req("q", "{!myRange field=price_range}[120 TO 180]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + + // Query: find ranges intersecting [0 TO 100] + assertQ( + req("q", "{!myRange field=price_range}[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", "{!myRange field=price_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", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[150 TO 250]")); + assertU(adoc("id", "3", "price_range", "[50 TO 80]")); + assertU(commit()); + + // Query: find ranges within [0 TO 300] + assertQ( + req("q", "{!myRange criteria=\"within\" field=price_range}[0 TO 300]"), + "//result[@numFound='3']"); + + // Query: find ranges within [100 TO 200] + assertQ( + req("q", "{!myRange criteria=\"within\" field=price_range}[100 TO 200]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Query: find ranges within [0 TO 100] + assertQ( + req("q", "{!myRange criteria=\"within\" field=price_range}[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]")); + assertU(commit()); + + // Query: find ranges containing [160 TO 170] + assertQ( + req("q", "{!myRange criteria=\"contains\" field=price_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']"); + + // Query: find ranges containing [0 TO 400] + assertQ( + req("q", "{!myRange criteria=\"contains\" field=price_range}[0 TO 400]"), + "//result[@numFound='0']"); + + // Query: find ranges containing [100 TO 200] + assertQ( + req("q", "{!myRange criteria=\"contains\" field=price_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", "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]")); + assertU(commit()); + + // Query: find ranges crossing [150 TO 250] + // Should match ranges that intersect but are not within + assertQ( + req("q", "{!myRange criteria=\"crosses\" field=price_range}[150 TO 250]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='4']"); + } + + @Test + public void test2DIntersectsQuery() { + // 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(commit()); + + // Query: find bboxes intersecting [8,8 TO 12,12] + assertQ( + req("q", "{!myRange field=bbox}[8,8 TO 12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + + // Query: find bboxes intersecting [25,25 TO 35,35] + assertQ( + req("q", "{!myRange field=bbox}[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", "{!myRange field=bbox}[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]")); + assertU(commit()); + + // Query: find bboxes within [0,0 TO 20,20] + assertQ( + req("q", "{!myRange criteria=\"within\" field=bbox}[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", "{!myRange criteria=\"within\" field=bbox}[0,0 TO 30,30]"), + "//result[@numFound='3']"); + } + + @Test + public void test3DQuery() { + // Index documents with 3D ranges (bounding cubes) + assertU(adoc("id", "1", "cube", "[0,0,0 TO 10,10,10]")); + assertU(adoc("id", "2", "cube", "[5,5,5 TO 15,15,15]")); + assertU(commit()); + + // Query: find cubes intersecting [8,8,8 TO 12,12,12] + assertQ( + req("q", "{!myRange field=cube}[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() { + // Index documents with 4D ranges (tesseracts) + assertU(adoc("id", "1", "tesseract", "[0,0,0,0 TO 10,10,10,10]")); + assertU(adoc("id", "2", "tesseract", "[5,5,5,5 TO 15,15,15,15]")); + assertU(commit()); + + // Query: find tesseracts intersecting [8,8,8,8 TO 12,12,12,12] + assertQ( + req("q", "{!myRange field=tesseract}[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() { + // Index document with multiple ranges + assertU( + adoc("id", "1", "price_range_multi", "[100 TO 200]", "price_range_multi", "[300 TO 400]")); + assertU(adoc("id", "2", "price_range_multi", "[150 TO 250]")); + assertU(commit()); + + // Query should match doc 1 via first range + assertQ( + req("q", "{!myRange field=price_range_multi}[110 TO 120]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Query should match doc 1 via second range + assertQ( + req("q", "{!myRange field=price_range_multi}[310 TO 320]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Query should match both docs + assertQ(req("q", "{!myRange field=price_range_multi}[150 TO 250]"), "//result[@numFound='2']"); + } + + @Test + public void testMissingFieldParameter() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query without field parameter should fail + assertQEx( + "Missing field parameter should fail", + "Missing required parameter: field", + req("q", "{!myRange}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidFieldType() { + assertU(adoc("id", "1", "title", "test")); + assertU(commit()); + + // Query on non-IntRangeField should fail + assertQEx( + "Query on wrong field type should fail", + "must be of type IntRangeField", + req("q", "{!myRange field=title}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidQueryType() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with invalid criteria parameter should fail + assertQEx( + "Invalid query criteria should fail", + "Unknown query criteria", + req("q", "{!myRange criteria=\"invalid\" field=price_range}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidRangeValue() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with invalid range format should fail + assertQEx( + "Invalid range format should fail", + "Invalid range", + req("q", "{!myRange field=price_range}invalid"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testEmptyRangeValue() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with empty range should fail + assertQEx( + "Empty range value should fail", + req("q", "{!myRange field=price_range}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testDefaultQueryTypeIsIntersects() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[250 TO 300]")); + assertU(commit()); + + // Query without type parameter should default to intersects + assertQ( + req("q", "{!myRange field=price_range}[150 TO 180]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + @Test + public void testNegativeValues() { + assertU(adoc("id", "1", "price_range", "[-100 TO -50]")); + assertU(adoc("id", "2", "price_range", "[-75 TO -25]")); + assertU(commit()); + + // Query with negative range + assertQ(req("q", "{!myRange field=price_range}[-80 TO -60]"), "//result[@numFound='2']"); + } + + @Test + public void testExtremeValues() { + int min = Integer.MIN_VALUE; + int max = Integer.MAX_VALUE; + + assertU(adoc("id", "1", "price_range", "[" + min + " TO " + max + "]")); + assertU(commit()); + + // Query should match the extreme range + assertQ( + req("q", "{!myRange field=price_range}[0 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + @Test + public void testPointRange() { + // Point range where min == max + assertU(adoc("id", "1", "price_range", "[100 TO 100]")); + assertU(commit()); + + // Intersects query with point + assertQ( + req("q", "{!myRange field=price_range}[100 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Intersects query containing point + assertQ( + req("q", "{!myRange field=price_range}[50 TO 150]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } +} From 8396e1e57878c27cb9c0ddf3a386ec8421e3d041 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 10:38:00 -0500 Subject: [PATCH 02/13] Rename myRange QParser to 'numericRange' --- .../org/apache/solr/schema/IntRangeField.java | 13 ++-- .../solr/search/IntRangeQParserPlugin.java | 20 +++--- .../search/IntRangeQParserPluginTest.java | 61 ++++++++++--------- 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java index 5968a4d03b75..a5e12b26387a 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -64,13 +64,14 @@ * *

Querying

* - * Use the {@code myRange} query parser for range queries with support for different query types: + * Use the {@code numericRange} query parser for range queries with support for different query + * types: * *
    - *
  • Intersects (default): {@code {!myRange field=price_range}[100 TO 200]} - *
  • Within: {@code {!myRange criteria="within" field=price_range}[0 TO 300]} - *
  • Contains: {@code {!myRange criteria="contains" field=price_range}[150 TO 175]} - *
  • Crosses: {@code {!myRange criteria="crosses" field=price_range}[150 TO 250]} + *
  • Intersects (default): {@code {!numericRange field=price_range}[100 TO 200]} + *
  • Within: {@code {!numericRange criteria="within" field=price_range}[0 TO 300]} + *
  • Contains: {@code {!numericRange criteria="contains" field=price_range}[150 TO 175]} + *
  • Crosses: {@code {!numericRange criteria="crosses" field=price_range}[150 TO 250]} *
* *

Limitations

@@ -313,7 +314,7 @@ protected Query getSpecializedRangeQuery( throw new SolrException( ErrorCode.BAD_REQUEST, "Standard range query syntax only supports 1D ranges. " - + "Use {!myRange field=" + + "Use {!numericRange field=" + field.getName() + "} for multi-dimensional queries."); } diff --git a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java index 25fb44bbca30..dee428aea499 100644 --- a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java @@ -54,20 +54,20 @@ * *
  * // 1D range queries
- * {!myRange field=price_range}[100 TO 200]                           // intersects (default)
- * {!myRange criteria="within" field=price_range}[0 TO 300]           // within
- * {!myRange criteria="contains" field=price_range}[150 TO 175]       // contains
- * {!myRange criteria="crosses" field=price_range}[150 TO 250]        // crosses
+ * {!numericRange field=price_range}[100 TO 200]                           // intersects (default)
+ * {!numericRange criteria="within" field=price_range}[0 TO 300]           // within
+ * {!numericRange criteria="contains" field=price_range}[150 TO 175]       // contains
+ * {!numericRange criteria="crosses" field=price_range}[150 TO 250]        // crosses
  *
  * // 2D range queries (bounding boxes)
- * {!myRange field=bbox}[0,0 TO 10,10]                                // intersects
- * {!myRange criteria="within" field=bbox}[-10,-10 TO 20,20]          // within
+ * {!numericRange field=bbox}[0,0 TO 10,10]                                // intersects
+ * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20]          // within
  *
  * // 3D range queries (bounding cubes)
- * {!myRange field=cube}[0,0,0 TO 10,10,10]                           // intersects
+ * {!numericRange field=cube}[0,0,0 TO 10,10,10]                           // intersects
  *
  * // 4D range queries (tesseracts)
- * {!myRange field=tesseract}[0,0,0,0 TO 10,10,10,10]                 // intersects
+ * {!numericRange field=tesseract}[0,0,0,0 TO 10,10,10,10]                 // intersects
  * 
* * @see IntRangeField @@ -75,8 +75,8 @@ */ public class IntRangeQParserPlugin extends QParserPlugin { - /** Parser name used in local params syntax: {@code {!myRange ...}} */ - public static final String NAME = "myRange"; + /** Parser name used in local params syntax: {@code {!numericRange ...}} */ + public static final String NAME = "numericRange"; /** Parameter name for the field to query */ public static final String FIELD_PARAM = "field"; diff --git a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java index 86d6ebd32660..2734aad6af7d 100644 --- a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java +++ b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java @@ -47,21 +47,21 @@ public void test1DIntersectsQuery() { // Query: find ranges intersecting [120 TO 180] assertQ( - req("q", "{!myRange field=price_range}[120 TO 180]"), + req("q", "{!numericRange field=price_range}[120 TO 180]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); // Query: find ranges intersecting [0 TO 100] assertQ( - req("q", "{!myRange field=price_range}[0 TO 100]"), + req("q", "{!numericRange field=price_range}[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", "{!myRange field=price_range}[175 TO 225]"), + req("q", "{!numericRange field=price_range}[175 TO 225]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -77,18 +77,18 @@ public void test1DWithinQuery() { // Query: find ranges within [0 TO 300] assertQ( - req("q", "{!myRange criteria=\"within\" field=price_range}[0 TO 300]"), + req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 300]"), "//result[@numFound='3']"); // Query: find ranges within [100 TO 200] assertQ( - req("q", "{!myRange criteria=\"within\" field=price_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"within\" field=price_range}[100 TO 200]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query: find ranges within [0 TO 100] assertQ( - req("q", "{!myRange criteria=\"within\" field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='3']"); } @@ -102,7 +102,7 @@ public void test1DContainsQuery() { // Query: find ranges containing [160 TO 170] assertQ( - req("q", "{!myRange criteria=\"contains\" field=price_range}[160 TO 170]"), + req("q", "{!numericRange criteria=\"contains\" field=price_range}[160 TO 170]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -110,12 +110,12 @@ public void test1DContainsQuery() { // Query: find ranges containing [0 TO 400] assertQ( - req("q", "{!myRange criteria=\"contains\" field=price_range}[0 TO 400]"), + req("q", "{!numericRange criteria=\"contains\" field=price_range}[0 TO 400]"), "//result[@numFound='0']"); // Query: find ranges containing [100 TO 200] assertQ( - req("q", "{!myRange criteria=\"contains\" field=price_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"contains\" field=price_range}[100 TO 200]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='3']"); @@ -132,7 +132,7 @@ public void test1DCrossesQuery() { // Query: find ranges crossing [150 TO 250] // Should match ranges that intersect but are not within assertQ( - req("q", "{!myRange criteria=\"crosses\" field=price_range}[150 TO 250]"), + req("q", "{!numericRange criteria=\"crosses\" field=price_range}[150 TO 250]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='4']"); @@ -148,19 +148,19 @@ public void test2DIntersectsQuery() { // Query: find bboxes intersecting [8,8 TO 12,12] assertQ( - req("q", "{!myRange field=bbox}[8,8 TO 12,12]"), + req("q", "{!numericRange field=bbox}[8,8 TO 12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); // Query: find bboxes intersecting [25,25 TO 35,35] assertQ( - req("q", "{!myRange field=bbox}[25,25 TO 35,35]"), + req("q", "{!numericRange field=bbox}[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", "{!myRange field=bbox}[100,100 TO 200,200]"), "//result[@numFound='0']"); + assertQ(req("q", "{!numericRange field=bbox}[100,100 TO 200,200]"), "//result[@numFound='0']"); } @Test @@ -172,14 +172,14 @@ public void test2DWithinQuery() { // Query: find bboxes within [0,0 TO 20,20] assertQ( - req("q", "{!myRange criteria=\"within\" field=bbox}[0,0 TO 20,20]"), + req("q", "{!numericRange criteria=\"within\" field=bbox}[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", "{!myRange criteria=\"within\" field=bbox}[0,0 TO 30,30]"), + req("q", "{!numericRange criteria=\"within\" field=bbox}[0,0 TO 30,30]"), "//result[@numFound='3']"); } @@ -192,7 +192,7 @@ public void test3DQuery() { // Query: find cubes intersecting [8,8,8 TO 12,12,12] assertQ( - req("q", "{!myRange field=cube}[8,8,8 TO 12,12,12]"), + req("q", "{!numericRange field=cube}[8,8,8 TO 12,12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -207,7 +207,7 @@ public void test4DQuery() { // Query: find tesseracts intersecting [8,8,8,8 TO 12,12,12,12] assertQ( - req("q", "{!myRange field=tesseract}[8,8,8,8 TO 12,12,12,12]"), + req("q", "{!numericRange field=tesseract}[8,8,8,8 TO 12,12,12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -223,18 +223,19 @@ public void testMultiValuedField() { // Query should match doc 1 via first range assertQ( - req("q", "{!myRange field=price_range_multi}[110 TO 120]"), + req("q", "{!numericRange field=price_range_multi}[110 TO 120]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query should match doc 1 via second range assertQ( - req("q", "{!myRange field=price_range_multi}[310 TO 320]"), + req("q", "{!numericRange field=price_range_multi}[310 TO 320]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query should match both docs - assertQ(req("q", "{!myRange field=price_range_multi}[150 TO 250]"), "//result[@numFound='2']"); + assertQ( + req("q", "{!numericRange field=price_range_multi}[150 TO 250]"), "//result[@numFound='2']"); } @Test @@ -246,7 +247,7 @@ public void testMissingFieldParameter() { assertQEx( "Missing field parameter should fail", "Missing required parameter: field", - req("q", "{!myRange}[100 TO 200]"), + req("q", "{!numericRange}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } @@ -259,7 +260,7 @@ public void testInvalidFieldType() { assertQEx( "Query on wrong field type should fail", "must be of type IntRangeField", - req("q", "{!myRange field=title}[100 TO 200]"), + req("q", "{!numericRange field=title}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } @@ -272,7 +273,7 @@ public void testInvalidQueryType() { assertQEx( "Invalid query criteria should fail", "Unknown query criteria", - req("q", "{!myRange criteria=\"invalid\" field=price_range}[100 TO 200]"), + req("q", "{!numericRange criteria=\"invalid\" field=price_range}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } @@ -285,7 +286,7 @@ public void testInvalidRangeValue() { assertQEx( "Invalid range format should fail", "Invalid range", - req("q", "{!myRange field=price_range}invalid"), + req("q", "{!numericRange field=price_range}invalid"), SolrException.ErrorCode.BAD_REQUEST); } @@ -297,7 +298,7 @@ public void testEmptyRangeValue() { // Query with empty range should fail assertQEx( "Empty range value should fail", - req("q", "{!myRange field=price_range}"), + req("q", "{!numericRange field=price_range}"), SolrException.ErrorCode.BAD_REQUEST); } @@ -309,7 +310,7 @@ public void testDefaultQueryTypeIsIntersects() { // Query without type parameter should default to intersects assertQ( - req("q", "{!myRange field=price_range}[150 TO 180]"), + req("q", "{!numericRange field=price_range}[150 TO 180]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -321,7 +322,7 @@ public void testNegativeValues() { assertU(commit()); // Query with negative range - assertQ(req("q", "{!myRange field=price_range}[-80 TO -60]"), "//result[@numFound='2']"); + assertQ(req("q", "{!numericRange field=price_range}[-80 TO -60]"), "//result[@numFound='2']"); } @Test @@ -334,7 +335,7 @@ public void testExtremeValues() { // Query should match the extreme range assertQ( - req("q", "{!myRange field=price_range}[0 TO 100]"), + req("q", "{!numericRange field=price_range}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -347,13 +348,13 @@ public void testPointRange() { // Intersects query with point assertQ( - req("q", "{!myRange field=price_range}[100 TO 100]"), + req("q", "{!numericRange field=price_range}[100 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Intersects query containing point assertQ( - req("q", "{!myRange field=price_range}[50 TO 150]"), + req("q", "{!numericRange field=price_range}[50 TO 150]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } From 0e2f9e3dc5d5733ddc9fcbddc1bae8378ef44771 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 10:43:19 -0500 Subject: [PATCH 03/13] Remove IntValueFieldType from IntRangeField --- solr/core/src/java/org/apache/solr/schema/IntRangeField.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java index a5e12b26387a..1578f059185f 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -82,7 +82,7 @@ * @see IntRange * @see org.apache.solr.search.IntRangeQParserPlugin */ -public class IntRangeField extends PrimitiveFieldType implements IntValueFieldType { +public class IntRangeField extends PrimitiveFieldType { private static final Pattern RANGE_PATTERN = Pattern.compile("\\[\\s*([^\\]]+?)\\s+TO\\s+([^\\]]+?)\\s*\\]"); From 440913efcc4c7b845a05e25fc8e328ac78b697a5 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 10:51:25 -0500 Subject: [PATCH 04/13] Minor error msg tweaks --- .../java/org/apache/solr/schema/IntRangeField.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java index 1578f059185f..a051fc1b0740 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -103,14 +103,20 @@ protected void init(IndexSchema schema, Map args) { numDimensions = Integer.parseInt(numDimensionsStr); if (numDimensions < 1 || numDimensions > 4) { throw new SolrException( - ErrorCode.SERVER_ERROR, "numDimensions must be between 1 and 4, got: " + numDimensions); + 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, "IntRangeField does not support docValues: " + typeName); + ErrorCode.SERVER_ERROR, + "docValues=true enabled but IntRangeField does not support docValues for field type " + + typeName); } } @@ -268,6 +274,7 @@ protected StoredField getStoredField(SchemaField sf, Object value) { return new StoredField(sf.getName(), value.toString()); } + // TODO - how would this be invoked? Should we force all queries to come through {!numericRange} @Override protected Query getSpecializedRangeQuery( QParser parser, From a0fc40e42d7b1a562bcadb35ff4f86bec167471d Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 11:07:46 -0500 Subject: [PATCH 05/13] Remove 'intersects' as default numericRange criteria --- .../org/apache/solr/schema/IntRangeField.java | 6 +- .../solr/search/IntRangeQParserPlugin.java | 32 +++++---- .../search/IntRangeQParserPluginTest.java | 69 ++++++++++--------- 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java index a051fc1b0740..996d228d8532 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -68,7 +68,7 @@ * types: * *
    - *
  • Intersects (default): {@code {!numericRange field=price_range}[100 TO 200]} + *
  • Intersects: {@code {!numericRange criteria="intersects" field=price_range}[100 TO 200]} *
  • Within: {@code {!numericRange criteria="within" field=price_range}[0 TO 300]} *
  • Contains: {@code {!numericRange criteria="contains" field=price_range}[150 TO 175]} *
  • Crosses: {@code {!numericRange criteria="crosses" field=price_range}[150 TO 250]} @@ -321,9 +321,7 @@ protected Query getSpecializedRangeQuery( throw new SolrException( ErrorCode.BAD_REQUEST, "Standard range query syntax only supports 1D ranges. " - + "Use {!numericRange field=" - + field.getName() - + "} for multi-dimensional queries."); + + "Use {!numericRange ...} for multi-dimensional queries."); } } diff --git a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java index dee428aea499..779e0227a3e0 100644 --- a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java @@ -36,8 +36,8 @@ * *
      *
    • field (required): The IntRangeField to query - *
    • criteria (optional, default=intersects): Query relationship criteria. One of: - * intersects, within, contains, crosses + *
    • criteria (required): Query relationship criteria. One of: intersects, within, + * contains, crosses *
    * *

    Query Types

    @@ -54,20 +54,20 @@ * *
      * // 1D range queries
    - * {!numericRange field=price_range}[100 TO 200]                           // intersects (default)
    - * {!numericRange criteria="within" field=price_range}[0 TO 300]           // within
    - * {!numericRange criteria="contains" field=price_range}[150 TO 175]       // contains
    - * {!numericRange criteria="crosses" field=price_range}[150 TO 250]        // crosses
    + * {!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)
    - * {!numericRange field=bbox}[0,0 TO 10,10]                                // intersects
    - * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20]          // within
    + * {!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 field=cube}[0,0,0 TO 10,10,10]                           // intersects
    + * {!numericRange criteria="intersects" field=cube}[0,0,0 TO 10,10,10]
      *
      * // 4D range queries (tesseracts)
    - * {!numericRange field=tesseract}[0,0,0,0 TO 10,10,10,10]                 // intersects
    + * {!numericRange criteria="intersects" field=tesseract}[0,0,0,0 TO 10,10,10,10]
      * 
    * * @see IntRangeField @@ -84,9 +84,6 @@ public class IntRangeQParserPlugin extends QParserPlugin { /** Parameter name for the query criteria (intersects, within, contains, crosses) */ public static final String CRITERIA_PARAM = "criteria"; - /** Default query criteria if not specified */ - public static final String DEFAULT_CRITERIA = "intersects"; - @Override public QParser createParser( String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { @@ -100,8 +97,13 @@ public Query parse() throws SyntaxError { ErrorCode.BAD_REQUEST, "Missing required parameter: " + FIELD_PARAM); } - // Get query criteria parameter (default to intersects) - String queryCriteria = localParams.get(CRITERIA_PARAM, DEFAULT_CRITERIA).toLowerCase(); + // Get required query criteria parameter + String queryCriteria = localParams.get(CRITERIA_PARAM); + if (queryCriteria == null || queryCriteria.trim().isEmpty()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, "Missing required parameter: " + CRITERIA_PARAM); + } + queryCriteria = queryCriteria.toLowerCase(); // Get the range value from the query string or 'v' param String rangeValue = localParams.get(QueryParsing.V, qstr); diff --git a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java index 2734aad6af7d..167f20c89570 100644 --- a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java +++ b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java @@ -47,21 +47,21 @@ public void test1DIntersectsQuery() { // Query: find ranges intersecting [120 TO 180] assertQ( - req("q", "{!numericRange field=price_range}[120 TO 180]"), + req("q", "{!numericRange criteria=intersects field=price_range}[120 TO 180]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); // Query: find ranges intersecting [0 TO 100] assertQ( - req("q", "{!numericRange field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=price_range}[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 field=price_range}[175 TO 225]"), + req("q", "{!numericRange criteria=intersects field=price_range}[175 TO 225]"), "//result[@numFound='3']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']", @@ -148,19 +148,21 @@ public void test2DIntersectsQuery() { // Query: find bboxes intersecting [8,8 TO 12,12] assertQ( - req("q", "{!numericRange field=bbox}[8,8 TO 12,12]"), + req("q", "{!numericRange criteria=intersects field=bbox}[8,8 TO 12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); // Query: find bboxes intersecting [25,25 TO 35,35] assertQ( - req("q", "{!numericRange field=bbox}[25,25 TO 35,35]"), + req("q", "{!numericRange criteria=intersects field=bbox}[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 field=bbox}[100,100 TO 200,200]"), "//result[@numFound='0']"); + assertQ( + req("q", "{!numericRange criteria=intersects field=bbox}[100,100 TO 200,200]"), + "//result[@numFound='0']"); } @Test @@ -192,7 +194,7 @@ public void test3DQuery() { // Query: find cubes intersecting [8,8,8 TO 12,12,12] assertQ( - req("q", "{!numericRange field=cube}[8,8,8 TO 12,12,12]"), + req("q", "{!numericRange criteria=intersects field=cube}[8,8,8 TO 12,12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -207,7 +209,7 @@ public void test4DQuery() { // Query: find tesseracts intersecting [8,8,8,8 TO 12,12,12,12] assertQ( - req("q", "{!numericRange field=tesseract}[8,8,8,8 TO 12,12,12,12]"), + req("q", "{!numericRange criteria=intersects field=tesseract}[8,8,8,8 TO 12,12,12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", "//result/doc/str[@name='id'][.='2']"); @@ -223,19 +225,20 @@ public void testMultiValuedField() { // Query should match doc 1 via first range assertQ( - req("q", "{!numericRange field=price_range_multi}[110 TO 120]"), + req("q", "{!numericRange criteria=intersects field=price_range_multi}[110 TO 120]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query should match doc 1 via second range assertQ( - req("q", "{!numericRange field=price_range_multi}[310 TO 320]"), + req("q", "{!numericRange criteria=intersects field=price_range_multi}[310 TO 320]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Query should match both docs assertQ( - req("q", "{!numericRange field=price_range_multi}[150 TO 250]"), "//result[@numFound='2']"); + req("q", "{!numericRange criteria=intersects field=price_range_multi}[150 TO 250]"), + "//result[@numFound='2']"); } @Test @@ -247,7 +250,20 @@ public void testMissingFieldParameter() { assertQEx( "Missing field parameter should fail", "Missing required parameter: field", - req("q", "{!numericRange}[100 TO 200]"), + req("q", "{!numericRange criteria=intersects}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testMissingCriteriaParameter() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query without field parameter should fail + assertQEx( + "Missing criteria parameter should fail", + "Missing required parameter: criteria", + req("q", "{!numericRange field=asdf}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } @@ -260,7 +276,7 @@ public void testInvalidFieldType() { assertQEx( "Query on wrong field type should fail", "must be of type IntRangeField", - req("q", "{!numericRange field=title}[100 TO 200]"), + req("q", "{!numericRange criteria=intersects field=title}[100 TO 200]"), SolrException.ErrorCode.BAD_REQUEST); } @@ -286,7 +302,7 @@ public void testInvalidRangeValue() { assertQEx( "Invalid range format should fail", "Invalid range", - req("q", "{!numericRange field=price_range}invalid"), + req("q", "{!numericRange criteria=intersects field=price_range}invalid"), SolrException.ErrorCode.BAD_REQUEST); } @@ -298,23 +314,10 @@ public void testEmptyRangeValue() { // Query with empty range should fail assertQEx( "Empty range value should fail", - req("q", "{!numericRange field=price_range}"), + req("q", "{!numericRange criteria=intersects field=price_range}"), SolrException.ErrorCode.BAD_REQUEST); } - @Test - public void testDefaultQueryTypeIsIntersects() { - assertU(adoc("id", "1", "price_range", "[100 TO 200]")); - assertU(adoc("id", "2", "price_range", "[250 TO 300]")); - assertU(commit()); - - // Query without type parameter should default to intersects - assertQ( - req("q", "{!numericRange field=price_range}[150 TO 180]"), - "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); - } - @Test public void testNegativeValues() { assertU(adoc("id", "1", "price_range", "[-100 TO -50]")); @@ -322,7 +325,9 @@ public void testNegativeValues() { assertU(commit()); // Query with negative range - assertQ(req("q", "{!numericRange field=price_range}[-80 TO -60]"), "//result[@numFound='2']"); + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[-80 TO -60]"), + "//result[@numFound='2']"); } @Test @@ -335,7 +340,7 @@ public void testExtremeValues() { // Query should match the extreme range assertQ( - req("q", "{!numericRange field=price_range}[0 TO 100]"), + req("q", "{!numericRange criteria=intersects field=price_range}[0 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } @@ -348,13 +353,13 @@ public void testPointRange() { // Intersects query with point assertQ( - req("q", "{!numericRange field=price_range}[100 TO 100]"), + req("q", "{!numericRange criteria=intersects field=price_range}[100 TO 100]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); // Intersects query containing point assertQ( - req("q", "{!numericRange field=price_range}[50 TO 150]"), + req("q", "{!numericRange criteria=intersects field=price_range}[50 TO 150]"), "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } From 74138836883209a220af26d39c4f0d01c7411ac7 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 11:15:47 -0500 Subject: [PATCH 06/13] Create enum for 'criteria' param values --- .../solr/search/IntRangeQParserPlugin.java | 87 +++++++++++++++---- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java index 779e0227a3e0..ee84ac9fde3c 100644 --- a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java @@ -75,6 +75,60 @@ */ public class IntRangeQParserPlugin extends QParserPlugin { + /** Query relationship criteria for range queries. */ + public enum QueryCriteria { + /** Matches ranges that overlap with the query range (most permissive). */ + INTERSECTS("intersects"), + + /** Matches ranges completely contained by the query range. */ + WITHIN("within"), + + /** Matches ranges that completely contain the query range. */ + CONTAINS("contains"), + + /** + * Matches ranges that cross the query range boundaries (not disjoint, not wholly contained). + */ + CROSSES("crosses"); + + private final String name; + + QueryCriteria(String name) { + this.name = name; + } + + /** + * Parse a criteria string into a QueryCriteria enum value. + * + * @param criteriaStr the criteria string (case-insensitive) + * @return the corresponding QueryCriteria + * @throws SolrException if the criteria string is not recognized + */ + public static QueryCriteria fromString(String criteriaStr) { + if (criteriaStr == null || criteriaStr.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Query criteria cannot be null or empty"); + } + + String normalized = criteriaStr.trim().toLowerCase(); + for (QueryCriteria criteria : values()) { + if (criteria.name.equals(normalized)) { + return criteria; + } + } + + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Unknown query criteria: '" + + criteriaStr + + "'. Valid criteria are: intersects, within, contains, crosses"); + } + + @Override + public String toString() { + return name; + } + } + /** Parser name used in local params syntax: {@code {!numericRange ...}} */ public static final String NAME = "numericRange"; @@ -97,13 +151,13 @@ public Query parse() throws SyntaxError { ErrorCode.BAD_REQUEST, "Missing required parameter: " + FIELD_PARAM); } - // Get required query criteria parameter - String queryCriteria = localParams.get(CRITERIA_PARAM); - if (queryCriteria == null || queryCriteria.trim().isEmpty()) { + // Get required query criteria parameter and parse to enum + String criteriaStr = localParams.get(CRITERIA_PARAM); + if (criteriaStr == null || criteriaStr.trim().isEmpty()) { throw new SolrException( ErrorCode.BAD_REQUEST, "Missing required parameter: " + CRITERIA_PARAM); } - queryCriteria = queryCriteria.toLowerCase(); + QueryCriteria criteria = QueryCriteria.fromString(criteriaStr); // Get the range value from the query string or 'v' param String rangeValue = localParams.get(QueryParsing.V, qstr); @@ -139,7 +193,7 @@ public Query parse() throws SyntaxError { } // Create appropriate query based on criteria - return createRangeQuery(fieldName, range.mins, range.maxs, queryCriteria); + return createRangeQuery(fieldName, range.mins, range.maxs, criteria); } /** @@ -148,31 +202,26 @@ public Query parse() throws SyntaxError { * @param fieldName the field to query * @param mins minimum values for each dimension * @param maxs maximum values for each dimension - * @param queryCriteria the query relationship criteria + * @param criteria the query relationship criteria * @return the created Lucene Query - * @throws SolrException if query criteria is invalid */ - private Query createRangeQuery(String fieldName, int[] mins, int[] maxs, String queryCriteria) - throws SolrException { - switch (queryCriteria) { - case "intersects": + private Query createRangeQuery( + String fieldName, int[] mins, int[] maxs, QueryCriteria criteria) { + switch (criteria) { + case INTERSECTS: return IntRange.newIntersectsQuery(fieldName, mins, maxs); - case "within": + case WITHIN: return IntRange.newWithinQuery(fieldName, mins, maxs); - case "contains": + case CONTAINS: return IntRange.newContainsQuery(fieldName, mins, maxs); - case "crosses": + case CROSSES: return IntRange.newCrossesQuery(fieldName, mins, maxs); default: - throw new SolrException( - ErrorCode.BAD_REQUEST, - "Unknown query criteria: '" - + queryCriteria - + "'. Valid criteria are: intersects, within, contains, crosses"); + throw new AssertionError("Unhandled QueryCriteria: " + criteria); } } }; From 4539951c04c6ace95031bdde425dabf934690cab Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 11:28:20 -0500 Subject: [PATCH 07/13] Addtl tests for search-result display format --- .../org/apache/solr/schema/IntRangeField.java | 2 +- .../search/IntRangeQParserPluginTest.java | 68 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java index 996d228d8532..790ac8f18228 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/IntRangeField.java @@ -151,7 +151,7 @@ public List createFields(SchemaField field, Object value) { @Override public void write(TextResponseWriter writer, String name, IndexableField f) throws IOException { - writer.writeStr(name, f.stringValue(), false); + writer.writeStr(name, toExternal(f), false); } @Override diff --git a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java index 167f20c89570..9a4024b69eb7 100644 --- a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java +++ b/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java @@ -50,7 +50,9 @@ public void test1DIntersectsQuery() { req("q", "{!numericRange criteria=intersects field=price_range}[120 TO 180]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", - "//result/doc/str[@name='id'][.='2']"); + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='price_range'][.='[100 TO 200]']", + "//result/doc/str[@name='price_range'][.='[150 TO 250]']"); // Query: find ranges intersecting [0 TO 100] assertQ( @@ -151,7 +153,9 @@ public void test2DIntersectsQuery() { req("q", "{!numericRange criteria=intersects field=bbox}[8,8 TO 12,12]"), "//result[@numFound='2']", "//result/doc/str[@name='id'][.='1']", - "//result/doc/str[@name='id'][.='2']"); + "//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]']"); // Query: find bboxes intersecting [25,25 TO 35,35] assertQ( @@ -227,7 +231,9 @@ public void testMultiValuedField() { assertQ( req("q", "{!numericRange criteria=intersects field=price_range_multi}[110 TO 120]"), "//result[@numFound='1']", - "//result/doc/str[@name='id'][.='1']"); + "//result/doc/str[@name='id'][.='1']", + "//result/doc/arr[@name='price_range_multi']/str[1][.='[100 TO 200]']", + "//result/doc/arr[@name='price_range_multi']/str[2][.='[300 TO 400]']"); // Query should match doc 1 via second range assertQ( @@ -363,4 +369,60 @@ public void testPointRange() { "//result[@numFound='1']", "//result/doc/str[@name='id'][.='1']"); } + + @Test + public void testFieldFormatting() { + // Test 1D field formatting + assertU(adoc("id", "1", "price_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", + "price_range_multi", + "[50 TO 100]", + "price_range_multi", + "[200 TO 300]", + "price_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='price_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='price_range_multi']/str[1][.='[50 TO 100]']", + "//result/doc/arr[@name='price_range_multi']/str[2][.='[200 TO 300]']", + "//result/doc/arr[@name='price_range_multi']/str[3][.='[400 TO 500]']"); + } } From 005260899d9fb4f01ae32735451dcb26c94b6106 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 11:36:58 -0500 Subject: [PATCH 08/13] Check error messages in IntRangeFieldTest edge-case tests --- .../apache/solr/schema/IntRangeFieldTest.java | 76 +++++++++++++------ 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java index 43ab7f7a6b64..d03d2cdd25ed 100644 --- a/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java +++ b/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java @@ -114,29 +114,39 @@ public void testInvalidRangeFormat() { IntRangeField fieldType = createFieldType(1); // Missing brackets - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + assertTrue(e1.getMessage().contains("Invalid range format")); + assertTrue(e1.getMessage().contains("Expected: [min1,min2,... TO max1,max2,...]")); // Missing TO keyword - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + SolrException e2 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + assertTrue(e2.getMessage().contains("Invalid range format")); // Empty value - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + assertTrue(e3.getMessage().contains("Range value cannot be null or empty")); // Null value - expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + SolrException e4 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + assertTrue(e4.getMessage().contains("Range value cannot be null or empty")); } public void testInvalidNumbers() { IntRangeField fieldType = createFieldType(1); // Non-numeric values - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + assertTrue(e1.getMessage().contains("Invalid integer")); + assertTrue(e1.getMessage().contains("min values")); // Partially numeric - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + SolrException e2 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + assertTrue(e2.getMessage().contains("Invalid integer")); + assertTrue(e2.getMessage().contains("max values")); // Floating point (should fail for IntRange) - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + assertTrue(e3.getMessage().contains("Invalid integer")); } public void testDimensionMismatch() { @@ -144,28 +154,39 @@ public void testDimensionMismatch() { IntRangeField fieldType2D = createFieldType(2); // 2D value on 1D field - expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + SolrException e1 = expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + assertTrue(e1.getMessage().contains("Range dimensions")); + assertTrue(e1.getMessage().contains("do not match field type numDimensions")); // 1D value on 2D field - expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + SolrException e2 = expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + assertTrue(e2.getMessage().contains("Range dimensions")); + assertTrue(e2.getMessage().contains("do not match field type numDimensions")); // Min/max dimension mismatch - expectThrows( - SolrException.class, - () -> fieldType2D.parseRangeValue("[10,20 TO 30]")); // 2D mins, 1D maxs + SolrException e3 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[10,20 TO 30]")); // 2D mins, 1D maxs + assertTrue(e3.getMessage().contains("Min and max dimensions must match")); } public void testMinGreaterThanMax() { IntRangeField fieldType = createFieldType(1); // Min > max should fail - expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + assertTrue(e1.getMessage().contains("Min value must be <= max value")); + assertTrue(e1.getMessage().contains("dimension 0")); // For 2D IntRangeField fieldType2D = createFieldType(2); - expectThrows( - SolrException.class, - () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + SolrException e2 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + assertTrue(e2.getMessage().contains("Min value must be <= max value")); + assertTrue(e2.getMessage().contains("dimension 0")); } public void testFieldCreation1D() { @@ -208,7 +229,8 @@ public void testToInternal() { assertEquals(value, internal); // Invalid value should throw exception - expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + SolrException e = expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + assertTrue(e.getMessage().contains("Invalid range format")); } public void testToNativeType() { @@ -236,7 +258,9 @@ public void testSortFieldThrowsException() { SchemaField schemaField = createSchemaField(fieldType, "price_range"); // Sorting should not be supported - expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + SolrException e = expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + assertTrue(e.getMessage().contains("Cannot sort on IntRangeField")); + assertTrue(e.getMessage().contains("price_range")); } public void testUninversionType() { @@ -256,7 +280,9 @@ public void testDocValuesNotSupported() { args.put("docValues", "true"); // Should throw exception when docValues is enabled - expectThrows(SolrException.class, () -> field.setArgs(schema, args)); + SolrException e = expectThrows(SolrException.class, () -> field.setArgs(schema, args)); + assertTrue(e.getMessage().contains("docValues=true enabled")); + assertTrue(e.getMessage().contains("IntRangeField does not support docValues")); } public void testInvalidNumDimensions() { @@ -266,17 +292,23 @@ public void testInvalidNumDimensions() { // Test numDimensions = 0 args.put("numDimensions", "0"); - expectThrows(SolrException.class, () -> field.init(schema, args)); + SolrException e1 = expectThrows(SolrException.class, () -> field.init(schema, args)); + assertTrue(e1.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e1.getMessage().contains("but was [0]")); // Test numDimensions = 5 (too high) args.put("numDimensions", "5"); IntRangeField field2 = new IntRangeField(); - expectThrows(SolrException.class, () -> field2.init(schema, args)); + SolrException e2 = expectThrows(SolrException.class, () -> field2.init(schema, args)); + assertTrue(e2.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e2.getMessage().contains("but was [5]")); // Test negative numDimensions args.put("numDimensions", "-1"); IntRangeField field3 = new IntRangeField(); - expectThrows(SolrException.class, () -> field3.init(schema, args)); + SolrException e3 = expectThrows(SolrException.class, () -> field3.init(schema, args)); + assertTrue(e3.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e3.getMessage().contains("but was [-1]")); } public void testRangeValueToString() { From 384042f2ee4b2e3540f89f71059aa5d1d9f1dd1d Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 13:02:23 -0500 Subject: [PATCH 09/13] Create new '.numericrange' sub-package for field type --- .../{ => numericrange}/IntRangeField.java | 9 ++-- .../schema/numericrange/package-info.java | 19 +++++++ .../solr/search/IntRangeQParserPlugin.java | 4 +- .../solr/collection1/conf/schema-intrange.xml | 8 +-- .../{ => numericrange}/IntRangeFieldTest.java | 49 +++++++++---------- .../schema/numericrange/package-info.java | 19 +++++++ 6 files changed, 74 insertions(+), 34 deletions(-) rename solr/core/src/java/org/apache/solr/schema/{ => numericrange}/IntRangeField.java (97%) create mode 100644 solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java rename solr/core/src/test/org/apache/solr/schema/{ => numericrange}/IntRangeFieldTest.java (87%) create mode 100644 solr/core/src/test/org/apache/solr/schema/numericrange/package-info.java diff --git a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java similarity index 97% rename from solr/core/src/java/org/apache/solr/schema/IntRangeField.java rename to solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java index 790ac8f18228..b655c1af6e38 100644 --- a/solr/core/src/java/org/apache/solr/schema/IntRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.schema; +package org.apache.solr.schema.numericrange; import java.io.IOException; import java.util.List; @@ -29,6 +29,9 @@ 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; @@ -56,8 +59,8 @@ *

    Schema Configuration

    * *
    - * <fieldType name="intrange" class="org.apache.solr.schema.IntRangeField" numDimensions="1"/>
    - * <fieldType name="intrange2d" class="org.apache.solr.schema.IntRangeField" numDimensions="2"/>
    + * <fieldType name="intrange" class="org.apache.solr.schema.numericrange.IntRangeField" numDimensions="1"/>
    + * <fieldType name="intrange2d" class="org.apache.solr.schema.numericrange.IntRangeField" numDimensions="2"/>
      * <field name="price_range" type="intrange" indexed="true" stored="true"/>
      * <field name="bbox" type="intrange2d" indexed="true" stored="true"/>
      * 
    diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java b/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java new file mode 100644 index 000000000000..1b30e748fde0 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ + +/** Support for numeric-range field types. */ +package org.apache.solr.schema.numericrange; diff --git a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java index ee84ac9fde3c..76cc4da83470 100644 --- a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java @@ -22,9 +22,9 @@ 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.IntRangeField; -import org.apache.solr.schema.IntRangeField.RangeValue; import org.apache.solr.schema.SchemaField; +import org.apache.solr.schema.numericrange.IntRangeField; +import org.apache.solr.schema.numericrange.IntRangeField.RangeValue; /** * Query parser for IntRangeField with support for different query relationship types. diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml index f3c1fb41b638..bcfd1b5dc1aa 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml @@ -25,10 +25,10 @@ - - - - + + + + diff --git a/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java similarity index 87% rename from solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java rename to solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java index d03d2cdd25ed..19cccce5c493 100644 --- a/solr/core/src/test/org/apache/solr/schema/IntRangeFieldTest.java +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.schema; +package org.apache.solr.schema.numericrange; import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; import static org.mockito.Mockito.mock; @@ -26,6 +26,8 @@ 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 IntRangeField} */ @@ -114,12 +116,14 @@ public void testInvalidRangeFormat() { IntRangeField fieldType = createFieldType(1); // Missing brackets - SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); assertTrue(e1.getMessage().contains("Invalid range format")); assertTrue(e1.getMessage().contains("Expected: [min1,min2,... TO max1,max2,...]")); // Missing TO keyword - SolrException e2 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); assertTrue(e2.getMessage().contains("Invalid range format")); // Empty value @@ -135,17 +139,20 @@ public void testInvalidNumbers() { IntRangeField fieldType = createFieldType(1); // Non-numeric values - SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); assertTrue(e1.getMessage().contains("Invalid integer")); assertTrue(e1.getMessage().contains("min values")); // Partially numeric - SolrException e2 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); assertTrue(e2.getMessage().contains("Invalid integer")); assertTrue(e2.getMessage().contains("max values")); // Floating point (should fail for IntRange) - SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + SolrException e3 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); assertTrue(e3.getMessage().contains("Invalid integer")); } @@ -154,12 +161,14 @@ public void testDimensionMismatch() { IntRangeField fieldType2D = createFieldType(2); // 2D value on 1D field - SolrException e1 = expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); assertTrue(e1.getMessage().contains("Range dimensions")); assertTrue(e1.getMessage().contains("do not match field type numDimensions")); // 1D value on 2D field - SolrException e2 = expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); assertTrue(e2.getMessage().contains("Range dimensions")); assertTrue(e2.getMessage().contains("do not match field type numDimensions")); @@ -175,7 +184,8 @@ public void testMinGreaterThanMax() { IntRangeField fieldType = createFieldType(1); // Min > max should fail - SolrException e1 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); assertTrue(e1.getMessage().contains("Min value must be <= max value")); assertTrue(e1.getMessage().contains("dimension 0")); @@ -258,7 +268,8 @@ public void testSortFieldThrowsException() { SchemaField schemaField = createSchemaField(fieldType, "price_range"); // Sorting should not be supported - SolrException e = expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + SolrException e = + expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); assertTrue(e.getMessage().contains("Cannot sort on IntRangeField")); assertTrue(e.getMessage().contains("price_range")); } @@ -271,20 +282,6 @@ public void testUninversionType() { assertNull(fieldType.getUninversionType(schemaField)); } - public void testDocValuesNotSupported() { - IndexSchema schema = createMockSchema(); - IntRangeField field = new IntRangeField(); - - Map args = new HashMap<>(); - args.put("numDimensions", "1"); - args.put("docValues", "true"); - - // Should throw exception when docValues is enabled - SolrException e = expectThrows(SolrException.class, () -> field.setArgs(schema, args)); - assertTrue(e.getMessage().contains("docValues=true enabled")); - assertTrue(e.getMessage().contains("IntRangeField does not support docValues")); - } - public void testInvalidNumDimensions() { IntRangeField field = new IntRangeField(); Map args = new HashMap<>(); @@ -346,6 +343,8 @@ private IntRangeField createFieldType(int numDimensions) { } private SchemaField createSchemaField(IntRangeField fieldType, String name) { - return new SchemaField(name, fieldType, SchemaField.INDEXED | SchemaField.STORED, null); + 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/schema/numericrange/package-info.java b/solr/core/src/test/org/apache/solr/schema/numericrange/package-info.java new file mode 100644 index 000000000000..053e0c13bc75 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ + +/** Tests for code in the corresponding 'main' package */ +package org.apache.solr.schema.numericrange; From 1045686a07f109ff1726f022d70e0ce31a58b16f Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 14:30:16 -0500 Subject: [PATCH 10/13] Create 'numericrange' subpackage for IntRangeQParserPlugin --- .../schema/numericrange/IntRangeField.java | 2 +- .../org/apache/solr/search/QParserPlugin.java | 1 + .../IntRangeQParserPlugin.java | 6 +++++- .../search/numericrange/package-info.java | 19 +++++++++++++++++++ .../IntRangeQParserPluginTest.java | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) rename solr/core/src/java/org/apache/solr/search/{ => numericrange}/IntRangeQParserPlugin.java (97%) create mode 100644 solr/core/src/java/org/apache/solr/search/numericrange/package-info.java rename solr/core/src/test/org/apache/solr/search/{ => numericrange}/IntRangeQParserPluginTest.java (99%) 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 b655c1af6e38..0105edac0f8c 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 @@ -83,7 +83,7 @@ * therefore can't be used for sorting, faceting, etc. * * @see IntRange - * @see org.apache.solr.search.IntRangeQParserPlugin + * @see org.apache.solr.search.numericrange.IntRangeQParserPlugin */ public class IntRangeField extends PrimitiveFieldType { 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 3da5259d5a28..ba27a86b1f29 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -28,6 +28,7 @@ import org.apache.solr.search.join.HashRangeQParserPlugin; import org.apache.solr.search.mlt.MLTContentQParserPlugin; import org.apache.solr.search.mlt.MLTQParserPlugin; +import org.apache.solr.search.numericrange.IntRangeQParserPlugin; import org.apache.solr.search.vector.KnnQParserPlugin; import org.apache.solr.search.vector.VectorSimilarityQParserPlugin; import org.apache.solr.util.plugin.NamedListInitializedPlugin; diff --git a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java similarity index 97% rename from solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java rename to solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java index 76cc4da83470..fc83881023c0 100644 --- a/solr/core/src/java/org/apache/solr/search/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.search; +package org.apache.solr.search.numericrange; import org.apache.lucene.document.IntRange; import org.apache.lucene.search.Query; @@ -25,6 +25,10 @@ import org.apache.solr.schema.SchemaField; import org.apache.solr.schema.numericrange.IntRangeField; import org.apache.solr.schema.numericrange.IntRangeField.RangeValue; +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. diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java b/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java new file mode 100644 index 000000000000..395317a01181 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * 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. + */ + +/** QParser and related classes for searching numeric range fields. */ +package org.apache.solr.search.numericrange; diff --git a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java similarity index 99% rename from solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java rename to solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java index 9a4024b69eb7..98ea57c521c5 100644 --- a/solr/core/src/test/org/apache/solr/search/IntRangeQParserPluginTest.java +++ b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.search; +package org.apache.solr.search.numericrange; import org.apache.solr.SolrTestCaseJ4; import org.apache.solr.common.SolrException; From 794da6bc8dc4ee4a328fc43081097635b5108b52 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Tue, 17 Feb 2026 14:36:24 -0500 Subject: [PATCH 11/13] Fix 'check' --- .../apache/solr/schema/numericrange/IntRangeField.java | 8 ++++---- .../solr/search/numericrange/IntRangeQParserPlugin.java | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) 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 0105edac0f8c..64a9a2acf501 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 @@ -42,7 +42,7 @@ * range values. Ranges can be 1-dimensional (simple ranges), 2-dimensional (bounding boxes), * 3-dimensional (bounding cubes), or 4-dimensional (tesseracts). * - *

    Value Format

    + *

    Value Format

    * * Values are specified using bracket notation with a TO keyword separator: * @@ -56,7 +56,7 @@ * 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. * - *

    Schema Configuration

    + *

    Schema Configuration

    * *
      * <fieldType name="intrange" class="org.apache.solr.schema.numericrange.IntRangeField" numDimensions="1"/>
    @@ -65,7 +65,7 @@
      * <field name="bbox" type="intrange2d" indexed="true" stored="true"/>
      * 
    * - *

    Querying

    + *

    Querying

    * * Use the {@code numericRange} query parser for range queries with support for different query * types: @@ -77,7 +77,7 @@ *
  • Crosses: {@code {!numericRange criteria="crosses" field=price_range}[150 TO 250]} *
* - *

Limitations

+ *

Limitations

* * 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. diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java index fc83881023c0..423bd034d4be 100644 --- a/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java @@ -16,6 +16,7 @@ */ 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; @@ -36,7 +37,7 @@ *

This parser enables queries against {@link IntRangeField} fields with explicit control over * the query relationship type (intersects, within, contains, crosses). * - *

Parameters

+ *

Parameters

* *
    *
  • field (required): The IntRangeField to query @@ -44,7 +45,7 @@ * contains, crosses *
* - *

Query Types

+ *

Query Types

* *
    *
  • intersects: Matches ranges that overlap with the query range (most permissive) @@ -54,7 +55,7 @@ * wholly contained) *
* - *

Example Usage

+ *

Example Usage

* *
  * // 1D range queries
@@ -113,7 +114,7 @@ public static QueryCriteria fromString(String criteriaStr) {
         throw new SolrException(ErrorCode.BAD_REQUEST, "Query criteria cannot be null or empty");
       }
 
-      String normalized = criteriaStr.trim().toLowerCase();
+      String normalized = criteriaStr.trim().toLowerCase(Locale.ROOT);
       for (QueryCriteria criteria : values()) {
         if (criteria.name.equals(normalized)) {
           return criteria;

From 9e447c8aefec5dee7ce199e735617d37dc788281 Mon Sep 17 00:00:00 2001
From: Jason Gerlowski 
Date: Wed, 18 Feb 2026 11:40:54 -0500
Subject: [PATCH 12/13] Initial ref-guide coverage

---
 .../pages/field-types-included-with-solr.adoc |  4 +-
 .../query-guide/pages/other-parsers.adoc      | 65 +++++++++++++++++++
 2 files changed, 68 insertions(+), 1 deletion(-)

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 4eaed0e04751..c437864867ae 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
@@ -18,7 +18,7 @@
 
 The following table lists the field types that are available in Solr and are recommended.
 The page further down, lists all the deprecated types for those migrating from older version of Solr.
-The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table.
+The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table, unless otherwise specified.
 
 == Recommended Field Types
 
@@ -51,6 +51,8 @@ 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.  This field type does not support docValues, and is typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser].  Field type is defined in the `org.apache.solr.schema.numericrange` package; XML definitions typically express this as: ``
+
 |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.
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 59f55acfedff..347b23285084 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
@@ -1002,6 +1002,71 @@ These parameters would be defined in `solrconfig.xml`, in the `defaults` section
 
 For more information about the possibilities of nested queries, see Yonik Seeley's blog post https://lucidworks.com/2009/03/31/nested-queries-in-solr/[Nested Queries in Solr].
 
+== Numeric Range Query Parser
+
+Allows users to search range fields (e.g. `IntRangeField`) using a specified query-range.
+A "criteria" parameter indicates what match-semantics should be used.
+Accepts a "criteria" local-param that indicates how matched documents should relate to the query-range:
+
+* `contains` if matching documents should entirely contain or enclose the query-range (i.e. doc-ranges that are a superset of query-range),
+* `within` if matching documents should be entirely within the query-range (i.e. doc-ranges that are sub-set of query-range)
+* `crosses` if matching documents should be partially **but not fully** contained within the query-range
+* `intersects` treats a document as a match if it meets any of the criteria above (i.e. `contains || within || crosses`)
+
+=== Numeric Range Parameters
+
+`field`::
++
+[%autowidth,frame=none]
+|===
+|Required |Default: none
+|===
++
+The field name to operate on.
+Must be a "range" field (e.g. `IntRangeField`)
+`criteria`::
++
+[%autowidth,frame=none]
+|===
+|Required |Default: none
+|===
++
+The criteria or semantics used to determine where a document matches the query-range.
+One of: `contains`, `within`, `crosses` or `intersects`.
+`v`::
++
+[%autowidth,frame=none]
+|===
+|Required |Default: none
+|===
++
+The query-range to be compared against field values.
+The specified range must have the same number of dimensions as the targeted field.
+Usually provided implicitly by specifying a range after the local-params block (see examples below).
+
+=== Numeric Range Example
+
+Find products whose `price_range` is fully bounded between 100 and 120.
+
+[source,text]
+----
+{!numericRange field="price_range" criteria="within"}[100 TO 120]
+----
+
+Find products whose `price_range` contains 100.
+
+[source,text]
+----
+{!numericRange field="price_range" criteria="contains"}[100 TO 100]
+----
+
+Find products whose `price_range` overlaps at all with a user's desired price range (e.g. 100-120).
+
+[source,text]
+----
+{!numericRange field="price_range" criteria="intersects" v="[100 TO 120]"}
+----
+
 == Vector Query Parsers
 
 

From 638c7e11f19b5ab431fed3087a843bf5c158d8c2 Mon Sep 17 00:00:00 2001
From: Jason Gerlowski 
Date: Wed, 18 Feb 2026 11:46:31 -0500
Subject: [PATCH 13/13] Changelog entry

---
 .../unreleased/SOLR-13309-introduce-int-range-field.yml   | 8 ++++++++
 1 file changed, 8 insertions(+)
 create mode 100644 changelog/unreleased/SOLR-13309-introduce-int-range-field.yml

diff --git a/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml b/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml
new file mode 100644
index 000000000000..bc913cecb6b7
--- /dev/null
+++ b/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml
@@ -0,0 +1,8 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: Introduce new `IntRangeField` field type and `{!numericRange}` query parser for storing and querying integer ranges.
+type: added 
+authors:
+  - name: Jason Gerlowski
+links:
+  - name: SOLR-13309
+    url: https://issues.apache.org/jira/browse/SOLR-13309