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..bc913cecb6b --- /dev/null +++ b/changelog/unreleased/SOLR-13309-introduce-int-range-field.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Introduce new `IntRangeField` field type and `{!numericRange}` query parser for storing and querying integer ranges. +type: added +authors: + - name: Jason Gerlowski +links: + - name: SOLR-13309 + url: https://issues.apache.org/jira/browse/SOLR-13309 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..64a9a2acf50 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/schema/numericrange/IntRangeField.java @@ -0,0 +1,361 @@ +/* + * 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.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. + * + *

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

Value Format

+ * + * Values are specified using bracket notation with a TO keyword separator: + * + * + * + * As the name suggests minimum values (those on the left) must always be less than or equal to the + * maximum value for the corresponding dimension. + * + *

Schema Configuration

+ * + *
+ * <fieldType name="intrange" class="org.apache.solr.schema.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"/>
+ * 
+ * + *

Querying

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

Limitations

+ * + * The main limitation of this field type is that it doesn't support docValues or uninversion, and + * therefore can't be used for sorting, faceting, etc. + * + * @see IntRange + * @see org.apache.solr.search.numericrange.IntRangeQParserPlugin + */ +public class IntRangeField extends PrimitiveFieldType { + + private static final Pattern RANGE_PATTERN = + Pattern.compile("\\[\\s*([^\\]]+?)\\s+TO\\s+([^\\]]+?)\\s*\\]"); + + private int numDimensions = 1; + + @Override + protected boolean enableDocValuesByDefault() { + return false; // IntRange does not support docValues + } + + @Override + protected void init(IndexSchema schema, Map args) { + super.init(schema, args); + + String numDimensionsStr = args.remove("numDimensions"); + if (numDimensionsStr != null) { + numDimensions = Integer.parseInt(numDimensionsStr); + if (numDimensions < 1 || numDimensions > 4) { + throw new SolrException( + ErrorCode.SERVER_ERROR, + "numDimensions must be between 1 and 4, 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 createFields(SchemaField field, Object value) { + IndexableField indexedField = createField(field, value); + List fields = new java.util.ArrayList<>(); + + if (indexedField != null) { + fields.add(indexedField); + } + + if (field.stored()) { + String valueStr = value.toString(); + fields.add(getStoredField(field, valueStr)); + } + + return fields; + } + + @Override + public void write(TextResponseWriter writer, String name, IndexableField f) throws IOException { + writer.writeStr(name, 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.matcher(value.trim()); + if (!matcher.matches()) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid range format. Expected: [min1,min2,... TO max1,max2,...], got: " + value); + } + + String minPart = matcher.group(1).trim(); + String maxPart = matcher.group(2).trim(); + + int[] mins = parseIntArray(minPart, "min values"); + int[] maxs = parseIntArray(maxPart, "max values"); + + if (mins.length != maxs.length) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min and max dimensions must match. Min dimensions: " + + mins.length + + ", max dimensions: " + + maxs.length); + } + + if (mins.length != numDimensions) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Range dimensions (" + + mins.length + + ") do not match field type numDimensions (" + + numDimensions + + ")"); + } + + // Validate that min <= max for each dimension + for (int i = 0; i < mins.length; i++) { + if (mins[i] > maxs[i]) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Min value must be <= max value for dimension " + + i + + ". Min: " + + mins[i] + + ", Max: " + + maxs[i]); + } + } + + return new RangeValue(mins, maxs); + } + + /** + * Parse a comma-separated string of integers into an array. + * + * @param str the string to parse + * @param description description for error messages + * @return array of parsed integers + */ + private int[] parseIntArray(String str, String description) { + String[] parts = str.split(","); + int[] result = new int[parts.length]; + + for (int i = 0; i < parts.length; i++) { + try { + result[i] = Integer.parseInt(parts[i].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer in " + description + ": '" + parts[i].trim() + "'", + e); + } + } + + return result; + } + + protected StoredField getStoredField(SchemaField sf, Object value) { + return new StoredField(sf.getName(), value.toString()); + } + + // TODO - how would this be invoked? Should we force all queries to come through {!numericRange} + @Override + protected Query getSpecializedRangeQuery( + QParser parser, + SchemaField field, + String part1, + String part2, + boolean minInclusive, + boolean maxInclusive) { + // For standard range syntax field:[value TO value], default to intersects query + if (part1 == null || part2 == null) { + return super.getSpecializedRangeQuery( + parser, field, part1, part2, minInclusive, maxInclusive); + } + + // Parse the range bounds as single-dimensional values + int min, max; + try { + min = Integer.parseInt(part1.trim()); + max = Integer.parseInt(part2.trim()); + } catch (NumberFormatException e) { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid integer values in range query: [" + part1 + " TO " + part2 + "]", + e); + } + + if (!minInclusive) { + min = (min == Integer.MAX_VALUE) ? min : min + 1; + } + if (!maxInclusive) { + max = (max == Integer.MIN_VALUE) ? max : max - 1; + } + + // Build arrays for the query based on configured dimensions + int[] mins = new int[numDimensions]; + int[] maxs = new int[numDimensions]; + + // For now, only support 1D range syntax with field:[X TO Y] + if (numDimensions == 1) { + mins[0] = min; + maxs[0] = max; + return IntRange.newIntersectsQuery(field.getName(), mins, maxs); + } else { + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Standard range query syntax only supports 1D ranges. " + + "Use {!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 45409cb8982..ba27a86b1f2 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -28,6 +28,7 @@ import org.apache.solr.search.join.HashRangeQParserPlugin; import org.apache.solr.search.mlt.MLTContentQParserPlugin; import org.apache.solr.search.mlt.MLTQParserPlugin; +import org.apache.solr.search.numericrange.IntRangeQParserPlugin; import org.apache.solr.search.vector.KnnQParserPlugin; import org.apache.solr.search.vector.VectorSimilarityQParserPlugin; import org.apache.solr.util.plugin.NamedListInitializedPlugin; @@ -90,6 +91,7 @@ public abstract class QParserPlugin implements NamedListInitializedPlugin { map.put(KnnQParserPlugin.NAME, new KnnQParserPlugin()); map.put(VectorSimilarityQParserPlugin.NAME, new VectorSimilarityQParserPlugin()); map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin()); + map.put(IntRangeQParserPlugin.NAME, new IntRangeQParserPlugin()); standardPlugins = Collections.unmodifiableMap(map); } diff --git a/solr/core/src/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..423bd034d4b --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/numericrange/IntRangeQParserPlugin.java @@ -0,0 +1,234 @@ +/* + * 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. + * + *

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

Parameters

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

Query Types

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

Example Usage

+ * + *
+ * // 1D range queries
+ * {!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]
+ * 
+ * + * @see IntRangeField + * @see IntRange + */ +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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id + 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..19cccce5c49 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/schema/numericrange/IntRangeFieldTest.java @@ -0,0 +1,350 @@ +/* + * 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.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")); + assertTrue(e1.getMessage().contains("Invalid range format")); + assertTrue(e1.getMessage().contains("Expected: [min1,min2,... TO max1,max2,...]")); + + // Missing TO keyword + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 20]")); + assertTrue(e2.getMessage().contains("Invalid range format")); + + // Empty value + SolrException e3 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue("")); + assertTrue(e3.getMessage().contains("Range value cannot be null or empty")); + + // Null value + SolrException e4 = expectThrows(SolrException.class, () -> fieldType.parseRangeValue(null)); + assertTrue(e4.getMessage().contains("Range value cannot be null or empty")); + } + + public void testInvalidNumbers() { + IntRangeField fieldType = createFieldType(1); + + // Non-numeric values + SolrException e1 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[abc TO def]")); + assertTrue(e1.getMessage().contains("Invalid integer")); + assertTrue(e1.getMessage().contains("min values")); + + // Partially numeric + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10 TO xyz]")); + assertTrue(e2.getMessage().contains("Invalid integer")); + assertTrue(e2.getMessage().contains("max values")); + + // Floating point (should fail for IntRange) + SolrException e3 = + expectThrows(SolrException.class, () -> fieldType.parseRangeValue("[10.5 TO 20.5]")); + assertTrue(e3.getMessage().contains("Invalid integer")); + } + + 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]")); + assertTrue(e1.getMessage().contains("Range dimensions")); + assertTrue(e1.getMessage().contains("do not match field type numDimensions")); + + // 1D value on 2D field + SolrException e2 = + expectThrows(SolrException.class, () -> fieldType2D.parseRangeValue("[10 TO 20]")); + assertTrue(e2.getMessage().contains("Range dimensions")); + assertTrue(e2.getMessage().contains("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 + assertTrue(e3.getMessage().contains("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]")); + assertTrue(e1.getMessage().contains("Min value must be <= max value")); + assertTrue(e1.getMessage().contains("dimension 0")); + + // For 2D + IntRangeField fieldType2D = createFieldType(2); + SolrException e2 = + expectThrows( + SolrException.class, + () -> fieldType2D.parseRangeValue("[30,20 TO 10,40]")); // First dimension invalid + assertTrue(e2.getMessage().contains("Min value must be <= max value")); + assertTrue(e2.getMessage().contains("dimension 0")); + } + + public void testFieldCreation1D() { + 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")); + assertTrue(e.getMessage().contains("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)); + assertTrue(e.getMessage().contains("Cannot sort on IntRangeField")); + assertTrue(e.getMessage().contains("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 args = new HashMap<>(); + IndexSchema schema = createMockSchema(); + + // Test numDimensions = 0 + args.put("numDimensions", "0"); + SolrException e1 = expectThrows(SolrException.class, () -> field.init(schema, args)); + assertTrue(e1.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e1.getMessage().contains("but was [0]")); + + // Test numDimensions = 5 (too high) + args.put("numDimensions", "5"); + IntRangeField field2 = new IntRangeField(); + SolrException e2 = expectThrows(SolrException.class, () -> field2.init(schema, args)); + assertTrue(e2.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e2.getMessage().contains("but was [5]")); + + // Test negative numDimensions + args.put("numDimensions", "-1"); + IntRangeField field3 = new IntRangeField(); + SolrException e3 = expectThrows(SolrException.class, () -> field3.init(schema, args)); + assertTrue(e3.getMessage().contains("numDimensions must be between 1 and 4")); + assertTrue(e3.getMessage().contains("but was [-1]")); + } + + public void testRangeValueToString() { + IntRangeField fieldType = createFieldType(2); + IntRangeField.RangeValue range = fieldType.parseRangeValue("[10,20 TO 30,40]"); + + String str = range.toString(); + assertEquals("[10,20 TO 30,40]", str); + } + + public void testExtremeValues() { + IntRangeField fieldType = createFieldType(1); + + // Test with Integer.MIN_VALUE and Integer.MAX_VALUE + IntRangeField.RangeValue range = + fieldType.parseRangeValue("[" + Integer.MIN_VALUE + " TO " + Integer.MAX_VALUE + "]"); + assertEquals(Integer.MIN_VALUE, range.mins[0]); + assertEquals(Integer.MAX_VALUE, range.maxs[0]); + } + + private IndexSchema createMockSchema() { + final var schema = mock(IndexSchema.class); + when(schema.getVersion()).thenReturn(1.7f); + return schema; + } + + private IntRangeField createFieldType(int numDimensions) { + IntRangeField field = new IntRangeField(); + Map args = new HashMap<>(); + args.put("numDimensions", String.valueOf(numDimensions)); + + field.init(createMockSchema(), args); + + return field; + } + + private SchemaField createSchemaField(IntRangeField fieldType, String name) { + 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/numericrange/IntRangeQParserPluginTest.java b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java new file mode 100644 index 00000000000..98ea57c521c --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/numericrange/IntRangeQParserPluginTest.java @@ -0,0 +1,428 @@ +/* + * 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']"); + } + + @Test + public void testFieldFormatting() { + // Test 1D field formatting + assertU(adoc("id", "1", "price_range", "[100 TO 200]")); + // Test 2D field formatting + assertU(adoc("id", "2", "bbox", "[10,20 TO 30,40]")); + // Test 3D field formatting + assertU(adoc("id", "3", "cube", "[5,10,15 TO 25,30,35]")); + // Test 4D field formatting + assertU(adoc("id", "4", "tesseract", "[1,2,3,4 TO 11,12,13,14]")); + // Test multi-valued field formatting + assertU( + adoc( + "id", + "5", + "price_range_multi", + "[50 TO 100]", + "price_range_multi", + "[200 TO 300]", + "price_range_multi", + "[400 TO 500]")); + assertU(commit()); + + // Verify 1D field returns correctly formatted value + assertQ( + req("q", "id:1"), + "//result[@numFound='1']", + "//result/doc/str[@name='price_range'][.='[100 TO 200]']"); + + // Verify 2D field returns correctly formatted value + assertQ( + req("q", "id:2"), + "//result[@numFound='1']", + "//result/doc/str[@name='bbox'][.='[10,20 TO 30,40]']"); + + // Verify 3D field returns correctly formatted value + assertQ( + req("q", "id:3"), + "//result[@numFound='1']", + "//result/doc/str[@name='cube'][.='[5,10,15 TO 25,30,35]']"); + + // Verify 4D field returns correctly formatted value + assertQ( + req("q", "id:4"), + "//result[@numFound='1']", + "//result/doc/str[@name='tesseract'][.='[1,2,3,4 TO 11,12,13,14]']"); + + // Verify multi-valued field returns all values correctly formatted + assertQ( + req("q", "id:5"), + "//result[@numFound='1']", + "//result/doc/arr[@name='price_range_multi']/str[1][.='[50 TO 100]']", + "//result/doc/arr[@name='price_range_multi']/str[2][.='[200 TO 300]']", + "//result/doc/arr[@name='price_range_multi']/str[3][.='[400 TO 500]']"); + } +} 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 4eaed0e0475..c437864867a 100644 --- a/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc +++ b/solr/solr-ref-guide/modules/indexing-guide/pages/field-types-included-with-solr.adoc @@ -18,7 +18,7 @@ The following table lists the field types that are available in Solr and are recommended. The page further down, lists all the deprecated types for those migrating from older version of Solr. -The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table. +The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache.solr.schema`] package includes all the classes listed in this table, unless otherwise specified. == Recommended Field Types @@ -51,6 +51,8 @@ The {solr-javadocs}/core/org/apache/solr/schema/package-summary.html[`org.apache |IntPointField |Integer field (32-bit signed integer). This class encodes int values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. +|IntRangeField |Stores single or multi-dimensional ranges, using syntax like `[1 TO 4]` or `[1,2 TO 3,4]`. Up to 4 dimensions are supported. Dimensionality is specified on new field-types using a `numDimensions` property, and all values for a particular field must have exactly this number of dimensions. This field type does not support docValues, and is typically queried using the xref:query-guide:other-parsers.adoc#numeric-range-query-parser[Numeric Range Query Parser]. Field type is defined in the `org.apache.solr.schema.numericrange` package; XML definitions typically express this as: `` + |LatLonPointSpatialField |A latitude/longitude coordinate pair; possibly multi-valued for multiple points. Usually it's specified as "lat,lon" order with a comma. See the section xref:query-guide:spatial-search.adoc[] for more information. |LongPointField |Long field (64-bit signed integer). This class encodes foo values using a "Dimensional Points" based data structure that allows for very efficient searches for specific values, or ranges of values. For single valued fields, `docValues="true"` must be used to enable sorting. diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc index 59f55acfedf..347b2328508 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc @@ -1002,6 +1002,71 @@ These parameters would be defined in `solrconfig.xml`, in the `defaults` section For more information about the possibilities of nested queries, see Yonik Seeley's blog post https://lucidworks.com/2009/03/31/nested-queries-in-solr/[Nested Queries in Solr]. +== Numeric Range Query Parser + +Allows users to search range fields (e.g. `IntRangeField`) using a specified query-range. +A "criteria" parameter indicates what match-semantics should be used. +Accepts a "criteria" local-param that indicates how matched documents should relate to the query-range: + +* `contains` if matching documents should entirely contain or enclose the query-range (i.e. doc-ranges that are a superset of query-range), +* `within` if matching documents should be entirely within the query-range (i.e. doc-ranges that are sub-set of query-range) +* `crosses` if matching documents should be partially **but not fully** contained within the query-range +* `intersects` treats a document as a match if it meets any of the criteria above (i.e. `contains || within || crosses`) + +=== Numeric Range Parameters + +`field`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The field name to operate on. +Must be a "range" field (e.g. `IntRangeField`) +`criteria`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The criteria or semantics used to determine where a document matches the query-range. +One of: `contains`, `within`, `crosses` or `intersects`. +`v`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +The query-range to be compared against field values. +The specified range must have the same number of dimensions as the targeted field. +Usually provided implicitly by specifying a range after the local-params block (see examples below). + +=== Numeric Range Example + +Find products whose `price_range` is fully bounded between 100 and 120. + +[source,text] +---- +{!numericRange field="price_range" criteria="within"}[100 TO 120] +---- + +Find products whose `price_range` contains 100. + +[source,text] +---- +{!numericRange field="price_range" criteria="contains"}[100 TO 100] +---- + +Find products whose `price_range` overlaps at all with a user's desired price range (e.g. 100-120). + +[source,text] +---- +{!numericRange field="price_range" criteria="intersects" v="[100 TO 120]"} +---- + == Vector Query Parsers