diff --git a/docs/changelog/121787.yaml b/docs/changelog/121787.yaml new file mode 100644 index 0000000000000..c99032722de4f --- /dev/null +++ b/docs/changelog/121787.yaml @@ -0,0 +1,6 @@ +pr: 121787 +summary: Added optional parameters to QSTR ES|QL function +area: Search +type: feature +issues: + - 120933 diff --git a/docs/reference/esql/functions/functionNamedParams/qstr.asciidoc b/docs/reference/esql/functions/functionNamedParams/qstr.asciidoc new file mode 100644 index 0000000000000..e12257c0bc2a0 --- /dev/null +++ b/docs/reference/esql/functions/functionNamedParams/qstr.asciidoc @@ -0,0 +1,29 @@ +// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it. + +*Supported function named parameters* + +[%header.monospaced.styled,format=dsv,separator=|] +|=== +name | types | description +max_determinized_states | [integer] | Maximum number of automaton states required for the query. Default is 10000. +fuzziness | [keyword] | Maximum edit distance allowed for matching. +auto_generate_synonyms_phrase_query | [boolean] | If true, match phrase queries are automatically created for multi-term synonyms. +phrase_slop | [integer] | Maximum number of positions allowed between matching tokens for phrases. +default_field | [keyword] | Default field to search if no field is provided in the query string. Supports wildcards (*). +allow_leading_wildcard | [boolean] | If true, the wildcard characters * and ? are allowed as the first character of the query string. +minimum_should_match | [string] | Minimum number of clauses that must match for a document to be returned. +fuzzy_transpositions | [boolean] | If true, edits for fuzzy matching include transpositions of two adjacent characters (ab → ba). +fuzzy_prefix_length | [integer] | Number of beginning characters left unchanged for fuzzy matching. Defaults to 0. +time_zone | [keyword] | Coordinated Universal Time (UTC) offset or IANA time zone used to convert date values in the query string to UTC. +lenient | [boolean] | If false, format-based errors, such as providing a text query value for a numeric field, are returned. +rewrite | [keyword] | Method used to rewrite the query. +default_operator | [keyword] | Default boolean logic used to interpret text in the query string if no operators are specified. +analyzer | [keyword] | Analyzer used to convert the text in the query value into token. +fuzzy_max_expansions | [integer] | Maximum number of terms to which the query expands for fuzzy matching. Defaults to 50. +quote_analyzer | [keyword] | Analyzer used to convert quoted text in the query string into tokens. +allow_wildcard | [boolean] | If true, the query attempts to analyze wildcard terms in the query string. +boost | [float] | Floating point number used to decrease or increase the relevance scores of the query. +quote_field_suffix | [keyword] | Suffix appended to quoted text in the query string. +enable_position_increments | [boolean] | If true, enable position increments in queries constructed from a query_string search. Defaults to true. +fields | [keyword] | Array of fields to search. Supports wildcards (*). +|=== diff --git a/docs/reference/esql/functions/kibana/definition/qstr.json b/docs/reference/esql/functions/kibana/definition/qstr.json index b617f9f9246c6..14ee536fd3b85 100644 --- a/docs/reference/esql/functions/kibana/definition/qstr.json +++ b/docs/reference/esql/functions/kibana/definition/qstr.json @@ -11,6 +11,13 @@ "type" : "keyword", "optional" : false, "description" : "Query string in Lucene query string format." + }, + { + "name" : "options", + "type" : "function_named_parameters", + "mapParams" : "{name='max_determinized_states', values=[10000], description='Maximum number of automaton states required for the query. Default is 10000.'}, {name='fuzziness', values=[AUTO, 1, 2], description='Maximum edit distance allowed for matching.'}, {name='auto_generate_synonyms_phrase_query', values=[true, false], description='If true, match phrase queries are automatically created for multi-term synonyms.'}, {name='phrase_slop', values=[0], description='Maximum number of positions allowed between matching tokens for phrases.'}, {name='default_field', values=[standard], description='Default field to search if no field is provided in the query string. Supports wildcards (*).'}, {name='allow_leading_wildcard', values=[true, false], description='If true, the wildcard characters * and ? are allowed as the first character of the query string.'}, {name='minimum_should_match', values=[standard], description='Minimum number of clauses that must match for a document to be returned.'}, {name='fuzzy_transpositions', values=[true, false], description='If true, edits for fuzzy matching include transpositions of two adjacent characters (ab → ba).'}, {name='fuzzy_prefix_length', values=[0], description='Number of beginning characters left unchanged for fuzzy matching. Defaults to 0.'}, {name='time_zone', values=[standard], description='Coordinated Universal Time (UTC) offset or IANA time zone used to convert date values in the query string to UTC.'}, {name='lenient', values=[true, false], description='If false, format-based errors, such as providing a text query value for a numeric field, are returned.'}, {name='rewrite', values=[standard], description='Method used to rewrite the query.'}, {name='default_operator', values=[OR, AND], description='Default boolean logic used to interpret text in the query string if no operators are specified.'}, {name='analyzer', values=[standard], description='Analyzer used to convert the text in the query value into token.'}, {name='fuzzy_max_expansions', values=[50], description='Maximum number of terms to which the query expands for fuzzy matching. Defaults to 50.'}, {name='quote_analyzer', values=[standard], description='Analyzer used to convert quoted text in the query string into tokens.'}, {name='allow_wildcard', values=[false, true], description='If true, the query attempts to analyze wildcard terms in the query string.'}, {name='boost', values=[2.5], description='Floating point number used to decrease or increase the relevance scores of the query.'}, {name='quote_field_suffix', values=[standard], description='Suffix appended to quoted text in the query string.'}, {name='enable_position_increments', values=[true, false], description='If true, enable position increments in queries constructed from a query_string search. Defaults to true.'}, {name='fields', values=[standard], description='Array of fields to search. Supports wildcards (*).'}", + "optional" : true, + "description" : "(Optional) Additional options for Query String as <>. See <> for more information." } ], "variadic" : false, @@ -23,6 +30,13 @@ "type" : "text", "optional" : false, "description" : "Query string in Lucene query string format." + }, + { + "name" : "options", + "type" : "function_named_parameters", + "mapParams" : "{name='max_determinized_states', values=[10000], description='Maximum number of automaton states required for the query. Default is 10000.'}, {name='fuzziness', values=[AUTO, 1, 2], description='Maximum edit distance allowed for matching.'}, {name='auto_generate_synonyms_phrase_query', values=[true, false], description='If true, match phrase queries are automatically created for multi-term synonyms.'}, {name='phrase_slop', values=[0], description='Maximum number of positions allowed between matching tokens for phrases.'}, {name='default_field', values=[standard], description='Default field to search if no field is provided in the query string. Supports wildcards (*).'}, {name='allow_leading_wildcard', values=[true, false], description='If true, the wildcard characters * and ? are allowed as the first character of the query string.'}, {name='minimum_should_match', values=[standard], description='Minimum number of clauses that must match for a document to be returned.'}, {name='fuzzy_transpositions', values=[true, false], description='If true, edits for fuzzy matching include transpositions of two adjacent characters (ab → ba).'}, {name='fuzzy_prefix_length', values=[0], description='Number of beginning characters left unchanged for fuzzy matching. Defaults to 0.'}, {name='time_zone', values=[standard], description='Coordinated Universal Time (UTC) offset or IANA time zone used to convert date values in the query string to UTC.'}, {name='lenient', values=[true, false], description='If false, format-based errors, such as providing a text query value for a numeric field, are returned.'}, {name='rewrite', values=[standard], description='Method used to rewrite the query.'}, {name='default_operator', values=[OR, AND], description='Default boolean logic used to interpret text in the query string if no operators are specified.'}, {name='analyzer', values=[standard], description='Analyzer used to convert the text in the query value into token.'}, {name='fuzzy_max_expansions', values=[50], description='Maximum number of terms to which the query expands for fuzzy matching. Defaults to 50.'}, {name='quote_analyzer', values=[standard], description='Analyzer used to convert quoted text in the query string into tokens.'}, {name='allow_wildcard', values=[false, true], description='If true, the query attempts to analyze wildcard terms in the query string.'}, {name='boost', values=[2.5], description='Floating point number used to decrease or increase the relevance scores of the query.'}, {name='quote_field_suffix', values=[standard], description='Suffix appended to quoted text in the query string.'}, {name='enable_position_increments', values=[true, false], description='If true, enable position increments in queries constructed from a query_string search. Defaults to true.'}, {name='fields', values=[standard], description='Array of fields to search. Supports wildcards (*).'}", + "optional" : true, + "description" : "(Optional) Additional options for Query String as <>. See <> for more information." } ], "variadic" : false, diff --git a/server/src/main/java/org/elasticsearch/index/query/QueryStringQueryBuilder.java b/server/src/main/java/org/elasticsearch/index/query/QueryStringQueryBuilder.java index 406642c46c809..c4a7a8f57cef4 100644 --- a/server/src/main/java/org/elasticsearch/index/query/QueryStringQueryBuilder.java +++ b/server/src/main/java/org/elasticsearch/index/query/QueryStringQueryBuilder.java @@ -61,30 +61,32 @@ public final class QueryStringQueryBuilder extends AbstractQueryBuilder> BUILDER_APPLIERS = Map.ofEntries( - entry("allow_leading_wildcard", (qb, s) -> qb.allowLeadingWildcard(Booleans.parseBoolean(s))), - entry("analyze_wildcard", (qb, s) -> qb.analyzeWildcard(Booleans.parseBoolean(s))), - entry("analyzer", QueryStringQueryBuilder::analyzer), - entry("auto_generate_synonyms_phrase_query", (qb, s) -> qb.autoGenerateSynonymsPhraseQuery(Booleans.parseBoolean(s))), - entry("default_field", QueryStringQueryBuilder::defaultField), - entry("default_operator", (qb, s) -> qb.defaultOperator(Operator.fromString(s))), - entry("enable_position_increments", (qb, s) -> qb.enablePositionIncrements(Booleans.parseBoolean(s))), - entry("escape", (qb, s) -> qb.escape(Booleans.parseBoolean(s))), - entry("fuzziness", (qb, s) -> qb.fuzziness(Fuzziness.fromString(s))), - entry("fuzzy_max_expansions", (qb, s) -> qb.fuzzyMaxExpansions(Integer.valueOf(s))), - entry("fuzzy_prefix_length", (qb, s) -> qb.fuzzyPrefixLength(Integer.valueOf(s))), - entry("fuzzy_rewrite", QueryStringQueryBuilder::fuzzyRewrite), - entry("fuzzy_transpositions", (qb, s) -> qb.fuzzyTranspositions(Booleans.parseBoolean(s))), - entry("lenient", (qb, s) -> qb.lenient(Booleans.parseBoolean(s))), - entry("max_determinized_states", (qb, s) -> qb.maxDeterminizedStates(Integer.valueOf(s))), - entry("minimum_should_match", QueryStringQueryBuilder::minimumShouldMatch), - entry("phrase_slop", (qb, s) -> qb.phraseSlop(Integer.valueOf(s))), - entry("rewrite", QueryStringQueryBuilder::rewrite), - entry("quote_analyzer", QueryStringQueryBuilder::quoteAnalyzer), - entry("quote_field_suffix", QueryStringQueryBuilder::quoteFieldSuffix), - entry("tie_breaker", (qb, s) -> qb.tieBreaker(Float.valueOf(s))), - entry("time_zone", QueryStringQueryBuilder::timeZone), - entry("type", (qb, s) -> qb.type(MultiMatchQueryBuilder.Type.parse(s, LoggingDeprecationHandler.INSTANCE))) + private static final Map> BUILDER_APPLIERS = Map.ofEntries( + entry(ALLOW_LEADING_WILDCARD_FIELD.getPreferredName(), (qb, obj) -> qb.allowLeadingWildcard((Boolean) obj)), + entry(ANALYZE_WILDCARD_FIELD.getPreferredName(), (qb, obj) -> qb.analyzeWildcard((Boolean) obj)), + entry(ANALYZER_FIELD.getPreferredName(), (qb, obj) -> qb.analyzer((String) obj)), + entry(GENERATE_SYNONYMS_PHRASE_QUERY.getPreferredName(), (qb, obj) -> qb.autoGenerateSynonymsPhraseQuery((Boolean) obj)), + entry(DEFAULT_FIELD_FIELD.getPreferredName(), (qb, obj) -> qb.defaultField((String) obj)), + entry(DEFAULT_OPERATOR_FIELD.getPreferredName(), (qb, obj) -> qb.defaultOperator(Operator.fromString((String) obj))), + entry(ENABLE_POSITION_INCREMENTS_FIELD.getPreferredName(), (qb, obj) -> qb.enablePositionIncrements((Boolean) obj)), + entry(ESCAPE_FIELD.getPreferredName(), (qb, obj) -> qb.escape((Boolean) obj)), + entry(FUZZINESS_FIELD.getPreferredName(), (qb, obj) -> qb.fuzziness(Fuzziness.fromString((String) obj))), + entry(FUZZY_MAX_EXPANSIONS_FIELD.getPreferredName(), (qb, obj) -> qb.fuzzyMaxExpansions((Integer) obj)), + entry(FUZZY_PREFIX_LENGTH_FIELD.getPreferredName(), (qb, obj) -> qb.fuzzyPrefixLength((Integer) obj)), + entry(FUZZY_REWRITE_FIELD.getPreferredName(), (qb, obj) -> qb.fuzzyRewrite((String) obj)), + entry(FUZZY_TRANSPOSITIONS_FIELD.getPreferredName(), (qb, obj) -> qb.fuzzyTranspositions((Boolean) obj)), + entry(LENIENT_FIELD.getPreferredName(), (qb, obj) -> qb.lenient((Boolean) obj)), + entry(MAX_DETERMINIZED_STATES_FIELD.getPreferredName(), (qb, obj) -> qb.maxDeterminizedStates((Integer) obj)), + entry(MINIMUM_SHOULD_MATCH_FIELD.getPreferredName(), (qb, obj) -> qb.minimumShouldMatch((String) obj)), + entry(PHRASE_SLOP_FIELD.getPreferredName(), (qb, obj) -> qb.phraseSlop((Integer) obj)), + entry(REWRITE_FIELD.getPreferredName(), (qb, obj) -> qb.rewrite((String) obj)), + entry(QUOTE_ANALYZER_FIELD.getPreferredName(), (qb, obj) -> qb.quoteAnalyzer((String) obj)), + entry(QUOTE_FIELD_SUFFIX_FIELD.getPreferredName(), (qb, obj) -> qb.quoteFieldSuffix((String) obj)), + entry(TIE_BREAKER_FIELD.getPreferredName(), (qb, obj) -> qb.tieBreaker((Float) obj)), + entry(TIME_ZONE_FIELD.getPreferredName(), (qb, obj) -> qb.timeZone((String) obj)), + entry( + TYPE_FIELD.getPreferredName(), + (qb, obj) -> qb.type(MultiMatchQueryBuilder.Type.parse((String) obj, LoggingDeprecationHandler.INSTANCE)) + ) ); private final String query; private final Map fields; - private final Map options; + private final Map options; - public QueryStringQuery(Source source, String query, Map fields, Map options) { + public QueryStringQuery(Source source, String query, Map fields, Map options) { super(source); this.query = query; this.fields = fields; diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec index 61c0f9a49e7a8..6b73e4bb774c9 100644 --- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec +++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec @@ -188,3 +188,23 @@ book_no:keyword 7140 2714 ; + +qstrWithFieldAndOptions +required_capability: qstr_function +required_capability: query_string_function_options + +// tag::qstr-with-options[] +FROM books +| WHERE QSTR("title: Hobbjt~", {"fuzziness": 2}) +| KEEP book_no, title +| SORT book_no +| LIMIT 5 +// end::qstr-with-options[] +; +ignoreOrder: true + +book_no:keyword | title:text +4289 | Poems from the Hobbit +6405 | The Hobbit or There and Back Again +7480 | The Hobbit +; diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java index 6ce8babcbfc1f..a1afdcb49ea91 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java @@ -809,6 +809,11 @@ public enum Cap { */ MATCH_FUNCTION_OPTIONS, + /** + * Support options in the query string function. + */ + QUERY_STRING_FUNCTION_OPTIONS, + /** * Support for aggregate_metric_double type */ diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java index f7683f9b78546..d88b3fe1a4ade 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java @@ -419,7 +419,7 @@ private static FunctionDefinition[][] functions() { new FunctionDefinition[] { def(Kql.class, uni(Kql::new), "kql"), def(Match.class, tri(Match::new), "match"), - def(QueryString.class, uni(QueryString::new), "qstr") } }; + def(QueryString.class, bi(QueryString::new), "qstr") } }; } diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java index 285aa3201c925..811b6ff6c6777 100644 --- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java +++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java @@ -7,31 +7,101 @@ package org.elasticsearch.xpack.esql.expression.function.fulltext; +import org.apache.lucene.util.BytesRef; import org.elasticsearch.TransportVersions; import org.elasticsearch.common.io.stream.NamedWriteableRegistry; import org.elasticsearch.common.io.stream.StreamInput; import org.elasticsearch.common.io.stream.StreamOutput; import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.xpack.esql.core.InvalidArgumentException; +import org.elasticsearch.xpack.esql.core.expression.EntryExpression; import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.MapExpression; import org.elasticsearch.xpack.esql.core.querydsl.query.Query; import org.elasticsearch.xpack.esql.core.querydsl.query.QueryStringQuery; import org.elasticsearch.xpack.esql.core.tree.NodeInfo; import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.core.type.DataTypeConverter; import org.elasticsearch.xpack.esql.expression.function.Example; import org.elasticsearch.xpack.esql.expression.function.FunctionInfo; +import org.elasticsearch.xpack.esql.expression.function.MapParam; +import org.elasticsearch.xpack.esql.expression.function.OptionalArgument; import org.elasticsearch.xpack.esql.expression.function.Param; import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput; import org.elasticsearch.xpack.esql.planner.TranslatorHandler; import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; + +import static java.util.Map.entry; +import static org.elasticsearch.common.logging.LoggerMessageFormat.format; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ALLOW_LEADING_WILDCARD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ANALYZER_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ANALYZE_WILDCARD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.BOOST_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.DEFAULT_FIELD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.DEFAULT_OPERATOR_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ENABLE_POSITION_INCREMENTS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZINESS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_MAX_EXPANSIONS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_PREFIX_LENGTH_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_TRANSPOSITIONS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.GENERATE_SYNONYMS_PHRASE_QUERY; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.LENIENT_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.MAX_DETERMINIZED_STATES_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.MINIMUM_SHOULD_MATCH_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.PHRASE_SLOP_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.QUOTE_ANALYZER_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.QUOTE_FIELD_SUFFIX_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.REWRITE_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.TIME_ZONE_FIELD; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isFoldable; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isMapExpression; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNullAndFoldable; +import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType; +import static org.elasticsearch.xpack.esql.core.type.DataType.BOOLEAN; +import static org.elasticsearch.xpack.esql.core.type.DataType.FLOAT; +import static org.elasticsearch.xpack.esql.core.type.DataType.INTEGER; +import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD; +import static org.elasticsearch.xpack.esql.core.type.DataType.SEMANTIC_TEXT; +import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT; /** * Full text function that performs a {@link QueryStringQuery} . */ -public class QueryString extends FullTextFunction { +public class QueryString extends FullTextFunction implements OptionalArgument { + + public static final Map ALLOWED_OPTIONS = Map.ofEntries( + entry(BOOST_FIELD.getPreferredName(), FLOAT), + entry(ALLOW_LEADING_WILDCARD_FIELD.getPreferredName(), BOOLEAN), + entry(ANALYZE_WILDCARD_FIELD.getPreferredName(), BOOLEAN), + entry(ANALYZER_FIELD.getPreferredName(), KEYWORD), + entry(GENERATE_SYNONYMS_PHRASE_QUERY.getPreferredName(), BOOLEAN), + entry(DEFAULT_FIELD_FIELD.getPreferredName(), KEYWORD), + entry(DEFAULT_OPERATOR_FIELD.getPreferredName(), KEYWORD), + entry(ENABLE_POSITION_INCREMENTS_FIELD.getPreferredName(), BOOLEAN), + entry(FUZZINESS_FIELD.getPreferredName(), KEYWORD), + entry(FUZZY_MAX_EXPANSIONS_FIELD.getPreferredName(), INTEGER), + entry(FUZZY_PREFIX_LENGTH_FIELD.getPreferredName(), INTEGER), + entry(FUZZY_TRANSPOSITIONS_FIELD.getPreferredName(), BOOLEAN), + entry(LENIENT_FIELD.getPreferredName(), BOOLEAN), + entry(MAX_DETERMINIZED_STATES_FIELD.getPreferredName(), INTEGER), + entry(MINIMUM_SHOULD_MATCH_FIELD.getPreferredName(), KEYWORD), + entry(QUOTE_ANALYZER_FIELD.getPreferredName(), KEYWORD), + entry(QUOTE_FIELD_SUFFIX_FIELD.getPreferredName(), KEYWORD), + entry(PHRASE_SLOP_FIELD.getPreferredName(), INTEGER), + entry(REWRITE_FIELD.getPreferredName(), KEYWORD), + entry(TIME_ZONE_FIELD.getPreferredName(), KEYWORD) + ); public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry( Expression.class, @@ -44,7 +114,9 @@ public class QueryString extends FullTextFunction { preview = true, description = "Performs a <>. " + "Returns true if the provided query string matches the row.", - examples = { @Example(file = "qstr-function", tag = "qstr-with-field") } + examples = { + @Example(file = "qstr-function", tag = "qstr-with-field"), + @Example(file = "qstr-function", tag = "qstr-with-options") } ) public QueryString( Source source, @@ -52,13 +124,157 @@ public QueryString( name = "query", type = { "keyword", "text" }, description = "Query string in Lucene query string format." - ) Expression queryString + ) Expression queryString, + @MapParam( + name = "options", + params = { + @MapParam.MapParamEntry( + name = "default_field", + type = "keyword", + valueHint = { "standard" }, + description = "Default field to search if no field is provided in the query string. Supports wildcards (*)." + ), + @MapParam.MapParamEntry( + name = "allow_leading_wildcard", + type = "boolean", + valueHint = { "true", "false" }, + description = "If true, the wildcard characters * and ? are allowed as the first character of the query string. " + + "Defaults to true." + ), + @MapParam.MapParamEntry( + name = "allow_wildcard", + type = "boolean", + valueHint = { "false", "true" }, + description = "If true, the query attempts to analyze wildcard terms in the query string. Defaults to false. " + ), + @MapParam.MapParamEntry( + name = "analyzer", + type = "keyword", + valueHint = { "standard" }, + description = "Analyzer used to convert the text in the query value into token. " + + "Defaults to the index-time analyzer mapped for the default_field." + ), + @MapParam.MapParamEntry( + name = "auto_generate_synonyms_phrase_query", + type = "boolean", + valueHint = { "true", "false" }, + description = "If true, match phrase queries are automatically created for multi-term synonyms. Defaults to true." + ), + @MapParam.MapParamEntry( + name = "fuzziness", + type = "keyword", + valueHint = { "AUTO", "1", "2" }, + description = "Maximum edit distance allowed for matching." + ), + @MapParam.MapParamEntry( + name = "boost", + type = "float", + valueHint = { "2.5" }, + description = "Floating point number used to decrease or increase the relevance scores of the query." + ), + @MapParam.MapParamEntry( + name = "default_operator", + type = "keyword", + valueHint = { "OR", "AND" }, + description = "Default boolean logic used to interpret text in the query string if no operators are specified." + ), + @MapParam.MapParamEntry( + name = "enable_position_increments", + type = "boolean", + valueHint = { "true", "false" }, + description = "If true, enable position increments in queries constructed from a query_string search. Defaults to true." + ), + @MapParam.MapParamEntry( + name = "fields", + type = "keyword", + valueHint = { "standard" }, + description = "Array of fields to search. Supports wildcards (*)." + ), + @MapParam.MapParamEntry( + name = "fuzzy_max_expansions", + type = "integer", + valueHint = { "50" }, + description = "Maximum number of terms to which the query expands for fuzzy matching. Defaults to 50." + ), + @MapParam.MapParamEntry( + name = "fuzzy_prefix_length", + type = "integer", + valueHint = { "0" }, + description = "Number of beginning characters left unchanged for fuzzy matching. Defaults to 0." + ), + @MapParam.MapParamEntry( + name = "fuzzy_transpositions", + type = "boolean", + valueHint = { "true", "false" }, + description = "If true, edits for fuzzy matching include transpositions of two adjacent characters (ab → ba). " + + "Defaults to true." + ), + @MapParam.MapParamEntry( + name = "lenient", + type = "boolean", + valueHint = { "true", "false" }, + description = "If false, format-based errors, such as providing a text query value for a numeric field, are returned. " + + "Defaults to false." + ), + @MapParam.MapParamEntry( + name = "max_determinized_states", + type = "integer", + valueHint = { "10000" }, + description = "Maximum number of automaton states required for the query. Default is 10000." + ), + @MapParam.MapParamEntry( + name = "minimum_should_match", + type = "string", + valueHint = { "standard" }, + description = "Minimum number of clauses that must match for a document to be returned." + ), + @MapParam.MapParamEntry( + name = "quote_analyzer", + type = "keyword", + valueHint = { "standard" }, + description = "Analyzer used to convert quoted text in the query string into tokens. " + + "Defaults to the search_quote_analyzer mapped for the default_field." + ), + @MapParam.MapParamEntry( + name = "phrase_slop", + type = "integer", + valueHint = { "0" }, + description = "Maximum number of positions allowed between matching tokens for phrases. " + + "Defaults to 0 (which means exact matches are required)." + ), + @MapParam.MapParamEntry( + name = "quote_field_suffix", + type = "keyword", + valueHint = { "standard" }, + description = "Suffix appended to quoted text in the query string." + ), + @MapParam.MapParamEntry( + name = "rewrite", + type = "keyword", + valueHint = { "standard" }, + description = "Method used to rewrite the query." + ), + @MapParam.MapParamEntry( + name = "time_zone", + type = "keyword", + valueHint = { "standard" }, + description = "Coordinated Universal Time (UTC) offset or IANA time zone used to convert date values in the " + + "query string to UTC." + ), }, + description = "(Optional) Additional options for Query String as <>." + + " See <> for more information.", + optional = true + ) Expression options ) { - super(source, queryString, List.of(queryString), null); + this(source, queryString, options, null); } - public QueryString(Source source, Expression queryString, QueryBuilder queryBuilder) { - super(source, queryString, List.of(queryString), queryBuilder); + // Options for QueryString. They don't need to be serialized as the data nodes will retrieve them from the query builder. + private final transient Expression options; + + public QueryString(Source source, Expression queryString, Expression options, QueryBuilder queryBuilder) { + super(source, queryString, options == null ? List.of(queryString) : List.of(queryString, options), queryBuilder); + this.options = options; } private static QueryString readFrom(StreamInput in) throws IOException { @@ -68,7 +284,7 @@ private static QueryString readFrom(StreamInput in) throws IOException { if (in.getTransportVersion().onOrAfter(TransportVersions.ESQL_QUERY_BUILDER_IN_SEARCH_FUNCTIONS)) { queryBuilder = in.readOptionalNamedWriteable(QueryBuilder.class); } - return new QueryString(source, query, queryBuilder); + return new QueryString(source, query, null, queryBuilder); } @Override @@ -90,23 +306,111 @@ public String functionName() { return "QSTR"; } + public Expression options() { + return options; + } + + public static final Set QUERY_DATA_TYPES = Set.of(KEYWORD, TEXT, SEMANTIC_TEXT); + + private TypeResolution resolveQuery() { + return isType(query(), QUERY_DATA_TYPES::contains, sourceText(), FIRST, "keyword, text, semantic_text").and( + isNotNullAndFoldable(query(), sourceText(), FIRST) + ); + } + + private Map queryStringOptions() throws InvalidArgumentException { + if (options() == null) { + return null; + } + + Map matchOptions = new HashMap<>(); + for (EntryExpression entry : ((MapExpression) options()).entryExpressions()) { + Expression optionExpr = entry.key(); + Expression valueExpr = entry.value(); + TypeResolution resolution = isFoldable(optionExpr, sourceText(), SECOND).and(isFoldable(valueExpr, sourceText(), SECOND)); + if (resolution.unresolved()) { + throw new InvalidArgumentException(resolution.message()); + } + Object optionExprLiteral = ((Literal) optionExpr).value(); + Object valueExprLiteral = ((Literal) valueExpr).value(); + String optionName = optionExprLiteral instanceof BytesRef br ? br.utf8ToString() : optionExprLiteral.toString(); + String optionValue = valueExprLiteral instanceof BytesRef br ? br.utf8ToString() : valueExprLiteral.toString(); + // validate the optionExpr is supported + DataType dataType = ALLOWED_OPTIONS.get(optionName); + if (dataType == null) { + throw new InvalidArgumentException( + format(null, "Invalid option [{}] in [{}], expected one of {}", optionName, sourceText(), ALLOWED_OPTIONS.keySet()) + ); + } + try { + matchOptions.put(optionName, DataTypeConverter.convert(optionValue, dataType)); + } catch (InvalidArgumentException e) { + throw new InvalidArgumentException( + format(null, "Invalid option [{}] in [{}], {}", optionName, sourceText(), e.getMessage()) + ); + } + } + + return matchOptions; + } + + private TypeResolution resolveOptions() { + if (options() != null) { + TypeResolution resolution = isNotNull(options(), sourceText(), SECOND); + if (resolution.unresolved()) { + return resolution; + } + // MapExpression does not have a DataType associated with it + resolution = isMapExpression(options(), sourceText(), SECOND); + if (resolution.unresolved()) { + return resolution; + } + + try { + queryStringOptions(); + } catch (InvalidArgumentException e) { + return new TypeResolution(e.getMessage()); + } + } + return TypeResolution.TYPE_RESOLVED; + } + + @Override + protected TypeResolution resolveParams() { + return resolveQuery().and(resolveOptions()); + } + @Override public Expression replaceChildren(List newChildren) { - return new QueryString(source(), newChildren.get(0), queryBuilder()); + return new QueryString(source(), newChildren.getFirst(), newChildren.size() == 1 ? null : newChildren.get(1), queryBuilder()); } @Override protected NodeInfo info() { - return NodeInfo.create(this, QueryString::new, query(), queryBuilder()); + return NodeInfo.create(this, QueryString::new, query(), options(), queryBuilder()); } @Override protected Query translate(TranslatorHandler handler) { - return new QueryStringQuery(source(), Objects.toString(queryAsObject()), Map.of(), Map.of()); + return new QueryStringQuery(source(), Objects.toString(queryAsObject()), Map.of(), queryStringOptions()); } @Override public Expression replaceQueryBuilder(QueryBuilder queryBuilder) { - return new QueryString(source(), query(), queryBuilder); + return new QueryString(source(), query(), options(), queryBuilder); + } + + @Override + public boolean equals(Object o) { + // QueryString does not serialize options, as they get included in the query builder. We need to override equals and hashcode to + // ignore options when comparing. + if (o == null || getClass() != o.getClass()) return false; + var qstr = (QueryString) o; + return Objects.equals(query(), qstr.query()) && Objects.equals(queryBuilder(), qstr.queryBuilder()); + } + + @Override + public int hashCode() { + return Objects.hash(query(), queryBuilder()); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java index b63a41faa9006..07a1b6054fe8a 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/AnalyzerTests.java @@ -42,6 +42,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.Min; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; import org.elasticsearch.xpack.esql.expression.function.fulltext.MatchOperator; +import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.Equals; import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThan; import org.elasticsearch.xpack.esql.index.EsIndex; @@ -2623,6 +2624,22 @@ public void testFunctionNamedParamsAsFunctionArgument() { assertEquals(DataType.DOUBLE, ee.dataType()); } + public void testFunctionNamedParamsAsFunctionArgument1() { + LogicalPlan plan = analyze(""" + from test + | WHERE QSTR("first_name: Anna", {"minimum_should_match": 3.0}) + """); + Limit limit = as(plan, Limit.class); + Filter filter = as(limit.child(), Filter.class); + QueryString qstr = as(filter.condition(), QueryString.class); + MapExpression me = as(qstr.options(), MapExpression.class); + assertEquals(1, me.entryExpressions().size()); + EntryExpression ee = as(me.entryExpressions().get(0), EntryExpression.class); + assertEquals(new Literal(EMPTY, "minimum_should_match", DataType.KEYWORD), ee.key()); + assertEquals(new Literal(EMPTY, 3.0, DataType.DOUBLE), ee.value()); + assertEquals(DataType.DOUBLE, ee.dataType()); + } + public void testResolveInsist_fieldExists_insistedOutputContainsNoUnmappedFields() { assumeTrue("Requires UNMAPPED FIELDS", EsqlCapabilities.Cap.UNMAPPED_FIELDS.isEnabled()); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java index 76e63aa853e2b..1bf5e76101aed 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java @@ -20,6 +20,7 @@ import org.elasticsearch.xpack.esql.core.type.InvalidMappedField; import org.elasticsearch.xpack.esql.core.type.UnsupportedEsField; import org.elasticsearch.xpack.esql.expression.function.fulltext.Match; +import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString; import org.elasticsearch.xpack.esql.index.EsIndex; import org.elasticsearch.xpack.esql.index.IndexResolution; import org.elasticsearch.xpack.esql.parser.EsqlParser; @@ -1409,10 +1410,10 @@ private void checkFullTextFunctionsOnlyAllowedInWhere(String functionName, Strin public void testQueryStringFunctionArgNotNullOrConstant() throws Exception { assertEquals( - "1:19: argument of [qstr(first_name)] must be a constant, received [first_name]", + "1:19: first argument of [qstr(first_name)] must be a constant, received [first_name]", error("from test | where qstr(first_name)") ); - assertEquals("1:19: argument of [qstr(null)] cannot be null, received [null]", error("from test | where qstr(null)")); + assertEquals("1:19: first argument of [qstr(null)] cannot be null, received [null]", error("from test | where qstr(null)")); // Other value types are tested in QueryStringFunctionTests } @@ -2209,6 +2210,84 @@ public void testMatchOptions() { ); } + public void testQueryStringOptions() { + // Check positive cases + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"analyzer\": \"standard\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"allow_leading_wildcard\": false})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"analyze_wildcard\": false})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"auto_generate_synonyms_phrase_query\": true})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"boost\": 2.1})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"default_field\": \"field1\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"default_operator\": \"AND\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"enable_position_increments\": false})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"fuzziness\": 2})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"fuzziness\": \"AUTO\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"fuzzy_prefix_length\": 5})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"fuzzy_transpositions\": false})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"lenient\": false})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"max_determinized_states\": 10})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"minimum_should_match\": \"2\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"quote_analyzer\": \"qnalyzer_1\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"quote_field_suffix\": \"q_suffix\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"phrase_slop\": 10})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"rewrite\": \"r1\"})"); + query("FROM test | WHERE QSTR(\"first_name: Jean\", {\"time_zone\": \"time_zone\"})"); + + // Check all data types for available options + DataType[] optionTypes = new DataType[] { INTEGER, LONG, FLOAT, DOUBLE, KEYWORD, BOOLEAN }; + for (Map.Entry allowedOptions : QueryString.ALLOWED_OPTIONS.entrySet()) { + String optionName = allowedOptions.getKey(); + DataType optionType = allowedOptions.getValue(); + // Check every possible type for the option - we'll try to convert it to the expected type + for (DataType currentType : optionTypes) { + String optionValue = switch (currentType) { + case BOOLEAN -> String.valueOf(randomBoolean()); + case INTEGER -> String.valueOf(randomIntBetween(0, 100000)); + case LONG -> String.valueOf(randomLong()); + case FLOAT -> String.valueOf(randomFloat()); + case DOUBLE -> String.valueOf(randomDouble()); + case KEYWORD -> randomAlphaOfLength(10); + default -> throw new IllegalArgumentException("Unsupported option type: " + currentType); + }; + String queryOptionValue = optionValue; + if (currentType == KEYWORD) { + queryOptionValue = "\"" + optionValue + "\""; + } + + String query = "FROM test | WHERE QSTR(\"first_name: Jean\", {\"" + optionName + "\": " + queryOptionValue + "})"; + try { + // Check conversion is possible + DataTypeConverter.convert(optionValue, optionType); + // If no exception was thrown, conversion is possible and should be done + query(query); + } catch (InvalidArgumentException e) { + // Conversion is not possible, query should fail + assertEquals( + "1:19: Invalid option [" + + optionName + + "] in [QSTR(\"first_name: Jean\", {\"" + + optionName + + "\": " + + queryOptionValue + + "})], cannot cast [" + + optionValue + + "] to [" + + optionType.typeName() + + "]", + error(query) + ); + } + } + } + + assertThat( + error("FROM test | WHERE QSTR(\"first_name: Jean\", {\"unknown_option\": true})"), + containsString( + "1:20: Invalid option [unknown_option] in [QSTR(\"first_name: Jean\", {\"unknown_option\": true})]," + " expected one of " + ) + ); + } + public void testInsistNotOnTopOfFrom() { assumeTrue("requires snapshot builds", Build.current().isSnapshot()); diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java index d528ee0a92de2..4bc228a892d08 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/NoneFieldFullTextFunctionTestCase.java @@ -26,12 +26,12 @@ public NoneFieldFullTextFunctionTestCase(Supplier tes this.testCase = testCaseSupplier.get(); } - public final void testFold() { + public void testFold() { Expression expression = buildLiteralExpression(testCase); assertFalse("expected resolved", expression.typeResolved().unresolved()); } - protected static Iterable generateParameters() { + protected static List getStringTestSupplier() { List suppliers = new LinkedList<>(); for (DataType strType : DataType.stringTypes()) { suppliers.add( @@ -42,7 +42,11 @@ protected static Iterable generateParameters() { ) ); } - return parameterSuppliersFromTypedData(suppliers); + return suppliers; + } + + protected static Iterable generateParameters() { + return parameterSuppliersFromTypedData(getStringTestSupplier()); } private static TestCaseSupplier.TestCase testCase(DataType strType, String str, Matcher matcher) { diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringErrorTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringErrorTests.java index b55543a0433c3..0f6fa626c0948 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringErrorTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringErrorTests.java @@ -8,16 +8,21 @@ package org.elasticsearch.xpack.esql.expression.function.fulltext; import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.TypeResolutions; import org.elasticsearch.xpack.esql.core.tree.Source; import org.elasticsearch.xpack.esql.core.type.DataType; +import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase; import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase; import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison; import org.hamcrest.Matcher; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.stream.Stream; +import static org.elasticsearch.common.logging.LoggerMessageFormat.format; import static org.hamcrest.Matchers.equalTo; public class QueryStringErrorTests extends ErrorsForCasesWithoutExamplesTestCase { @@ -34,11 +39,42 @@ protected Stream> testCandidates(List cases, Se @Override protected Expression build(Source source, List args) { - return new QueryString(source, args.get(0)); + return new QueryString(source, args.getFirst(), args.size() > 1 ? args.get(1) : null); } @Override protected Matcher expectedTypeErrorMatcher(List> validPerPosition, List signature) { - return equalTo(typeErrorMessage(false, validPerPosition, signature, (v, p) -> "string")); + return equalTo(errorMessageStringForMatch(validPerPosition, signature, (l, p) -> "keyword, text, semantic_text")); + } + + private static String errorMessageStringForMatch( + List> validPerPosition, + List signature, + AbstractFunctionTestCase.PositionalErrorMessageSupplier positionalErrorMessageSupplier + ) { + boolean invalid = false; + for (int i = 0; i < signature.size() && invalid == false; i++) { + // Need to check for nulls and bad parameters in order + if (signature.get(i) == DataType.NULL) { + return TypeResolutions.ParamOrdinal.fromIndex(i).name().toLowerCase(Locale.ROOT) + + " argument of [" + + sourceForSignature(signature) + + "] cannot be null, received []"; + } + if (validPerPosition.get(i).contains(signature.get(i)) == false) { + // Map expressions have different error messages + if (i == 1) { + return format(null, "second argument of [{}] must be a map expression, received []", sourceForSignature(signature)); + } + break; + } + } + + try { + return typeErrorMessage(true, validPerPosition, signature, positionalErrorMessageSupplier); + } catch (IllegalStateException e) { + // This means all the positional args were okay, so the expected error is for nulls or from the combination + return EsqlBinaryComparison.formatIncompatibleTypesMessage(signature.get(0), signature.get(1), sourceForSignature(signature)); + } } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java index f573e59ab205a..958e3c81ff04d 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java @@ -10,14 +10,27 @@ import com.carrotsearch.randomizedtesting.annotations.Name; import com.carrotsearch.randomizedtesting.annotations.ParametersFactory; +import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.xpack.esql.core.expression.Expression; +import org.elasticsearch.xpack.esql.core.expression.Literal; +import org.elasticsearch.xpack.esql.core.expression.MapExpression; import org.elasticsearch.xpack.esql.core.tree.Source; +import org.elasticsearch.xpack.esql.core.type.DataType; import org.elasticsearch.xpack.esql.expression.function.FunctionName; import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier; +import org.elasticsearch.xpack.esql.io.stream.PlanStreamOutput; +import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; +import static org.elasticsearch.xpack.esql.SerializationTestUtils.serializeDeserialize; +import static org.elasticsearch.xpack.esql.core.type.DataType.BOOLEAN; +import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD; +import static org.elasticsearch.xpack.esql.core.type.DataType.UNSUPPORTED; +import static org.elasticsearch.xpack.esql.planner.TranslatorHandler.TRANSLATOR_HANDLER; +import static org.hamcrest.Matchers.equalTo; + @FunctionName("qstr") public class QueryStringTests extends NoneFieldFullTextFunctionTestCase { @@ -27,11 +40,68 @@ public QueryStringTests(@Name("TestCase") Supplier te @ParametersFactory public static Iterable parameters() { - return generateParameters(); + return parameterSuppliersFromTypedData(addFunctionNamedParams(getStringTestSupplier())); + } + + /** + * Adds function named parameters to all the test case suppliers provided + */ + private static List addFunctionNamedParams(List suppliers) { + List result = new ArrayList<>(); + for (TestCaseSupplier supplier : suppliers) { + List dataTypes = new ArrayList<>(supplier.types()); + dataTypes.add(UNSUPPORTED); + result.add(new TestCaseSupplier(supplier.name() + ", options", dataTypes, () -> { + List values = new ArrayList<>(supplier.get().getData()); + values.add( + new TestCaseSupplier.TypedData( + new MapExpression( + Source.EMPTY, + List.of( + new Literal(Source.EMPTY, "default_field", KEYWORD), + new Literal(Source.EMPTY, randomAlphaOfLength(10), KEYWORD) + ) + ), + UNSUPPORTED, + "options" + ).forceLiteral() + ); + + return new TestCaseSupplier.TestCase(values, equalTo("MatchEvaluator"), BOOLEAN, equalTo(true)); + })); + } + return result; } @Override protected Expression build(Source source, List args) { - return new QueryString(source, args.get(0)); + var qstr = new QueryString(source, args.get(0), args.size() > 1 ? args.get(1) : null); + // We need to add the QueryBuilder to the match expression, as it is used to implement equals() and hashCode() and + // thus test the serialization methods. But we can only do this if the parameters make sense . + if (args.get(0).foldable()) { + QueryBuilder queryBuilder = TRANSLATOR_HANDLER.asQuery(qstr).asBuilder(); + qstr.replaceQueryBuilder(queryBuilder); + } + return qstr; + } + + @Override + public void testFold() { + // Query string cannot be folded. + } + + /** + * Copy of the overridden method that doesn't check for children size, as the {@code options} child isn't serialized for Query String. + */ + @Override + protected Expression serializeDeserializeExpression(Expression expression) { + Expression newExpression = serializeDeserialize( + expression, + PlanStreamOutput::writeNamedWriteable, + in -> in.readNamedWriteable(Expression.class), + testCase.getConfiguration() // The configuration query should be == to the source text of the function for this to work + ); + // Fields use synthetic sources, which can't be serialized. So we use the originals instead. + return newExpression.replaceChildren(expression.children()); } } diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java index 8bdd7a4e1645f..d894c8182021c 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java @@ -87,6 +87,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Function; @@ -1611,6 +1612,43 @@ public void testMatchOptionsPushDown() { assertThat(actualLuceneQuery.toString(), is(expectedLuceneQuery.toString())); } + public void testQStrOptionsPushDown() { + String query = """ + from test + | where QSTR("first_name: Anna", {"allow_leading_wildcard": "true", "analyze_wildcard": "true", + "analyzer": "auto", "auto_generate_synonyms_phrase_query": "false", "default_field": "test", "default_operator": "AND", + "enable_position_increments": "true", "fuzziness": "auto", "fuzzy_max_expansions": 4, "fuzzy_prefix_length": 3, + "fuzzy_transpositions": "true", "lenient": "false", "max_determinized_states": 10, "minimum_should_match": 3, + "quote_analyzer": "q_analyzer", "quote_field_suffix": "q_field_suffix", "phrase_slop": 20, "rewrite": "fuzzy", + "time_zone": "America/Los_Angeles"}) + """; + var plan = plannerOptimizer.plan(query); + + AtomicReference planStr = new AtomicReference<>(); + plan.forEachDown(EsQueryExec.class, result -> planStr.set(result.query().toString())); + + var expectedQStrQuery = new QueryStringQueryBuilder("first_name: Anna").allowLeadingWildcard(true) + .analyzeWildcard(true) + .analyzer("auto") + .autoGenerateSynonymsPhraseQuery(false) + .defaultField("test") + .defaultOperator(Operator.fromString("AND")) + .enablePositionIncrements(true) + .fuzziness(Fuzziness.fromString("auto")) + .fuzzyPrefixLength(3) + .fuzzyMaxExpansions(4) + .fuzzyTranspositions(true) + .lenient(false) + .maxDeterminizedStates(10) + .minimumShouldMatch("3") + .quoteAnalyzer("q_analyzer") + .quoteFieldSuffix("q_field_suffix") + .phraseSlop(20) + .rewrite("fuzzy") + .timeZone("America/Los_Angeles"); + assertThat(expectedQStrQuery.toString(), is(planStr.get())); + } + /** * Expecting * LimitExec[1000[INTEGER]] diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/QueryStringQueryTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/QueryStringQueryTests.java index 3114b852aac70..9777f6e996dfe 100644 --- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/QueryStringQueryTests.java +++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/querydsl/query/QueryStringQueryTests.java @@ -22,10 +22,10 @@ public class QueryStringQueryTests extends ESTestCase { public void testQueryBuilding() { - QueryStringQueryBuilder qb = getBuilder(Map.of("lenient", "true")); + QueryStringQueryBuilder qb = getBuilder(Map.of("lenient", true)); assertThat(qb.lenient(), equalTo(true)); - qb = getBuilder(Map.of("lenient", "true", "default_operator", "AND")); + qb = getBuilder(Map.of("lenient", true, "default_operator", "AND")); assertThat(qb.lenient(), equalTo(true)); assertThat(qb.defaultOperator(), equalTo(Operator.AND)); @@ -36,7 +36,7 @@ public void testQueryBuilding() { assertThat(e.getMessage(), equalTo("failed to parse [multi_match] query type [aoeu]. unknown type.")); } - private static QueryStringQueryBuilder getBuilder(Map options) { + private static QueryStringQueryBuilder getBuilder(Map options) { final Source source = new Source(1, 1, StringUtils.EMPTY); final QueryStringQuery query = new QueryStringQuery(source, "eggplant", Collections.singletonMap("foo", 1.0f), options); return (QueryStringQueryBuilder) query.asBuilder(); diff --git a/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/querydsl/query/QueryStringQuery.java b/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/querydsl/query/QueryStringQuery.java index 39c179ee41691..1b72f346238a8 100644 --- a/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/querydsl/query/QueryStringQuery.java +++ b/x-pack/plugin/ql/src/main/java/org/elasticsearch/xpack/ql/querydsl/query/QueryStringQuery.java @@ -23,44 +23,66 @@ import java.util.function.BiConsumer; import static java.util.Map.entry; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ALLOW_LEADING_WILDCARD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ANALYZER_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ANALYZE_WILDCARD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.DEFAULT_FIELD_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.DEFAULT_OPERATOR_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ENABLE_POSITION_INCREMENTS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.ESCAPE_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZINESS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_MAX_EXPANSIONS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_PREFIX_LENGTH_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_REWRITE_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.FUZZY_TRANSPOSITIONS_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.GENERATE_SYNONYMS_PHRASE_QUERY; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.LENIENT_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.MAX_DETERMINIZED_STATES_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.MINIMUM_SHOULD_MATCH_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.PHRASE_SLOP_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.QUOTE_ANALYZER_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.QUOTE_FIELD_SUFFIX_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.REWRITE_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.TIE_BREAKER_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.TIME_ZONE_FIELD; +import static org.elasticsearch.index.query.QueryStringQueryBuilder.TYPE_FIELD; public class QueryStringQuery extends LeafQuery { - // TODO: it'd be great if these could be constants instead of Strings, needs a core change to make the fields public first private static final Map> BUILDER_APPLIERS = Map.ofEntries( - entry("allow_leading_wildcard", (qb, s) -> qb.allowLeadingWildcard(Booleans.parseBoolean(s))), - entry("analyze_wildcard", (qb, s) -> qb.analyzeWildcard(Booleans.parseBoolean(s))), - entry("analyzer", QueryStringQueryBuilder::analyzer), - entry("auto_generate_synonyms_phrase_query", (qb, s) -> qb.autoGenerateSynonymsPhraseQuery(Booleans.parseBoolean(s))), - entry("default_field", QueryStringQueryBuilder::defaultField), - entry("default_operator", (qb, s) -> qb.defaultOperator(Operator.fromString(s))), - entry("enable_position_increments", (qb, s) -> qb.enablePositionIncrements(Booleans.parseBoolean(s))), - entry("escape", (qb, s) -> qb.escape(Booleans.parseBoolean(s))), - entry("fuzziness", (qb, s) -> qb.fuzziness(Fuzziness.fromString(s))), - entry("fuzzy_max_expansions", (qb, s) -> qb.fuzzyMaxExpansions(Integer.valueOf(s))), - entry("fuzzy_prefix_length", (qb, s) -> qb.fuzzyPrefixLength(Integer.valueOf(s))), - entry("fuzzy_rewrite", QueryStringQueryBuilder::fuzzyRewrite), - entry("fuzzy_transpositions", (qb, s) -> qb.fuzzyTranspositions(Booleans.parseBoolean(s))), - entry("lenient", (qb, s) -> qb.lenient(Booleans.parseBoolean(s))), - entry("max_determinized_states", (qb, s) -> qb.maxDeterminizedStates(Integer.valueOf(s))), - entry("minimum_should_match", QueryStringQueryBuilder::minimumShouldMatch), - entry("phrase_slop", (qb, s) -> qb.phraseSlop(Integer.valueOf(s))), - entry("rewrite", QueryStringQueryBuilder::rewrite), - entry("quote_analyzer", QueryStringQueryBuilder::quoteAnalyzer), - entry("quote_field_suffix", QueryStringQueryBuilder::quoteFieldSuffix), - entry("tie_breaker", (qb, s) -> qb.tieBreaker(Float.valueOf(s))), - entry("time_zone", QueryStringQueryBuilder::timeZone), - entry("type", (qb, s) -> qb.type(MultiMatchQueryBuilder.Type.parse(s, LoggingDeprecationHandler.INSTANCE))) + entry(ALLOW_LEADING_WILDCARD_FIELD.getPreferredName(), (qb, s) -> qb.allowLeadingWildcard(Booleans.parseBoolean(s))), + entry(ANALYZE_WILDCARD_FIELD.getPreferredName(), (qb, s) -> qb.analyzeWildcard(Booleans.parseBoolean(s))), + entry(ANALYZER_FIELD.getPreferredName(), QueryStringQueryBuilder::analyzer), + entry(GENERATE_SYNONYMS_PHRASE_QUERY.getPreferredName(), (qb, s) -> qb.autoGenerateSynonymsPhraseQuery(Booleans.parseBoolean(s))), + entry(DEFAULT_FIELD_FIELD.getPreferredName(), QueryStringQueryBuilder::defaultField), + entry(DEFAULT_OPERATOR_FIELD.getPreferredName(), (qb, s) -> qb.defaultOperator(Operator.fromString(s))), + entry(ENABLE_POSITION_INCREMENTS_FIELD.getPreferredName(), (qb, s) -> qb.enablePositionIncrements(Booleans.parseBoolean(s))), + entry(ESCAPE_FIELD.getPreferredName(), (qb, s) -> qb.escape(Booleans.parseBoolean(s))), + entry(FUZZINESS_FIELD.getPreferredName(), (qb, s) -> qb.fuzziness(Fuzziness.fromString(s))), + entry(FUZZY_MAX_EXPANSIONS_FIELD.getPreferredName(), (qb, s) -> qb.fuzzyMaxExpansions(Integer.parseInt(s))), + entry(FUZZY_PREFIX_LENGTH_FIELD.getPreferredName(), (qb, s) -> qb.fuzzyPrefixLength(Integer.parseInt(s))), + entry(FUZZY_REWRITE_FIELD.getPreferredName(), QueryStringQueryBuilder::fuzzyRewrite), + entry(FUZZY_TRANSPOSITIONS_FIELD.getPreferredName(), (qb, s) -> qb.fuzzyTranspositions(Booleans.parseBoolean(s))), + entry(LENIENT_FIELD.getPreferredName(), (qb, s) -> qb.lenient(Booleans.parseBoolean(s))), + entry(MAX_DETERMINIZED_STATES_FIELD.getPreferredName(), (qb, s) -> qb.maxDeterminizedStates(Integer.parseInt(s))), + entry(MINIMUM_SHOULD_MATCH_FIELD.getPreferredName(), QueryStringQueryBuilder::minimumShouldMatch), + entry(PHRASE_SLOP_FIELD.getPreferredName(), (qb, s) -> qb.phraseSlop(Integer.parseInt(s))), + entry(REWRITE_FIELD.getPreferredName(), QueryStringQueryBuilder::rewrite), + entry(QUOTE_ANALYZER_FIELD.getPreferredName(), QueryStringQueryBuilder::quoteAnalyzer), + entry(QUOTE_FIELD_SUFFIX_FIELD.getPreferredName(), QueryStringQueryBuilder::quoteFieldSuffix), + entry(TIE_BREAKER_FIELD.getPreferredName(), (qb, s) -> qb.tieBreaker(Float.parseFloat(s))), + entry(TIME_ZONE_FIELD.getPreferredName(), QueryStringQueryBuilder::timeZone), + entry(TYPE_FIELD.getPreferredName(), (qb, s) -> qb.type(MultiMatchQueryBuilder.Type.parse(s, LoggingDeprecationHandler.INSTANCE))) ); private final String query; private final Map fields; - private StringQueryPredicate predicate; + private final StringQueryPredicate predicate; private final Map options; // dedicated constructor for QueryTranslator public QueryStringQuery(Source source, String query, String fieldName) { - this(source, query, Collections.singletonMap(fieldName, Float.valueOf(1.0f)), null); + this(source, query, Collections.singletonMap(fieldName, 1.0f), null); } public QueryStringQuery(Source source, String query, Map fields, StringQueryPredicate predicate) {