diff --git a/docs/changelog/118619.yaml b/docs/changelog/118619.yaml
new file mode 100644
index 0000000000000..824d1511606de
--- /dev/null
+++ b/docs/changelog/118619.yaml
@@ -0,0 +1,5 @@
+pr: 118619
+summary: Optional named arguments for function in map
+area: EQL
+type: enhancement
+issues: []
diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/EntryExpression.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/EntryExpression.java
new file mode 100644
index 0000000000000..e6f05e95a0757
--- /dev/null
+++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/EntryExpression.java
@@ -0,0 +1,111 @@
+/*
+ * 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.core.expression;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+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.util.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represent a key-value pair.
+ */
+public class EntryExpression extends Expression {
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+ Expression.class,
+ "EntryExpression",
+ EntryExpression::readFrom
+ );
+
+ private final Expression key;
+
+ private final Expression value;
+
+ public EntryExpression(Source source, Expression key, Expression value) {
+ super(source, List.of(key, value));
+ this.key = key;
+ this.value = value;
+ }
+
+ private static EntryExpression readFrom(StreamInput in) throws IOException {
+ return new EntryExpression(
+ Source.readFrom((StreamInput & PlanStreamInput) in),
+ in.readNamedWriteable(Expression.class),
+ in.readNamedWriteable(Expression.class)
+ );
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ source().writeTo(out);
+ out.writeNamedWriteable(key);
+ out.writeNamedWriteable(value);
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ public Expression replaceChildren(List newChildren) {
+ return new EntryExpression(source(), newChildren.get(0), newChildren.get(1));
+ }
+
+ @Override
+ protected NodeInfo extends Expression> info() {
+ return NodeInfo.create(this, EntryExpression::new, key, value);
+ }
+
+ public Expression key() {
+ return key;
+ }
+
+ public Expression value() {
+ return value;
+ }
+
+ @Override
+ public DataType dataType() {
+ return value.dataType();
+ }
+
+ @Override
+ public Nullability nullable() {
+ return Nullability.FALSE;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(key, value);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || getClass() != obj.getClass()) {
+ return false;
+ }
+
+ EntryExpression other = (EntryExpression) obj;
+ return Objects.equals(key, other.key) && Objects.equals(value, other.value);
+ }
+
+ @Override
+ public String toString() {
+ return key.toString() + ":" + value.toString();
+ }
+}
diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/ExpressionCoreWritables.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/ExpressionCoreWritables.java
index 174a0321a3057..3ea37f88d80b2 100644
--- a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/ExpressionCoreWritables.java
+++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/ExpressionCoreWritables.java
@@ -29,6 +29,7 @@ public static List expressions() {
entries.add(new NamedWriteableRegistry.Entry(Expression.class, e.name, in -> (Expression) e.reader.read(in)));
}
entries.add(Literal.ENTRY);
+ entries.addAll(mapExpressions());
return entries;
}
@@ -45,4 +46,8 @@ public static List namedExpressions() {
public static List attributes() {
return List.of(FieldAttribute.ENTRY, MetadataAttribute.ENTRY, ReferenceAttribute.ENTRY);
}
+
+ public static List mapExpressions() {
+ return List.of(EntryExpression.ENTRY, MapExpression.ENTRY);
+ }
}
diff --git a/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MapExpression.java b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MapExpression.java
new file mode 100644
index 0000000000000..861ecc4ca0368
--- /dev/null
+++ b/x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/MapExpression.java
@@ -0,0 +1,144 @@
+/*
+ * 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.core.expression;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+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.util.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.esql.core.type.DataType.UNSUPPORTED;
+
+/**
+ * Represent a collect of key-value pairs.
+ */
+public class MapExpression extends Expression {
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+ Expression.class,
+ "MapExpression",
+ MapExpression::readFrom
+ );
+
+ private final List entryExpressions;
+
+ private final Map map;
+
+ private final Map
*/
@Override public T visitFunctionName(EsqlBaseParser.FunctionNameContext ctx) { return visitChildren(ctx); }
+ /**
+ * {@inheritDoc}
+ *
+ * The default implementation returns the result of calling
+ * {@link #visitChildren} on {@code ctx}.
+ */
+ @Override public T visitMapExpression(EsqlBaseParser.MapExpressionContext ctx) { return visitChildren(ctx); }
+ /**
+ * {@inheritDoc}
+ *
+ * The default implementation returns the result of calling
+ * {@link #visitChildren} on {@code ctx}.
+ */
+ @Override public T visitEntryExpression(EsqlBaseParser.EntryExpressionContext ctx) { return visitChildren(ctx); }
/**
* {@inheritDoc}
*
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
index f45184e920658..2c1faa374695e 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserListener.java
@@ -323,6 +323,26 @@ public interface EsqlBaseParserListener extends ParseTreeListener {
* @param ctx the parse tree
*/
void exitFunctionName(EsqlBaseParser.FunctionNameContext ctx);
+ /**
+ * Enter a parse tree produced by {@link EsqlBaseParser#mapExpression}.
+ * @param ctx the parse tree
+ */
+ void enterMapExpression(EsqlBaseParser.MapExpressionContext ctx);
+ /**
+ * Exit a parse tree produced by {@link EsqlBaseParser#mapExpression}.
+ * @param ctx the parse tree
+ */
+ void exitMapExpression(EsqlBaseParser.MapExpressionContext ctx);
+ /**
+ * Enter a parse tree produced by {@link EsqlBaseParser#entryExpression}.
+ * @param ctx the parse tree
+ */
+ void enterEntryExpression(EsqlBaseParser.EntryExpressionContext ctx);
+ /**
+ * Exit a parse tree produced by {@link EsqlBaseParser#entryExpression}.
+ * @param ctx the parse tree
+ */
+ void exitEntryExpression(EsqlBaseParser.EntryExpressionContext ctx);
/**
* Enter a parse tree produced by the {@code toDataType}
* labeled alternative in {@link EsqlBaseParser#dataType}.
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
index 30c5e0ce78092..73afd23393cdb 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/EsqlBaseParserVisitor.java
@@ -199,6 +199,18 @@ public interface EsqlBaseParserVisitor extends ParseTreeVisitor {
* @return the visitor result
*/
T visitFunctionName(EsqlBaseParser.FunctionNameContext ctx);
+ /**
+ * Visit a parse tree produced by {@link EsqlBaseParser#mapExpression}.
+ * @param ctx the parse tree
+ * @return the visitor result
+ */
+ T visitMapExpression(EsqlBaseParser.MapExpressionContext ctx);
+ /**
+ * Visit a parse tree produced by {@link EsqlBaseParser#entryExpression}.
+ * @param ctx the parse tree
+ * @return the visitor result
+ */
+ T visitEntryExpression(EsqlBaseParser.EntryExpressionContext ctx);
/**
* Visit a parse tree produced by the {@code toDataType}
* labeled alternative in {@link EsqlBaseParser#dataType}.
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java
index eb81446f9ddea..4b08f1708ccd6 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/parser/ExpressionBuilder.java
@@ -23,6 +23,7 @@
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.FoldContext;
import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.expression.MapExpression;
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute;
import org.elasticsearch.xpack.esql.core.expression.UnresolvedStar;
@@ -76,6 +77,8 @@
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.elasticsearch.xpack.esql.core.type.DataType.DATE_PERIOD;
+import static org.elasticsearch.xpack.esql.core.type.DataType.KEYWORD;
+import static org.elasticsearch.xpack.esql.core.type.DataType.NULL;
import static org.elasticsearch.xpack.esql.core.type.DataType.TIME_DURATION;
import static org.elasticsearch.xpack.esql.core.util.NumericUtils.asLongUnsigned;
import static org.elasticsearch.xpack.esql.core.util.NumericUtils.unsignedLongAsNumber;
@@ -597,6 +600,10 @@ public UnresolvedAttribute visitDereference(EsqlBaseParser.DereferenceContext ct
public Expression visitFunctionExpression(EsqlBaseParser.FunctionExpressionContext ctx) {
String name = visitFunctionName(ctx.functionName());
List args = expressions(ctx.booleanExpression());
+ if (ctx.mapExpression() != null) {
+ MapExpression mapArg = visitMapExpression(ctx.mapExpression());
+ args.add(mapArg);
+ }
if ("is_null".equals(EsqlFunctionRegistry.normalizeName(name))) {
throw new ParsingException(
source(ctx),
@@ -617,6 +624,44 @@ public String visitFunctionName(EsqlBaseParser.FunctionNameContext ctx) {
return visitIdentifierOrParameter(ctx.identifierOrParameter());
}
+ @Override
+ public MapExpression visitMapExpression(EsqlBaseParser.MapExpressionContext ctx) {
+ List namedArgs = new ArrayList<>(ctx.entryExpression().size());
+ List names = new ArrayList<>(ctx.entryExpression().size());
+ List kvCtx = ctx.entryExpression();
+ for (EsqlBaseParser.EntryExpressionContext entry : kvCtx) {
+ EsqlBaseParser.StringContext stringCtx = entry.string();
+ String key = unquote(stringCtx.QUOTED_STRING().getText()); // key is case-sensitive
+ if (key.isBlank()) {
+ throw new ParsingException(
+ source(ctx),
+ "Invalid named function argument [{}], empty key is not supported",
+ entry.getText()
+ );
+ }
+ if (names.contains(key)) {
+ throw new ParsingException(source(ctx), "Duplicated function arguments with the same name [{}] is not supported", key);
+ }
+ Expression value = expression(entry.constant());
+ String entryText = entry.getText();
+ if (value instanceof Literal l) {
+ if (l.dataType() == NULL) {
+ throw new ParsingException(source(ctx), "Invalid named function argument [{}], NULL is not supported", entryText);
+ }
+ namedArgs.add(new Literal(source(stringCtx), key, KEYWORD));
+ namedArgs.add(l);
+ names.add(key);
+ } else {
+ throw new ParsingException(
+ source(ctx),
+ "Invalid named function argument [{}], only constant value is supported",
+ entryText
+ );
+ }
+ }
+ return new MapExpression(Source.EMPTY, namedArgs);
+ }
+
@Override
public String visitIdentifierOrParameter(EsqlBaseParser.IdentifierOrParameterContext ctx) {
if (ctx.identifier() != null) {
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 84af46a8cbbf0..feab6bbbe56a6 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
@@ -21,9 +21,11 @@
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
+import org.elasticsearch.xpack.esql.core.expression.EntryExpression;
import org.elasticsearch.xpack.esql.core.expression.Expressions;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.expression.MapExpression;
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute;
import org.elasticsearch.xpack.esql.core.expression.UnresolvedAttribute;
@@ -34,6 +36,7 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.Max;
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.scalar.map.LogWithBaseInMap;
import org.elasticsearch.xpack.esql.index.EsIndex;
import org.elasticsearch.xpack.esql.index.IndexResolution;
import org.elasticsearch.xpack.esql.parser.ParsingException;
@@ -2581,7 +2584,52 @@ public void testFromEnrichAndMatchColonUsage() {
var eval = as(enrich.child(), Eval.class);
var esRelation = as(eval.child(), EsRelation.class);
assertEquals(esRelation.index().name(), "test");
+ }
+
+ public void testMapExpressionAsFunctionArgument() {
+ assumeTrue("MapExpression require snapshot build", EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled());
+ LogicalPlan plan = analyze("""
+ from test
+ | EVAL l = log_with_base_in_map(languages, {"base":2.0})
+ | KEEP l
+ """, "mapping-default.json");
+ Limit limit = as(plan, Limit.class);
+ EsqlProject proj = as(limit.child(), EsqlProject.class);
+ List extends NamedExpression> fields = proj.projections();
+ assertEquals(1, fields.size());
+ ReferenceAttribute ra = as(fields.get(0), ReferenceAttribute.class);
+ assertEquals("l", ra.name());
+ assertEquals(DataType.DOUBLE, ra.dataType());
+ Eval eval = as(proj.child(), Eval.class);
+ assertEquals(1, eval.fields().size());
+ Alias a = as(eval.fields().get(0), Alias.class);
+ LogWithBaseInMap l = as(a.child(), LogWithBaseInMap.class);
+ MapExpression me = as(l.base(), MapExpression.class);
+ assertEquals(1, me.entryExpressions().size());
+ EntryExpression ee = as(me.entryExpressions().get(0), EntryExpression.class);
+ assertEquals(new Literal(EMPTY, "base", DataType.KEYWORD), ee.key());
+ assertEquals(new Literal(EMPTY, 2.0, DataType.DOUBLE), ee.value());
+ assertEquals(DataType.DOUBLE, ee.dataType());
+ EsRelation esRelation = as(eval.child(), EsRelation.class);
+ assertEquals(esRelation.index().name(), "test");
+ }
+
+ private void verifyMapExpression(MapExpression me) {
+ Literal option1 = new Literal(EMPTY, "option1", DataType.KEYWORD);
+ Literal value1 = new Literal(EMPTY, "value1", DataType.KEYWORD);
+ Literal option2 = new Literal(EMPTY, "option2", DataType.KEYWORD);
+ Literal value2 = new Literal(EMPTY, List.of(1, 2, 3), DataType.INTEGER);
+
+ assertEquals(2, me.entryExpressions().size());
+ EntryExpression ee = as(me.entryExpressions().get(0), EntryExpression.class);
+ assertEquals(option1, ee.key());
+ assertEquals(value1, ee.value());
+ assertEquals(value1.dataType(), ee.dataType());
+ ee = as(me.entryExpressions().get(1), EntryExpression.class);
+ assertEquals(option2, ee.key());
+ assertEquals(value2, ee.value());
+ assertEquals(value2.dataType(), ee.dataType());
}
private void verifyUnsupported(String query, String errorMessage) {
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 f932992e81557..619c0a24e80c6 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
@@ -1994,6 +1994,27 @@ public void testLookupJoinDataTypeMismatch() {
);
}
+ public void testInvalidMapOption() {
+ assumeTrue("MapExpression require snapshot build", EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled());
+ // invalid key
+ assertEquals(
+ "1:22: Invalid option key in [log_with_base_in_map(languages, {\"base\":2.0, \"invalidOption\":true})], "
+ + "expected base but got [\"invalidOption\"]",
+ error("FROM test | EVAL l = log_with_base_in_map(languages, {\"base\":2.0, \"invalidOption\":true})")
+ );
+ // key is case-sensitive
+ assertEquals(
+ "1:22: Invalid option key in [log_with_base_in_map(languages, {\"Base\":2.0})], " + "expected base but got [\"Base\"]",
+ error("FROM test | EVAL l = log_with_base_in_map(languages, {\"Base\":2.0})")
+ );
+ // invalid value
+ assertEquals(
+ "1:22: Invalid option value in [log_with_base_in_map(languages, {\"base\":\"invalid\"})], "
+ + "expected a numeric number but got [invalid]",
+ error("FROM test | EVAL l = log_with_base_in_map(languages, {\"base\":\"invalid\"})")
+ );
+ }
+
private void query(String query) {
query(query, defaultAnalyzer);
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java
index 662afe03c4e84..1ac797f60c21e 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java
@@ -101,6 +101,9 @@
import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomLiteral;
import static org.elasticsearch.xpack.esql.EsqlTestUtils.unboundLogicalOptimizerContext;
import static org.elasticsearch.xpack.esql.SerializationTestUtils.assertSerialization;
+import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.mapParam;
+import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.param;
+import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.paramWithoutAnnotation;
import static org.hamcrest.Matchers.either;
import static org.hamcrest.Matchers.endsWith;
import static org.hamcrest.Matchers.equalTo;
@@ -763,6 +766,9 @@ public static void testFunctionInfo() {
continue;
}
log.info("{}: tested {} vs annotated {}", arg.name(), signatureTypes, annotationTypes);
+ if (annotationTypes.size() == 1 && annotationTypes.iterator().next().equalsIgnoreCase("map")) { // map is not a DataType
+ continue;
+ }
assertEquals(
"Mismatch between actual and declared param type for ["
+ arg.name()
@@ -916,7 +922,7 @@ public static void renderDocs() throws IOException {
description.isAggregation()
);
}
- renderTypes(description.argNames());
+ renderTypes(description.args());
renderParametersList(description.argNames(), description.argDescriptions());
FunctionInfo info = EsqlFunctionRegistry.functionInfo(definition);
renderDescription(description.description(), info.detailedDescription(), info.note());
@@ -938,8 +944,9 @@ public static void renderDocs() throws IOException {
+ "may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview "
+ "are not subject to the support SLA of official GA features.\"]\n";
- private static void renderTypes(List argNames) throws IOException {
+ private static void renderTypes(List args) throws IOException {
StringBuilder header = new StringBuilder();
+ List argNames = args.stream().map(EsqlFunctionRegistry.ArgSignature::name).toList();
for (String arg : argNames) {
header.append(arg).append(" | ");
}
@@ -954,8 +961,13 @@ private static void renderTypes(List argNames) throws IOException {
continue;
}
StringBuilder b = new StringBuilder();
- for (DataType arg : sig.getKey()) {
- b.append(arg.esNameIfPossible()).append(" | ");
+ for (int i = 0; i < sig.getKey().size(); i++) {
+ DataType argType = sig.getKey().get(i);
+ if (args.get(i).mapArg()) {
+ b.append("map | ");
+ } else {
+ b.append(argType.esNameIfPossible()).append(" | ");
+ }
}
b.append("| ".repeat(argNames.size() - sig.getKey().size()));
b.append(sig.getValue().esNameIfPossible());
@@ -1105,16 +1117,17 @@ private static void renderDocsForOperators(String name) throws IOException {
List args = new ArrayList<>(params.length);
for (int i = 1; i < params.length; i++) { // skipping 1st argument, the source
if (Configuration.class.isAssignableFrom(params[i].getType()) == false) {
- Param paramInfo = params[i].getAnnotation(Param.class);
- String paramName = paramInfo == null ? params[i].getName() : paramInfo.name();
- String[] type = paramInfo == null ? new String[] { "?" } : paramInfo.type();
- String desc = paramInfo == null ? "" : paramInfo.description().replace('\n', ' ');
- boolean optional = paramInfo == null ? false : paramInfo.optional();
- args.add(new EsqlFunctionRegistry.ArgSignature(paramName, type, desc, optional));
+ MapParam mapParamInfo = params[i].getAnnotation(MapParam.class);
+ if (mapParamInfo != null) {
+ args.add(mapParam(mapParamInfo));
+ } else {
+ Param paramInfo = params[i].getAnnotation(Param.class);
+ args.add(paramInfo != null ? param(paramInfo) : paramWithoutAnnotation(params[i].getName()));
+ }
}
}
renderKibanaFunctionDefinition(name, functionInfo, args, likeOrInOperator(name));
- renderTypes(args.stream().map(EsqlFunctionRegistry.ArgSignature::name).toList());
+ renderTypes(args);
}
private static void renderKibanaInlineDocs(String name, FunctionInfo info) throws IOException {
@@ -1195,7 +1208,19 @@ private static void renderKibanaFunctionDefinition(
EsqlFunctionRegistry.ArgSignature arg = args.get(i);
builder.startObject();
builder.field("name", arg.name());
- builder.field("type", sig.getKey().get(i).esNameIfPossible());
+ if (arg.mapArg()) {
+ builder.field("type", "map");
+ builder.field(
+ "mapParams",
+ arg.mapParams()
+ .values()
+ .stream()
+ .map(mapArgSignature -> "{" + mapArgSignature + "}")
+ .collect(Collectors.joining(", "))
+ );
+ } else {
+ builder.field("type", sig.getKey().get(i).esNameIfPossible());
+ }
builder.field("optional", arg.optional());
builder.field("description", arg.description());
builder.endObject();
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/TestCaseSupplier.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/TestCaseSupplier.java
index 4cd1aa59c6fdf..4f89ba6bd0504 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/TestCaseSupplier.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/TestCaseSupplier.java
@@ -18,6 +18,7 @@
import org.elasticsearch.test.ESTestCase;
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.core.util.NumericUtils;
@@ -1522,7 +1523,7 @@ public List getDataAsDeepCopiedFields() {
}
public List getDataAsLiterals() {
- return data.stream().map(TypedData::asLiteral).collect(Collectors.toList());
+ return data.stream().map(e -> e.mapExpression ? e.asMapExpression() : e.asLiteral()).collect(Collectors.toList());
}
public List getDataValues() {
@@ -1743,6 +1744,7 @@ public static class TypedData {
private final String name;
private final boolean forceLiteral;
private final boolean multiRow;
+ private final boolean mapExpression;
/**
* @param data value to test against
@@ -1764,6 +1766,7 @@ private TypedData(Object data, DataType type, String name, boolean forceLiteral,
this.name = name;
this.forceLiteral = forceLiteral;
this.multiRow = multiRow;
+ this.mapExpression = data instanceof MapExpression;
}
/**
@@ -1839,7 +1842,7 @@ public String toString() {
*/
public Expression asField() {
if (forceLiteral) {
- return asLiteral();
+ return mapExpression ? asMapExpression() : asLiteral();
}
return AbstractFunctionTestCase.field(name, type);
}
@@ -1849,7 +1852,7 @@ public Expression asField() {
*/
public Expression asDeepCopyOfField() {
if (forceLiteral) {
- return asLiteral();
+ return mapExpression ? asMapExpression() : asLiteral();
}
return AbstractFunctionTestCase.deepCopyOfField(name, type);
}
@@ -1885,6 +1888,13 @@ public List multiRowData() {
return (List) data;
}
+ /**
+ * If the data is a MapExpression, return it as it is.
+ */
+ public MapExpression asMapExpression() {
+ return mapExpression ? (MapExpression) data : null;
+ }
+
/**
* @return the data value being supplied, casting to java objects when appropriate
*/
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/math/LogWithBaseInMapSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/math/LogWithBaseInMapSerializationTests.java
new file mode 100644
index 0000000000000..a2a97e11bfc0f
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/math/LogWithBaseInMapSerializationTests.java
@@ -0,0 +1,38 @@
+/*
+ * 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.scalar.math;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests;
+import org.elasticsearch.xpack.esql.expression.function.scalar.map.LogWithBaseInMap;
+
+import java.io.IOException;
+
+public class LogWithBaseInMapSerializationTests extends AbstractExpressionSerializationTests {
+ @Override
+ protected LogWithBaseInMap createTestInstance() {
+ Source source = randomSource();
+ Expression number = randomChild();
+ Expression base = randomBoolean() ? null : randomChild();
+ return new LogWithBaseInMap(source, number, base);
+ }
+
+ @Override
+ protected LogWithBaseInMap mutateInstance(LogWithBaseInMap instance) throws IOException {
+ Source source = instance.source();
+ Expression number = instance.number();
+ Expression base = instance.base();
+ if (randomBoolean()) {
+ number = randomValueOtherThan(number, AbstractExpressionSerializationTests::randomChild);
+ } else {
+ base = randomValueOtherThan(base, () -> randomBoolean() ? null : randomChild());
+ }
+ return new LogWithBaseInMap(source, number, base);
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
index a8f8054fbc6b1..53c44c7844bf5 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LogicalPlanOptimizerTests.java
@@ -30,11 +30,13 @@
import org.elasticsearch.xpack.esql.core.expression.Alias;
import org.elasticsearch.xpack.esql.core.expression.Attribute;
import org.elasticsearch.xpack.esql.core.expression.AttributeSet;
+import org.elasticsearch.xpack.esql.core.expression.EntryExpression;
import org.elasticsearch.xpack.esql.core.expression.Expression;
import org.elasticsearch.xpack.esql.core.expression.Expressions;
import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
import org.elasticsearch.xpack.esql.core.expression.FoldContext;
import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.expression.MapExpression;
import org.elasticsearch.xpack.esql.core.expression.NamedExpression;
import org.elasticsearch.xpack.esql.core.expression.Nullability;
import org.elasticsearch.xpack.esql.core.expression.ReferenceAttribute;
@@ -67,6 +69,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToInteger;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToLong;
import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
+import org.elasticsearch.xpack.esql.expression.function.scalar.map.LogWithBaseInMap;
import org.elasticsearch.xpack.esql.expression.function.scalar.math.Round;
import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvAvg;
import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvCount;
@@ -6821,4 +6824,34 @@ public void testWhereNull() {
var local = as(plan, LocalRelation.class);
assertThat(local.supplier(), equalTo(LocalSupplier.EMPTY));
}
+
+ public void testMapExpressionAsFunctionArgument() {
+ assumeTrue("MapExpression require snapshot build", EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled());
+ var query = """
+ from test
+ | EVAL l = log_with_base_in_map(languages, {"base":2.0})
+ | KEEP l
+ """;
+ var plan = optimizedPlan(query);
+ Project proj = as(plan, EsqlProject.class);
+ List> fields = proj.projections();
+ assertEquals(1, fields.size());
+ ReferenceAttribute ra = as(fields.get(0), ReferenceAttribute.class);
+ assertEquals("l", ra.name());
+ assertEquals(DataType.DOUBLE, ra.dataType());
+ Eval eval = as(proj.child(), Eval.class);
+ assertEquals(1, eval.fields().size());
+ Alias a = as(eval.fields().get(0), Alias.class);
+ LogWithBaseInMap l = as(a.child(), LogWithBaseInMap.class);
+ MapExpression me = as(l.base(), MapExpression.class);
+ assertEquals(1, me.entryExpressions().size());
+ EntryExpression ee = as(me.entryExpressions().get(0), EntryExpression.class);
+ BytesRef key = as(ee.key().fold(FoldContext.small()), BytesRef.class);
+ assertEquals("base", key.utf8ToString());
+ assertEquals(new Literal(EMPTY, 2.0, DataType.DOUBLE), ee.value());
+ assertEquals(DataType.DOUBLE, ee.dataType());
+ Limit limit = as(eval.child(), Limit.class);
+ EsRelation esRelation = as(limit.child(), EsRelation.class);
+ assertEquals(esRelation.index().name(), "test");
+ }
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/AbstractStatementParserTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/AbstractStatementParserTests.java
index 99a04b6ed8f10..31ea4f2712b98 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/AbstractStatementParserTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/AbstractStatementParserTests.java
@@ -13,6 +13,7 @@
import org.elasticsearch.xpack.esql.VerificationException;
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.expression.UnresolvedAttribute;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.expression.function.UnresolvedFunction;
@@ -24,6 +25,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import static org.elasticsearch.xpack.esql.core.tree.Source.EMPTY;
import static org.elasticsearch.xpack.esql.core.util.NumericUtils.asLongUnsigned;
@@ -125,6 +127,18 @@ static Literal literalStrings(String... strings) {
return new Literal(EMPTY, Arrays.asList(strings), DataType.KEYWORD);
}
+ static MapExpression mapExpression(Map keyValuePairs) {
+ List ees = new ArrayList<>(keyValuePairs.size());
+ for (Map.Entry entry : keyValuePairs.entrySet()) {
+ String key = entry.getKey();
+ Object value = entry.getValue();
+ DataType type = (value instanceof List> l) ? DataType.fromJava(l.get(0)) : DataType.fromJava(value);
+ ees.add(new Literal(EMPTY, key, DataType.KEYWORD));
+ ees.add(new Literal(EMPTY, value, type));
+ }
+ return new MapExpression(EMPTY, ees);
+ }
+
void expectError(String query, String errorMessage) {
ParsingException e = expectThrows(ParsingException.class, "Expected syntax error for " + query, () -> statement(query));
assertThat(e.getMessage(), containsString(errorMessage));
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
index a6243d25ba579..74d97f250ef7f 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/parser/StatementParserTests.java
@@ -8,6 +8,7 @@
package org.elasticsearch.xpack.esql.parser;
import org.elasticsearch.Build;
+import org.elasticsearch.common.logging.LoggerMessageFormat;
import org.elasticsearch.core.Tuple;
import org.elasticsearch.index.IndexMode;
import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
@@ -64,6 +65,7 @@
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -2358,4 +2360,583 @@ public void testFailingMetadataWithSquareBrackets() {
"line 1:11: mismatched input '[' expecting {, '|', ',', 'metadata'}"
);
}
+
+ public void testNamedFunctionArgumentInMap() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ // functions can be scalar, grouping and aggregation
+ // functions can be in eval/where/stats/sort/dissect/grok commands, commands in snapshot are not covered
+ // positive
+ // In eval and where clause as function arguments
+ LinkedHashMap expectedMap1 = new LinkedHashMap<>(4);
+ expectedMap1.put("option1", "string");
+ expectedMap1.put("option2", 1);
+ expectedMap1.put("option3", List.of(2.0, 3.0, 4.0));
+ expectedMap1.put("option4", List.of(true, false));
+ LinkedHashMap expectedMap2 = new LinkedHashMap<>(4);
+ expectedMap2.put("option1", List.of("string1", "string2"));
+ expectedMap2.put("option2", List.of(1, 2, 3));
+ expectedMap2.put("option3", 2.0);
+ expectedMap2.put("option4", true);
+ LinkedHashMap expectedMap3 = new LinkedHashMap<>(4);
+ expectedMap3.put("option1", "string");
+ expectedMap3.put("option2", 2.0);
+ expectedMap3.put("option3", List.of(1, 2, 3));
+ expectedMap3.put("option4", List.of(true, false));
+
+ assertEquals(
+ new Filter(
+ EMPTY,
+ new Eval(
+ EMPTY,
+ relation("test"),
+ List.of(
+ new Alias(
+ EMPTY,
+ "x",
+ function(
+ "fn1",
+ List.of(attribute("f1"), new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap1))
+ )
+ )
+ )
+ ),
+ new Equals(
+ EMPTY,
+ attribute("y"),
+ function("fn2", List.of(new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap2)))
+ )
+ ),
+ statement("""
+ from test
+ | eval x = fn1(f1, "testString", {"option1":"string","option2":1,"option3":[2.0,3.0,4.0],"option4":[true,false]})
+ | where y == fn2("testString", {"option1":["string1","string2"],"option2":[1,2,3],"option3":2.0,"option4":true})
+ """)
+ );
+
+ // In stats, by and sort as function arguments
+ assertEquals(
+ new OrderBy(
+ EMPTY,
+ new Aggregate(
+ EMPTY,
+ relation("test"),
+ Aggregate.AggregateType.STANDARD,
+ List.of(
+ new Alias(
+ EMPTY,
+ "fn2(f3, {\"option1\":[\"string1\",\"string2\"],\"option2\":[1,2,3],\"option3\":2.0,\"option4\":true})",
+ function("fn2", List.of(attribute("f3"), mapExpression(expectedMap2)))
+ )
+ ),
+ List.of(
+ new Alias(EMPTY, "x", function("fn1", List.of(attribute("f1"), attribute("f2"), mapExpression(expectedMap1)))),
+ attribute("fn2(f3, {\"option1\":[\"string1\",\"string2\"],\"option2\":[1,2,3],\"option3\":2.0,\"option4\":true})")
+ )
+ ),
+ List.of(
+ new Order(
+ EMPTY,
+ function("fn3", List.of(attribute("f4"), mapExpression(expectedMap3))),
+ Order.OrderDirection.ASC,
+ Order.NullsPosition.LAST
+ )
+ )
+ ),
+ statement("""
+ from test
+ | stats x = fn1(f1, f2, {"option1":"string","option2":1,"option3":[2.0,3.0,4.0],"option4":[true,false]})
+ by fn2(f3, {"option1":["string1","string2"],"option2":[1,2,3],"option3":2.0,"option4":true})
+ | sort fn3(f4, {"option1":"string","option2":2.0,"option3":[1,2,3],"option4":[true,false]})
+ """)
+ );
+
+ // In dissect and grok as function arguments
+ LogicalPlan plan = statement("""
+ from test
+ | dissect fn1(f1, f2, {"option1":"string", "option2":1,"option3":[2.0,3.0,4.0],"option4":[true,false]}) "%{bar}"
+ | grok fn2(f3, {"option1":["string1","string2"],"option2":[1,2,3],"option3":2.0,"option4":true}) "%{WORD:foo}"
+ """);
+ Grok grok = as(plan, Grok.class);
+ assertEquals(function("fn2", List.of(attribute("f3"), mapExpression(expectedMap2))), grok.input());
+ assertEquals("%{WORD:foo}", grok.parser().pattern());
+ assertEquals(List.of(referenceAttribute("foo", KEYWORD)), grok.extractedFields());
+ Dissect dissect = as(grok.child(), Dissect.class);
+ assertEquals(function("fn1", List.of(attribute("f1"), attribute("f2"), mapExpression(expectedMap1))), dissect.input());
+ assertEquals("%{bar}", dissect.parser().pattern());
+ assertEquals("", dissect.parser().appendSeparator());
+ assertEquals(List.of(referenceAttribute("bar", KEYWORD)), dissect.extractedFields());
+ UnresolvedRelation ur = as(dissect.child(), UnresolvedRelation.class);
+ assertEquals(ur, relation("test"));
+
+ // map entry values provided in named parameter, arrays are not supported by named parameters yet
+ assertEquals(
+ new Filter(
+ EMPTY,
+ new Eval(
+ EMPTY,
+ relation("test"),
+ List.of(
+ new Alias(
+ EMPTY,
+ "x",
+ function(
+ "fn1",
+ List.of(attribute("f1"), new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap1))
+ )
+ )
+ )
+ ),
+ new Equals(
+ EMPTY,
+ attribute("y"),
+ function("fn2", List.of(new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap2)))
+ )
+ ),
+ statement(
+ """
+ from test
+ | eval x = ?fn1(?n1, ?n2, {"option1":?n3,"option2":?n4,"option3":[2.0,3.0,4.0],"option4":[true,false]})
+ | where y == ?fn2(?n2, {"option1":["string1","string2"],"option2":[1,2,3],"option3":?n5,"option4":?n6})
+ """,
+ new QueryParams(
+ List.of(
+ paramAsIdentifier("fn1", "fn1"),
+ paramAsIdentifier("fn2", "fn2"),
+ paramAsIdentifier("n1", "f1"),
+ paramAsConstant("n2", "testString"),
+ paramAsConstant("n3", "string"),
+ paramAsConstant("n4", 1),
+ paramAsConstant("n5", 2.0),
+ paramAsConstant("n6", true)
+ )
+ )
+ )
+ );
+
+ assertEquals(
+ new OrderBy(
+ EMPTY,
+ new Aggregate(
+ EMPTY,
+ relation("test"),
+ Aggregate.AggregateType.STANDARD,
+ List.of(
+ new Alias(
+ EMPTY,
+ "?fn2(?n7, {\"option1\":[\"string1\",\"string2\"],\"option2\":[1,2,3],\"option3\":?n5,\"option4\":?n6})",
+ function("fn2", List.of(attribute("f3"), mapExpression(expectedMap2)))
+ )
+ ),
+ List.of(
+ new Alias(EMPTY, "x", function("fn1", List.of(attribute("f1"), attribute("f2"), mapExpression(expectedMap1)))),
+ attribute("?fn2(?n7, {\"option1\":[\"string1\",\"string2\"],\"option2\":[1,2,3],\"option3\":?n5,\"option4\":?n6})")
+ )
+ ),
+ List.of(
+ new Order(
+ EMPTY,
+ function("fn3", List.of(attribute("f4"), mapExpression(expectedMap3))),
+ Order.OrderDirection.ASC,
+ Order.NullsPosition.LAST
+ )
+ )
+ ),
+ statement(
+ """
+ from test
+ | stats x = ?fn1(?n1, ?n2, {"option1":?n3,"option2":?n4,"option3":[2.0,3.0,4.0],"option4":[true,false]})
+ by ?fn2(?n7, {"option1":["string1","string2"],"option2":[1,2,3],"option3":?n5,"option4":?n6})
+ | sort ?fn3(?n8, {"option1":?n3,"option2":?n5,"option3":[1,2,3],"option4":[true,false]})
+ """,
+ new QueryParams(
+ List.of(
+ paramAsIdentifier("fn1", "fn1"),
+ paramAsIdentifier("fn2", "fn2"),
+ paramAsIdentifier("fn3", "fn3"),
+ paramAsIdentifier("n1", "f1"),
+ paramAsIdentifier("n2", "f2"),
+ paramAsConstant("n3", "string"),
+ paramAsConstant("n4", 1),
+ paramAsConstant("n5", 2.0),
+ paramAsConstant("n6", true),
+ paramAsIdentifier("n7", "f3"),
+ paramAsIdentifier("n8", "f4")
+ )
+ )
+ )
+ );
+
+ plan = statement(
+ """
+ from test
+ | dissect ?fn1(?n1, ?n2, {"option1":?n3,"option2":?n4,"option3":[2.0,3.0,4.0],"option4":[true,false]}) "%{bar}"
+ | grok ?fn2(?n7, {"option1":["string1","string2"],"option2":[1,2,3],"option3":?n5,"option4":?n6}) "%{WORD:foo}"
+ """,
+ new QueryParams(
+ List.of(
+ paramAsIdentifier("fn1", "fn1"),
+ paramAsIdentifier("fn2", "fn2"),
+ paramAsIdentifier("n1", "f1"),
+ paramAsIdentifier("n2", "f2"),
+ paramAsConstant("n3", "string"),
+ paramAsConstant("n4", 1),
+ paramAsConstant("n5", 2.0),
+ paramAsConstant("n6", true),
+ paramAsIdentifier("n7", "f3")
+ )
+ )
+ );
+ grok = as(plan, Grok.class);
+ assertEquals(function("fn2", List.of(attribute("f3"), mapExpression(expectedMap2))), grok.input());
+ assertEquals("%{WORD:foo}", grok.parser().pattern());
+ assertEquals(List.of(referenceAttribute("foo", KEYWORD)), grok.extractedFields());
+ dissect = as(grok.child(), Dissect.class);
+ assertEquals(function("fn1", List.of(attribute("f1"), attribute("f2"), mapExpression(expectedMap1))), dissect.input());
+ assertEquals("%{bar}", dissect.parser().pattern());
+ assertEquals("", dissect.parser().appendSeparator());
+ assertEquals(List.of(referenceAttribute("bar", KEYWORD)), dissect.extractedFields());
+ ur = as(dissect.child(), UnresolvedRelation.class);
+ assertEquals(ur, relation("test"));
+ }
+
+ public void testNamedFunctionArgumentWithCaseSensitiveKeys() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ LinkedHashMap expectedMap1 = new LinkedHashMap<>(3);
+ expectedMap1.put("option", "string");
+ expectedMap1.put("Option", 1);
+ expectedMap1.put("oPtion", List.of(2.0, 3.0, 4.0));
+ LinkedHashMap expectedMap2 = new LinkedHashMap<>(3);
+ expectedMap2.put("option", List.of("string1", "string2"));
+ expectedMap2.put("Option", List.of(1, 2, 3));
+ expectedMap2.put("oPtion", 2.0);
+
+ assertEquals(
+ new Filter(
+ EMPTY,
+ new Eval(
+ EMPTY,
+ relation("test"),
+ List.of(
+ new Alias(
+ EMPTY,
+ "x",
+ function(
+ "fn1",
+ List.of(attribute("f1"), new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap1))
+ )
+ )
+ )
+ ),
+ new Equals(
+ EMPTY,
+ attribute("y"),
+ function("fn2", List.of(new Literal(EMPTY, "testString", KEYWORD), mapExpression(expectedMap2)))
+ )
+ ),
+ statement("""
+ from test
+ | eval x = fn1(f1, "testString", {"option":"string","Option":1,"oPtion":[2.0,3.0,4.0]})
+ | where y == fn2("testString", {"option":["string1","string2"],"Option":[1,2,3],"oPtion":2.0})
+ """)
+ );
+ }
+
+ public void testMultipleNamedFunctionArgumentsNotAllowed() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "41"),
+ Map.entry("where {}", "38"),
+ Map.entry("stats {}", "38"),
+ Map.entry("stats agg() by {}", "47"),
+ Map.entry("sort {}", "37"),
+ Map.entry("dissect {} \"%{bar}\"", "40"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "37")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ String errorMessage = cmd.startsWith("dissect") || cmd.startsWith("grok")
+ ? "mismatched input ',' expecting ')'"
+ : "no viable alternative at input 'fn(f1,";
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"option\":1}, {\"option\":2})"),
+ LoggerMessageFormat.format(null, "line 1:{}: {}", error, errorMessage)
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentNotInMap() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "38"),
+ Map.entry("where {}", "35"),
+ Map.entry("stats {}", "35"),
+ Map.entry("stats agg() by {}", "44"),
+ Map.entry("sort {}", "34"),
+ Map.entry("dissect {} \"%{bar}\"", "37"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "34")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ String errorMessage = cmd.startsWith("dissect") || cmd.startsWith("grok")
+ ? "extraneous input ':' expecting {',', ')'}"
+ : "no viable alternative at input 'fn(f1, \"option1\":'";
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, \"option1\":\"string\")"),
+ LoggerMessageFormat.format(null, "line 1:{}: {}", error, errorMessage)
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentNotConstant() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", new String[] { "31", "35" }),
+ Map.entry("where {}", new String[] { "28", "32" }),
+ Map.entry("stats {}", new String[] { "28", "32" }),
+ Map.entry("stats agg() by {}", new String[] { "37", "41" }),
+ Map.entry("sort {}", new String[] { "27", "31" }),
+ Map.entry("dissect {} \"%{bar}\"", new String[] { "30", "34" }),
+ Map.entry("grok {} \"%{WORD:foo}\"", new String[] { "27", "31" })
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error1 = command.getValue()[0];
+ String error2 = command.getValue()[1];
+ String errorMessage1 = cmd.startsWith("dissect") || cmd.startsWith("grok")
+ ? "mismatched input '1' expecting QUOTED_STRING"
+ : "no viable alternative at input 'fn(f1, { 1'";
+ String errorMessage2 = cmd.startsWith("dissect") || cmd.startsWith("grok")
+ ? "mismatched input 'string' expecting {QUOTED_STRING"
+ : "no viable alternative at input 'fn(f1, {";
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, { 1:\"string\" })"),
+ LoggerMessageFormat.format(null, "line 1:{}: {}", error1, errorMessage1)
+ );
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, { \"1\":string })"),
+ LoggerMessageFormat.format(null, "line 1:{}: {}", error2, errorMessage2)
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentEmptyMap() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "30"),
+ Map.entry("where {}", "27"),
+ Map.entry("stats {}", "27"),
+ Map.entry("stats agg() by {}", "36"),
+ Map.entry("sort {}", "26"),
+ Map.entry("dissect {} \"%{bar}\"", "29"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "26")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ String errorMessage = cmd.startsWith("dissect") || cmd.startsWith("grok")
+ ? "mismatched input '}' expecting QUOTED_STRING"
+ : "no viable alternative at input 'fn(f1, {}'";
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {}})"),
+ LoggerMessageFormat.format(null, "line 1:{}: {}", error, errorMessage)
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentMapWithNULL() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "29"),
+ Map.entry("where {}", "26"),
+ Map.entry("stats {}", "26"),
+ Map.entry("stats agg() by {}", "35"),
+ Map.entry("sort {}", "25"),
+ Map.entry("dissect {} \"%{bar}\"", "28"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "25")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"option\":null})"),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Invalid named function argument [\"option\":null], NULL is not supported"
+ )
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentMapWithEmptyKey() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "29"),
+ Map.entry("where {}", "26"),
+ Map.entry("stats {}", "26"),
+ Map.entry("stats agg() by {}", "35"),
+ Map.entry("sort {}", "25"),
+ Map.entry("dissect {} \"%{bar}\"", "28"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "25")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"\":1})"),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Invalid named function argument [\"\":1], empty key is not supported"
+ )
+ );
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\" \":1})"),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Invalid named function argument [\" \":1], empty key is not supported"
+ )
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentMapWithDuplicatedKey() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "29"),
+ Map.entry("where {}", "26"),
+ Map.entry("stats {}", "26"),
+ Map.entry("stats agg() by {}", "35"),
+ Map.entry("sort {}", "25"),
+ Map.entry("dissect {} \"%{bar}\"", "28"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "25")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"dup\":1,\"dup\":2})"),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Duplicated function arguments with the same name [dup] is not supported"
+ )
+ );
+ }
+ }
+
+ public void testNamedFunctionArgumentInInvalidPositions() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ // negative, named arguments are not supported outside of a functionExpression where booleanExpression or indexPattern is supported
+ String map = "{\"option1\":\"string\", \"option2\":1}";
+
+ Map commands = Map.ofEntries(
+ Map.entry("from {}", "line 1:7: mismatched input '\"option1\"' expecting {, '|', ',', 'metadata'}"),
+ Map.entry("row x = {}", "line 1:9: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL"),
+ Map.entry("eval x = {}", "line 1:22: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL"),
+ Map.entry("where x > {}", "line 1:23: no viable alternative at input 'x > {'"),
+ Map.entry("stats agg() by {}", "line 1:28: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL"),
+ Map.entry("sort {}", "line 1:18: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL"),
+ Map.entry("keep {}", "line 1:18: token recognition error at: '{'"),
+ Map.entry("drop {}", "line 1:18: token recognition error at: '{'"),
+ Map.entry("rename a as {}", "line 1:25: token recognition error at: '{'"),
+ Map.entry("mv_expand {}", "line 1:23: token recognition error at: '{'"),
+ Map.entry("limit {}", "line 1:19: mismatched input '{' expecting INTEGER_LITERAL"),
+ Map.entry("enrich idx2 on f1 with f2 = {}", "line 1:41: token recognition error at: '{'"),
+ Map.entry("dissect {} \"%{bar}\"", "line 1:21: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "line 1:18: extraneous input '{' expecting {QUOTED_STRING, INTEGER_LITERAL")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String errorMessage = command.getValue();
+ String from = cmd.startsWith("row") || cmd.startsWith("from") ? "" : "from test | ";
+ expectError(LoggerMessageFormat.format(null, from + cmd, map), errorMessage);
+ }
+ }
+
+ public void testNamedFunctionArgumentWithUnsupportedNamedParameterTypes() {
+ assumeTrue(
+ "named function arguments require snapshot build",
+ EsqlCapabilities.Cap.OPTIONAL_NAMED_ARGUMENT_MAP_FOR_FUNCTION.isEnabled()
+ );
+ Map commands = Map.ofEntries(
+ Map.entry("eval x = {}", "29"),
+ Map.entry("where {}", "26"),
+ Map.entry("stats {}", "26"),
+ Map.entry("stats agg() by {}", "35"),
+ Map.entry("sort {}", "25"),
+ Map.entry("dissect {} \"%{bar}\"", "28"),
+ Map.entry("grok {} \"%{WORD:foo}\"", "25")
+ );
+
+ for (Map.Entry command : commands.entrySet()) {
+ String cmd = command.getKey();
+ String error = command.getValue();
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"option1\":?n1})"),
+ List.of(paramAsIdentifier("n1", "v1")),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Invalid named function argument [\"option1\":?n1], only constant value is supported"
+ )
+ );
+ expectError(
+ LoggerMessageFormat.format(null, "from test | " + cmd, "fn(f1, {\"option1\":?n1})"),
+ List.of(paramAsPattern("n1", "v1")),
+ LoggerMessageFormat.format(
+ null,
+ "line 1:{}: {}",
+ error,
+ "Invalid named function argument [\"option1\":?n1], only constant value is supported"
+ )
+ );
+ }
+ }
}
diff --git a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml
index 0d9c66012dbfc..da8290a1e185d 100644
--- a/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml
+++ b/x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml
@@ -92,7 +92,7 @@ setup:
- gt: {esql.functions.to_long: $functions_to_long}
- match: {esql.functions.coalesce: $functions_coalesce}
# Testing for the entire function set isn't feasbile, so we just check that we return the correct count as an approximation.
- - length: {esql.functions: 133} # check the "sister" test below for a likely update to the same esql.functions length check
+ - length: {esql.functions: 134} # check the "sister" test below for a likely update to the same esql.functions length check
---
"Basic ESQL usage output (telemetry) non-snapshot version":