Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
whereSimpleFullTextSearch
required_capability: multi_match_function
required_capability: match_function
from employees | where "Eberhardt" | keep emp_no, first_name;

emp_no:integer | first_name:keyword
10013 | Eberhardt
;
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ public void testScoreInWhereWithFilter() {
""";

var error = expectThrows(VerificationException.class, () -> run(query));
assertThat(error.getMessage(), containsString("Condition expression needs to be boolean, found [DOUBLE]"));
assertThat(
error.getMessage(),
containsString("Condition expression needs to be a boolean for conditions or a string for full text search, found [DOUBLE]")
);
}

public void testScoreNonFullTextFunction() {
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugin/esql/src/main/antlr/EsqlBaseParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ processingCommand
;

whereCommand
: WHERE booleanExpression
: WHERE string #whereMatchStringExpression
| WHERE booleanExpression #whereBooleanExpression
;

dataType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.SumOverTime;
import org.elasticsearch.xpack.esql.expression.function.aggregate.SummationMode;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Values;
import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextSearch;
import org.elasticsearch.xpack.esql.expression.function.fulltext.MultiMatch;
import org.elasticsearch.xpack.esql.expression.function.grouping.GroupingFunction;
import org.elasticsearch.xpack.esql.expression.function.inference.InferenceFunction;
import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction;
Expand Down Expand Up @@ -108,6 +110,7 @@
import org.elasticsearch.xpack.esql.plan.logical.Enrich;
import org.elasticsearch.xpack.esql.plan.logical.EsRelation;
import org.elasticsearch.xpack.esql.plan.logical.Eval;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.Fork;
import org.elasticsearch.xpack.esql.plan.logical.InlineStats;
import org.elasticsearch.xpack.esql.plan.logical.Insist;
Expand Down Expand Up @@ -161,6 +164,7 @@

import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static java.util.stream.Collectors.toList;
import static org.elasticsearch.xpack.core.enrich.EnrichPolicy.GEO_MATCH_TYPE;
import static org.elasticsearch.xpack.esql.core.type.DataType.AGGREGATE_METRIC_DOUBLE;
import static org.elasticsearch.xpack.esql.core.type.DataType.BOOLEAN;
Expand All @@ -181,6 +185,7 @@
import static org.elasticsearch.xpack.esql.core.type.DataType.TIME_DURATION;
import static org.elasticsearch.xpack.esql.core.type.DataType.UNSUPPORTED;
import static org.elasticsearch.xpack.esql.core.type.DataType.VERSION;
import static org.elasticsearch.xpack.esql.core.type.DataType.isString;
import static org.elasticsearch.xpack.esql.core.type.DataType.isTemporalAmount;
import static org.elasticsearch.xpack.esql.telemetry.FeatureMetric.LIMIT;
import static org.elasticsearch.xpack.esql.telemetry.FeatureMetric.STATS;
Expand All @@ -206,7 +211,8 @@ public class Analyzer extends ParameterizedRuleExecutor<LogicalPlan, AnalyzerCon
new ResolveLookupTables(),
new ResolveFunctions(),
new ResolveInference(),
new DateMillisToNanosInEsRelation()
new DateMillisToNanosInEsRelation(),
new ResolveFullTextSearch()
),
new Batch<>(
"Resolution",
Expand Down Expand Up @@ -2263,4 +2269,30 @@ private static AggregateMetricDoubleBlockBuilder.Metric getMetric(AggregateFunct
return null;
}
}

private static class ResolveFullTextSearch extends ParameterizedAnalyzerRule<Filter, AnalyzerContext> {
@Override
protected LogicalPlan rule(Filter filter, AnalyzerContext context) {
Expression condition = filter.condition();

// Replace every instance of FullTextSearch
if (condition instanceof FullTextSearch fts) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively we could check, if MultiMatch contains one field, which is an instance of UnresolvedStar instead of introducing a separate "marker class" FullTextSearch.

LogicalPlan plan = filter.child();
List<Expression> textFields = findTextFields(plan);
Expression multiMatch = new MultiMatch(fts.source(), fts.query(), textFields, null);

return new Filter(filter.source(), plan, multiMatch);
}

return filter;
}

private List<Expression> findTextFields(LogicalPlan child) {
return child.output()
.stream()
.filter(attr -> attr instanceof FieldAttribute)
.filter(attr -> isString(attr.dataType()))
.collect(toList());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.expression.function.fulltext;

import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.UnresolvedStar;
import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.optimizer.rules.physical.local.LucenePushdownPredicates;
import org.elasticsearch.xpack.esql.planner.TranslatorHandler;

import java.util.List;

/**
* Marker class for simplified full-text search over all text fields in an index represented in ES|QL as:
* "FROM index | WHERE query"
*
* This will be resolved to {@link MultiMatch} over all text fields during the analysis phase.
*/
public class FullTextSearch extends MultiMatch {

public FullTextSearch(Source source, Expression query) {
super(source, query, List.of(new UnresolvedStar(source, null)), null);
}

@Override
protected Query translate(LucenePushdownPredicates pushdownPredicates, TranslatorHandler handler) {
throw new IllegalStateException("FullTextSearch function should be resolved to MULTI_MATCH before translation");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceOrderByExpressionWithEval;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRegexMatch;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceRowAsLocalRelation;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceSingleMultiMatchWithMatch;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceStatsFilteredAggWithEval;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceStringCasingWithInsensitiveEquals;
import org.elasticsearch.xpack.esql.optimizer.rules.logical.ReplaceTrivialTypeConversions;
Expand Down Expand Up @@ -157,8 +158,9 @@ protected static Batch<LogicalPlan> substitutions() {
new ReplaceAliasingEvalWithProject(),
new SkipQueryOnEmptyMappings(),
new SubstituteSurrogateExpressions(),
new ReplaceOrderByExpressionWithEval()
new ReplaceOrderByExpressionWithEval(),
// new NormalizeAggregate(), - waits on https://github.com/elastic/elasticsearch/issues/100634
new ReplaceSingleMultiMatchWithMatch()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.esql.optimizer.rules.logical;

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.expression.function.fulltext.Match;
import org.elasticsearch.xpack.esql.expression.function.fulltext.MultiMatch;
import org.elasticsearch.xpack.esql.plan.logical.Filter;
import org.elasticsearch.xpack.esql.plan.logical.LogicalPlan;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;

/**
* Replaces {@link MultiMatch} with {@link Match}, iff {@link MultiMatch} only specifies one text field.
* This enables matching on a single `semantic_text` field as {@link MultiMatch} doesn't work with `semantic_text`.
*/
public final class ReplaceSingleMultiMatchWithMatch extends OptimizerRules.OptimizerRule<Filter> {
@Override
protected LogicalPlan rule(Filter filter) {
Expression condition = filter.condition();

if (condition instanceof MultiMatch multiMatch && multiMatch.fields().size() == 1) {
// MULTI_MATCH and MATCH have a subset of common options
Expression filteredOptions = filterOptionsForMatch(multiMatch.options());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add a test making sure options are passed correctly from MultiMatch to Match

Match match = new Match(Source.EMPTY, multiMatch.fields().getFirst(), multiMatch.query(), filteredOptions);

return new Filter(filter.source(), filter.child(), match);
}

return filter;
}

private Expression filterOptionsForMatch(Expression multiMatchOptions) {
if ((multiMatchOptions instanceof MapExpression) == false) {
return null;
}

MapExpression mapExpr = (MapExpression) multiMatchOptions;

// Filter the map entries to only include allowed options
List<Map.Entry<Expression, Expression>> filteredEntries = mapExpr.map().entrySet().stream().filter(entry -> {
if (entry.getKey() instanceof Literal literal && literal.dataType() == KEYWORD) {
String optionName = literal.value().toString();
return Match.ALLOWED_OPTIONS.containsKey(optionName);
}
return false;
}).toList();

// Return null if no valid options remain
if (filteredEntries.isEmpty()) {
return null;
}

List<Expression> entries = new ArrayList<>();

filteredEntries.forEach(entry -> {
entries.add(entry.getKey());
entries.add(entry.getValue());
});

return new MapExpression(multiMatchOptions.source(), entries);
}
}

Large diffs are not rendered by default.

Loading