This is an automated email from the ASF dual-hosted git repository. gerlowskija pushed a commit to branch branch_9x in repository https://gitbox.apache.org/repos/asf/solr.git
commit 8debaea06774e5ad44ad862ad2cf7ee09c991e38 Author: Jason Gerlowski <[email protected]> AuthorDate: Tue Mar 3 07:07:02 2026 -0500 SOLR-13309: Add IntRangeField for Lucenes IntRange (#4141) This commit adds a new field type, IntRangeField, that can be used to hold singular or multi-dimensional (up to 4) ranges of integers. Field values are represented using brackets and the "TO" operator, with commas used to delimit dimensions (when a particular field is defined as having more than 1 dimension), e.g. [-1 TO 5] [1,2 TO 5,10] [1 TO 1] IntRangeField does not support docValues or uninversion, meaning it's primarily only used for querying. The field can be stored and returned in search-results. Searches on these range-fields rely on a new QParserPlugin implementation, {!numericRange}, which supports "intersects", "crosses", "within", and "contains" semantics via a "criteria" local param. e.g. - {!numericRange field=price_range criteria=within}[1 TO 5] Matches docs whose 'price_range' field falls fully within [1 TO 5]. A doc with [2 TO 3] would match; [3 TO 6] or [8 TO 10] would not. - {!numericRange field=price_range criteria=crosses}[1,10 TO 5,20] Matches docs whose 'price_range' field is partially but not fully contained within [1,10 TO 5,20]. A doc with [2,11 TO 6,21] would match, but [3,11 TO 5,19] would not. This syntax is experimental and may change in the future. (cherry picked from commit 2501d331c8a14dfaa1c3e09e6445f6b26f26a00b) --- .../SOLR-13309-introduce-int-range-field.yml | 8 + .../solr/schema/numericrange/IntRangeField.java | 418 +++++++++++++++++ .../solr/schema/numericrange/package-info.java | 19 + .../java/org/apache/solr/search/QParserPlugin.java | 2 + .../search/numericrange/IntRangeQParserPlugin.java | 235 ++++++++++ .../solr/search/numericrange/package-info.java | 19 + .../solr/collection1/conf/schema-intrange.xml | 53 +++ .../schema/numericrange/IntRangeFieldTest.java | 352 +++++++++++++++ .../solr/schema/numericrange/package-info.java | 19 + .../org/apache/solr/search/QueryEqualityTest.java | 6 +- .../numericrange/IntRangeQParserPluginTest.java | 499 +++++++++++++++++++++ .../pages/field-types-included-with-solr.adoc | 4 +- .../modules/query-guide/pages/other-parsers.adoc | 66 +++ 13 files changed, 1698 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml b/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml new file mode 100644 index 00000000000..c4e2159df65 --- /dev/null +++ b/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Introduce new `IntRangeField` field type and (experimental) `{!numericRange}` query parser for storing and querying integer ranges. +type: added +authors: + - name: Jason Gerlowski +links: + - name: SOLR-13309 + url: https://issues.apache.org/jira/browse/SOLR-13309 diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java new file mode 100644 index 00000000000..0dd63b366bb --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java @@ -0,0 +1,418 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.schema.numericrange; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.lucene.document.IntRange; +import org.apache.lucene.document.StoredField; +import org.apache.lucene.index.IndexableField; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.SortField; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.response.TextResponseWriter; +import org.apache.solr.schema.IndexSchema; +import org.apache.solr.schema.PrimitiveFieldType; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.search.QParser; +import org.apache.solr.uninverting.UninvertingReader.Type; + +/** + * Field type for integer ranges with support for 1-4 dimensions. + * + * <p>This field type wraps Lucene's {@link IntRange} to provide storage and querying of integer + * range values. Ranges can be 1-dimensional (simple ranges), 2-dimensional (bounding boxes), + * 3-dimensional (bounding cubes), or 4-dimensional (tesseracts). + * + * <h2>Value Format</h2> + * + * Values are specified using bracket notation with a TO keyword separator: + * + * <ul> + * <li>1D: {@code [10 TO 20]} + * <li>2D: {@code [10,20 TO 30,40]} + * <li>3D: {@code [10,20,30 TO 40,50,60]} + * <li>4D: {@code [10,20,30,40 TO 50,60,70,80]} + * </ul> + * + * As the name suggests minimum values (those on the left) must always be less than or equal to the + * maximum value for the corresponding dimension. + * + * <h2>Schema Configuration</h2> + * + * <pre> + * <fieldType name="intrange" class="org.apache.solr.schema.numericrange.IntRangeField" numDimensions="1"/> + * <fieldType name="intrange2d" class="org.apache.solr.schema.numericrange.IntRangeField" numDimensions="2"/> + * <field name="price_range" type="intrange" indexed="true" stored="true"/> + * <field name="bbox" type="intrange2d" indexed="true" stored="true"/> + * </pre> + * + * <h2>Querying</h2> + * + * Use the {@code numericRange} query parser for range queries with support for different query + * types: + * + * <ul> + * <li>Intersects: {@code {!numericRange criteria="intersects" field=price_range}[100 TO 200]} + * <li>Within: {@code {!numericRange criteria="within" field=price_range}[0 TO 300]} + * <li>Contains: {@code {!numericRange criteria="contains" field=price_range}[150 TO 175]} + * <li>Crosses: {@code {!numericRange criteria="crosses" field=price_range}[150 TO 250]} + * </ul> + * + * <h2>Limitations</h2> + * + * The main limitation of this field type is that it doesn't support docValues or uninversion, and + * therefore can't be used for sorting, faceting, etc. + * + * @see IntRange + * @see org.apache.solr.search.numericrange.IntRangeQParserPlugin + */ +public class IntRangeField extends PrimitiveFieldType { + + private static final String COMMA_DELIMITED_INTS = "-?\\d+(?:\\s*,\\s*-?\\d+)*"; + private static final String RANGE_PATTERN = + "\\[\\s*(" + COMMA_DELIMITED_INTS + ")\\s+TO\\s+(" + COMMA_DELIMITED_INTS + ")\\s*\\]"; + private static final Pattern RANGE_PATTERN_REGEX = Pattern.compile(RANGE_PATTERN); + private static final Pattern SINGLE_BOUND_PATTERN = + Pattern.compile("^" + COMMA_DELIMITED_INTS + "$"); + + private int numDimensions = 1; + + @Override + protected boolean enableDocValuesByDefault() { + return false; // IntRange does not support docValues + } + + @Override + protected void init(IndexSchema schema, Map<String, String> args) { + super.init(schema, args); + + String numDimensionsStr = args.remove("numDimensions"); + if (numDimensionsStr != null) { + numDimensions = Integer.parseInt(numDimensionsStr); + if (numDimensions < 1 || numDimensions > 4) { + throw new SolrException( + ErrorCode.SERVER_ERROR, + "numDimensions must be between 1 and 4, but was [" + + numDimensions + + "] for field type " + + typeName); + } + } + + // IntRange does not support docValues - validate this wasn't explicitly set + if (hasProperty(DOC_VALUES)) { + throw new SolrException( + ErrorCode.SERVER_ERROR, + "docValues=true enabled but IntRangeField does not support docValues for field type " + + typeName); + } + } + + @Override + public IndexableField createField(SchemaField field, Object value) { + if (!field.indexed() && !field.stored()) { + return null; + } + + String valueStr = value.toString(); + RangeValue rangeValue = parseRangeValue(valueStr); + + return new IntRange(field.getName(), rangeValue.mins, rangeValue.maxs); + } + + @Override + public List<IndexableField> createFields(SchemaField field, Object value) { + IndexableField indexedField = createField(field, value); + List<IndexableField> fields = new ArrayList<>(); + + if (indexedField != null) { + fields.add(indexedField); + } + + if (field.stored()) { + String valueStr = value.toString(); + fields.add(getStoredField(field, valueStr)); + } + + return fields; + } + + @Override + public void write(TextResponseWriter writer, String name, IndexableField f) throws IOException { + writer.writeStr(name, toExternal(f), false); + } + + @Override + public SortField getSortField(SchemaField field, boolean top) { + throw new SolrException( + ErrorCode.BAD_REQUEST, "Cannot sort on IntRangeField: " + field.getName()); + } + + @Override + public Type getUninversionType(SchemaField sf) { + return null; // No field cache support + } + + @Override + public String toInternal(String val) { + // Validate format and return as-is + parseRangeValue(val); + return val; + } + + @Override + public String toExternal(IndexableField f) { + return f.stringValue(); + } + + @Override + public Object toNativeType(Object val) { + if (val == null) return null; + if (val instanceof RangeValue) return val; + return parseRangeValue(val.toString()); + } + + /** + * Parse a range value string into a RangeValue object. + * + * @param value the string value in format "[min1,min2,... TO max1,max2,...]" + * @return parsed RangeValue + * @throws SolrException if value format is invalid + */ + public RangeValue parseRangeValue(String value) { + if (value == null || value.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be null or empty"); + } + + Matcher matcher = RANGE_PATTERN_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 ints, but got: " + + value); + } + + String minPart = matcher.group(1).trim(); + String maxPart = matcher.group(2).trim(); + + int[] mins = parseIntArray(minPart, "min values"); + int[] maxs = parseIntArray(maxPart, "max values"); + + if (mins.length != maxs.length) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min and max dimensions must match. Min dimensions: " + + mins.length + + ", max dimensions: " + + maxs.length); + } + + if (mins.length != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Range dimensions (" + + mins.length + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + // Validate that min <= max for each dimension + for (int i = 0; i < mins.length; i++) { + if (mins[i] > maxs[i]) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min value must be <= max value for dimension " + + i + + ". Min: " + + mins[i] + + ", Max: " + + maxs[i]); + } + } + + return new RangeValue(mins, maxs); + } + + /** + * Parse a comma-separated string of integers into an array. + * + * @param str the string to parse + * @param description description for error messages + * @return array of parsed integers + */ + private int[] parseIntArray(String str, String description) { + String[] parts = str.split(","); + int[] result = new int[parts.length]; + + for (int i = 0; i < parts.length; i++) { + try { + result[i] = Integer.parseInt(parts[i].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer in " + description + ": '" + parts[i].trim() + "'", + e); + } + } + + return result; + } + + protected StoredField getStoredField(SchemaField sf, Object value) { + return new StoredField(sf.getName(), value.toString()); + } + + /** + * Creates a query for this field that matches docs where the query-range is fully contained by + * the field value. + * + * <p>Queries requiring other match semantics can use {@link + * org.apache.solr.search.numericrange.IntRangeQParserPlugin} + * + * @param parser The {@link org.apache.solr.search.QParser} calling the method + * @param field The {@link org.apache.solr.schema.SchemaField} of the field to search + * @param externalVal The String representation of the value to search. Supports both a + * (multi-)dimensional range of the form [1,2 TO 3,4], or a single (multi-)dimensional bound + * (e.g. 1,2). In the latter case, the single bound will be used as both the min and max. Both + * formats use "contains" query semantics to find indexed ranges that contain the query range. + * @return Query for this field using contains semantics + */ + @Override + public Query getFieldQuery(QParser parser, SchemaField field, String externalVal) { + if (externalVal == null || externalVal.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Query value cannot be null or empty"); + } + + String trimmed = externalVal.trim(); + + // Check if it's the full range syntax: [min1,min2 TO max1,max2] + if (RANGE_PATTERN_REGEX.matcher(trimmed).matches()) { + RangeValue rangeValue = parseRangeValue(trimmed); + return IntRange.newContainsQuery(field.getName(), rangeValue.mins, rangeValue.maxs); + } + + // Syntax sugar: also accept a single-bound (i.e pX,pY,pZ) + if (SINGLE_BOUND_PATTERN.matcher(trimmed).matches()) { + int[] bound = parseIntArray(trimmed, "single bound values"); + + if (bound.length != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Single bound dimensions (" + + bound.length + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + return IntRange.newContainsQuery(field.getName(), bound, bound); + } + + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid query format. Expected either a range [min TO max] or a single bound to search for, got: " + + externalVal); + } + + @Override + protected Query getSpecializedRangeQuery( + QParser parser, + SchemaField field, + String part1, + String part2, + boolean minInclusive, + boolean maxInclusive) { + // For standard range syntax field:[value TO value], default to intersects query + if (part1 == null || part2 == null) { + return super.getSpecializedRangeQuery( + parser, field, part1, part2, minInclusive, maxInclusive); + } + + // Parse the range bounds as single-dimensional values + int min, max; + try { + min = Integer.parseInt(part1.trim()); + max = Integer.parseInt(part2.trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer values in range query: [" + part1 + " TO " + part2 + "]", + e); + } + + if (!minInclusive) { + min = (min == Integer.MAX_VALUE) ? min : min + 1; + } + if (!maxInclusive) { + max = (max == Integer.MIN_VALUE) ? max : max - 1; + } + + // Build arrays for the query based on configured dimensions + int[] mins = new int[numDimensions]; + int[] maxs = new int[numDimensions]; + + // For now, only support 1D range syntax with field:[X TO Y] + if (numDimensions == 1) { + mins[0] = min; + maxs[0] = max; + return IntRange.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 range values. */ + public static class RangeValue { + public final int[] mins; + public final int[] maxs; + + public RangeValue(int[] mins, int[] maxs) { + this.mins = mins; + this.maxs = maxs; + } + + public int getDimensions() { + return mins.length; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < mins.length; i++) { + if (i > 0) sb.append(","); + sb.append(mins[i]); + } + sb.append(" TO "); + for (int i = 0; i < maxs.length; i++) { + if (i > 0) sb.append(","); + sb.append(maxs[i]); + } + sb.append("]"); + return sb.toString(); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java b/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java new file mode 100644 index 00000000000..1b30e748fde --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Support for numeric-range field types. */ +package org.apache.solr.schema.numericrange; diff --git a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java index 46d2c9282fc..a424bda109e 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -32,6 +32,7 @@ import org.apache.solr.search.mlt.MLTContentQParserPlugin; import org.apache.solr.search.mlt.MLTQParserPlugin; import org.apache.solr.search.neural.KnnQParserPlugin; import org.apache.solr.search.neural.VectorSimilarityQParserPlugin; +import org.apache.solr.search.numericrange.IntRangeQParserPlugin; import org.apache.solr.util.plugin.NamedListInitializedPlugin; public abstract class QParserPlugin implements NamedListInitializedPlugin, SolrInfoBean { @@ -92,6 +93,7 @@ public abstract class QParserPlugin implements NamedListInitializedPlugin, SolrI map.put(KnnQParserPlugin.NAME, new KnnQParserPlugin()); map.put(VectorSimilarityQParserPlugin.NAME, new VectorSimilarityQParserPlugin()); map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin()); + map.put(IntRangeQParserPlugin.NAME, new IntRangeQParserPlugin()); standardPlugins = Collections.unmodifiableMap(map); } diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java new file mode 100644 index 00000000000..5c1e8a6402a --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java @@ -0,0 +1,235 @@ +/* + * 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 java.util.Locale; +import org.apache.lucene.document.IntRange; +import org.apache.lucene.search.Query; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrException.ErrorCode; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.schema.numericrange.IntRangeField; +import org.apache.solr.schema.numericrange.IntRangeField.RangeValue; +import org.apache.solr.search.QParser; +import org.apache.solr.search.QParserPlugin; +import org.apache.solr.search.QueryParsing; +import org.apache.solr.search.SyntaxError; + +/** + * Query parser for IntRangeField with support for different query relationship types. + * + * <p>This parser enables queries against {@link IntRangeField} fields with explicit control over + * the query relationship type (intersects, within, contains, crosses). + * + * <h2>Parameters</h2> + * + * <ul> + * <li><b>field</b> (required): The IntRangeField to query + * <li><b>criteria</b> (required): Query relationship criteria. One of: intersects, within, + * contains, crosses + * </ul> + * + * <h2>Query Types</h2> + * + * <ul> + * <li><b>intersects</b>: Matches ranges that overlap with the query range (most permissive) + * <li><b>within</b>: Matches ranges completely contained by the query range + * <li><b>contains</b>: Matches ranges that completely contain the query range + * <li><b>crosses</b>: Matches ranges that cross the query range boundaries (not disjoint, not + * wholly contained) + * </ul> + * + * <h2>Example Usage</h2> + * + * <pre> + * // 1D range queries + * {!numericRange criteria="intersects" field=price_range}[100 TO 200] + * {!numericRange criteria="within" field=price_range}[0 TO 300] + * {!numericRange criteria="contains" field=price_range}[150 TO 175] + * {!numericRange criteria="crosses" field=price_range}[150 TO 250] + * + * // 2D range queries (bounding boxes) + * {!numericRange criteria="intersects" field=bbox}[0,0 TO 10,10] + * {!numericRange criteria="within" field=bbox}[-10,-10 TO 20,20] + * + * // 3D range queries (bounding cubes) + * {!numericRange criteria="intersects" field=cube}[0,0,0 TO 10,10,10] + * + * // 4D range queries (tesseracts) + * {!numericRange criteria="intersects" field=tesseract}[0,0,0,0 TO 10,10,10,10] + * </pre> + * + * @see IntRangeField + * @see IntRange + * @lucene.experimental + */ +public class IntRangeQParserPlugin extends QParserPlugin { + + /** Query relationship criteria for range queries. */ + public enum QueryCriteria { + /** Matches ranges that overlap with the query range (most permissive). */ + INTERSECTS("intersects"), + + /** Matches ranges completely contained by the query range. */ + WITHIN("within"), + + /** Matches ranges that completely contain the query range. */ + CONTAINS("contains"), + + /** + * Matches ranges that cross the query range boundaries (not disjoint, not wholly contained). + */ + CROSSES("crosses"); + + private final String name; + + QueryCriteria(String name) { + this.name = name; + } + + /** + * Parse a criteria string into a QueryCriteria enum value. + * + * @param criteriaStr the criteria string (case-insensitive) + * @return the corresponding QueryCriteria + * @throws SolrException if the criteria string is not recognized + */ + public static QueryCriteria fromString(String criteriaStr) { + if (criteriaStr == null || criteriaStr.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Query criteria cannot be null or empty"); + } + + String normalized = criteriaStr.trim().toLowerCase(Locale.ROOT); + for (QueryCriteria criteria : values()) { + if (criteria.name.equals(normalized)) { + return criteria; + } + } + + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Unknown query criteria: '" + + criteriaStr + + "'. Valid criteria are: intersects, within, contains, crosses"); + } + + @Override + public String toString() { + return name; + } + } + + /** Parser name used in local params syntax: {@code {!numericRange ...}} */ + public static final String NAME = "numericRange"; + + /** Parameter name for the field to query */ + public static final String FIELD_PARAM = "field"; + + /** Parameter name for the query criteria (intersects, within, contains, crosses) */ + public static final String CRITERIA_PARAM = "criteria"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() throws SyntaxError { + // Get required field parameter + String fieldName = localParams.get(FIELD_PARAM); + if (fieldName == null || fieldName.trim().isEmpty()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, "Missing required parameter: " + FIELD_PARAM); + } + + // Get required query criteria parameter and parse to enum + String criteriaStr = localParams.get(CRITERIA_PARAM); + if (criteriaStr == null || criteriaStr.trim().isEmpty()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, "Missing required parameter: " + CRITERIA_PARAM); + } + QueryCriteria criteria = QueryCriteria.fromString(criteriaStr); + + // Get the range value from the query string or 'v' param + String rangeValue = localParams.get(QueryParsing.V, qstr); + if (rangeValue == null || rangeValue.trim().isEmpty()) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Range value cannot be empty"); + } + + // Validate field exists and is an IntRangeField + SchemaField schemaField; + try { + schemaField = req.getSchema().getField(fieldName); + } catch (SolrException e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Field not found: " + fieldName, e); + } + + if (!(schemaField.getType() instanceof IntRangeField)) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Field '" + + fieldName + + "' must be of type IntRangeField, but is: " + + schemaField.getType().getTypeName()); + } + + IntRangeField fieldType = (IntRangeField) schemaField.getType(); + + // Parse the range value + RangeValue range; + try { + range = fieldType.parseRangeValue(rangeValue); + } catch (SolrException e) { + throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid range value: " + rangeValue, e); + } + + // Create appropriate query based on criteria + return createRangeQuery(fieldName, range.mins, range.maxs, criteria); + } + + /** + * Create the appropriate Lucene query based on the query criteria. + * + * @param fieldName the field to query + * @param mins minimum values for each dimension + * @param maxs maximum values for each dimension + * @param criteria the query relationship criteria + * @return the created Lucene Query + */ + private Query createRangeQuery( + String fieldName, int[] mins, int[] maxs, QueryCriteria criteria) { + switch (criteria) { + case INTERSECTS: + return IntRange.newIntersectsQuery(fieldName, mins, maxs); + + case WITHIN: + return IntRange.newWithinQuery(fieldName, mins, maxs); + + case CONTAINS: + return IntRange.newContainsQuery(fieldName, mins, maxs); + + case CROSSES: + return IntRange.newCrossesQuery(fieldName, mins, maxs); + + default: + throw new AssertionError("Unhandled QueryCriteria: " + criteria); + } + } + }; + } +} diff --git a/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java b/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java new file mode 100644 index 00000000000..395317a0118 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** QParser and related classes for searching numeric range fields. */ +package org.apache.solr.search.numericrange; diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml new file mode 100644 index 00000000000..bcfd1b5dc1a --- /dev/null +++ b/solr/core/src/test-files/solr/collection1/conf/schema-intrange.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" ?> +<!-- + 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. +--> + +<!-- Test schema for IntRangeField --> +<schema name="intrange-test" version="1.7"> + + <!-- Basic field types --> + <fieldType name="string" class="solr.StrField" sortMissingLast="true" /> + <fieldType name="int" class="solr.IntPointField"/> + <fieldType name="long" class="solr.LongPointField"/> + + <!-- IntRangeField types with different dimensions --> + <fieldType name="intrange" class="solr.numericrange.IntRangeField" numDimensions="1"/> + <fieldType name="intrange2d" class="solr.numericrange.IntRangeField" numDimensions="2"/> + <fieldType name="intrange3d" class="solr.numericrange.IntRangeField" numDimensions="3"/> + <fieldType name="intrange4d" class="solr.numericrange.IntRangeField" numDimensions="4"/> + + <!-- Field definitions --> + <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false"/> + <field name="_version_" type="long" indexed="false" stored="false" docValues="true"/> + <field name="title" type="string" indexed="true" stored="true"/> + + <!-- 1D IntRangeField --> + <field name="price_range" type="intrange" indexed="true" stored="true"/> + <field name="price_range_multi" type="intrange" indexed="true" stored="true" multiValued="true"/> + + <!-- 2D IntRangeField (bounding box) --> + <field name="bbox" type="intrange2d" indexed="true" stored="true"/> + + <!-- 3D IntRangeField (bounding cube) --> + <field name="cube" type="intrange3d" indexed="true" stored="true"/> + + <!-- 4D IntRangeField (tesseract) --> + <field name="tesseract" type="intrange4d" indexed="true" stored="true"/> + + <!-- Required by Solr --> + <uniqueKey>id</uniqueKey> +</schema> diff --git a/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java b/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java new file mode 100644 index 00000000000..60f8d3b19ab --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java @@ -0,0 +1,352 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.schema.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.IntRange; +import org.apache.lucene.index.IndexableField; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.schema.IndexSchema; +import org.apache.solr.schema.SchemaField; +import org.junit.BeforeClass; + +/** Tests for {@link IntRangeField} */ +public class IntRangeFieldTest extends SolrTestCase { + + @BeforeClass + public static void ensureAssumptions() { + assumeWorkingMockito(); + } + + public void test1DRangeParsing() { + IntRangeField fieldType = createFieldType(1); + + // Valid 1D range + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10 TO 20]"); + assertEquals(1, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 TO 20 ]"); + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // Negative numbers + range = fieldType.parseRangeValue("[-100 TO -50]"); + assertEquals(-100, range.mins[0]); + assertEquals(-50, range.maxs[0]); + + // Point range (min == max) + range = fieldType.parseRangeValue("[5 TO 5]"); + assertEquals(5, range.mins[0]); + assertEquals(5, range.maxs[0]); + } + + public void test2DRangeParsing() { + IntRangeField fieldType = createFieldType(2); + + // Valid 2D range (bounding box) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + assertEquals(2, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.maxs[0]); + assertEquals(40, range.maxs[1]); + + // With extra whitespace + range = fieldType.parseRangeValue("[ 10 , 20 TO 30 , 40 ]"); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.maxs[0]); + assertEquals(40, range.maxs[1]); + } + + public void test3DRangeParsing() { + IntRangeField fieldType = createFieldType(3); + + // Valid 3D range (bounding cube) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30 TO 40,50,60]"); + assertEquals(3, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.mins[2]); + assertEquals(40, range.maxs[0]); + assertEquals(50, range.maxs[1]); + assertEquals(60, range.maxs[2]); + } + + public void test4DRangeParsing() { + IntRangeField fieldType = createFieldType(4); + + // Valid 4D range (tesseract) + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20,30,40 TO 50,60,70,80]"); + assertEquals(4, range.getDimensions()); + assertEquals(10, range.mins[0]); + assertEquals(20, range.mins[1]); + assertEquals(30, range.mins[2]); + assertEquals(40, range.mins[3]); + assertEquals(50, range.maxs[0]); + assertEquals(60, range.maxs[1]); + assertEquals(70, range.maxs[2]); + assertEquals(80, range.maxs[3]); + } + + public void testInvalidRangeFormat() { + IntRangeField fieldType = createFieldType(1); + + // Missing brackets + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("10 TO 20")); + assertThat(e1.getMessage(), containsString("Invalid range format")); + assertThat(e1.getMessage(), containsString("Expected: [min1,min2,... TO max1,max2,...]")); + + // Missing TO keyword + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + assertThat(e2.getMessage(), containsString("Invalid range format")); + + // Empty value + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + assertThat(e3.getMessage(), containsString("Range value cannot be null or empty")); + + // Null value + SolrException e4 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + assertThat(e4.getMessage(), containsString("Range value cannot be null or empty")); + } + + public void testInvalidNumbers() { + IntRangeField 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 ints")); + + // Partially numeric + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + assertThat(e2.getMessage(), containsString("Invalid range")); + assertThat(e2.getMessage(), containsString("where min and max values are ints")); + + // Floating point (should fail for IntRange) + SolrException e3 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + assertThat(e3.getMessage(), containsString("Invalid range")); + assertThat(e3.getMessage(), containsString("where min and max values are ints")); + } + + public void testDimensionMismatch() { + IntRangeField fieldType1D = createFieldType(1); + IntRangeField fieldType2D = createFieldType(2); + + // 2D value on 1D field + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType1D.parseRangeValue("[10,20 TO 30,40]")); + assertThat(e1.getMessage(), containsString("Range dimensions")); + assertThat(e1.getMessage(), containsString("do not match field type numDimensions")); + + // 1D value on 2D field + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + assertThat(e2.getMessage(), containsString("Range dimensions")); + assertThat(e2.getMessage(), containsString("do not match field type numDimensions")); + + // Min/max dimension mismatch + SolrException e3 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[10,20 TO 30]")); // 2D mins, 1D maxs + assertThat(e3.getMessage(), containsString("Min and max dimensions must match")); + } + + public void testMinGreaterThanMax() { + IntRangeField fieldType = createFieldType(1); + + // Min > max should fail + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[20 TO 10]")); + assertThat(e1.getMessage(), containsString("Min value must be <= max value")); + assertThat(e1.getMessage(), containsString("dimension 0")); + + // For 2D + IntRangeField fieldType2D = createFieldType(2); + SolrException e2 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + assertThat(e2.getMessage(), containsString("Min value must be <= max value")); + assertThat(e2.getMessage(), containsString("dimension 0")); + } + + public void testFieldCreation1D() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + IndexableField field = fieldType.createField(schemaField, "[100 TO 200]"); + assertNotNull(field); + assertTrue(field instanceof IntRange); + assertEquals("price_range", field.name()); + } + + public void testFieldCreation2D() { + IntRangeField fieldType = createFieldType(2); + SchemaField schemaField = createSchemaField(fieldType, "bbox"); + + IndexableField field = fieldType.createField(schemaField, "[0,0 TO 10,10]"); + assertNotNull(field); + assertTrue(field instanceof IntRange); + assertEquals("bbox", field.name()); + } + + public void testStoredField() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + String value = "[100 TO 200]"; + IndexableField storedField = fieldType.getStoredField(schemaField, value); + assertNotNull(storedField); + assertEquals("price_range", storedField.name()); + assertEquals(value, storedField.stringValue()); + } + + public void testToInternal() { + IntRangeField fieldType = createFieldType(1); + + // Valid value should pass through after validation + String value = "[10 TO 20]"; + String internal = fieldType.toInternal(value); + assertEquals(value, internal); + + // Invalid value should throw exception + SolrException e = expectThrows(SolrException.class, () -> fieldType.toInternal("invalid")); + assertThat(e.getMessage(), containsString("Invalid range format")); + } + + public void testToNativeType() { + IntRangeField fieldType = createFieldType(1); + + // String input + Object nativeType = fieldType.toNativeType("[10 TO 20]"); + assertTrue(nativeType instanceof IntRangeField.RangeValue); + IntRangeField.RangeValue range = (IntRangeField.RangeValue) nativeType; + assertEquals(10, range.mins[0]); + assertEquals(20, range.maxs[0]); + + // RangeValue input (should pass through) + IntRangeField.RangeValue inputRange = + new IntRangeField.RangeValue(new int[] {5}, new int[] {15}); + Object result = fieldType.toNativeType(inputRange); + assertSame(inputRange, result); + + // Null input + assertNull(fieldType.toNativeType(null)); + } + + public void testSortFieldThrowsException() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + // Sorting should not be supported + SolrException e = + expectThrows(SolrException.class, () -> fieldType.getSortField(schemaField, true)); + assertThat(e.getMessage(), containsString("Cannot sort on IntRangeField")); + assertThat(e.getMessage(), containsString("price_range")); + } + + public void testUninversionType() { + IntRangeField fieldType = createFieldType(1); + SchemaField schemaField = createSchemaField(fieldType, "price_range"); + + // Should return null (no field cache support) + assertNull(fieldType.getUninversionType(schemaField)); + } + + public void testInvalidNumDimensions() { + IntRangeField field = new IntRangeField(); + Map<String, String> args = new HashMap<>(); + IndexSchema schema = createMockSchema(); + + // Test numDimensions = 0 + args.put("numDimensions", "0"); + SolrException e1 = expectThrows(SolrException.class, () -> field.init(schema, args)); + assertThat(e1.getMessage(), containsString("numDimensions must be between 1 and 4")); + assertThat(e1.getMessage(), containsString("but was [0]")); + + // Test numDimensions = 5 (too high) + args.put("numDimensions", "5"); + IntRangeField field2 = new IntRangeField(); + 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"); + IntRangeField field3 = new IntRangeField(); + 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() { + IntRangeField fieldType = createFieldType(2); + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + + String str = range.toString(); + assertEquals("[10,20 TO 30,40]", str); + } + + public void testExtremeValues() { + IntRangeField fieldType = createFieldType(1); + + // Test with Integer.MIN_VALUE and Integer.MAX_VALUE + IntRangeField.RangeValue range = + fieldType.parseRangeValue("[" + Integer.MIN_VALUE + " TO " + Integer.MAX_VALUE + "]"); + assertEquals(Integer.MIN_VALUE, range.mins[0]); + assertEquals(Integer.MAX_VALUE, range.maxs[0]); + } + + private IndexSchema createMockSchema() { + final var schema = mock(IndexSchema.class); + when(schema.getVersion()).thenReturn(1.7f); + return schema; + } + + private IntRangeField createFieldType(int numDimensions) { + IntRangeField field = new IntRangeField(); + Map<String, String> args = new HashMap<>(); + args.put("numDimensions", String.valueOf(numDimensions)); + + field.init(createMockSchema(), args); + + return field; + } + + private SchemaField createSchemaField(IntRangeField 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/schema/numericrange/package-info.java b/solr/core/src/test/org/apache/solr/schema/numericrange/package-info.java new file mode 100644 index 00000000000..053e0c13bc7 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/package-info.java @@ -0,0 +1,19 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Tests for code in the corresponding 'main' package */ +package org.apache.solr.schema.numericrange; diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java index fd71ae4b8b6..6756fabcb47 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -36,6 +36,7 @@ import org.apache.solr.common.SolrInputDocument; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.request.SolrRequestInfo; import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.search.numericrange.IntRangeQParserPlugin; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -64,7 +65,10 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { public static void afterClassParserCoverageTest() { if (!doAssertParserCoverage) return; - for (String name : QParserPlugin.standardPlugins.keySet()) { + + final var qParsersToTest = new HashSet<>(QParserPlugin.standardPlugins.keySet()); + qParsersToTest.remove(IntRangeQParserPlugin.NAME); // Tested in IntRangeQParserPluginTest + for (String name : qParsersToTest) { assertTrue( "testParserCoverage was run w/o any other method explicitly testing qparser: " + name, qParsersTested.contains(name)); diff --git a/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java new file mode 100644 index 00000000000..40fb2e6396d --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java @@ -0,0 +1,499 @@ +/* + * 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 IntRangeQParserPlugin} */ +public class IntRangeQParserPluginTest extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema-intrange.xml"); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + clearIndex(); + assertU(commit()); + } + + @Test + public void test1DIntersectsQuery() { + // Index documents with 1D ranges + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[150 TO 250]")); + assertU(adoc("id", "3", "price_range", "[50 TO 80]")); + assertU(adoc("id", "4", "price_range", "[200 TO 300]")); + assertU(commit()); + + // Query: find ranges intersecting [120 TO 180] + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[120 TO 180]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='price_range'][.='[100 TO 200]']", + "//result/doc/str[@name='price_range'][.='[150 TO 250]']"); + + // Query: find ranges intersecting [0 TO 100] + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[0 TO 100]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='3']"); + + // Query: find ranges intersecting [175 TO 225] + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[175 TO 225]"), + "//result[@numFound='3']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='id'][.='4']"); + } + + @Test + public void test1DWithinQuery() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[150 TO 250]")); + assertU(adoc("id", "3", "price_range", "[50 TO 80]")); + assertU(commit()); + + // Query: find ranges within [0 TO 300] + assertQ( + req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 300]"), + "//result[@numFound='3']"); + + // Query: find ranges within [100 TO 200] + assertQ( + req("q", "{!numericRange criteria=\"within\" field=price_range}[100 TO 200]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Query: find ranges within [0 TO 100] + assertQ( + req("q", "{!numericRange criteria=\"within\" field=price_range}[0 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='3']"); + } + + @Test + public void test1DContainsQuery() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[150 TO 250]")); + assertU(adoc("id", "3", "price_range", "[50 TO 300]")); + assertU(commit()); + + // Query: find ranges containing [160 TO 170] + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=price_range}[160 TO 170]"), + "//result[@numFound='3']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='id'][.='3']"); + + // Query: find ranges containing [0 TO 400] + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=price_range}[0 TO 400]"), + "//result[@numFound='0']"); + + // Query: find ranges containing [100 TO 200] + assertQ( + req("q", "{!numericRange criteria=\"contains\" field=price_range}[100 TO 200]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='3']"); + } + + @Test + public void test1DCrossesQuery() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(adoc("id", "2", "price_range", "[150 TO 250]")); + assertU(adoc("id", "3", "price_range", "[50 TO 80]")); + assertU(adoc("id", "4", "price_range", "[120 TO 180]")); + assertU(commit()); + + // Query: find ranges crossing [150 TO 250] + // Should match ranges that intersect but are not within + assertQ( + req("q", "{!numericRange criteria=\"crosses\" field=price_range}[150 TO 250]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='4']"); + } + + @Test + public void test2DIntersectsQuery() { + // Index documents with 2D ranges (bounding boxes) + assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]")); + assertU(adoc("id", "2", "bbox", "[5,5 TO 15,15]")); + assertU(adoc("id", "3", "bbox", "[20,20 TO 30,30]")); + assertU(commit()); + + // Query: find bboxes intersecting [8,8 TO 12,12] + assertQ( + req("q", "{!numericRange criteria=intersects field=bbox}[8,8 TO 12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']", + "//result/doc/str[@name='bbox'][.='[0,0 TO 10,10]']", + "//result/doc/str[@name='bbox'][.='[5,5 TO 15,15]']"); + + // Query: find bboxes intersecting [25,25 TO 35,35] + assertQ( + req("q", "{!numericRange criteria=intersects field=bbox}[25,25 TO 35,35]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='3']"); + + // Query: find bboxes intersecting [100,100 TO 200,200] + assertQ( + req("q", "{!numericRange criteria=intersects field=bbox}[100,100 TO 200,200]"), + "//result[@numFound='0']"); + } + + @Test + public void test2DWithinQuery() { + assertU(adoc("id", "1", "bbox", "[5,5 TO 10,10]")); + assertU(adoc("id", "2", "bbox", "[0,0 TO 20,20]")); + assertU(adoc("id", "3", "bbox", "[15,15 TO 25,25]")); + assertU(commit()); + + // Query: find bboxes within [0,0 TO 20,20] + assertQ( + req("q", "{!numericRange criteria=\"within\" field=bbox}[0,0 TO 20,20]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + + // Query: find bboxes within [0,0 TO 30,30] + assertQ( + req("q", "{!numericRange criteria=\"within\" field=bbox}[0,0 TO 30,30]"), + "//result[@numFound='3']"); + } + + @Test + public void test3DQuery() { + // Index documents with 3D ranges (bounding cubes) + assertU(adoc("id", "1", "cube", "[0,0,0 TO 10,10,10]")); + assertU(adoc("id", "2", "cube", "[5,5,5 TO 15,15,15]")); + assertU(commit()); + + // Query: find cubes intersecting [8,8,8 TO 12,12,12] + assertQ( + req("q", "{!numericRange criteria=intersects field=cube}[8,8,8 TO 12,12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void test4DQuery() { + // Index documents with 4D ranges (tesseracts) + assertU(adoc("id", "1", "tesseract", "[0,0,0,0 TO 10,10,10,10]")); + assertU(adoc("id", "2", "tesseract", "[5,5,5,5 TO 15,15,15,15]")); + assertU(commit()); + + // Query: find tesseracts intersecting [8,8,8,8 TO 12,12,12,12] + assertQ( + req("q", "{!numericRange criteria=intersects field=tesseract}[8,8,8,8 TO 12,12,12,12]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testMultiValuedField() { + // Index document with multiple ranges + assertU( + adoc("id", "1", "price_range_multi", "[100 TO 200]", "price_range_multi", "[300 TO 400]")); + assertU(adoc("id", "2", "price_range_multi", "[150 TO 250]")); + assertU(commit()); + + // Query should match doc 1 via first range + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range_multi}[110 TO 120]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/arr[@name='price_range_multi']/str[1][.='[100 TO 200]']", + "//result/doc/arr[@name='price_range_multi']/str[2][.='[300 TO 400]']"); + + // Query should match doc 1 via second range + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range_multi}[310 TO 320]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Query should match both docs + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range_multi}[150 TO 250]"), + "//result[@numFound='2']"); + } + + @Test + public void testMissingFieldParameter() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query without field parameter should fail + assertQEx( + "Missing field parameter should fail", + "Missing required parameter: field", + req("q", "{!numericRange criteria=intersects}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testMissingCriteriaParameter() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query without field parameter should fail + assertQEx( + "Missing criteria parameter should fail", + "Missing required parameter: criteria", + req("q", "{!numericRange field=asdf}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidFieldType() { + assertU(adoc("id", "1", "title", "test")); + assertU(commit()); + + // Query on non-IntRangeField should fail + assertQEx( + "Query on wrong field type should fail", + "must be of type IntRangeField", + req("q", "{!numericRange criteria=intersects field=title}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidQueryType() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with invalid criteria parameter should fail + assertQEx( + "Invalid query criteria should fail", + "Unknown query criteria", + req("q", "{!numericRange criteria=\"invalid\" field=price_range}[100 TO 200]"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testInvalidRangeValue() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with invalid range format should fail + assertQEx( + "Invalid range format should fail", + "Invalid range", + req("q", "{!numericRange criteria=intersects field=price_range}invalid"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testEmptyRangeValue() { + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + assertU(commit()); + + // Query with empty range should fail + assertQEx( + "Empty range value should fail", + req("q", "{!numericRange criteria=intersects field=price_range}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testNegativeValues() { + assertU(adoc("id", "1", "price_range", "[-100 TO -50]")); + assertU(adoc("id", "2", "price_range", "[-75 TO -25]")); + assertU(commit()); + + // Query with negative range + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[-80 TO -60]"), + "//result[@numFound='2']"); + } + + @Test + public void testExtremeValues() { + int min = Integer.MIN_VALUE; + int max = Integer.MAX_VALUE; + + assertU(adoc("id", "1", "price_range", "[" + min + " TO " + max + "]")); + assertU(commit()); + + // Query should match the extreme range + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[0 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + @Test + public void testPointRange() { + // Point range where min == max + assertU(adoc("id", "1", "price_range", "[100 TO 100]")); + assertU(commit()); + + // Intersects query with point + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[100 TO 100]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + + // Intersects query containing point + assertQ( + req("q", "{!numericRange criteria=intersects field=price_range}[50 TO 150]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='1']"); + } + + // ------------------------------------------ + // These tests don't use the IntRangeQParser, but rather the use of IntRangeField through the more + // standard query parser(s). Not really the best place, but it didn't seem worth creating a whole + // new test class for this. Maybe worth a refactor later. When used with parsers other than + // {!numericRange} we default to "contains" semantics. In other words: "match only those + // documents with a field-val that fully contains the query value. + + @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", "price_range", "[130 TO 160]")); // No match + assertU(adoc("id", "2", "price_range", "[100 TO 200]")); // Match! + assertU(adoc("id", "3", "price_range", "[150 TO 250]")); // No match + assertU(commit()); + + // Contains semantics: find indexed ranges that fully contain [120 TO 180] + assertQ( + req("q", "price_range:[120 TO 180]"), + "//result[@numFound='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQueryFullRangeMultipleMatches() { + assertU(adoc("id", "1", "price_range", "[0 TO 1000]")); // Match! + assertU(adoc("id", "2", "price_range", "[100 TO 200]")); // Match! + assertU(adoc("id", "3", "price_range", "[100 TO 199]")); // No match - max too low + assertU(adoc("id", "4", "price_range", "[101 TO 200]")); // No match - min too high + assertU(commit()); + + assertQ( + req("q", "price_range:[100 TO 200]"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQuerySingleBound() { + // Single-bound syntax: price_range:150 is sugar for contains([150 TO 150]) + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); // Match! + assertU(adoc("id", "2", "price_range", "[150 TO 150]")); // Match! + assertU(adoc("id", "3", "price_range", "[100 TO 149]")); // No match - max below 150 + assertU(adoc("id", "4", "price_range", "[151 TO 300]")); // No match - min above 150 + assertU(commit()); + + assertQ( + req("q", "price_range:150"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQuerySingleBound2D() { + // 2D single-bound: bbox:5,5 is sugar for contains([5,5 TO 5,5]) + assertU(adoc("id", "1", "bbox", "[0,0 TO 10,10]")); // Match! + assertU(adoc("id", "2", "bbox", "[5,5 TO 5,5]")); // Match! + assertU(adoc("id", "3", "bbox", "[0,0 TO 4,10]")); // No match - X dimension ends too low + assertU(adoc("id", "4", "bbox", "[6,0 TO 10,10]")); // No match - X dimension starts too high + assertU(commit()); + + assertQ( + req("q", "bbox:5,5"), + "//result[@numFound='2']", + "//result/doc/str[@name='id'][.='1']", + "//result/doc/str[@name='id'][.='2']"); + } + + @Test + public void testGetFieldQueryFieldFormatting() { + // Test 1D field formatting + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + // Test 2D field formatting + assertU(adoc("id", "2", "bbox", "[10,20 TO 30,40]")); + // Test 3D field formatting + assertU(adoc("id", "3", "cube", "[5,10,15 TO 25,30,35]")); + // Test 4D field formatting + assertU(adoc("id", "4", "tesseract", "[1,2,3,4 TO 11,12,13,14]")); + // Test multi-valued field formatting + assertU( + adoc( + "id", + "5", + "price_range_multi", + "[50 TO 100]", + "price_range_multi", + "[200 TO 300]", + "price_range_multi", + "[400 TO 500]")); + assertU(commit()); + + // Verify 1D field returns correctly formatted value + assertQ( + req("q", "id:1"), + "//result[@numFound='1']", + "//result/doc/str[@name='price_range'][.='[100 TO 200]']"); + + // Verify 2D field returns correctly formatted value + assertQ( + req("q", "id:2"), + "//result[@numFound='1']", + "//result/doc/str[@name='bbox'][.='[10,20 TO 30,40]']"); + + // Verify 3D field returns correctly formatted value + assertQ( + req("q", "id:3"), + "//result[@numFound='1']", + "//result/doc/str[@name='cube'][.='[5,10,15 TO 25,30,35]']"); + + // Verify 4D field returns correctly formatted value + assertQ( + req("q", "id:4"), + "//result[@numFound='1']", + "//result/doc/str[@name='tesseract'][.='[1,2,3,4 TO 11,12,13,14]']"); + + // Verify multi-valued field returns all values correctly formatted + assertQ( + req("q", "id:5"), + "//result[@numFound='1']", + "//result/doc/arr[@name='price_range_multi']/str[1][.='[50 TO 100]']", + "//result/doc/arr[@name='price_range_multi']/str[2][.='[200 TO 300]']", + "//result/doc/arr[@name='price_range_multi']/str[3][.='[400 TO 500]']"); + } +} 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 ede6ade2961..60d6b377284 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc @@ -18,7 +18,7 @@ The following table lists the field types that are available in Solr and are recommended. The page further down, lists all the deprecated types for those migrating from older version of Solr. -The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table. +The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table, unless otherwise specified. == Recommended Field Types @@ -53,6 +53,8 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |IntPointField |Integer field (32-bit signed integer). This class encodes int values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. +|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. Field type is defined in the `org.apache.solr.schema.numericrange` package; fieldType definitions typically reference this as: `<fieldType name="intrange1D" class="solr.numericrange.Int [...] + |LatLonPointSpatialField |A latitude/longitude coordinate pair; possibly multi-valued for multiple points. Usually it's specified as "lat,lon" order with a comma. See the section xref:query-guide:spatial-search.adoc[] for more information. |LongPointField |Long field (64-bit signed integer). This class encodes foo values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc index f46e6f69781..e5358937478 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc @@ -1002,6 +1002,72 @@ These parameters would be defined in `solrconfig.xml`, in the `defaults` section For more information about the possibilities of nested queries, see Yonik Seeley's blog post https://lucidworks.com/2009/03/31/nested-queries-in-solr/[Nested Queries in Solr]. +== Numeric Range Query Parser + +NOTE: Syntax specifics of the `{!numericRange}` query parser are considered experimental and may change in the future. + +Allows users to search range fields (e.g. `IntRangeField`) using a specified query-range. +Multiple match semantics supported, see the `criteria` parameter below for more details. + +=== Numeric Range Parameters + +`field`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The field name to operate on. +Must be a "range" field (e.g. `IntRangeField`) +`criteria`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The criteria or semantics used to determine where a document matches the query-range. +One of: `contains`, `within`, `crosses` or `intersects`. + +* `contains` if matching documents should entirely contain or enclose the query-range (i.e. doc-ranges that are a superset of query-range), +* `within` if matching documents should be entirely within the query-range (i.e. doc-ranges that are sub-set of query-range) +* `crosses` if matching documents should be partially **but not fully** contained within the query-range +* `intersects` treats a document as a match if it meets any of the criteria above (i.e. `contains || within || crosses`) +`v`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The query-range to be compared against field values. +The specified range must have the same number of dimensions as the targeted field. +Usually provided implicitly by specifying a range after the local-params block (see examples below). + +=== Numeric Range Examples + +Find products whose `price_range` is fully bounded between 100 and 120. + +[source,text] +---- +{!numericRange field="price_range" criteria="within"}[100 TO 120] +---- + +Find products whose `price_range` contains 100. + +[source,text] +---- +{!numericRange field="price_range" criteria="contains"}[100 TO 100] +---- + +Find products whose `price_range` overlaps at all with a user's desired price range (e.g. 100-120). + +[source,text] +---- +{!numericRange field="price_range" criteria="intersects" v="[100 TO 120]"} +---- + == Neural Query Parsers There is currently one Query Parser in Solr to provide Neural Search: `knn`.
