瀏覽代碼

Remove deprecated function isNotNullAndFoldable (#130944)

This PR removes the deprecated function isNotNullAndFoldable.
It was getting called in the TypeResolution for various functions.
With the change, we still check the isNotNull part during TypeResolution. However, a lot of the other checks are moved to postOptimizationVerification(), that happens after the constant have been folded during normal logical planning.

Resolves #119756
However, it seems that there are still a few places we do folding outside logical planning. They are marked with /* TODO remove me */ and will be handled in a separate PR
Julian Kiryakov 2 月之前
父節點
當前提交
55141a2f67
共有 18 個文件被更改,包括 979 次插入138 次删除
  1. 6 0
      docs/changelog/130944.yaml
  2. 0 30
      x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/TypeResolutions.java
  3. 151 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionUtils.java
  4. 17 9
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Sample.java
  5. 95 25
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Top.java
  6. 21 15
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java
  7. 2 2
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java
  8. 14 6
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Match.java
  9. 14 6
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/MatchPhrase.java
  10. 14 4
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/MultiMatch.java
  11. 15 4
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java
  12. 2 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Term.java
  13. 40 9
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/vector/Knn.java
  14. 7 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/PostOptimizationPhasePlanVerifier.java
  15. 0 25
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
  16. 2 2
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java
  17. 18 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KnnTests.java
  18. 561 0
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/230_folding.yml

+ 6 - 0
docs/changelog/130944.yaml

@@ -0,0 +1,6 @@
+pr: 130944
+summary: Remove unnecessary calls to Fold
+area: ES|QL
+type: enhancement
+issues:
+ - 119756

+ 0 - 30
x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/expression/TypeResolutions.java

@@ -133,36 +133,6 @@ public final class TypeResolutions {
         return TypeResolution.TYPE_RESOLVED;
     }
 
-    /**
-     * Is this {@link Expression#foldable()} and not {@code null}.
-     *
-     * @deprecated instead of calling this, check for a {@link Literal} containing
-     *             {@code null}. Foldable expressions will be folded by other rules,
-     *             eventually, to a {@link Literal}.
-     */
-    @Deprecated
-    public static TypeResolution isNotNullAndFoldable(Expression e, String operationName, ParamOrdinal paramOrd) {
-        TypeResolution resolution = isFoldable(e, operationName, paramOrd);
-
-        if (resolution.unresolved()) {
-            return resolution;
-        }
-
-        if (e.dataType() == DataType.NULL || e.fold(FoldContext.small()) == null) {
-            resolution = new TypeResolution(
-                format(
-                    null,
-                    "{}argument of [{}] cannot be null, received [{}]",
-                    paramOrd == null || paramOrd == DEFAULT ? "" : paramOrd.name().toLowerCase(Locale.ROOT) + " ",
-                    operationName,
-                    Expressions.name(e)
-                )
-            );
-        }
-
-        return resolution;
-    }
-
     public static TypeResolution isNotNull(Expression e, String operationName, ParamOrdinal paramOrd) {
         if (e.dataType() == DataType.NULL) {
             return new TypeResolution(

+ 151 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/FunctionUtils.java

@@ -0,0 +1,151 @@
+/*
+ * 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;
+
+import org.elasticsearch.common.lucene.BytesRefs;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.common.Failure;
+import org.elasticsearch.xpack.esql.common.Failures;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Literal;
+
+import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
+import static org.elasticsearch.xpack.esql.common.Failure.fail;
+
+public class FunctionUtils {
+    /**
+     * A utility class to validate the type resolution of expressions before and after logical planning.
+     * If null is passed for Failures to the constructor, it means we are only type resolution.
+     * This is usually called when doing pre-logical planning validation.
+     * If a {@link Failures} instance is passed, it means we are doing post-logical planning validation as well.
+     * This is usually called after folding is done, during
+     * {@link org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware} verification
+     */
+    public static class TypeResolutionValidator {
+
+        Expression.TypeResolution typeResolution = Expression.TypeResolution.TYPE_RESOLVED;
+        @Nullable
+        private final Failures postValidationFailures; // null means we are doing pre-folding validation only
+        private final Expression field;
+
+        public static TypeResolutionValidator forPreOptimizationValidation(Expression field) {
+            return new TypeResolutionValidator(field, null);
+        }
+
+        public static TypeResolutionValidator forPostOptimizationValidation(Expression field, Failures failures) {
+            return new TypeResolutionValidator(field, failures);
+        }
+
+        private TypeResolutionValidator(Expression field, Failures failures) {
+            this.field = field;
+            this.postValidationFailures = failures;
+        }
+
+        public void invalidIfPostValidation(Failure failure) {
+            if (postValidationFailures != null) {
+                postValidationFailures.add(failure);
+            }
+        }
+
+        public void invalid(Expression.TypeResolution message) {
+            typeResolution = message;
+            if (postValidationFailures != null) {
+                postValidationFailures.add(fail(field, message.message()));
+            }
+        }
+
+        public Expression.TypeResolution getResolvedType() {
+            return typeResolution;
+        }
+    }
+
+    public static Integer limitValue(Expression limitField, String sourceText) {
+        if (limitField instanceof Literal literal) {
+            Object value = literal.value();
+            if (value instanceof Integer intValue) {
+                return intValue;
+            }
+        }
+        throw new EsqlIllegalArgumentException(format(null, "Limit value must be an integer in [{}], found [{}]", sourceText, limitField));
+    }
+
+    /**
+     * We check that the limit is not null and that if it is a literal, it is a positive integer
+     * During postOptimizationVerification folding is already done, so we also verify that it is definitively a literal
+     */
+    public static Expression.TypeResolution resolveTypeLimit(Expression limitField, String sourceText, TypeResolutionValidator validator) {
+        if (limitField == null) {
+            validator.invalid(
+                new Expression.TypeResolution(format(null, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField))
+            );
+        } else if (limitField instanceof Literal literal) {
+            if (literal.value() == null) {
+                validator.invalid(
+                    new Expression.TypeResolution(
+                        format(null, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField)
+                    )
+                );
+            } else {
+                int value = (Integer) literal.value();
+                if (value <= 0) {
+                    validator.invalid(
+                        new Expression.TypeResolution(format(null, "Limit must be greater than 0 in [{}], found [{}]", sourceText, value))
+                    );
+                }
+            }
+        } else {
+            // it is expected that the expression is a literal after folding
+            // we fail if it is not a literal
+            validator.invalidIfPostValidation(
+                fail(limitField, "Limit must be a constant integer in [{}], found [{}]", sourceText, limitField)
+            );
+        }
+        return validator.getResolvedType();
+    }
+
+    /**
+     * We check that the query is not null and that if it is a literal, it is a string
+     * During postOptimizationVerification folding is already done, so we also verify that it is definitively a literal
+     */
+    public static Expression.TypeResolution resolveTypeQuery(Expression queryField, String sourceText, TypeResolutionValidator validator) {
+        if (queryField == null) {
+            validator.invalid(
+                new Expression.TypeResolution(format(null, "Query must be a valid string in [{}], found [{}]", sourceText, queryField))
+            );
+        } else if (queryField instanceof Literal literal) {
+            if (literal.value() == null) {
+                validator.invalid(
+                    new Expression.TypeResolution(format(null, "Query value cannot be null in [{}], but got [{}]", sourceText, queryField))
+                );
+            }
+        } else {
+            // it is expected that the expression is a literal after folding
+            // we fail if it is not a literal
+            validator.invalidIfPostValidation(fail(queryField, "Query must be a valid string in [{}], found [{}]", sourceText, queryField));
+        }
+        return validator.getResolvedType();
+    }
+
+    public static Object queryAsObject(Expression queryField, String sourceText) {
+        if (queryField instanceof Literal literal) {
+            return literal.value();
+        }
+        throw new EsqlIllegalArgumentException(
+            format(null, "Query value must be a constant string in [{}], found [{}]", sourceText, queryField)
+        );
+    }
+
+    public static String queryAsString(Expression queryField, String sourceText) {
+        if (queryField instanceof Literal literal) {
+            return BytesRefs.toString(literal.value());
+        }
+        throw new EsqlIllegalArgumentException(
+            format(null, "Query value must be a constant string in [{}], found [{}]", sourceText, queryField)
+        );
+    }
+}

+ 17 - 9
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Sample.java

@@ -17,8 +17,9 @@ import org.elasticsearch.compute.aggregation.SampleDoubleAggregatorFunctionSuppl
 import org.elasticsearch.compute.aggregation.SampleIntAggregatorFunctionSupplier;
 import org.elasticsearch.compute.aggregation.SampleLongAggregatorFunctionSupplier;
 import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
+import org.elasticsearch.xpack.esql.common.Failures;
 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.tree.NodeInfo;
 import org.elasticsearch.xpack.esql.core.tree.Source;
@@ -26,6 +27,7 @@ import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionType;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.planner.PlannerUtils;
 import org.elasticsearch.xpack.esql.planner.ToAggregator;
@@ -33,13 +35,15 @@ import org.elasticsearch.xpack.esql.planner.ToAggregator;
 import java.io.IOException;
 import java.util.List;
 
-import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
 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.isNotNullAndFoldable;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPostOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeLimit;
 
-public class Sample extends AggregateFunction implements ToAggregator {
+public class Sample extends AggregateFunction implements ToAggregator, PostOptimizationVerificationAware {
     public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Sample", Sample::new);
 
     @FunctionInfo(
@@ -110,14 +114,14 @@ public class Sample extends AggregateFunction implements ToAggregator {
             return new TypeResolution("Unresolved children");
         }
         var typeResolution = isType(field(), dt -> dt != DataType.UNSIGNED_LONG, sourceText(), FIRST, "any type except unsigned_long").and(
-            isNotNullAndFoldable(limitField(), sourceText(), SECOND)
+            isNotNull(limitField(), sourceText(), SECOND)
         ).and(isType(limitField(), dt -> dt == DataType.INTEGER, sourceText(), SECOND, "integer"));
         if (typeResolution.unresolved()) {
             return typeResolution;
         }
-        int limit = limitValue();
-        if (limit <= 0) {
-            return new TypeResolution(format(null, "Limit must be greater than 0 in [{}], found [{}]", sourceText(), limit));
+        TypeResolution result = resolveTypeLimit(limitField(), sourceText(), forPreOptimizationValidation(limitField()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
         }
         return TypeResolution.TYPE_RESOLVED;
     }
@@ -164,11 +168,15 @@ public class Sample extends AggregateFunction implements ToAggregator {
     }
 
     private int limitValue() {
-        return (int) limitField().fold(FoldContext.small() /* TODO remove me */);
+        return FunctionUtils.limitValue(limitField(), sourceText());
     }
 
     Expression uuid() {
         return parameters().get(1);
     }
 
+    @Override
+    public void postOptimizationVerification(Failures failures) {
+        FunctionUtils.resolveTypeLimit(limitField(), sourceText(), forPostOptimizationValidation(limitField(), failures));
+    }
 }

+ 95 - 25
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Top.java

@@ -20,8 +20,9 @@ import org.elasticsearch.compute.aggregation.TopIntAggregatorFunctionSupplier;
 import org.elasticsearch.compute.aggregation.TopIpAggregatorFunctionSupplier;
 import org.elasticsearch.compute.aggregation.TopLongAggregatorFunctionSupplier;
 import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
+import org.elasticsearch.xpack.esql.common.Failures;
 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.tree.NodeInfo;
 import org.elasticsearch.xpack.esql.core.tree.Source;
@@ -30,6 +31,8 @@ import org.elasticsearch.xpack.esql.expression.SurrogateExpression;
 import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionType;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
 import org.elasticsearch.xpack.esql.planner.ToAggregator;
@@ -39,14 +42,17 @@ import java.util.List;
 
 import static java.util.Arrays.asList;
 import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
+import static org.elasticsearch.xpack.esql.common.Failure.fail;
 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.ParamOrdinal.THIRD;
-import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNullAndFoldable;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPostOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
 
-public class Top extends AggregateFunction implements ToAggregator, SurrogateExpression {
+public class Top extends AggregateFunction implements ToAggregator, SurrogateExpression, PostOptimizationVerificationAware {
     public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Top", Top::new);
 
     private static final String ORDER_ASC = "ASC";
@@ -116,16 +122,18 @@ public class Top extends AggregateFunction implements ToAggregator, SurrogateExp
         return parameters().get(1);
     }
 
-    private int limitValue() {
-        return (int) limitField().fold(FoldContext.small() /* TODO remove me */);
-    }
-
-    private String orderRawValue() {
-        return BytesRefs.toString(orderField().fold(FoldContext.small() /* TODO remove me */));
+    private Integer limitValue() {
+        return FunctionUtils.limitValue(limitField(), sourceText());
     }
 
     private boolean orderValue() {
-        return orderRawValue().equalsIgnoreCase(ORDER_ASC);
+        if (orderField() instanceof Literal literal) {
+            String order = BytesRefs.toString(literal.value());
+            if (ORDER_ASC.equalsIgnoreCase(order) || ORDER_DESC.equalsIgnoreCase(order)) {
+                return order.equalsIgnoreCase(ORDER_ASC);
+            }
+        }
+        throw new EsqlIllegalArgumentException("Order value must be a literal, found: " + orderField());
     }
 
     @Override
@@ -148,29 +156,93 @@ public class Top extends AggregateFunction implements ToAggregator, SurrogateExp
             "ip",
             "string",
             "numeric except unsigned_long or counter types"
-        ).and(isNotNullAndFoldable(limitField(), sourceText(), SECOND))
+        ).and(isNotNull(limitField(), sourceText(), SECOND))
             .and(isType(limitField(), dt -> dt == DataType.INTEGER, sourceText(), SECOND, "integer"))
-            .and(isNotNullAndFoldable(orderField(), sourceText(), THIRD))
+            .and(isNotNull(orderField(), sourceText(), THIRD))
             .and(isString(orderField(), sourceText(), THIRD));
 
         if (typeResolution.unresolved()) {
             return typeResolution;
         }
 
-        var limit = limitValue();
-        var order = orderRawValue();
-
-        if (limit <= 0) {
-            return new TypeResolution(format(null, "Limit must be greater than 0 in [{}], found [{}]", sourceText(), limit));
+        TypeResolution result = resolveTypeLimit();
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        result = resolveTypeOrder(forPreOptimizationValidation(orderField()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
         }
+        return TypeResolution.TYPE_RESOLVED;
+    }
 
-        if (order.equalsIgnoreCase(ORDER_ASC) == false && order.equalsIgnoreCase(ORDER_DESC) == false) {
-            return new TypeResolution(
-                format(null, "Invalid order value in [{}], expected [{}, {}] but got [{}]", sourceText(), ORDER_ASC, ORDER_DESC, order)
-            );
+    /**
+     * We check that the limit is not null and that if it is a literal, it is a positive integer
+     * During postOptimizationVerification folding is already done, so we also verify that it is definitively a literal
+     */
+    private TypeResolution resolveTypeLimit() {
+        return FunctionUtils.resolveTypeLimit(limitField(), sourceText(), forPreOptimizationValidation(limitField()));
+    }
+
+    /**
+     * We check that the order is not null and that if it is a literal, it is one of the two valid values: "asc" or "desc".
+     * During postOptimizationVerification folding is already done, so we also verify that it is definitively a literal
+     */
+    private Expression.TypeResolution resolveTypeOrder(TypeResolutionValidator validator) {
+        Expression order = orderField();
+        if (order == null) {
+            validator.invalid(new TypeResolution(format(null, "Order must be a valid string in [{}], found [{}]", sourceText(), order)));
+        } else if (order instanceof Literal literal) {
+            if (literal.value() == null) {
+                validator.invalid(
+                    new TypeResolution(
+                        format(
+                            null,
+                            "Invalid order value in [{}], expected [{}, {}] but got [{}]",
+                            sourceText(),
+                            ORDER_ASC,
+                            ORDER_DESC,
+                            order
+                        )
+                    )
+                );
+            } else {
+                String value = BytesRefs.toString(literal.value());
+                if (value == null || value.equalsIgnoreCase(ORDER_ASC) == false && value.equalsIgnoreCase(ORDER_DESC) == false) {
+                    validator.invalid(
+                        new TypeResolution(
+                            format(
+                                null,
+                                "Invalid order value in [{}], expected [{}, {}] but got [{}]",
+                                sourceText(),
+                                ORDER_ASC,
+                                ORDER_DESC,
+                                order
+                            )
+                        )
+                    );
+                }
+            }
+        } else {
+            // it is expected that the expression is a literal after folding
+            // we fail if it is not a literal
+            validator.invalidIfPostValidation(fail(order, "Order must be a valid string in [{}], found [{}]", sourceText(), order));
         }
+        return validator.getResolvedType();
+    }
 
-        return TypeResolution.TYPE_RESOLVED;
+    @Override
+    public void postOptimizationVerification(Failures failures) {
+        postOptimizationVerificationLimit(failures);
+        postOptimizationVerificationOrder(failures);
+    }
+
+    private void postOptimizationVerificationLimit(Failures failures) {
+        FunctionUtils.resolveTypeLimit(limitField(), sourceText(), forPostOptimizationValidation(limitField(), failures));
+    }
+
+    private void postOptimizationVerificationOrder(Failures failures) {
+        resolveTypeOrder(forPostOptimizationValidation(orderField(), failures));
     }
 
     @Override
@@ -215,15 +287,13 @@ public class Top extends AggregateFunction implements ToAggregator, SurrogateExp
     @Override
     public Expression surrogate() {
         var s = source();
-
-        if (limitValue() == 1) {
+        if (orderField() instanceof Literal && limitField() instanceof Literal && limitValue() == 1) {
             if (orderValue()) {
                 return new Min(s, field());
             } else {
                 return new Max(s, field());
             }
         }
-
         return null;
     }
 }

+ 21 - 15
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextFunction.java

@@ -7,7 +7,6 @@
 
 package org.elasticsearch.xpack.esql.expression.function.fulltext;
 
-import org.elasticsearch.common.lucene.BytesRefs;
 import org.elasticsearch.compute.lucene.LuceneQueryEvaluator.ShardConfig;
 import org.elasticsearch.compute.lucene.LuceneQueryExpressionEvaluator;
 import org.elasticsearch.compute.lucene.LuceneQueryScoreEvaluator;
@@ -17,11 +16,11 @@ import org.elasticsearch.index.IndexMode;
 import org.elasticsearch.index.query.QueryBuilder;
 import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
 import org.elasticsearch.xpack.esql.capabilities.PostAnalysisPlanVerificationAware;
+import org.elasticsearch.xpack.esql.capabilities.PostOptimizationVerificationAware;
 import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
 import org.elasticsearch.xpack.esql.common.Failures;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
-import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.expression.Nullability;
 import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
 import org.elasticsearch.xpack.esql.core.expression.function.Function;
@@ -55,8 +54,11 @@ import java.util.function.Predicate;
 
 import static org.elasticsearch.xpack.esql.common.Failure.fail;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT;
-import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNullAndFoldable;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPostOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 
 /**
  * Base class for full-text functions that use ES queries to match documents.
@@ -68,7 +70,8 @@ public abstract class FullTextFunction extends Function
         TranslationAware,
         PostAnalysisPlanVerificationAware,
         EvaluatorMapper,
-        ExpressionScoreMapper {
+        ExpressionScoreMapper,
+        PostOptimizationVerificationAware {
 
     private final Expression query;
     private final QueryBuilder queryBuilder;
@@ -108,23 +111,21 @@ public abstract class FullTextFunction extends Function
      * @return type resolution for the query parameter
      */
     protected TypeResolution resolveQuery(TypeResolutions.ParamOrdinal queryOrdinal) {
-        return isString(query(), sourceText(), queryOrdinal).and(isNotNullAndFoldable(query(), sourceText(), queryOrdinal));
+        TypeResolution result = isString(query(), sourceText(), queryOrdinal).and(isNotNull(query(), sourceText(), queryOrdinal));
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     public Expression query() {
         return query;
     }
 
-    /**
-     * Returns the resulting query as an object
-     *
-     * @return query expression as an object
-     */
-    public Object queryAsObject() {
-        Object queryAsObject = query().fold(FoldContext.small() /* TODO remove me */);
-        return BytesRefs.toString(queryAsObject);
-    }
-
     @Override
     public Nullability nullable() {
         return Nullability.FALSE;
@@ -417,4 +418,9 @@ public abstract class FullTextFunction extends Function
         }
         return fieldExpression instanceof FieldAttribute fieldAttribute ? fieldAttribute : null;
     }
+
+    @Override
+    public void postOptimizationVerification(Failures failures) {
+        resolveTypeQuery(query(), sourceText(), forPostOptimizationValidation(query(), failures));
+    }
 }

+ 2 - 2
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java

@@ -20,6 +20,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
 import org.elasticsearch.xpack.esql.optimizer.rules.physical.local.LucenePushdownPredicates;
@@ -28,7 +29,6 @@ import org.elasticsearch.xpack.esql.querydsl.query.KqlQuery;
 
 import java.io.IOException;
 import java.util.List;
-import java.util.Objects;
 
 /**
  * Full text function that performs a {@link KqlQuery} .
@@ -95,7 +95,7 @@ public class Kql extends FullTextFunction {
 
     @Override
     protected Query translate(LucenePushdownPredicates pushdownPredicates, TranslatorHandler handler) {
-        return new KqlQuery(source(), Objects.toString(queryAsObject()));
+        return new KqlQuery(source(), FunctionUtils.queryAsString(query(), sourceText()));
     }
 
     @Override

+ 14 - 6
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Match.java

@@ -19,7 +19,6 @@ import org.elasticsearch.xpack.esql.common.Failures;
 import org.elasticsearch.xpack.esql.core.InvalidArgumentException;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
-import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.expression.MapExpression;
 import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
 import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
@@ -31,6 +30,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.MapParam;
 import org.elasticsearch.xpack.esql.expression.function.OptionalArgument;
 import org.elasticsearch.xpack.esql.expression.function.Options;
@@ -66,7 +66,6 @@ import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.Param
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.THIRD;
 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.DATETIME;
@@ -80,6 +79,8 @@ import static org.elasticsearch.xpack.esql.core.type.DataType.LONG;
 import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT;
 import static org.elasticsearch.xpack.esql.core.type.DataType.UNSIGNED_LONG;
 import static org.elasticsearch.xpack.esql.core.type.DataType.VERSION;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 import static org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison.formatIncompatibleTypesMessage;
 
 /**
@@ -317,13 +318,21 @@ public class Match extends FullTextFunction implements OptionalArgument, PostAna
     }
 
     private TypeResolution resolveQuery() {
-        return isType(
+        TypeResolution result = isType(
             query(),
             QUERY_DATA_TYPES::contains,
             sourceText(),
             SECOND,
             "keyword, boolean, date, date_nanos, double, integer, ip, long, unsigned_long, version"
-        ).and(isNotNullAndFoldable(query(), sourceText(), SECOND));
+        ).and(isNotNull(query(), sourceText(), SECOND));
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     private TypeResolution checkParamCompatibility() {
@@ -395,9 +404,8 @@ public class Match extends FullTextFunction implements OptionalArgument, PostAna
         };
     }
 
-    @Override
     public Object queryAsObject() {
-        Object queryAsObject = query().fold(FoldContext.small() /* TODO remove me */);
+        Object queryAsObject = FunctionUtils.queryAsObject(query(), sourceText());
 
         // Convert BytesRef to string for string-based values
         if (queryAsObject instanceof BytesRef bytesRef) {

+ 14 - 6
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/MatchPhrase.java

@@ -17,7 +17,6 @@ import org.elasticsearch.xpack.esql.common.Failures;
 import org.elasticsearch.xpack.esql.core.InvalidArgumentException;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
-import org.elasticsearch.xpack.esql.core.expression.FoldContext;
 import org.elasticsearch.xpack.esql.core.expression.MapExpression;
 import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
 import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
@@ -28,6 +27,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.MapParam;
 import org.elasticsearch.xpack.esql.expression.function.OptionalArgument;
 import org.elasticsearch.xpack.esql.expression.function.Options;
@@ -56,13 +56,14 @@ import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.Param
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.THIRD;
 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.DATE_NANOS;
 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.TEXT;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 
 /**
  * Full text function that performs a {@link org.elasticsearch.xpack.esql.querydsl.query.MatchPhraseQuery} .
@@ -196,9 +197,17 @@ public class MatchPhrase extends FullTextFunction implements OptionalArgument, P
     }
 
     private TypeResolution resolveQuery() {
-        return isType(query(), QUERY_DATA_TYPES::contains, sourceText(), SECOND, "keyword").and(
-            isNotNullAndFoldable(query(), sourceText(), SECOND)
+        TypeResolution result = isType(query(), QUERY_DATA_TYPES::contains, sourceText(), SECOND, "keyword").and(
+            isNotNull(query(), sourceText(), SECOND)
         );
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     private Map<String, Object> matchPhraseQueryOptions() throws InvalidArgumentException {
@@ -248,9 +257,8 @@ public class MatchPhrase extends FullTextFunction implements OptionalArgument, P
         };
     }
 
-    @Override
     public Object queryAsObject() {
-        Object queryAsObject = query().fold(FoldContext.small() /* TODO remove me */);
+        Object queryAsObject = FunctionUtils.queryAsObject(query(), sourceText());
 
         // Convert BytesRef to string for string-based values
         if (queryAsObject instanceof BytesRef bytesRef) {

+ 14 - 4
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/MultiMatch.java

@@ -27,6 +27,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.MapParam;
 import org.elasticsearch.xpack.esql.expression.function.OptionalArgument;
 import org.elasticsearch.xpack.esql.expression.function.Options;
@@ -66,7 +67,6 @@ import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.Param
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.THIRD;
 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.DATETIME;
@@ -80,6 +80,8 @@ import static org.elasticsearch.xpack.esql.core.type.DataType.LONG;
 import static org.elasticsearch.xpack.esql.core.type.DataType.TEXT;
 import static org.elasticsearch.xpack.esql.core.type.DataType.UNSIGNED_LONG;
 import static org.elasticsearch.xpack.esql.core.type.DataType.VERSION;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 
 /**
  * Full text function that performs a {@link org.elasticsearch.xpack.esql.querydsl.query.MultiMatchQuery} .
@@ -345,7 +347,7 @@ public class MultiMatch extends FullTextFunction implements OptionalArgument, Po
             String fieldName = Match.getNameFromFieldAttribute(fieldAttribute);
             fieldsWithBoost.put(fieldName, 1.0f);
         }
-        return new MultiMatchQuery(source(), Objects.toString(queryAsObject()), fieldsWithBoost, getOptions());
+        return new MultiMatchQuery(source(), FunctionUtils.queryAsString(query(), sourceText()), fieldsWithBoost, getOptions());
     }
 
     @Override
@@ -417,13 +419,21 @@ public class MultiMatch extends FullTextFunction implements OptionalArgument, Po
     }
 
     private TypeResolution resolveQuery() {
-        return isType(
+        TypeResolution result = isType(
             query(),
             QUERY_DATA_TYPES::contains,
             sourceText(),
             FIRST,
             "keyword, boolean, date, date_nanos, double, integer, ip, long, unsigned_long, version"
-        ).and(isNotNullAndFoldable(query(), sourceText(), FIRST));
+        ).and(isNotNull(query(), sourceText(), FIRST));
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     @Override

+ 15 - 4
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryString.java

@@ -24,6 +24,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.MapParam;
 import org.elasticsearch.xpack.esql.expression.function.OptionalArgument;
 import org.elasticsearch.xpack.esql.expression.function.Options;
@@ -62,13 +63,15 @@ import static org.elasticsearch.index.query.QueryStringQueryBuilder.REWRITE_FIEL
 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.isNotNullAndFoldable;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
 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.TEXT;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 
 /**
  * Full text function that performs a {@link QueryStringQuery} .
@@ -311,9 +314,17 @@ public class QueryString extends FullTextFunction implements OptionalArgument {
     public static final Set<DataType> QUERY_DATA_TYPES = Set.of(KEYWORD, TEXT);
 
     private TypeResolution resolveQuery() {
-        return isType(query(), QUERY_DATA_TYPES::contains, sourceText(), FIRST, "keyword, text").and(
-            isNotNullAndFoldable(query(), sourceText(), FIRST)
+        TypeResolution result = isType(query(), QUERY_DATA_TYPES::contains, sourceText(), FIRST, "keyword, text").and(
+            isNotNull(query(), sourceText(), FIRST)
         );
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     private Map<String, Object> queryStringOptions() throws InvalidArgumentException {
@@ -343,7 +354,7 @@ public class QueryString extends FullTextFunction implements OptionalArgument {
 
     @Override
     protected Query translate(LucenePushdownPredicates pushdownPredicates, TranslatorHandler handler) {
-        return new QueryStringQuery(source(), Objects.toString(queryAsObject()), Map.of(), queryStringOptions());
+        return new QueryStringQuery(source(), FunctionUtils.queryAsString(query(), sourceText()), Map.of(), queryStringOptions());
     }
 
     @Override

+ 2 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Term.java

@@ -25,6 +25,7 @@ import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
 import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionUtils;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
 import org.elasticsearch.xpack.esql.optimizer.rules.physical.local.LucenePushdownPredicates;
@@ -133,7 +134,7 @@ public class Term extends FullTextFunction implements PostAnalysisPlanVerificati
     @Override
     protected Query translate(LucenePushdownPredicates pushdownPredicates, TranslatorHandler handler) {
         // Uses a term query that contributes to scoring
-        return new TermQuery(source(), ((FieldAttribute) field()).name(), queryAsObject(), false, true);
+        return new TermQuery(source(), ((FieldAttribute) field()).name(), FunctionUtils.queryAsObject(query(), sourceText()), false, true);
     }
 
     @Override

+ 40 - 9
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/vector/Knn.java

@@ -7,16 +7,19 @@
 
 package org.elasticsearch.xpack.esql.expression.function.vector;
 
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
 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.EsqlIllegalArgumentException;
 import org.elasticsearch.xpack.esql.capabilities.PostAnalysisPlanVerificationAware;
 import org.elasticsearch.xpack.esql.capabilities.TranslationAware;
 import org.elasticsearch.xpack.esql.common.Failures;
 import org.elasticsearch.xpack.esql.core.InvalidArgumentException;
 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.TypeResolutions;
 import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
@@ -49,6 +52,7 @@ import java.util.Objects;
 import java.util.function.BiConsumer;
 
 import static java.util.Map.entry;
+import static org.elasticsearch.common.logging.LoggerMessageFormat.format;
 import static org.elasticsearch.index.query.AbstractQueryBuilder.BOOST_FIELD;
 import static org.elasticsearch.search.vectors.KnnVectorQueryBuilder.K_FIELD;
 import static org.elasticsearch.search.vectors.KnnVectorQueryBuilder.NUM_CANDS_FIELD;
@@ -59,13 +63,15 @@ import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.Param
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.THIRD;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isFoldable;
 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.DENSE_VECTOR;
 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.expression.function.FunctionUtils.TypeResolutionValidator.forPreOptimizationValidation;
+import static org.elasticsearch.xpack.esql.expression.function.FunctionUtils.resolveTypeQuery;
 
 public class Knn extends FullTextFunction implements OptionalArgument, VectorFunction, PostAnalysisPlanVerificationAware {
+    private final Logger log = LogManager.getLogger(getClass());
 
     public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Knn", Knn::readFrom);
 
@@ -207,9 +213,16 @@ public class Knn extends FullTextFunction implements OptionalArgument, VectorFun
     }
 
     private TypeResolution resolveQuery() {
-        return isType(query(), dt -> dt == DENSE_VECTOR, sourceText(), TypeResolutions.ParamOrdinal.SECOND, "dense_vector").and(
-            isNotNullAndFoldable(query(), sourceText(), SECOND)
-        );
+        TypeResolution result = isType(query(), dt -> dt == DENSE_VECTOR, sourceText(), TypeResolutions.ParamOrdinal.SECOND, "dense_vector")
+            .and(isNotNull(query(), sourceText(), SECOND));
+        if (result.unresolved()) {
+            return result;
+        }
+        result = resolveTypeQuery(query(), sourceText(), forPreOptimizationValidation(query()));
+        if (result.equals(TypeResolution.TYPE_RESOLVED) == false) {
+            return result;
+        }
+        return TypeResolution.TYPE_RESOLVED;
     }
 
     private TypeResolution resolveK() {
@@ -222,6 +235,24 @@ public class Knn extends FullTextFunction implements OptionalArgument, VectorFun
             .and(isNotNull(k(), sourceText(), THIRD));
     }
 
+    public List<Number> queryAsObject() {
+        // we need to check that we got a list and every element in the list is a number
+        Expression query = query();
+        if (query instanceof Literal literal) {
+            @SuppressWarnings("unchecked")
+            List<Number> result = ((List<Number>) literal.value());
+            return result;
+        }
+        throw new EsqlIllegalArgumentException(format(null, "Query value must be a list of numbers in [{}], found [{}]", source(), query));
+    }
+
+    int getKIntValue() {
+        if (k() instanceof Literal literal) {
+            return (int) (Number) literal.value();
+        }
+        throw new EsqlIllegalArgumentException(format(null, "K value must be a constant integer in [{}], found [{}]", source(), k()));
+    }
+
     @Override
     public Expression replaceQueryBuilder(QueryBuilder queryBuilder) {
         return new Knn(source(), field(), query(), k(), options(), queryBuilder, filterExpressions());
@@ -244,13 +275,12 @@ public class Knn extends FullTextFunction implements OptionalArgument, VectorFun
 
         Check.notNull(fieldAttribute, "Knn must have a field attribute as the first argument");
         String fieldName = getNameFromFieldAttribute(fieldAttribute);
-        @SuppressWarnings("unchecked")
-        List<Number> queryFolded = (List<Number>) query().fold(FoldContext.small() /* TODO remove me */);
+        List<Number> queryFolded = queryAsObject();
         float[] queryAsFloats = new float[queryFolded.size()];
         for (int i = 0; i < queryFolded.size(); i++) {
             queryAsFloats[i] = queryFolded.get(i).floatValue();
         }
-        int kValue = ((Number) k().fold(FoldContext.small())).intValue();
+        int kValue = getKIntValue();
 
         Map<String, Object> opts = queryOptions();
         opts.put(K_FIELD.getPreferredName(), kValue);
@@ -340,12 +370,13 @@ public class Knn extends FullTextFunction implements OptionalArgument, VectorFun
         return Objects.equals(field(), knn.field())
             && Objects.equals(query(), knn.query())
             && Objects.equals(queryBuilder(), knn.queryBuilder())
+            && Objects.equals(k(), knn.k())
             && Objects.equals(filterExpressions(), knn.filterExpressions());
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(field(), query(), queryBuilder(), filterExpressions());
+        return Objects.hash(field(), query(), queryBuilder(), k(), filterExpressions());
     }
 
 }

+ 7 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/PostOptimizationPhasePlanVerifier.java

@@ -52,6 +52,13 @@ public abstract class PostOptimizationPhasePlanVerifier<P extends QueryPlan<P>>
     abstract void checkPlanConsistency(P optimizedPlan, Failures failures, Failures depFailures);
 
     private static void verifyOutputNotChanged(QueryPlan<?> optimizedPlan, List<Attribute> expectedOutputAttributes, Failures failures) {
+        // disable this check if there are other failures already
+        // it is possible that some of the attributes are not resolved yet and that is reflected in the failures
+        // we cannot get the datatype on an unresolved attribute
+        // if we try it, it causes an exception and the exception hides the more detailed error message
+        if (failures.hasFailures()) {
+            return;
+        }
         if (dataTypeEquals(expectedOutputAttributes, optimizedPlan.output()) == false) {
             // If the output level is empty we add a column called ProjectAwayColumns.ALL_FIELDS_PROJECTED
             // We will ignore such cases for output verification

+ 0 - 25
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java

@@ -2249,31 +2249,6 @@ public class VerifierTests extends ESTestCase {
         );
     }
 
-    public void testFullTextFunctionsConstantArg() throws Exception {
-        checkFullTextFunctionsConstantArg("match(title, category)", "second");
-        checkFullTextFunctionsConstantArg("qstr(title)", "");
-        checkFullTextFunctionsConstantArg("kql(title)", "");
-        checkFullTextFunctionsConstantArg("match_phrase(title, tags)", "second");
-        if (EsqlCapabilities.Cap.MULTI_MATCH_FUNCTION.isEnabled()) {
-            checkFullTextFunctionsConstantArg("multi_match(category, body)", "first");
-            checkFullTextFunctionsConstantArg("multi_match(concat(title, \"world\"), title)", "first");
-        }
-        if (EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled()) {
-            checkFullTextFunctionsConstantArg("term(title, tags)", "second");
-        }
-        if (EsqlCapabilities.Cap.KNN_FUNCTION_V3.isEnabled()) {
-            checkFullTextFunctionsConstantArg("knn(vector, vector, 10)", "second");
-            checkFullTextFunctionsConstantArg("knn(vector, [0, 1, 2], category)", "third");
-        }
-    }
-
-    private void checkFullTextFunctionsConstantArg(String functionInvocation, String argOrdinal) throws Exception {
-        assertThat(
-            error("from test | where " + functionInvocation, fullTextAnalyzer),
-            containsString(argOrdinal + " argument of [" + functionInvocation + "] must be a constant")
-        );
-    }
-
     public void testInsistNotOnTopOfFrom() {
         assumeTrue("requires snapshot builds", Build.current().isSnapshot());
 

+ 2 - 2
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/TopTests.java

@@ -262,7 +262,7 @@ public class TopTests extends AbstractAggregationTestCase {
                             new TestCaseSupplier.TypedData(null, DataType.INTEGER, "limit").forceLiteral(),
                             new TestCaseSupplier.TypedData(new BytesRef("desc"), DataType.KEYWORD, "order").forceLiteral()
                         ),
-                        "second argument of [source] cannot be null, received [limit]"
+                        "Limit must be a constant integer in [source], found [null]"
                     )
                 ),
                 new TestCaseSupplier(
@@ -273,7 +273,7 @@ public class TopTests extends AbstractAggregationTestCase {
                             new TestCaseSupplier.TypedData(1, DataType.INTEGER, "limit").forceLiteral(),
                             new TestCaseSupplier.TypedData(null, DataType.KEYWORD, "order").forceLiteral()
                         ),
-                        "third argument of [source] cannot be null, received [order]"
+                        "Invalid order value in [source], expected [ASC, DESC] but got [null]"
                     )
                 )
             )

+ 18 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KnnTests.java

@@ -31,6 +31,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.function.Supplier;
 
+import static org.elasticsearch.xpack.esql.SerializationTestUtils.assertSerialization;
 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.DENSE_VECTOR;
@@ -144,4 +145,21 @@ public class KnnTests extends AbstractFunctionTestCase {
         // Fields use synthetic sources, which can't be serialized. So we use the originals instead.
         return newExpression.replaceChildren(expression.children());
     }
+
+    public void testSerializationOfSimple() {
+        // do nothing
+        assumeTrue("can't serialize function", canSerialize());
+        Expression expression = buildFieldExpression(testCase);
+        if (expression instanceof Knn knn) {
+            // The K parameter is not serialized, so we need to remove it from the children
+            // before we compare the serialization results
+            List<Expression> newChildren = knn.children();
+            newChildren.set(2, null); // remove the k parameter
+            Expression knnWithoutK = knn.replaceChildren(newChildren);
+            assertSerialization(knnWithoutK, testCase.getConfiguration());
+        } else {
+            // If not a Knn instance we fail the test as it is supposed to be a Knn function
+            fail("Expression is not Knn");
+        }
+    }
 }

+ 561 - 0
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/230_folding.yml

@@ -0,0 +1,561 @@
+---
+setup:
+  - requires:
+      test_runner_features: [ capabilities, contains ]
+      reason: "make sure new functions run where supported only"
+  - do:
+      indices.create:
+        index: employees
+        body:
+          mappings:
+            properties:
+              hire_date:
+                type: date
+              salary_change:
+                type: double
+              salary:
+                type: integer
+              salary_change_long:
+                type: long
+              name:
+                type: keyword
+              image_vector:
+                type: dense_vector
+                dims: 3
+                index: true
+                similarity: l2_norm
+
+  - do:
+      bulk:
+        index: employees
+        refresh: true
+        body:
+          - { "index": { } }
+          - { "hire_date": "2020-01-01", "salary_change": 100.5, "salary": 50000, "salary_change_long": 100, "name": "Alice Smith", "image_vector": [ 0.1, 0.2, 0.3 ] }
+          - { "index": { } }
+          - { "hire_date": "2021-01-01", "salary_change": 200.5, "salary": 60000, "salary_change_long": 200, "name": "Bob Johnson", "image_vector": [ 0.4, 0.5, 0.6 ] }
+          - { "index": { } }
+          - { "hire_date": "2019-01-01", "salary_change": 50.5, "salary": 40000, "salary_change_long": 50, "name": "Charlie Smith", "image_vector": [ 0.7, 0.8, 0.9 ] }
+
+---
+TOP function with constant folding:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ agg_top ]
+      reason: "Uses TOP function"
+
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | STATS
+                date = TOP(hire_date, 1+1, "dEsc"),
+                double = TOP(salary_change, 100-98, REVERSE("csed")),
+                integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
+                long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
+            | LIMIT 5
+  - match: { columns.0.name: "date" }
+  - match: { columns.1.name: "double" }
+  - match: { columns.2.name: "integer" }
+  - match: { columns.3.name: "long" }
+  - length: { values: 1 }
+  - length: { values.0: 4 }
+  # Check that the values are as expected for the folded constants
+  - match: { values.0.0: [ "2021-01-01T00:00:00.000Z", "2020-01-01T00:00:00.000Z" ] }
+  - match: { values.0.1: [ 200.5, 100.5 ] }
+  - match: { values.0.2: [ 40000, 50000 ] }
+  - match: { values.0.3: [ 50, 100 ] }
+
+
+---
+
+TOP function with negative limit value after folding:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ agg_top ]
+      reason: "Uses TOP function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | STATS
+                date = TOP(hire_date, 10 - 20, "dEsc"),
+                double = TOP(salary_change, 100-98, REVERSE("csed")),
+                integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
+                long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "Limit must be greater than 0 in [TOP(hire_date, 10 - 20, \"dEsc\")], found [-10]" }
+
+---
+
+Top function with invalid sort order:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ agg_top ]
+      reason: "Uses TOP function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | STATS
+                date = TOP(hire_date, 2, REVERSE("csed123")),
+                double = TOP(salary_change, 100-98, REVERSE("csed")),
+                integer = TOP(salary, 4-(1+1), Substring("Ascending",0,3)),
+                long = TOP(salary_change_long, 10 - 4*2, Concat("as","c"))
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "Invalid order value in [TOP(hire_date, 2, REVERSE(\"csed123\"))], expected [ASC, DESC] but got [321desc]" }
+
+---
+
+SAMPLE function with constant folding:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ sample_v3 ]
+      reason: "Uses SAMPLE function"
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | STATS
+                sample_salary = SAMPLE(salary, 1+2)
+            | LIMIT 5
+  - match: { columns.0.name: "sample_salary" }
+  - length: { values: 1 }
+  - length: { values.0: 1 }
+
+---
+
+SAMPLE function with negative limit value after folding:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ sample_v3 ]
+      reason: "Uses SAMPLE function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | STATS
+                sample_salary = SAMPLE(salary, 2-5)
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "Limit must be greater than 0 in [SAMPLE(salary, 2-5)], found [-3]" }
+
+---
+
+MATCH function with foldable query:
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MATCH(salary, 50000+10000)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { columns.0.name: "hire_date" }
+  - match: { columns.1.name: "salary" }
+  - match: { columns.2.name: "salary_change" }
+  - match: { columns.3.name: "salary_change_long" }
+  - match: { columns.4.name: "name" }
+  - length: { values: 1 }
+  - match: { values.0.0: "2021-01-01T00:00:00.000Z" }
+  - match: { values.0.1: 60000 }
+  - match: { values.0.2: 200.5 }
+  - match: { values.0.3: 200 }
+  - match: { values.0.4: "Bob Johnson" }
+
+---
+
+MATCH function with non-foldable query:
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MATCH(salary, salary + 10000 )
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    #We only check that the problematic string is there, because the error message is slightly different in old versions
+    #"Query must be a valid string in [MATCH(salary, salary + 10000 )], found [salary + 10000]"
+    #second argument of [MATCH(salary, salary + 10000 )] must be a constant, received [salary + 10000]
+  - contains: { error.reason: "[MATCH(salary, salary + 10000 )]" }
+
+---
+
+Foldable query using MATCH_PHRASE on name:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ match_phrase_function ]
+      reason: "Uses MATCH_PHRASE function"
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MATCH_PHRASE(name, CONCAT("Bob ", "Johnson"))
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { columns.0.name: "hire_date" }
+  - match: { columns.1.name: "salary" }
+  - match: { columns.2.name: "salary_change" }
+  - match: { columns.3.name: "salary_change_long" }
+  - match: { columns.4.name: "name" }
+  - length: { values: 1 }
+  - match: { values.0.0: "2021-01-01T00:00:00.000Z" }
+  - match: { values.0.1: 60000 }
+  - match: { values.0.2: 200.5 }
+  - match: { values.0.3: 200 }
+  - match: { values.0.4: "Bob Johnson" }
+
+---
+
+Foldable query using MATCH_PHRASE on name but with non-foldable expression:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ match_phrase_function ]
+      reason: "Uses MATCH_PHRASE function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MATCH_PHRASE(name, CONCAT("Bob ", name))
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    #second argument of [MATCH_PHRASE(name, CONCAT("Bob ", name))] must be a constant, received [CONCAT("Bob ", name)]"
+    #Query must be a valid string in [MATCH_PHRASE(name, CONCAT(\"Bob \", name))], found [CONCAT(\"Bob \", name)]
+  - contains: { error.reason: "[MATCH_PHRASE(name, CONCAT(\"Bob \", name))]" }
+
+---
+
+Foldable query using MATCH_PHRASE on name but with non constant query:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ match_phrase_function ]
+      reason: "Uses MATCH_PHRASE function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MATCH_PHRASE(name, name)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    # second argument of [MATCH_PHRASE(name, name)] must be a constant, received [name]
+    # Query must be a valid string in [MATCH_PHRASE(name, name)], found [name
+  - contains: { error.reason: "[MATCH_PHRASE(name, name)]" }
+
+---
+
+
+Foldable query using MULTI_MATCH on name:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ multi_match_function ]
+      reason: "Uses MULTI_MATCH function"
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MULTI_MATCH(CONCAT("Bob ", "Johnson"), name)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { columns.0.name: "hire_date" }
+  - match: { columns.1.name: "salary" }
+  - match: { columns.2.name: "salary_change" }
+  - match: { columns.3.name: "salary_change_long" }
+  - match: { columns.4.name: "name" }
+  - length: { values: 1 }
+  - match: { values.0.0: "2021-01-01T00:00:00.000Z" }
+  - match: { values.0.1: 60000 }
+  - match: { values.0.2: 200.5 }
+  - match: { values.0.3: 200 }
+  - match: { values.0.4: "Bob Johnson" }
+
+---
+
+Foldable query using MULTI_MATCH on name but with non-foldable expression:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ multi_match_function ]
+      reason: "Uses MULTI_MATCH function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MULTI_MATCH(CONCAT("Bob ", name), name)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    # first argument of [MULTI_MATCH(CONCAT("Bob ", name), name)] must be a constant, received [CONCAT("Bob ", name)]
+    # Query must be a valid string in [MULTI_MATCH(CONCAT(\"Bob \", name), name)], found [CONCAT(\"Bob \", name)]
+  - contains: { error.reason: "[MULTI_MATCH(CONCAT(\"Bob \", name), name)]" }
+
+---
+
+Query using MULTI_MATCH on name but with non constant query:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ multi_match_function ]
+      reason: "Uses MULTI_MATCH function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE MULTI_MATCH(name, name)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    #first argument of [MULTI_MATCH(CONCAT("Bob ", name), name)] must be a constant, received [CONCAT("Bob ", name)]
+    #Query must be a valid string in [MULTI_MATCH(name, name)], found [name
+  - contains: { error.reason: "[MULTI_MATCH(name, name)]" }
+
+---
+
+Foldable query using QSTR on name:
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE QSTR(CONCAT("name:", "Bob*"))
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { columns.0.name: "hire_date" }
+  - match: { columns.1.name: "salary" }
+  - match: { columns.2.name: "salary_change" }
+  - match: { columns.3.name: "salary_change_long" }
+  - match: { columns.4.name: "name" }
+  - length: { values: 1 }
+  - match: { values.0.0: "2021-01-01T00:00:00.000Z" }
+  - match: { values.0.1: 60000 }
+  - match: { values.0.2: 200.5 }
+  - match: { values.0.3: 200 }
+  - match: { values.0.4: "Bob Johnson" }
+
+---
+
+Foldable query using QSTR on name but with non-foldable expression:
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE QSTR(CONCAT(name, "Bob"))
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    #first argument of [QSTR(CONCAT(name, "Bob"))] must be a constant, received [CONCAT(name, "Bob")]"
+    #Query must be a valid string in [QSTR(CONCAT(name, \"Bob\"))], found [CONCAT(name, \"Bob\")]
+  - contains: { error.reason: "[QSTR(CONCAT(name, \"Bob\"))]" }
+
+---
+
+Foldable query using KQL on name but with non-foldable expression:
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE KQL(name)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    # We only check that the problematic string is there, because the error message is slightly different in old versions
+    #Query must be a valid string in [KQL(name)], found [name
+    #argument of [KQL(name)] must be a constant, received [name]
+  - contains: { error.reason: "[KQL(name)]" }
+
+---
+
+Foldable query using KNN on image_vector:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ knn_function_v3 ]
+      reason: "Uses KNN function"
+  - do:
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE KNN(image_vector, [0.4, 0.5, 0.9], 1 + 1)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name, image_vector
+            | SORT name
+            | LIMIT 2
+  - match: { columns.0.name: "hire_date" }
+  - match: { columns.1.name: "salary" }
+  - match: { columns.2.name: "salary_change" }
+  - match: { columns.3.name: "salary_change_long" }
+  - match: { columns.4.name: "name" }
+  - match: { columns.5.name: "image_vector" }
+  - length: { values: 2 }
+
+---
+
+Foldable query using KNN on image_vector but with non-foldable expression:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ knn_function_v3 ]
+      reason: "Uses KNN function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE KNN(image_vector, [0.4, 0.5, 0.9], 1+salary)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name, image_vector
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "third argument of [KNN(image_vector, [0.4, 0.5, 0.9], 1+salary)] must be a constant, received [1+salary]" }
+
+---
+
+KNN on non constant k():
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ knn_function_v3 ]
+      reason: "Uses KNN function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE KNN(image_vector, [0.4, 0.5, 0.9], salary)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name, image_vector
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "third argument of [KNN(image_vector, [0.4, 0.5, 0.9], salary)] must be a constant, received [salary" }
+
+---
+
+KNN on non constant query:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ knn_function_v3 ]
+      reason: "Uses KNN function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE KNN(image_vector, image_vector, 1)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name, image_vector
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+    #We only check that the problematic string is there, because the error message is slightly different in old versions
+    #Query must be a valid string in [KNN(image_vector, image_vector, 1)], found [image_vector
+    #second argument of [KNN(image_vector, image_vector, 1)] must be a constant, received [image_vector]
+  - contains: { error.reason: "[KNN(image_vector, image_vector, 1)]" }
+
+---
+
+Query using TERM function on name but with non constant query:
+  - requires:
+      test_runner_features: [ capabilities ]
+      capabilities:
+        - method: POST
+          path: /_query
+          parameters: [ ]
+          capabilities: [ term_function ]
+      reason: "Uses TERM function"
+  - do:
+      catch: bad_request
+      esql.query:
+        body:
+          query: |
+            FROM employees
+            | WHERE TERM(name, salary)
+            | KEEP hire_date, salary, salary_change, salary_change_long, name
+            | LIMIT 5
+  - match: { error.type: "verification_exception" }
+  - contains: { error.reason: "second argument of [TERM(name, salary)] must be [string], found value [salary] type [integer]" }
+