Copilot commented on code in PR #4582: URL: https://github.com/apache/solr/pull/4582#discussion_r3505933863
########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + Review Comment: fuzzy term normalization should use the field’s multi-term analyzer by default, but the current code always resolves an analyzer and passes it to normalizeMultiTerm(). Also, prefix_length is documented as a length and should reject negative values rather than passing them to Lucene (which may throw an unchecked exception). ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); Review Comment: prefix normalization should use the field’s multi-term analyzer by default (as documented), but currently it always resolves an analyzer and passes it to normalizeMultiTerm(), which prevents normalizeMultiTerm() from selecting TextField.getMultiTermAnalyzer(). This can change prefix behavior for many field types. ########## solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc: ########## @@ -0,0 +1,743 @@ += Intervals Query Parser +// 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. + +The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval queries from a xref:json-request-api.adoc[JSON DSL] description. +Interval queries allow you to express positional constraints such as "these terms must appear within N positions of each other" or "this phrase must appear before that one". +See the https://lucene.apache.org/core/10_4_0/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. + +Invoked with the syntax `{!intervals json_query=<name> df=<field>}`. + +== Query Format + +Specify the target field using the `df` local param (or the `df` query param as a fallback). +The named entry in `json_queries` is then the rule object directly, with no field wrapper. + +[source,text] +---- +q={!intervals json_query=myQuery df=title} +---- + +[source,text] +---- +json={ + "json_queries": { + "myQuery": { + "match": { "query": "apache solr" } + } + } +} +---- + +== Parameters + +`json_query`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +Names an entry in the `json_queries` map that defines the interval query to execute. + +`df`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: none +|=== ++ +The field to run the interval query against. +Required; may be specified as a local param inside `{! }` or as a regular query param. + +== Interval Rules + +Each rule object contains exactly one key that identifies the rule type, mapped to an object of rule parameters. + +=== `match` + +Analyzes query text and matches documents where the resulting tokens appear according to positional constraints. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`query` +|Yes +|— +|The text to analyse and match. Review Comment: Spelling inconsistency: this page uses US spelling elsewhere ("Analyzes"), but this line says "analyse". ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + DEFAULT_FUZZY_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "all_of"); + boolean ordered = getBoolean(params, "ordered", false, "all_of"); + int maxGaps = getInt(params, "max_gaps", -1, "all_of"); + + IntervalsSource source = + ordered + ? Intervals.ordered(intervals.toArray(IntervalsSource[]::new)) + : Intervals.unordered(intervals.toArray(IntervalsSource[]::new)); + if (maxGaps >= 0) { + source = Intervals.maxgaps(maxGaps, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseAnyOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseTermRule(Map<String, Object> params, String topField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parsePhraseRule(Map<String, Object> params, String topField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("intervals"); + if (termsObj == null && intervalsObj == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires either 'terms' (string array) or 'intervals' (rule array)"); + } Review Comment: phrase currently accepts both 'terms' and 'intervals' but silently ignores 'intervals' when 'terms' is present. Since these are mutually exclusive forms, it should throw BAD_REQUEST when both are provided to avoid surprising behavior. ########## solr/core/src/java/org/apache/solr/request/json/RequestUtil.java: ########## @@ -254,6 +254,10 @@ public static void processParams( SolrException.ErrorCode.BAD_REQUEST, "Expected Map for 'queries', received " + queriesJsonObj); } + } else if ("json_queries".equals(key)) { + // passed through as a parsed object for use by SearchComponent.prepare() at subordinate + // nodes; not processed here + continue; Review Comment: RequestUtil accepts json_queries but doesn’t validate its type. For consistency with the existing 'queries' key (and to fail fast), json_queries should be required to be a Map when present; otherwise return BAD_REQUEST instead of passing an unexpected type through to downstream components. ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + DEFAULT_FUZZY_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "all_of"); + boolean ordered = getBoolean(params, "ordered", false, "all_of"); + int maxGaps = getInt(params, "max_gaps", -1, "all_of"); + + IntervalsSource source = + ordered + ? Intervals.ordered(intervals.toArray(IntervalsSource[]::new)) + : Intervals.unordered(intervals.toArray(IntervalsSource[]::new)); + if (maxGaps >= 0) { + source = Intervals.maxgaps(maxGaps, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseAnyOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseTermRule(Map<String, Object> params, String topField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parsePhraseRule(Map<String, Object> params, String topField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("intervals"); + if (termsObj == null && intervalsObj == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires either 'terms' (string array) or 'intervals' (rule array)"); + } + if (termsObj != null) { + if (!(termsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires 'terms' to be an array of strings"); + } + List<?> rawTerms = (List<?>) termsObj; + String[] terms = new String[rawTerms.size()]; + for (int i = 0; i < rawTerms.size(); i++) { + Object t = rawTerms.get(i); + if (!(t instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires all 'terms' elements to be strings, got " + + describeType(t)); + } + terms[i] = (String) t; + } + return Intervals.phrase(terms); + } else { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "phrase"); + return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); + } + } + + private IntervalsSource parseRegexpRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "regexp"); + String useField = getOptionalString(params, "use_field", "regexp"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "regexp"); + IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseRangeRule(Map<String, Object> params, String topField) { + String lowerTermStr = getOptionalString(params, "lower_term", "range"); + String upperTermStr = getOptionalString(params, "upper_term", "range"); + boolean includeLower = getBoolean(params, "include_lower", true, "range"); + boolean includeUpper = getBoolean(params, "include_upper", false, "range"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "range"); + BytesRef lowerTerm = lowerTermStr == null ? null : new BytesRef(lowerTermStr); + BytesRef upperTerm = upperTermStr == null ? null : new BytesRef(upperTermStr); + return Intervals.range(lowerTerm, upperTerm, includeLower, includeUpper, maxExpansions); + } + + private IntervalsSource parseMaxWidthRule(Map<String, Object> params, String topField) { + int width = getInt(params, "width", -1, "max_width"); + if (width < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'max_width' requires a non-negative integer 'width'"); + } + IntervalsSource source = parseNestedRule(params, "source", "max_width", topField); + return Intervals.maxwidth(width, source); + } + + private IntervalsSource parseExtendRule(Map<String, Object> params, String topField) { + int before = getInt(params, "before", 0, "extend"); + int after = getInt(params, "after", 0, "extend"); + IntervalsSource source = parseNestedRule(params, "source", "extend", topField); + return Intervals.extend(source, before, after); + } + + private IntervalsSource parseUnorderedNoOverlapsRule( + Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = + parseIntervalsArray(params, topField, "unordered_no_overlaps"); + if (intervals.size() != 2) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'unordered_no_overlaps' requires exactly 2 intervals, got " + intervals.size()); + } + return Intervals.unorderedNoOverlaps(intervals.get(0), intervals.get(1)); + } + + private IntervalsSource parseNotWithinRule(Map<String, Object> params, String topField) { + IntervalsSource source = parseNestedRule(params, "source", "not_within", topField); + int positions = getInt(params, "positions", -1, "not_within"); + if (positions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'not_within' requires a non-negative integer 'positions'"); + } + IntervalsSource reference = parseNestedRule(params, "reference", "not_within", topField); + return Intervals.notWithin(source, positions, reference); + } + + private IntervalsSource parseWithinRule(Map<String, Object> params, String topField) { + IntervalsSource source = parseNestedRule(params, "source", "within", topField); + int positions = getInt(params, "positions", -1, "within"); + if (positions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'within' requires a non-negative integer 'positions'"); + } + IntervalsSource reference = parseNestedRule(params, "reference", "within", topField); + return Intervals.within(source, positions, reference); + } + + private IntervalsSource parseAtLeastRule(Map<String, Object> params, String topField) { + int minShouldMatch = getInt(params, "min_should_match", -1, "at_least"); + if (minShouldMatch < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'at_least' requires a non-negative integer 'min_should_match'"); + } + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "at_least"); + return Intervals.atLeast(minShouldMatch, intervals.toArray(IntervalsSource[]::new)); + } + + private IntervalsSource parseNoIntervalsRule(Map<String, Object> params) { + String reason = getOptionalString(params, "reason", "no_intervals"); + return Intervals.noIntervals(reason == null ? "no_intervals rule" : reason); + } + + private IntervalsSource parseNestedRule( + Map<String, Object> params, String key, String ruleName, String topField) { + Object nested = params.get(key); + if (nested == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires '" + key + "' parameter"); + } + return parseRuleObject( + asStringObjectMap(nested, "'" + key + "' in rule '" + ruleName + "'"), topField); + } + + private List<IntervalsSource> parseIntervalsArray( + Map<String, Object> params, String topField, String ruleName) { + Object intervalsObj = params.get("intervals"); + if (!(intervalsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires an 'intervals' array"); + } + List<?> rawIntervals = (List<?>) intervalsObj; + if (rawIntervals.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires at least one interval rule"); + } + List<IntervalsSource> parsed = new ArrayList<>(rawIntervals.size()); + for (Object intervalObj : rawIntervals) { + parsed.add( + parseRuleObject(asStringObjectMap(intervalObj, "intervals array element"), topField)); + } + return parsed; + } + + private IntervalsSource applyFilter( + IntervalsSource source, Object filterObj, String topField) { + if (filterObj == null) { + return source; + } + Map<String, Object> filterMap = asStringObjectMap(filterObj, "filter"); + if (filterMap.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Filter must contain exactly one operator, got " + filterMap.keySet()); + } + + Map.Entry<String, Object> entry = filterMap.entrySet().iterator().next(); + String op = entry.getKey(); + if ("script".equals(op)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Filter operator 'script' is not supported"); + } + IntervalsSource other = + parseRuleObject(asStringObjectMap(entry.getValue(), "filter '" + op + "'"), topField); + return switch (op) { + case "after" -> Intervals.after(source, other); + case "before" -> Intervals.before(source, other); + case "contained_by" -> Intervals.containedBy(source, other); + case "containing" -> Intervals.containing(source, other); + case "not_contained_by" -> Intervals.notContainedBy(source, other); + case "not_containing" -> Intervals.notContaining(source, other); + case "not_overlapping" -> Intervals.nonOverlapping(source, other); + case "overlapping" -> Intervals.overlapping(source, other); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported filter operator: " + op); + }; + } + + private Analyzer resolveAnalyzer(Map<String, Object> params, String field, String ruleName) { + String analyzerName = getOptionalString(params, "analyzer", ruleName); + if (analyzerName == null) { + return req.getSchema().getQueryAnalyzer(); + } Review Comment: resolveAnalyzer() defaults to req.getSchema().getQueryAnalyzer(), which ignores the target field’s configured query analyzer. That can produce incorrect tokenization for per-field analyzers (and differs from how other query parsers resolve analyzers). When no explicit analyzer is provided, default should be the field type’s query analyzer. ########## solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc: ########## @@ -0,0 +1,743 @@ += Intervals Query Parser +// 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. + +The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval queries from a xref:json-request-api.adoc[JSON DSL] description. +Interval queries allow you to express positional constraints such as "these terms must appear within N positions of each other" or "this phrase must appear before that one". +See the https://lucene.apache.org/core/10_4_0/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. Review Comment: The Lucene Intervals documentation link hard-codes Lucene 10_4_0, but the ref-guide defines the current Lucene javadocs base URL via the {lucene-javadocs} attribute (currently 10_3_1). Hard-coding a different version will go stale and may point at non-existent docs for this Solr branch. ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); Review Comment: wildcard normalization should use the field’s multi-term analyzer by default (as documented). Currently it always resolves an analyzer and passes it into normalizeMultiTerm(), preventing multi-term analysis fallback. ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + DEFAULT_FUZZY_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "all_of"); + boolean ordered = getBoolean(params, "ordered", false, "all_of"); + int maxGaps = getInt(params, "max_gaps", -1, "all_of"); + + IntervalsSource source = + ordered + ? Intervals.ordered(intervals.toArray(IntervalsSource[]::new)) + : Intervals.unordered(intervals.toArray(IntervalsSource[]::new)); + if (maxGaps >= 0) { + source = Intervals.maxgaps(maxGaps, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseAnyOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseTermRule(Map<String, Object> params, String topField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parsePhraseRule(Map<String, Object> params, String topField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("intervals"); + if (termsObj == null && intervalsObj == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires either 'terms' (string array) or 'intervals' (rule array)"); + } + if (termsObj != null) { + if (!(termsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires 'terms' to be an array of strings"); + } + List<?> rawTerms = (List<?>) termsObj; + String[] terms = new String[rawTerms.size()]; + for (int i = 0; i < rawTerms.size(); i++) { + Object t = rawTerms.get(i); + if (!(t instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires all 'terms' elements to be strings, got " + + describeType(t)); + } + terms[i] = (String) t; + } + return Intervals.phrase(terms); + } else { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "phrase"); + return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); + } + } + + private IntervalsSource parseRegexpRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "regexp"); + String useField = getOptionalString(params, "use_field", "regexp"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "regexp"); + IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseRangeRule(Map<String, Object> params, String topField) { + String lowerTermStr = getOptionalString(params, "lower_term", "range"); + String upperTermStr = getOptionalString(params, "upper_term", "range"); + boolean includeLower = getBoolean(params, "include_lower", true, "range"); + boolean includeUpper = getBoolean(params, "include_upper", false, "range"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "range"); + BytesRef lowerTerm = lowerTermStr == null ? null : new BytesRef(lowerTermStr); + BytesRef upperTerm = upperTermStr == null ? null : new BytesRef(upperTermStr); + return Intervals.range(lowerTerm, upperTerm, includeLower, includeUpper, maxExpansions); Review Comment: range max_expansions is not validated. Negative values should be rejected with BAD_REQUEST to avoid surprising unchecked errors from Lucene. ########## solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java: ########## @@ -0,0 +1,623 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar df=title}}. + * + * <p>The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map<String, Object> json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map<String, Object> jsonQueries = (Map<String, Object>) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + Map<String, Object> queryDefMap = asStringObjectMap(queryDef, "json query definition"); + + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + } + + IntervalsSource source = parseRuleObject(queryDefMap, field); + return new IntervalQuery(field, source); + } + + private IntervalsSource parseRuleObject(Map<String, Object> ruleObject, String topField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, String topField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map<String, Object> params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + DEFAULT_FUZZY_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "all_of"); + boolean ordered = getBoolean(params, "ordered", false, "all_of"); + int maxGaps = getInt(params, "max_gaps", -1, "all_of"); + + IntervalsSource source = + ordered + ? Intervals.ordered(intervals.toArray(IntervalsSource[]::new)) + : Intervals.unordered(intervals.toArray(IntervalsSource[]::new)); + if (maxGaps >= 0) { + source = Intervals.maxgaps(maxGaps, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseAnyOfRule(Map<String, Object> params, String topField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parseTermRule(Map<String, Object> params, String topField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parsePhraseRule(Map<String, Object> params, String topField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("intervals"); + if (termsObj == null && intervalsObj == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires either 'terms' (string array) or 'intervals' (rule array)"); + } + if (termsObj != null) { + if (!(termsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires 'terms' to be an array of strings"); + } + List<?> rawTerms = (List<?>) termsObj; + String[] terms = new String[rawTerms.size()]; + for (int i = 0; i < rawTerms.size(); i++) { + Object t = rawTerms.get(i); + if (!(t instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires all 'terms' elements to be strings, got " + + describeType(t)); + } + terms[i] = (String) t; + } + return Intervals.phrase(terms); + } else { + List<IntervalsSource> intervals = parseIntervalsArray(params, topField, "phrase"); + return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); + } + } + + private IntervalsSource parseRegexpRule(Map<String, Object> params, String topField) { + String pattern = requireString(params, "pattern", "regexp"); + String useField = getOptionalString(params, "use_field", "regexp"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "regexp"); + IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); Review Comment: max_expansions is not validated. Negative values should be rejected with BAD_REQUEST so user input errors don’t fall through into Lucene and potentially become unchecked exceptions. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
