Skip to content

Commit 4f2b4e2

Browse files
authored
SOLR-13309: Add DoubleRangeField exposing Lucene 'DoubleRange' (#4239)
Completes our exposure of Lucene's "range" field types.
1 parent 24b5765 commit 4f2b4e2

9 files changed

Lines changed: 1165 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
title: Introduce new `DoubleRangeField` field type for storing and querying double-based ranges
2+
type: added
3+
authors:
4+
- name: Jason Gerlowski
5+
links:
6+
- name: SOLR-13309
7+
url: https://issues.apache.org/jira/browse/SOLR-13309
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.solr.schema.numericrange;
18+
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
import org.apache.lucene.document.DoubleRange;
22+
import org.apache.lucene.index.IndexableField;
23+
import org.apache.lucene.search.Query;
24+
import org.apache.solr.common.SolrException;
25+
import org.apache.solr.common.SolrException.ErrorCode;
26+
import org.apache.solr.schema.SchemaField;
27+
import org.apache.solr.search.QParser;
28+
29+
/**
30+
* Field type for double ranges with support for 1-4 dimensions.
31+
*
32+
* <p>This field type wraps Lucene's {@link DoubleRange} to provide storage and querying of
33+
* double-precision floating-point range values. Ranges can be 1-dimensional (simple ranges),
34+
* 2-dimensional (bounding boxes), 3-dimensional (bounding cubes), or 4-dimensional (tesseracts).
35+
*
36+
* <h2>Value Format</h2>
37+
*
38+
* Values are specified using bracket notation with a TO keyword separator:
39+
*
40+
* <ul>
41+
* <li>1D: {@code [1.5 TO 2.5]}
42+
* <li>2D: {@code [1.0,2.0 TO 3.0,4.0]}
43+
* <li>3D: {@code [1.0,2.0,3.0 TO 4.0,5.0,6.0]}
44+
* <li>4D: {@code [1.0,2.0,3.0,4.0 TO 5.0,6.0,7.0,8.0]}
45+
* </ul>
46+
*
47+
* As the name suggests minimum values (those on the left) must always be less than or equal to the
48+
* maximum value for the corresponding dimension. Integer values (e.g. {@code [10 TO 20]}) are also
49+
* accepted and parsed as doubles.
50+
*
51+
* <h2>Schema Configuration</h2>
52+
*
53+
* <pre>
54+
* &lt;fieldType name="doublerange" class="org.apache.solr.schema.numericrange.DoubleRangeField" numDimensions="1"/&gt;
55+
* &lt;fieldType name="doublerange2d" class="org.apache.solr.schema.numericrange.DoubleRangeField" numDimensions="2"/&gt;
56+
* &lt;field name="price_range" type="doublerange" indexed="true" stored="true"/&gt;
57+
* &lt;field name="my_2d_range" type="doublerange2d" indexed="true" stored="true"/&gt;
58+
* </pre>
59+
*
60+
* <h2>Querying</h2>
61+
*
62+
* Use the {@code numericRange} query parser for range queries with support for different query
63+
* types:
64+
*
65+
* <ul>
66+
* <li>Intersects: {@code {!numericRange criteria="intersects" field=price_range}[1.0 TO 2.0]}
67+
* <li>Within: {@code {!numericRange criteria="within" field=price_range}[0.0 TO 3.0]}
68+
* <li>Contains: {@code {!numericRange criteria="contains" field=price_range}[1.5 TO 1.75]}
69+
* <li>Crosses: {@code {!numericRange criteria="crosses" field=price_range}[1.5 TO 2.5]}
70+
* </ul>
71+
*
72+
* <h2>Limitations</h2>
73+
*
74+
* The main limitation of this field type is that it doesn't support docValues or uninversion, and
75+
* therefore can't be used for sorting, faceting, etc.
76+
*
77+
* @see DoubleRange
78+
* @see org.apache.solr.search.numericrange.NumericRangeQParserPlugin
79+
*/
80+
public class DoubleRangeField extends AbstractNumericRangeField {
81+
82+
@Override
83+
protected Pattern getRangePattern() {
84+
return FP_RANGE_PATTERN_REGEX;
85+
}
86+
87+
@Override
88+
protected Pattern getSingleBoundPattern() {
89+
return FP_SINGLE_BOUND_PATTERN;
90+
}
91+
92+
@Override
93+
public IndexableField createField(SchemaField field, Object value) {
94+
if (!field.indexed() && !field.stored()) {
95+
return null;
96+
}
97+
98+
String valueStr = value.toString();
99+
RangeValue rangeValue = parseRangeValue(valueStr);
100+
101+
return new DoubleRange(field.getName(), rangeValue.mins, rangeValue.maxs);
102+
}
103+
104+
/**
105+
* Parse a range value string into a RangeValue object.
106+
*
107+
* @param value the string value in format "[min1,min2,... TO max1,max2,...]"
108+
* @return parsed RangeValue
109+
* @throws SolrException if value format is invalid
110+
*/
111+
@Override
112+
public RangeValue parseRangeValue(String value) {
113+
if (value == null || value.trim().isEmpty()) {
114+
throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be null or empty");
115+
}
116+
117+
Matcher matcher = FP_RANGE_PATTERN_REGEX.matcher(value.trim());
118+
if (!matcher.matches()) {
119+
throw new SolrException(
120+
ErrorCode.BAD_REQUEST,
121+
"Invalid range format. Expected: [min1,min2,... TO max1,max2,...] where min and max values are doubles, but got: "
122+
+ value);
123+
}
124+
125+
String minPart = matcher.group(1).trim();
126+
String maxPart = matcher.group(2).trim();
127+
128+
double[] mins = parseDoubleArray(minPart, "min values");
129+
double[] maxs = parseDoubleArray(maxPart, "max values");
130+
131+
if (mins.length != maxs.length) {
132+
throw new SolrException(
133+
ErrorCode.BAD_REQUEST,
134+
"Min and max dimensions must match. Min dimensions: "
135+
+ mins.length
136+
+ ", max dimensions: "
137+
+ maxs.length);
138+
}
139+
140+
if (mins.length != numDimensions) {
141+
throw new SolrException(
142+
ErrorCode.BAD_REQUEST,
143+
"Range dimensions ("
144+
+ mins.length
145+
+ ") do not match field type numDimensions ("
146+
+ numDimensions
147+
+ ")");
148+
}
149+
150+
// Validate that min <= max for each dimension
151+
for (int i = 0; i < mins.length; i++) {
152+
if (mins[i] > maxs[i]) {
153+
throw new SolrException(
154+
ErrorCode.BAD_REQUEST,
155+
"Min value must be <= max value for dimension "
156+
+ i
157+
+ ". Min: "
158+
+ mins[i]
159+
+ ", Max: "
160+
+ maxs[i]);
161+
}
162+
}
163+
164+
return new RangeValue(mins, maxs);
165+
}
166+
167+
@Override
168+
public NumericRangeValue parseSingleBound(String value) {
169+
final var singleBoundTyped = parseDoubleArray(value, "single bound values");
170+
return new RangeValue(singleBoundTyped, singleBoundTyped);
171+
}
172+
173+
/**
174+
* Parse a comma-separated string of doubles into an array.
175+
*
176+
* @param str the string to parse
177+
* @param description description for error messages
178+
* @return array of parsed doubles
179+
*/
180+
private double[] parseDoubleArray(String str, String description) {
181+
String[] parts = str.split(",");
182+
double[] result = new double[parts.length];
183+
184+
for (int i = 0; i < parts.length; i++) {
185+
try {
186+
result[i] = Double.parseDouble(parts[i].trim());
187+
} catch (NumberFormatException e) {
188+
throw new SolrException(
189+
ErrorCode.BAD_REQUEST,
190+
"Invalid double in " + description + ": '" + parts[i].trim() + "'",
191+
e);
192+
}
193+
}
194+
195+
return result;
196+
}
197+
198+
@Override
199+
public Query newContainsQuery(String fieldName, NumericRangeValue rangeValue) {
200+
final var rv = (RangeValue) rangeValue;
201+
return DoubleRange.newContainsQuery(fieldName, rv.mins, rv.maxs);
202+
}
203+
204+
@Override
205+
public Query newIntersectsQuery(String fieldName, NumericRangeValue rangeValue) {
206+
final var rv = (RangeValue) rangeValue;
207+
return DoubleRange.newIntersectsQuery(fieldName, rv.mins, rv.maxs);
208+
}
209+
210+
@Override
211+
public Query newWithinQuery(String fieldName, NumericRangeValue rangeValue) {
212+
final var rv = (RangeValue) rangeValue;
213+
return DoubleRange.newWithinQuery(fieldName, rv.mins, rv.maxs);
214+
}
215+
216+
@Override
217+
public Query newCrossesQuery(String fieldName, NumericRangeValue rangeValue) {
218+
final var rv = (RangeValue) rangeValue;
219+
return DoubleRange.newCrossesQuery(fieldName, rv.mins, rv.maxs);
220+
}
221+
222+
@Override
223+
protected Query getSpecializedRangeQuery(
224+
QParser parser,
225+
SchemaField field,
226+
String part1,
227+
String part2,
228+
boolean minInclusive,
229+
boolean maxInclusive) {
230+
// For standard range syntax field:[value TO value], default to contains query
231+
if (part1 == null || part2 == null) {
232+
return super.getSpecializedRangeQuery(
233+
parser, field, part1, part2, minInclusive, maxInclusive);
234+
}
235+
236+
// Parse the range bounds as single-dimensional double values
237+
double min, max;
238+
try {
239+
min = Double.parseDouble(part1.trim());
240+
max = Double.parseDouble(part2.trim());
241+
} catch (NumberFormatException e) {
242+
throw new SolrException(
243+
ErrorCode.BAD_REQUEST,
244+
"Invalid double values in range query: [" + part1 + " TO " + part2 + "]",
245+
e);
246+
}
247+
248+
// For exclusive bounds, step to the next representable double value
249+
if (!minInclusive) {
250+
min = Math.nextUp(min);
251+
}
252+
if (!maxInclusive) {
253+
max = Math.nextDown(max);
254+
}
255+
256+
// Build arrays for the query based on configured dimensions
257+
double[] mins = new double[numDimensions];
258+
double[] maxs = new double[numDimensions];
259+
260+
// For now, only support 1D range syntax with field:[X TO Y]
261+
if (numDimensions == 1) {
262+
mins[0] = min;
263+
maxs[0] = max;
264+
return DoubleRange.newContainsQuery(field.getName(), mins, maxs);
265+
} else {
266+
throw new SolrException(
267+
ErrorCode.BAD_REQUEST,
268+
"Standard range query syntax only supports 1D ranges. "
269+
+ "Use {!numericRange ...} for multi-dimensional queries.");
270+
}
271+
}
272+
273+
/** Simple holder class for parsed double range values. */
274+
public static class RangeValue implements AbstractNumericRangeField.NumericRangeValue {
275+
public final double[] mins;
276+
public final double[] maxs;
277+
278+
public RangeValue(double[] mins, double[] maxs) {
279+
this.mins = mins;
280+
this.maxs = maxs;
281+
}
282+
283+
@Override
284+
public int getDimensions() {
285+
return mins.length;
286+
}
287+
288+
@Override
289+
public String toString() {
290+
StringBuilder sb = new StringBuilder("[");
291+
for (int i = 0; i < mins.length; i++) {
292+
if (i > 0) sb.append(",");
293+
sb.append(mins[i]);
294+
}
295+
sb.append(" TO ");
296+
for (int i = 0; i < maxs.length; i++) {
297+
if (i > 0) sb.append(",");
298+
sb.append(maxs[i]);
299+
}
300+
sb.append("]");
301+
return sb.toString();
302+
}
303+
}
304+
}

solr/core/src/java/org/apache/solr/schema/numericrange/FloatRangeField.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
* &lt;fieldType name="floatrange" class="org.apache.solr.schema.numericrange.FloatRangeField" numDimensions="1"/&gt;
5555
* &lt;fieldType name="floatrange2d" class="org.apache.solr.schema.numericrange.FloatRangeField" numDimensions="2"/&gt;
5656
* &lt;field name="price_range" type="floatrange" indexed="true" stored="true"/&gt;
57-
* &lt;field name="bbox" type="floatrange2d" indexed="true" stored="true"/&gt;
57+
* &lt;field name="my_2d_range" type="floatrange2d" indexed="true" stored="true"/&gt;
5858
* </pre>
5959
*
6060
* <h2>Querying</h2>

solr/core/src/java/org/apache/solr/search/numericrange/NumericRangeQParserPlugin.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.apache.solr.schema.SchemaField;
2626
import org.apache.solr.schema.numericrange.AbstractNumericRangeField;
2727
import org.apache.solr.schema.numericrange.AbstractNumericRangeField.NumericRangeValue;
28+
import org.apache.solr.schema.numericrange.DoubleRangeField;
2829
import org.apache.solr.schema.numericrange.FloatRangeField;
2930
import org.apache.solr.schema.numericrange.IntRangeField;
3031
import org.apache.solr.schema.numericrange.LongRangeField;
@@ -36,9 +37,9 @@
3637
/**
3738
* Query parser for numeric range fields with support for different query relationship types.
3839
*
39-
* <p>This parser enables queries against {@link IntRangeField}, {@link LongRangeField}, and {@link
40-
* FloatRangeField} fields with explicit control over the query relationship type (intersects,
41-
* within, contains, crosses).
40+
* <p>This parser enables queries against {@link IntRangeField}, {@link LongRangeField}, {@link
41+
* FloatRangeField}, and {@link DoubleRangeField} fields with explicit control over the query
42+
* relationship type (intersects, within, contains, crosses).
4243
*
4344
* <h2>Parameters</h2>
4445
*
@@ -82,6 +83,7 @@
8283
* @see IntRangeField
8384
* @see LongRangeField
8485
* @see FloatRangeField
86+
* @see DoubleRangeField
8587
* @lucene.experimental
8688
*/
8789
public class NumericRangeQParserPlugin extends QParserPlugin {

solr/core/src/test-files/solr/collection1/conf/schema-numericrange.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
<fieldType name="floatrange3d" class="solr.numericrange.FloatRangeField" numDimensions="3"/>
4343
<fieldType name="floatrange4d" class="solr.numericrange.FloatRangeField" numDimensions="4"/>
4444

45+
<!-- DoubleRangeField types with different dimensions -->
46+
<fieldType name="doublerange" class="solr.numericrange.DoubleRangeField" numDimensions="1"/>
47+
<fieldType name="doublerange2d" class="solr.numericrange.DoubleRangeField" numDimensions="2"/>
48+
<fieldType name="doublerange3d" class="solr.numericrange.DoubleRangeField" numDimensions="3"/>
49+
<fieldType name="doublerange4d" class="solr.numericrange.DoubleRangeField" numDimensions="4"/>
50+
4551
<!-- Field definitions -->
4652
<field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false"/>
4753
<field name="_version_" type="long" indexed="false" stored="false" docValues="true"/>
@@ -86,6 +92,19 @@
8692
<!-- 4D FloatRangeField (tesseract) -->
8793
<field name="float_range_4d" type="floatrange4d" indexed="true" stored="true"/>
8894

95+
<!-- 1D DoubleRangeField -->
96+
<field name="double_range" type="doublerange" indexed="true" stored="true"/>
97+
<field name="double_range_multi" type="doublerange" indexed="true" stored="true" multiValued="true"/>
98+
99+
<!-- 2D DoubleRangeField (bounding box) -->
100+
<field name="double_range_2d" type="doublerange2d" indexed="true" stored="true"/>
101+
102+
<!-- 3D DoubleRangeField (bounding cube) -->
103+
<field name="double_range_3d" type="doublerange3d" indexed="true" stored="true"/>
104+
105+
<!-- 4D DoubleRangeField (tesseract) -->
106+
<field name="double_range_4d" type="doublerange4d" indexed="true" stored="true"/>
107+
89108
<!-- Required by Solr -->
90109
<uniqueKey>id</uniqueKey>
91110
</schema>

0 commit comments

Comments
 (0)