From 5c4ed5e2bef7b27dc0b793fbded514271e6c5f6c Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Fri, 20 Mar 2026 11:16:56 -0400 Subject: [PATCH 1/3] SOLR-13309: Introduce FloatRangeField to expose Lucene 'FloatRange' This commit adds a new field type, FloatRangeField, that can be used to hold singular or multi-dimensional (up to 4) ranges of floats. FloatRangeField is compatible with the previously added `{!numericRange}` and supports similar syntax. --- .../AbstractNumericRangeField.java | 50 ++- .../schema/numericrange/FloatRangeField.java | 304 +++++++++++++ .../NumericRangeQParserPlugin.java | 11 +- .../collection1/conf/schema-numericrange.xml | 19 + .../numericrange/FloatRangeFieldTest.java | 357 +++++++++++++++ .../NumericRangeQParserPluginFloatTest.java | 421 ++++++++++++++++++ 6 files changed, 1158 insertions(+), 4 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java create mode 100644 solr/core/src/test/org/apache/solr/schema/numericrange/FloatRangeFieldTest.java create mode 100644 solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java index 8d61af15df8..65653d644b5 100644 --- a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java @@ -53,6 +53,7 @@ * * @see IntRangeField * @see LongRangeField + * @see FloatRangeField */ public abstract class AbstractNumericRangeField extends PrimitiveFieldType { @@ -82,9 +83,54 @@ public interface NumericRangeValue { protected static final Pattern SINGLE_BOUND_PATTERN = Pattern.compile("^" + COMMA_DELIMITED_NUMS + "$"); + /** + * Regex fragment matching a comma-separated list of signed floating-point numbers (integers or + * floating-point literals). + */ + protected static final String COMMA_DELIMITED_FP_NUMS = + "-?\\d+(?:\\.\\d+)?(?:\\s*,\\s*-?\\d+(?:\\.\\d+)?)*"; + + private static final String FP_RANGE_PATTERN_STR = + "\\[\\s*(" + COMMA_DELIMITED_FP_NUMS + ")\\s+TO\\s+(" + COMMA_DELIMITED_FP_NUMS + ")\\s*\\]"; + + /** + * Pre-compiled pattern matching {@code [min1,min2,... TO max1,max2,...]} range syntax where + * values may be floating-point numbers. + */ + protected static final Pattern FP_RANGE_PATTERN_REGEX = Pattern.compile(FP_RANGE_PATTERN_STR); + + /** + * Pre-compiled pattern matching a single (multi-dimensional) floating-point bound, e.g. {@code + * 1.5,2.0,3.14}. + */ + protected static final Pattern FP_SINGLE_BOUND_PATTERN = + Pattern.compile("^" + COMMA_DELIMITED_FP_NUMS + "$"); + /** Configured number of dimensions for this field type; defaults to 1. */ protected int numDimensions = 1; + /** + * Returns the regex {@link Pattern} used to match a full range value string of the form {@code + * [min TO max]}. Subclasses may override to use an alternative pattern (e.g. one that accepts + * floating-point numbers). + * + * @return the range pattern for this field type + */ + protected Pattern getRangePattern() { + return RANGE_PATTERN_REGEX; + } + + /** + * Returns the regex {@link Pattern} used to match a single multi-dimensional bound (e.g. {@code + * 1,2,3}). Subclasses may override to use an alternative pattern (e.g. one that accepts + * floating-point numbers). + * + * @return the single-bound pattern for this field type + */ + protected Pattern getSingleBoundPattern() { + return SINGLE_BOUND_PATTERN; + } + @Override protected boolean enableDocValuesByDefault() { return false; // Range fields do not support docValues @@ -287,13 +333,13 @@ public Query getFieldQuery(QParser parser, SchemaField field, String externalVal String trimmed = externalVal.trim(); // Check if it's the full range syntax: [min1,min2 TO max1,max2] - if (RANGE_PATTERN_REGEX.matcher(trimmed).matches()) { + if (getRangePattern().matcher(trimmed).matches()) { final var rangeValue = parseRangeValue(trimmed); return newContainsQuery(field.getName(), rangeValue); } // Syntax sugar: also accept a single-bound (i.e pX,pY,pZ) - if (SINGLE_BOUND_PATTERN.matcher(trimmed).matches()) { + if (getSingleBoundPattern().matcher(trimmed).matches()) { final var singleBoundRange = parseSingleBound(trimmed); if (singleBoundRange.getDimensions() != numDimensions) { diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java new file mode 100644 index 00000000000..fb8c19b5a44 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.schema.numericrange; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.lucene.document.FloatRange; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.search.Query; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.search.QParser; + +/** + * Field type for float ranges with support for 1-4 dimensions. + * + *

This field type wraps Lucene's {@link FloatRange} to provide storage and querying of float + * 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. Integer values (e.g. {@code [10 TO 20]}) are also + * accepted and parsed as floats. + * + *

Schema Configuration

+ * + *
+ * <fieldType name="floatrange" class="org.apache.solr.schema.numericrange.FloatRangeField" numDimensions="1"/>
+ * <fieldType name="floatrange2d" class="org.apache.solr.schema.numericrange.FloatRangeField" numDimensions="2"/>
+ * <field name="price_range" type="floatrange" indexed="true" stored="true"/>
+ * <field name="bbox" type="floatrange2d" indexed="true" stored="true"/>
+ * 
+ * + *

Querying

+ * + * Use the {@code numericRange} 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 FloatRange + * @see org.apache.solr.search.numericrange.NumericRangeQParserPlugin + */ +public class FloatRangeField extends AbstractNumericRangeField { + + @Override + protected Pattern getRangePattern() { + return FP_RANGE_PATTERN_REGEX; + } + + @Override + protected Pattern getSingleBoundPattern() { + return FP_SINGLE_BOUND_PATTERN; + } + + @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 FloatRange(field.getName(), rangeValue.mins, rangeValue.maxs); + } + + /** + * Parse a range value string into a RangeValue object. + * + * @param value the string value in format "[min1,min2,... TO max1,max2,...]" + * @return parsed RangeValue + * @throws SolrException if value format is invalid + */ + @Override + public RangeValue parseRangeValue(String value) { + if (value == null || value.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be null or empty"); + } + + Matcher matcher = FP_RANGE_PATTERN_REGEX.matcher(value.trim()); + if (!matcher.matches()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid range format. Expected: [min1,min2,... TO max1,max2,...] where min and max values are floats, but got: " + + value); + } + + String minPart = matcher.group(1).trim(); + String maxPart = matcher.group(2).trim(); + + float[] mins = parseFloatArray(minPart, "min values"); + float[] maxs = parseFloatArray(maxPart, "max values"); + + if (mins.length != maxs.length) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min and max dimensions must match. Min dimensions: " + + mins.length + + ", max dimensions: " + + maxs.length); + } + + if (mins.length != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Range dimensions (" + + mins.length + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + // Validate that min <= max for each dimension + for (int i = 0; i < mins.length; i++) { + if (mins[i] > maxs[i]) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min value must be <= max value for dimension " + + i + + ". Min: " + + mins[i] + + ", Max: " + + maxs[i]); + } + } + + return new RangeValue(mins, maxs); + } + + @Override + public NumericRangeValue parseSingleBound(String value) { + final var singleBoundTyped = parseFloatArray(value, "single bound values"); + return new RangeValue(singleBoundTyped, singleBoundTyped); + } + + /** + * Parse a comma-separated string of floats into an array. + * + * @param str the string to parse + * @param description description for error messages + * @return array of parsed floats + */ + private float[] parseFloatArray(String str, String description) { + String[] parts = str.split(","); + float[] result = new float[parts.length]; + + for (int i = 0; i < parts.length; i++) { + try { + result[i] = Float.parseFloat(parts[i].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid float in " + description + ": '" + parts[i].trim() + "'", + e); + } + } + + return result; + } + + @Override + public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return FloatRange.newContainsQuery(fieldName, rv.mins, rv.maxs); + } + + @Override + public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return FloatRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs); + } + + @Override + public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return FloatRange.newWithinQuery(fieldName, rv.mins, rv.maxs); + } + + @Override + public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) { + final var rv = (RangeValue) rangeValue; + return FloatRange.newCrossesQuery(fieldName, rv.mins, rv.maxs); + } + + @Override + protected Query getSpecializedRangeQuery( + QParser parser, + SchemaField field, + String part1, + String part2, + boolean minInclusive, + boolean maxInclusive) { + // For standard range syntax field:[value TO value], default to contains query + if (part1 == null || part2 == null) { + return super.getSpecializedRangeQuery( + parser, field, part1, part2, minInclusive, maxInclusive); + } + + // Parse the range bounds as single-dimensional float values + float min, max; + try { + min = Float.parseFloat(part1.trim()); + max = Float.parseFloat(part2.trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid float values in range query: [" + part1 + " TO " + part2 + "]", + e); + } + + // For exclusive bounds, step to the next representable float value + if (!minInclusive) { + min = Math.nextUp(min); + } + if (!maxInclusive) { + max = Math.nextDown(max); + } + + // Build arrays for the query based on configured dimensions + float[] mins = new float[numDimensions]; + float[] maxs = new float[numDimensions]; + + // For now, only support 1D range syntax with field:[X TO Y] + if (numDimensions == 1) { + mins[0] = min; + maxs[0] = max; + return FloatRange.newContainsQuery(field.getName(), mins, maxs); + } else { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Standard range query syntax only supports 1D ranges. " + + "Use {!numericRange ...} for multi-dimensional queries."); + } + } + + /** Simple holder class for parsed float range values. */ + public static class RangeValue implements AbstractNumericRangeField.NumericRangeValue { + public final float[] mins; + public final float[] maxs; + + public RangeValue(float[] mins, float[] maxs) { + this.mins = mins; + this.maxs = maxs; + } + + @Override + public int getDimensions() { + return mins.length; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < mins.length; i++) { + if (i > 0) sb.append(","); + sb.append(mins[i]); + } + sb.append(" TO "); + for (int i = 0; i < maxs.length; i++) { + if (i > 0) sb.append(","); + sb.append(maxs[i]); + } + sb.append("]"); + return sb.toString(); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java index b19be4b5b16..4d0970d418f 100644 --- a/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java @@ -25,6 +25,7 @@ import org.apache.solr.schema.SchemaField; import org.apache.solr.schema.numericrange.AbstractNumericRangeField; import org.apache.solr.schema.numericrange.AbstractNumericRangeField.NumericRangeValue; +import org.apache.solr.schema.numericrange.FloatRangeField; import org.apache.solr.schema.numericrange.IntRangeField; import org.apache.solr.schema.numericrange.LongRangeField; import org.apache.solr.search.QParser; @@ -35,8 +36,9 @@ /** * Query parser for numeric range fields with support for different query relationship types. * - *

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

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

Parameters

* @@ -69,6 +71,10 @@ * {!numericRange criteria="intersects" field=long_range}[1000000000 TO 2000000000] * {!numericRange criteria="within" field=long_range}[0 TO 9999999999] * + * // FloatRangeField queries + * {!numericRange criteria="intersects" field=float_range}[1.0 TO 2.5] + * {!numericRange criteria="within" field=float_range}[0.0 TO 9.99] + * * // Multi-dimensional queries (bounding boxes, cubes, tesseracts) * {!numericRange criteria="intersects" field=bbox}[0,0 TO 10,10] * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20] @@ -76,6 +82,7 @@ * * @see IntRangeField * @see LongRangeField + * @see FloatRangeField * @lucene.experimental */ public class NumericRangeQParserPlugin extends QParserPlugin { diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml index 4d70167a59e..42adfe39011 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml @@ -36,6 +36,12 @@ + + + + + + @@ -67,6 +73,19 @@ + + + + + + + + + + + + + id diff --git a/solr/core/src/test/org/apache/solr/schema/numericrange/FloatRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/FloatRangeFieldTest.java new file mode 100644 index 00000000000..012d4980e33 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/FloatRangeFieldTest.java @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.schema.numericrange; + +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; +import org.apache.lucene.document.FloatRange; +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 FloatRangeField} */ +public class FloatRangeFieldTest extends SolrTestCase { + + @BeforeClass + public static void ensureAssumptions() { + assumeWorkingMockito(); + } + + public void test1DRangeParsing() { + FloatRangeField fieldType = createFieldType(1); + + // Valid 1D range with floating-point values + FloatRangeField.RangeValue range = fieldType.parseRangeValue("[1.5 TO 3.14]"); + assertEquals(1, range.getDimensions()); + assertEquals(1.5f, range.mins[0], 0.0f); + assertEquals(3.14f, range.maxs[0], 0.0f); + + // Integer values are accepted and parsed as floats + range = fieldType.parseRangeValue("[10 TO 20]"); + assertEquals(10.0f, range.mins[0], 0.0f); + assertEquals(20.0f, range.maxs[0], 0.0f); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 1.5 TO 3.14 ]"); + assertEquals(1.5f, range.mins[0], 0.0f); + assertEquals(3.14f, range.maxs[0], 0.0f); + + // Negative numbers + range = fieldType.parseRangeValue("[-3.5 TO -1.0]"); + assertEquals(-3.5f, range.mins[0], 0.0f); + assertEquals(-1.0f, range.maxs[0], 0.0f); + + // Point range (min == max) + range = fieldType.parseRangeValue("[5.0 TO 5.0]"); + assertEquals(5.0f, range.mins[0], 0.0f); + assertEquals(5.0f, range.maxs[0], 0.0f); + } + + public void test2DRangeParsing() { + FloatRangeField fieldType = createFieldType(2); + + // Valid 2D range (bounding box) + FloatRangeField.RangeValue range = fieldType.parseRangeValue("[1.0,2.0 TO 3.0,4.0]"); + assertEquals(2, range.getDimensions()); + assertEquals(1.0f, range.mins[0], 0.0f); + assertEquals(2.0f, range.mins[1], 0.0f); + assertEquals(3.0f, range.maxs[0], 0.0f); + assertEquals(4.0f, range.maxs[1], 0.0f); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 1.0 , 2.0 TO 3.0 , 4.0 ]"); + assertEquals(1.0f, range.mins[0], 0.0f); + assertEquals(2.0f, range.mins[1], 0.0f); + assertEquals(3.0f, range.maxs[0], 0.0f); + assertEquals(4.0f, range.maxs[1], 0.0f); + } + + public void test3DRangeParsing() { + FloatRangeField fieldType = createFieldType(3); + + // Valid 3D range (bounding cube) + FloatRangeField.RangeValue range = fieldType.parseRangeValue("[1.0,2.0,3.0 TO 4.0,5.0,6.0]"); + assertEquals(3, range.getDimensions()); + assertEquals(1.0f, range.mins[0], 0.0f); + assertEquals(2.0f, range.mins[1], 0.0f); + assertEquals(3.0f, range.mins[2], 0.0f); + assertEquals(4.0f, range.maxs[0], 0.0f); + assertEquals(5.0f, range.maxs[1], 0.0f); + assertEquals(6.0f, range.maxs[2], 0.0f); + } + + public void test4DRangeParsing() { + FloatRangeField fieldType = createFieldType(4); + + // Valid 4D range (tesseract) + FloatRangeField.RangeValue range = + fieldType.parseRangeValue("[1.0,2.0,3.0,4.0 TO 5.0,6.0,7.0,8.0]"); + assertEquals(4, range.getDimensions()); + assertEquals(1.0f, range.mins[0], 0.0f); + assertEquals(2.0f, range.mins[1], 0.0f); + assertEquals(3.0f, range.mins[2], 0.0f); + assertEquals(4.0f, range.mins[3], 0.0f); + assertEquals(5.0f, range.maxs[0], 0.0f); + assertEquals(6.0f, range.maxs[1], 0.0f); + assertEquals(7.0f, range.maxs[2], 0.0f); + assertEquals(8.0f, range.maxs[3], 0.0f); + } + + public void testInvalidRangeFormat() { + FloatRangeField fieldType = createFieldType(1); + + // Missing brackets + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("1.5 TO 3.14")); + assertThat(e1.getMessage(), containsString("Invalid range format")); + assertThat(e1.getMessage(), containsString("Expected: [min1,min2,... TO max1,max2,...]")); + + // Missing TO keyword + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[1.5 3.14]")); + assertThat(e2.getMessage(), containsString("Invalid range format")); + + // Empty value + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + assertThat(e3.getMessage(), containsString("Range value cannot be null or empty")); + + // Null value + SolrException e4 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + assertThat(e4.getMessage(), containsString("Range value cannot be null or empty")); + } + + public void testInvalidNumbers() { + FloatRangeField fieldType = createFieldType(1); + + // Non-numeric values + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + assertThat(e1.getMessage(), containsString("Invalid range")); + assertThat(e1.getMessage(), containsString("where min and max values are floats")); + + // Partially numeric + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[1.5 TO xyz]")); + assertThat(e2.getMessage(), containsString("Invalid range")); + assertThat(e2.getMessage(), containsString("where min and max values are floats")); + } + + public void testDimensionMismatch() { + FloatRangeField fieldType1D = createFieldType(1); + FloatRangeField fieldType2D = createFieldType(2); + + // 2D value on 1D field + SolrException e1 = + expectThrows( + SolrException.class, () -> fieldType1D.parseRangeValue("[1.0,2.0 TO 3.0,4.0]")); + assertThat(e1.getMessage(), containsString("Range dimensions")); + assertThat(e1.getMessage(), containsString("do not match field type numDimensions")); + + // 1D value on 2D field + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[1.0 TO 2.0]")); + assertThat(e2.getMessage(), containsString("Range dimensions")); + assertThat(e2.getMessage(), containsString("do not match field type numDimensions")); + + // Min/max dimension mismatch + SolrException e3 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[1.0,2.0 TO 3.0]")); // 2D mins, 1D maxs + assertThat(e3.getMessage(), containsString("Min and max dimensions must match")); + } + + public void testMinGreaterThanMax() { + FloatRangeField fieldType = createFieldType(1); + + // Min > max should fail + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[3.14 TO 1.5]")); + assertThat(e1.getMessage(), containsString("Min value must be <= max value")); + assertThat(e1.getMessage(), containsString("dimension 0")); + + // For 2D + FloatRangeField fieldType2D = createFieldType(2); + SolrException e2 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[3.0,2.0 TO 1.0,4.0]")); // First dimension invalid + assertThat(e2.getMessage(), containsString("Min value must be <= max value")); + assertThat(e2.getMessage(), containsString("dimension 0")); + } + + public void testFieldCreation1D() { + FloatRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "float_range"); + + IndexableField field = fieldType.createField(schemaField, "[1.0 TO 2.0]"); + assertNotNull(field); + assertTrue(field instanceof FloatRange); + assertEquals("float_range", field.name()); + } + + public void testFieldCreation2D() { + FloatRangeField fieldType = createFieldType(2); + SchemaField schemaField = createSchemaField(fieldType, "float_range_2d"); + + IndexableField field = fieldType.createField(schemaField, "[0.0,0.0 TO 10.0,10.0]"); + assertNotNull(field); + assertTrue(field instanceof FloatRange); + assertEquals("float_range_2d", field.name()); + } + + public void testStoredField() { + FloatRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "float_range"); + + String value = "[1.0 TO 2.0]"; + IndexableField storedField = fieldType.getStoredField(schemaField, value); + assertNotNull(storedField); + assertEquals("float_range", storedField.name()); + assertEquals(value, storedField.stringValue()); + } + + public void testToInternal() { + FloatRangeField fieldType = createFieldType(1); + + // Valid value should pass through after validation + String value = "[1.5 TO 3.14]"; + String internal = fieldType.toInternal(value); + assertEquals(value, internal); + + // Invalid value should throw exception + SolrException e = expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + assertThat(e.getMessage(), containsString("Invalid range format")); + } + + public void testToNativeType() { + FloatRangeField fieldType = createFieldType(1); + + // String input + Object nativeType = fieldType.toNativeType("[1.5 TO 3.14]"); + assertTrue(nativeType instanceof FloatRangeField.RangeValue); + FloatRangeField.RangeValue range = (FloatRangeField.RangeValue) nativeType; + assertEquals(1.5f, range.mins[0], 0.0f); + assertEquals(3.14f, range.maxs[0], 0.0f); + + // RangeValue input (should pass through) + FloatRangeField.RangeValue inputRange = + new FloatRangeField.RangeValue(new float[] {5.0f}, new float[] {15.0f}); + Object result = fieldType.toNativeType(inputRange); + assertSame(inputRange, result); + + // Null input + assertNull(fieldType.toNativeType(null)); + } + + public void testSortFieldThrowsException() { + FloatRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "float_range"); + + // Sorting should not be supported + SolrException e = + expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + assertThat(e.getMessage(), containsString("Cannot sort on FloatRangeField")); + assertThat(e.getMessage(), containsString("float_range")); + } + + public void testUninversionType() { + FloatRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "float_range"); + + // Should return null (no field cache support) + assertNull(fieldType.getUninversionType(schemaField)); + } + + public void testInvalidNumDimensions() { + FloatRangeField field = new FloatRangeField(); + Map args = new HashMap<>(); + IndexSchema schema = createMockSchema(); + + // Test numDimensions = 0 + args.put("numDimensions", "0"); + SolrException e1 = expectThrows(SolrException.class, () -> field.init(schema, args)); + assertThat(e1.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e1.getMessage(), containsString("but was [0]")); + + // Test numDimensions = 5 (too high) + args.put("numDimensions", "5"); + FloatRangeField field2 = new FloatRangeField(); + SolrException e2 = expectThrows(SolrException.class, () -> field2.init(schema, args)); + assertThat(e2.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e2.getMessage(), containsString("but was [5]")); + + // Test negative numDimensions + args.put("numDimensions", "-1"); + FloatRangeField field3 = new FloatRangeField(); + SolrException e3 = expectThrows(SolrException.class, () -> field3.init(schema, args)); + assertThat(e3.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e3.getMessage(), containsString("but was [-1]")); + } + + public void testRangeValueToString() { + FloatRangeField fieldType = createFieldType(2); + FloatRangeField.RangeValue range = fieldType.parseRangeValue("[1.0,2.0 TO 3.0,4.0]"); + + String str = range.toString(); + assertEquals("[1.0,2.0 TO 3.0,4.0]", str); + } + + public void testExtremeValues() { + FloatRangeField fieldType = createFieldType(1); + + // Test with very negative and very positive values expressible without scientific notation + FloatRangeField.RangeValue range = fieldType.parseRangeValue("[-9999999.0 TO 9999999.0]"); + assertEquals(-9999999.0f, range.mins[0], 0.0f); + assertEquals(9999999.0f, range.maxs[0], 0.0f); + + // Test with small fractional values + range = fieldType.parseRangeValue("[0.0001 TO 0.9999]"); + assertEquals(0.0001f, range.mins[0], 0.0f); + assertEquals(0.9999f, range.maxs[0], 0.0f); + } + + private IndexSchema createMockSchema() { + final var schema = mock(IndexSchema.class); + when(schema.getVersion()).thenReturn(1.7f); + return schema; + } + + private FloatRangeField createFieldType(int numDimensions) { + FloatRangeField field = new FloatRangeField(); + Map args = new HashMap<>(); + args.put("numDimensions", String.valueOf(numDimensions)); + + field.init(createMockSchema(), args); + + return field; + } + + private SchemaField createSchemaField(FloatRangeField fieldType, String name) { + final var fieldProperties = + 0b1 | 0b100; // INDEXED | STORED - constants cannot be accessed directly due to visibility. + return new SchemaField(name, fieldType, fieldProperties, null); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java new file mode 100644 index 00000000000..32b1e1200bd --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/NumericRangeQParserPluginFloatTest.java @@ -0,0 +1,421 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.search.numericrange; + +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Tests for {@link NumericRangeQParserPlugin} using {@link + * org.apache.solr.schema.numericrange.FloatRangeField} fields. + */ +public class NumericRangeQParserPluginFloatTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema-numericrange.xml"); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + clearIndex(); + assertU(commit()); + } + + @Test + public void test1DIntersectsQuery() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); + assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); + assertU(adoc("id", "4", "float_range", "[2.0 TO 3.0]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[1.2 TO 1.8]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='float_range'][.='[1.0 TO 2.0]']", + "//result/doc/str[@name='float_range'][.='[1.5 TO 2.5]']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[0.0 TO 1.0]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='3']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[1.75 TO 2.25]"), + "//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", "float_range", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); + assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=float_range}[0.0 TO 3.0]"), + "//result[@numFound='3']"); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=float_range}[1.0 TO 2.0]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=\"within\" field=float_range}[0.0 TO 1.0]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='3']"); + } + + @Test + public void test1DContainsQuery() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); + assertU(adoc("id", "3", "float_range", "[0.5 TO 3.0]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=float_range}[1.6 TO 1.7]"), + "//result[@numFound='3']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='id'][.='3']"); + + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=float_range}[0.0 TO 4.0]"), + "//result[@numFound='0']"); + + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=float_range}[1.0 TO 2.0]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='3']"); + } + + @Test + public void test1DCrossesQuery() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range", "[1.5 TO 2.5]")); + assertU(adoc("id", "3", "float_range", "[0.5 TO 0.8]")); + assertU(adoc("id", "4", "float_range", "[1.2 TO 1.8]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=\"crosses\" field=float_range}[1.5 TO 2.5]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='4']"); + } + + @Test + public void test2DIntersectsQuery() { + assertU(adoc("id", "1", "float_range_2d", "[0.0,0.0 TO 1.0,1.0]")); + assertU(adoc("id", "2", "float_range_2d", "[0.5,0.5 TO 1.5,1.5]")); + assertU(adoc("id", "3", "float_range_2d", "[2.0,2.0 TO 3.0,3.0]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range_2d}[0.8,0.8 TO 1.2,1.2]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range_2d}[2.5,2.5 TO 3.5,3.5]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='3']"); + + assertQ( + req( + "q", + "{!numericRange criteria=intersects field=float_range_2d}[10.0,10.0 TO 20.0,20.0]"), + "//result[@numFound='0']"); + } + + @Test + public void test3DQuery() { + assertU(adoc("id", "1", "float_range_3d", "[0.0,0.0,0.0 TO 1.0,1.0,1.0]")); + assertU(adoc("id", "2", "float_range_3d", "[0.5,0.5,0.5 TO 1.5,1.5,1.5]")); + assertU(commit()); + + assertQ( + req( + "q", + "{!numericRange criteria=intersects field=float_range_3d}[0.8,0.8,0.8 TO 1.2,1.2,1.2]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void test4DQuery() { + assertU(adoc("id", "1", "float_range_4d", "[0.0,0.0,0.0,0.0 TO 1.0,1.0,1.0,1.0]")); + assertU(adoc("id", "2", "float_range_4d", "[0.5,0.5,0.5,0.5 TO 1.5,1.5,1.5,1.5]")); + assertU(commit()); + + assertQ( + req( + "q", + "{!numericRange criteria=intersects field=float_range_4d}[0.8,0.8,0.8,0.8 TO 1.2,1.2,1.2,1.2]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testMultiValuedField() { + assertU( + adoc("id", "1", "float_range_multi", "[1.0 TO 2.0]", "float_range_multi", "[3.0 TO 4.0]")); + assertU(adoc("id", "2", "float_range_multi", "[1.5 TO 2.5]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range_multi}[1.1 TO 1.2]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/arr[@name='float_range_multi']/str[1][.='[1.0 TO 2.0]']", + "//result/doc/arr[@name='float_range_multi']/str[2][.='[3.0 TO 4.0]']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range_multi}[3.1 TO 3.2]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range_multi}[1.5 TO 2.5]"), + "//result[@numFound='2']"); + } + + @Test + public void testNegativeValues() { + assertU(adoc("id", "1", "float_range", "[-1.0 TO -0.5]")); + assertU(adoc("id", "2", "float_range", "[-0.75 TO -0.25]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[-0.8 TO -0.6]"), + "//result[@numFound='2']"); + } + + @Test + public void testPointRange() { + assertU(adoc("id", "1", "float_range", "[1.5 TO 1.5]")); + assertU(commit()); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[1.5 TO 1.5]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + assertQ( + req("q", "{!numericRange criteria=intersects field=float_range}[0.5 TO 1.75]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + @Test + public void testMissingFieldParameter() { + assertQEx( + "Missing field parameter should fail", + "Missing required parameter: field", + req("q", "{!numericRange criteria=intersects}[1.0 TO 2.0]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testMissingCriteriaParameter() { + assertQEx( + "Missing criteria parameter should fail", + "Missing required parameter: criteria", + req("q", "{!numericRange field=float_range}[1.0 TO 2.0]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidFieldType() { + // Query on a plain string field should fail + assertQEx( + "Query on wrong field type should fail", + "must be a numeric range field type", + req("q", "{!numericRange criteria=intersects field=title}[1.0 TO 2.0]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidQueryType() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(commit()); + + assertQEx( + "Invalid query criteria should fail", + "Unknown query criteria", + req("q", "{!numericRange criteria=\"invalid\" field=float_range}[1.0 TO 2.0]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidRangeValue() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(commit()); + + assertQEx( + "Invalid range format should fail", + "Invalid range", + req("q", "{!numericRange criteria=intersects field=float_range}invalid"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testEmptyRangeValue() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(commit()); + + assertQEx( + "Empty range value should fail", + req("q", "{!numericRange criteria=intersects field=float_range}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + // ------------------------------------------ + // Tests for getFieldQuery and getSpecializedRangeQuery via the standard query parser. + // These default to "contains" semantics. + + @Test + public void testGetFieldQueryFullRange() { + // doc 1: narrow range, fully inside the query range → should NOT match (doc contains query) + // doc 2: wide range that fully contains the query range → should match + // doc 3: range that only partially overlaps → should NOT match + assertU(adoc("id", "1", "float_range", "[1.3 TO 1.6]")); // No match + assertU(adoc("id", "2", "float_range", "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", "float_range", "[1.5 TO 2.5]")); // No match + assertU(commit()); + + // Contains semantics: find indexed ranges that fully contain [1.2 TO 1.8] + assertQ( + req("q", "float_range:[1.2 TO 1.8]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQueryFullRangeMultipleMatches() { + assertU(adoc("id", "1", "float_range", "[0.0 TO 10.0]")); // Match! + assertU(adoc("id", "2", "float_range", "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "3", "float_range", "[1.0 TO 1.99]")); // No match - max too low + assertU(adoc("id", "4", "float_range", "[1.01 TO 2.0]")); // No match - min too high + assertU(commit()); + + assertQ( + req("q", "float_range:[1.0 TO 2.0]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQuerySingleBound() { + // Single-bound syntax: float_range:1.5 is sugar for contains([1.5 TO 1.5]) + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); // Match! + assertU(adoc("id", "2", "float_range", "[1.5 TO 1.5]")); // Match! + assertU(adoc("id", "3", "float_range", "[1.0 TO 1.49]")); // No match - max below 1.5 + assertU(adoc("id", "4", "float_range", "[1.51 TO 3.0]")); // No match - min above 1.5 + assertU(commit()); + + assertQ( + req("q", "float_range:1.5"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQuerySingleBound2D() { + // 2D single-bound: float_range_2d:0.5,0.5 is sugar for contains([0.5,0.5 TO 0.5,0.5]) + assertU(adoc("id", "1", "float_range_2d", "[0.0,0.0 TO 1.0,1.0]")); // Match! + assertU(adoc("id", "2", "float_range_2d", "[0.5,0.5 TO 0.5,0.5]")); // Match! + assertU(adoc("id", "3", "float_range_2d", "[0.0,0.0 TO 0.4,1.0]")); // No match - X too low + assertU(adoc("id", "4", "float_range_2d", "[0.6,0.0 TO 1.0,1.0]")); // No match - X too high + assertU(commit()); + + assertQ( + req("q", "float_range_2d:0.5,0.5"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQueryFieldFormatting() { + assertU(adoc("id", "1", "float_range", "[1.0 TO 2.0]")); + assertU(adoc("id", "2", "float_range_2d", "[1.0,2.0 TO 3.0,4.0]")); + assertU(adoc("id", "3", "float_range_3d", "[0.5,1.0,1.5 TO 2.5,3.0,3.5]")); + assertU(adoc("id", "4", "float_range_4d", "[0.1,0.2,0.3,0.4 TO 1.1,1.2,1.3,1.4]")); + assertU( + adoc( + "id", + "5", + "float_range_multi", + "[0.5 TO 1.0]", + "float_range_multi", + "[2.0 TO 3.0]", + "float_range_multi", + "[4.0 TO 5.0]")); + assertU(commit()); + + // Verify 1D field returns correctly formatted value + assertQ( + req("q", "id:1"), + "//result[@numFound='1']", + "//result/doc/str[@name='float_range'][.='[1.0 TO 2.0]']"); + + // Verify 2D field returns correctly formatted value + assertQ( + req("q", "id:2"), + "//result[@numFound='1']", + "//result/doc/str[@name='float_range_2d'][.='[1.0,2.0 TO 3.0,4.0]']"); + + // Verify 3D field returns correctly formatted value + assertQ( + req("q", "id:3"), + "//result[@numFound='1']", + "//result/doc/str[@name='float_range_3d'][.='[0.5,1.0,1.5 TO 2.5,3.0,3.5]']"); + + // Verify 4D field returns correctly formatted value + assertQ( + req("q", "id:4"), + "//result[@numFound='1']", + "//result/doc/str[@name='float_range_4d'][.='[0.1,0.2,0.3,0.4 TO 1.1,1.2,1.3,1.4]']"); + + // Verify multi-valued field returns all values correctly formatted + assertQ( + req("q", "id:5"), + "//result[@numFound='1']", + "//result/doc/arr[@name='float_range_multi']/str[1][.='[0.5 TO 1.0]']", + "//result/doc/arr[@name='float_range_multi']/str[2][.='[2.0 TO 3.0]']", + "//result/doc/arr[@name='float_range_multi']/str[3][.='[4.0 TO 5.0]']"); + } +} From 10fd1b1319715d5762c4e02dd4ba8ebc152bee5d Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Fri, 20 Mar 2026 12:14:19 -0400 Subject: [PATCH 2/3] Add ref-guide coverage, changelog entry --- changelog/unreleased/SOLR-13309-floatRangeField.yml | 7 +++++++ .../pages/field-types-included-with-solr.adoc | 2 ++ .../modules/query-guide/pages/other-parsers.adoc | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 changelog/unreleased/SOLR-13309-floatRangeField.yml diff --git a/changelog/unreleased/SOLR-13309-floatRangeField.yml b/changelog/unreleased/SOLR-13309-floatRangeField.yml new file mode 100644 index 00000000000..cf24fa236e0 --- /dev/null +++ b/changelog/unreleased/SOLR-13309-floatRangeField.yml @@ -0,0 +1,7 @@ +title: Introduce new `FloatRangeField` field type and (experimental) `{!numericRange}` query parser for storing and querying float ranges +type: added +authors: + - name: Jason Gerlowski +links: + - name: SOLR-13309 + url: https://issues.apache.org/jira/browse/SOLR-13309 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 35d634b48ad..d3cda92cf71 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc @@ -59,6 +59,8 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |LongRangeField |Stores single or multi-dimensional ranges of long integers, using syntax like `[1000000000 TO 4000000000]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type is defined in the `org.apache.solr.schema.numericrange` package; fieldType definitions typically reference this as: ``. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. +|FloatRangeField |Stores single or multi-dimensional ranges of floating-point numbers, using syntax like `[1.5 TO 4.5]` or `[1.0,2.0 TO 3.0,4.0]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type is defined in the `org.apache.solr.schema.numericrange` package; fieldType definitions typically reference this as: ``. Field type does not support docValues. Typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser], though the Lucene and other query parsers also support this field by assuming "contains" semantics for searches. + |NestPathField | Specialized field type storing enhanced information, when xref:indexing-nested-documents.adoc#schema-configuration[working with nested documents]. |PointType |A single-valued n-dimensional point. It's both for sorting spatial data that is _not_ lat-lon, and for some more rare use-cases. (NOTE: this is _not_ related to the "Point" based numeric fields). See xref:query-guide:spatial-search.adoc[] for more information. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc index dabb283a0b5..04fa0125dfd 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc @@ -1006,7 +1006,7 @@ For more information about the possibilities of nested queries, see Yonik Seeley NOTE: Syntax specifics of the `{!numericRange}` query parser are considered experimental and may change in the future. -Allows users to search range fields (e.g. `IntRangeField`, `LongRangeField`) using a specified query-range. +Allows users to search range fields (e.g. `IntRangeField`, `LongRangeField`, `FloatRangeField`) using a specified query-range. Multiple match semantics supported, see the `criteria` parameter below for more details. === Numeric Range Parameters @@ -1019,7 +1019,7 @@ Multiple match semantics supported, see the `criteria` parameter below for more |=== + The field name to operate on. -Must be a numeric range field type (e.g. `IntRangeField`, `LongRangeField`) +Must be a numeric range field type (e.g. `IntRangeField`, `LongRangeField`, `FloatRangeField`) `criteria`:: + [%autowidth,frame=none] From 7d263acf77f5c3b6aee6b91f3c2e1191c2df1a02 Mon Sep 17 00:00:00 2001 From: Jason Gerlowski Date: Fri, 20 Mar 2026 13:00:11 -0400 Subject: [PATCH 3/3] Minor changelog update --- changelog/unreleased/SOLR-13309-floatRangeField.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-13309-floatRangeField.yml b/changelog/unreleased/SOLR-13309-floatRangeField.yml index cf24fa236e0..f447e5f3902 100644 --- a/changelog/unreleased/SOLR-13309-floatRangeField.yml +++ b/changelog/unreleased/SOLR-13309-floatRangeField.yml @@ -1,4 +1,4 @@ -title: Introduce new `FloatRangeField` field type and (experimental) `{!numericRange}` query parser for storing and querying float ranges +title: Introduce new `FloatRangeField` field type for storing and querying float-based ranges type: added authors: - name: Jason Gerlowski