diff --git a/changelog/unreleased/SOLR-13309-floatRangeField.yml b/changelog/unreleased/SOLR-13309-floatRangeField.yml
new file mode 100644
index 00000000000..f447e5f3902
--- /dev/null
+++ b/changelog/unreleased/SOLR-13309-floatRangeField.yml
@@ -0,0 +1,7 @@
+title: Introduce new `FloatRangeField` field type for storing and querying float-based ranges
+type: added
+authors:
+ - name: Jason Gerlowski
+links:
+ - name: SOLR-13309
+ url: https://issues.apache.org/jira/browse/SOLR-13309
diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/AbstractNumericRangeField.java
index 8d61af15df8..a05a766ac52 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,55 @@ 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,
+ * floating-point literals, or values in scientific notation such as {@code 1.2e3} or {@code
+ * -4.5E-6}).
+ */
+ protected static final String COMMA_DELIMITED_FP_NUMS =
+ "-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?(?:\\s*,\\s*-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\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 +334,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:
+ *
+ *
+ * - 1D: {@code [1.5 TO 2.5]}
+ *
- 2D: {@code [1.0,2.0 TO 3.0,4.0]}
+ *
- 3D: {@code [1.0,2.0,3.0 TO 4.0,5.0,6.0]}
+ *
- 4D: {@code [1.0,2.0,3.0,4.0 TO 5.0,6.0,7.0,8.0]}
+ *
+ *
+ * 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:
+ *
+ *
+ * - Intersects: {@code {!numericRange criteria="intersects" field=price_range}[1.0 TO 2.0]}
+ *
- Within: {@code {!numericRange criteria="within" field=price_range}[0.0 TO 3.0]}
+ *
- Contains: {@code {!numericRange criteria="contains" field=price_range}[1.5 TO 1.75]}
+ *
- Crosses: {@code {!numericRange criteria="crosses" field=price_range}[1.5 TO 2.5]}
+ *
+ *
+ * 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..709f7ac565a 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,13 +71,17 @@
* {!numericRange criteria="intersects" field=long_range}[1000000000 TO 2000000000]
* {!numericRange criteria="within" field=long_range}[0 TO 9999999999]
*
- * // Multi-dimensional queries (bounding boxes, cubes, tesseracts)
- * {!numericRange criteria="intersects" field=bbox}[0,0 TO 10,10]
- * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20]
+ * // 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 (2D, 3D, 4D)
+ * {!numericRange criteria="within" field=bounding_cube}[-10,-10,-4 TO 20,20,18]
*
*
* @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..a2349abe11d
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/schema/numericrange/FloatRangeFieldTest.java
@@ -0,0 +1,398 @@
+/*
+ * 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 testScientificNotation() {
+ FloatRangeField fieldType = createFieldType(1);
+
+ // Integer mantissa with positive exponent
+ FloatRangeField.RangeValue range = fieldType.parseRangeValue("[123e4 TO 567e8]");
+ assertEquals(123e4f, range.mins[0], 0.0f);
+ assertEquals(567e8f, range.maxs[0], 0.0f);
+
+ // Decimal mantissa with negative exponent
+ range = fieldType.parseRangeValue("[-1.2e-4 TO 3.4e-2]");
+ assertEquals(-1.2e-4f, range.mins[0], 0.0f);
+ assertEquals(3.4e-2f, range.maxs[0], 0.0f);
+
+ // Uppercase E
+ range = fieldType.parseRangeValue("[1.5E3 TO 2.5E3]");
+ assertEquals(1.5e3f, range.mins[0], 0.0f);
+ assertEquals(2.5e3f, range.maxs[0], 0.0f);
+
+ // Explicit positive exponent sign
+ range = fieldType.parseRangeValue("[1.0e+2 TO 9.9e+2]");
+ assertEquals(1.0e+2f, range.mins[0], 0.0f);
+ assertEquals(9.9e+2f, range.maxs[0], 0.0f);
+
+ // Negative mantissa with negative exponent
+ range = fieldType.parseRangeValue("[-9.9E-9 TO -1.1E-9]");
+ assertEquals(-9.9e-9f, range.mins[0], 0.0f);
+ assertEquals(-1.1e-9f, range.maxs[0], 0.0f);
+
+ // Multi-dimensional with scientific notation
+ FloatRangeField fieldType2D = createFieldType(2);
+ range = fieldType2D.parseRangeValue("[1e2,2.0e1 TO 3e2,4.0e1]");
+ assertEquals(1e2f, range.mins[0], 0.0f);
+ assertEquals(2.0e1f, range.mins[1], 0.0f);
+ assertEquals(3e2f, range.maxs[0], 0.0f);
+ assertEquals(4.0e1f, range.maxs[1], 0.0f);
+
+ // Single-bound scientific notation via toInternal
+ String val = "[1.2e3 TO 4.5e3]";
+ assertEquals(val, fieldType.toInternal(val));
+ }
+
+ 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]']");
+ }
+}
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]