浏览代码

Adding Contains ESQL String function (#133016)

Adding ES:QL Contains function and tests
Michael Bischoff 1 月之前
父节点
当前提交
b7aaf3105d
共有 19 个文件被更改,包括 743 次插入0 次删除
  1. 5 0
      docs/changelog/133016.yaml
  2. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/contains.md
  3. 14 0
      docs/reference/query-languages/esql/_snippets/functions/examples/contains.md
  4. 23 0
      docs/reference/query-languages/esql/_snippets/functions/layout/contains.md
  5. 10 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/contains.md
  6. 11 0
      docs/reference/query-languages/esql/_snippets/functions/types/contains.md
  7. 1 0
      docs/reference/query-languages/esql/_snippets/lists/string-functions.md
  8. 3 0
      docs/reference/query-languages/esql/functions-operators/string-functions.md
  9. 1 0
      docs/reference/query-languages/esql/images/functions/contains.svg
  10. 85 0
      docs/reference/query-languages/esql/kibana/definition/functions/contains.json
  11. 10 0
      docs/reference/query-languages/esql/kibana/docs/functions/contains.md
  12. 15 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec
  13. 95 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
  14. 154 0
      x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ContainsEvaluator.java
  15. 5 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  16. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  17. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java
  18. 134 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Contains.java
  19. 167 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ContainsTests.java

+ 5 - 0
docs/changelog/133016.yaml

@@ -0,0 +1,5 @@
+pr: 133016
+summary: Adding Contains ESQL String function
+area: ES|QL
+type: feature
+issues: []

+ 6 - 0
docs/reference/query-languages/esql/_snippets/functions/description/contains.md

@@ -0,0 +1,6 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+Returns true if a keyword substring is within another string. Returns false if the substring cannot be found.
+

+ 14 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/contains.md

@@ -0,0 +1,14 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Example**
+
+```esql
+ROW a = "hello"
+| EVAL has_ll = CONTAINS(a, "ll")
+```
+
+| a:keyword | has_ll:boolean |
+| --- | --- |
+| hello | true |
+
+

+ 23 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/contains.md

@@ -0,0 +1,23 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+## `CONTAINS` [esql-contains]
+
+**Syntax**
+
+:::{image} ../../../images/functions/contains.svg
+:alt: Embedded
+:class: text-center
+:::
+
+
+:::{include} ../parameters/contains.md
+:::
+
+:::{include} ../description/contains.md
+:::
+
+:::{include} ../types/contains.md
+:::
+
+:::{include} ../examples/contains.md
+:::

+ 10 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/contains.md

@@ -0,0 +1,10 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`string`
+:   An input string
+
+`substring`
+:   A substring to find in the input string
+

+ 11 - 0
docs/reference/query-languages/esql/_snippets/functions/types/contains.md

@@ -0,0 +1,11 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Supported types**
+
+| string | substring | result |
+| --- | --- | --- |
+| keyword | keyword | boolean |
+| keyword | text | boolean |
+| text | keyword | boolean |
+| text | text | boolean |
+

+ 1 - 0
docs/reference/query-languages/esql/_snippets/lists/string-functions.md

@@ -1,6 +1,7 @@
 * [`BIT_LENGTH`](../../functions-operators/string-functions.md#esql-bit_length)
 * [`BYTE_LENGTH`](../../functions-operators/string-functions.md#esql-byte_length)
 * [`CONCAT`](../../functions-operators/string-functions.md#esql-concat)
+* [`CONTAINS`](../../functions-operators/string-functions.md#esql-contains)
 * [`ENDS_WITH`](../../functions-operators/string-functions.md#esql-ends_with)
 * [`FROM_BASE64`](../../functions-operators/string-functions.md#esql-from_base64)
 * [`HASH`](../../functions-operators/string-functions.md#esql-hash)

+ 3 - 0
docs/reference/query-languages/esql/functions-operators/string-functions.md

@@ -21,6 +21,9 @@ mapped_pages:
 :::{include} ../_snippets/functions/layout/concat.md
 :::
 
+:::{include} ../_snippets/functions/layout/contains.md
+:::
+
 :::{include} ../_snippets/functions/layout/ends_with.md
 :::
 

+ 1 - 0
docs/reference/query-languages/esql/images/functions/contains.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="492" height="46" viewbox="0 0 492 46"><defs><style type="text/css">.c{fill:none;stroke:#222222;}.k{fill:#000000;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}.s{fill:#e4f4ff;stroke:#222222;}.syn{fill:#8D8D8D;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}</style></defs><path class="c" d="M0 31h5m116 0h10m32 0h10m92 0h10m32 0h10m128 0h10m32 0h5"/><rect class="s" x="5" y="5" width="116" height="36"/><text class="k" x="15" y="31">CONTAINS</text><rect class="s" x="131" y="5" width="32" height="36" rx="7"/><text class="syn" x="141" y="31">(</text><rect class="s" x="173" y="5" width="92" height="36" rx="7"/><text class="k" x="183" y="31">string</text><rect class="s" x="275" y="5" width="32" height="36" rx="7"/><text class="syn" x="285" y="31">,</text><rect class="s" x="317" y="5" width="128" height="36" rx="7"/><text class="k" x="327" y="31">substring</text><rect class="s" x="455" y="5" width="32" height="36" rx="7"/><text class="syn" x="465" y="31">)</text></svg>

+ 85 - 0
docs/reference/query-languages/esql/kibana/definition/functions/contains.json

@@ -0,0 +1,85 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "scalar",
+  "name" : "contains",
+  "description" : "Returns true if a keyword substring is within another string.\nReturns false if the substring cannot be found.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "An input string"
+        },
+        {
+          "name" : "substring",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "A substring to find in the input string"
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "An input string"
+        },
+        {
+          "name" : "substring",
+          "type" : "text",
+          "optional" : false,
+          "description" : "A substring to find in the input string"
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "text",
+          "optional" : false,
+          "description" : "An input string"
+        },
+        {
+          "name" : "substring",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "A substring to find in the input string"
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "text",
+          "optional" : false,
+          "description" : "An input string"
+        },
+        {
+          "name" : "substring",
+          "type" : "text",
+          "optional" : false,
+          "description" : "A substring to find in the input string"
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "ROW a = \"hello\"\n| EVAL has_ll = CONTAINS(a, \"ll\")"
+  ],
+  "preview" : false,
+  "snapshot_only" : false
+}

+ 10 - 0
docs/reference/query-languages/esql/kibana/docs/functions/contains.md

@@ -0,0 +1,10 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### CONTAINS
+Returns true if a keyword substring is within another string.
+Returns false if the substring cannot be found.
+
+```esql
+ROW a = "hello"
+| EVAL has_ll = CONTAINS(a, "ll")
+```

+ 15 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/semantic_text.csv-spec

@@ -515,6 +515,21 @@ result:keyword
 <em>live long and prosper</em>
 ;
 
+contains
+required_capability: semantic_text_field_caps
+
+FROM semantic_text METADATA _id
+| EVAL result = contains(semantic_text_field, "all")
+| KEEP _id, result
+| SORT _id
+;
+
+_id:keyword | result:boolean
+1           | false
+2           | true
+3           | false
+;
+
 endsWith
 required_capability: semantic_text_field_caps
 

+ 95 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec

@@ -388,6 +388,101 @@ emp_no:integer | name:keyword
 10010          | null
 ;
 
+
+contains
+required_capability: fn_contains
+// tag::contains[]
+ROW a = "hello"
+| EVAL has_ll = CONTAINS(a, "ll")
+// end::contains[]
+;
+
+// tag::contains-result[]
+a:keyword | has_ll:boolean
+hello     | true
+// end::contains-result[]
+;
+
+containsFail
+required_capability: fn_contains
+row a = "hello" | eval a_ll = contains(a, "int");
+
+a:keyword | a_ll:boolean
+hello | false
+;
+
+containsLongerSubstr
+required_capability: fn_contains
+row a = "hello" | eval a_ll = contains(a, "farewell");
+
+a:keyword | a_ll:boolean
+hello | false
+;
+
+containsSame
+required_capability: fn_contains
+row a = "hello" | eval a_ll = contains(a, "hello");
+
+a:keyword | a_ll:boolean
+hello | true
+;
+
+containsWithSubstring
+required_capability: fn_contains
+from employees | where emp_no <= 10010 | eval f_s = substring(last_name, 2) | eval f_l = contains(last_name, f_s) | keep emp_no, last_name, f_s, f_l;
+ignoreOrder:true
+
+emp_no:integer | last_name:keyword | f_s:keyword | f_l:boolean
+10001 | Facello   | acello | true
+10002 | Simmel    | immel | true
+10003 | Bamford   | amford | true
+10004 | Koblick   | oblick | true
+10005 | Maliniak  | aliniak | true
+10006 | Preusig   | reusig | true
+10007 | Zielinski | ielinski | true
+10008 | Kalloufi  | alloufi | true
+10009 | Peac      | eac | true
+10010 | Piveteau  | iveteau | true
+;
+
+containsUtf16Emoji
+required_capability: fn_contains
+row a = "🐱Meow!🐶Woof!" | eval f_s = substring(a, 2) | eval f_l = contains(a, f_s);
+
+a:keyword | f_s:keyword | f_l:boolean
+🐱Meow!🐶Woof! | Meow!🐶Woof! | true
+;
+
+containsNestedCase
+required_capability: fn_contains
+row a = "hello" | eval a_ll = CASE(contains(a, "ll"), "success","fail");
+
+a:keyword | a_ll:keyword
+hello | success
+;
+
+containsNestSubstring
+required_capability: fn_contains
+row a = "hello" | eval a_ll = contains(substring(a, 2), "ll");
+
+a:keyword | a_ll:boolean
+hello | true
+;
+
+containsWarnings
+required_capability: fn_contains
+
+from hosts | where host=="epsilon" | eval l1 = contains(host_group, "ate"), l2 = contains(description, "ate") | keep l1, l2;
+ignoreOrder:true
+warning:Line 1:82: evaluation of [contains(description, \"ate\")] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 1:82: java.lang.IllegalArgumentException: single-value function encountered multi-value
+
+l1:boolean | l2:boolean
+true          | null
+true          | null
+null       | false
+;
+
 // Note: no matches in MV returned
 in
 

+ 154 - 0
x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/string/ContainsEvaluator.java

@@ -0,0 +1,154 @@
+// 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.string;
+
+import java.lang.IllegalArgumentException;
+import java.lang.Override;
+import java.lang.String;
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.compute.operator.Warnings;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link Contains}.
+ * This class is generated. Edit {@code EvaluatorImplementer} instead.
+ */
+public final class ContainsEvaluator implements EvalOperator.ExpressionEvaluator {
+  private final Source source;
+
+  private final EvalOperator.ExpressionEvaluator str;
+
+  private final EvalOperator.ExpressionEvaluator substr;
+
+  private final DriverContext driverContext;
+
+  private Warnings warnings;
+
+  public ContainsEvaluator(Source source, EvalOperator.ExpressionEvaluator str,
+      EvalOperator.ExpressionEvaluator substr, DriverContext driverContext) {
+    this.source = source;
+    this.str = str;
+    this.substr = substr;
+    this.driverContext = driverContext;
+  }
+
+  @Override
+  public Block eval(Page page) {
+    try (BytesRefBlock strBlock = (BytesRefBlock) str.eval(page)) {
+      try (BytesRefBlock substrBlock = (BytesRefBlock) substr.eval(page)) {
+        BytesRefVector strVector = strBlock.asVector();
+        if (strVector == null) {
+          return eval(page.getPositionCount(), strBlock, substrBlock);
+        }
+        BytesRefVector substrVector = substrBlock.asVector();
+        if (substrVector == null) {
+          return eval(page.getPositionCount(), strBlock, substrBlock);
+        }
+        return eval(page.getPositionCount(), strVector, substrVector).asBlock();
+      }
+    }
+  }
+
+  public BooleanBlock eval(int positionCount, BytesRefBlock strBlock, BytesRefBlock substrBlock) {
+    try(BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+      BytesRef strScratch = new BytesRef();
+      BytesRef substrScratch = new BytesRef();
+      position: for (int p = 0; p < positionCount; p++) {
+        if (strBlock.isNull(p)) {
+          result.appendNull();
+          continue position;
+        }
+        if (strBlock.getValueCount(p) != 1) {
+          if (strBlock.getValueCount(p) > 1) {
+            warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value"));
+          }
+          result.appendNull();
+          continue position;
+        }
+        if (substrBlock.isNull(p)) {
+          result.appendNull();
+          continue position;
+        }
+        if (substrBlock.getValueCount(p) != 1) {
+          if (substrBlock.getValueCount(p) > 1) {
+            warnings().registerException(new IllegalArgumentException("single-value function encountered multi-value"));
+          }
+          result.appendNull();
+          continue position;
+        }
+        result.appendBoolean(Contains.process(strBlock.getBytesRef(strBlock.getFirstValueIndex(p), strScratch), substrBlock.getBytesRef(substrBlock.getFirstValueIndex(p), substrScratch)));
+      }
+      return result.build();
+    }
+  }
+
+  public BooleanVector eval(int positionCount, BytesRefVector strVector,
+      BytesRefVector substrVector) {
+    try(BooleanVector.FixedBuilder result = driverContext.blockFactory().newBooleanVectorFixedBuilder(positionCount)) {
+      BytesRef strScratch = new BytesRef();
+      BytesRef substrScratch = new BytesRef();
+      position: for (int p = 0; p < positionCount; p++) {
+        result.appendBoolean(p, Contains.process(strVector.getBytesRef(p, strScratch), substrVector.getBytesRef(p, substrScratch)));
+      }
+      return result.build();
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "ContainsEvaluator[" + "str=" + str + ", substr=" + substr + "]";
+  }
+
+  @Override
+  public void close() {
+    Releasables.closeExpectNoException(str, substr);
+  }
+
+  private Warnings warnings() {
+    if (warnings == null) {
+      this.warnings = Warnings.createWarnings(
+              driverContext.warningsMode(),
+              source.source().getLineNumber(),
+              source.source().getColumnNumber(),
+              source.text()
+          );
+    }
+    return warnings;
+  }
+
+  static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final Source source;
+
+    private final EvalOperator.ExpressionEvaluator.Factory str;
+
+    private final EvalOperator.ExpressionEvaluator.Factory substr;
+
+    public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory str,
+        EvalOperator.ExpressionEvaluator.Factory substr) {
+      this.source = source;
+      this.str = str;
+      this.substr = substr;
+    }
+
+    @Override
+    public ContainsEvaluator get(DriverContext context) {
+      return new ContainsEvaluator(source, str.get(context), substr.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "ContainsEvaluator[" + "str=" + str + ", substr=" + substr + "]";
+    }
+  }
+}

+ 5 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java

@@ -190,6 +190,11 @@ public class EsqlCapabilities {
          */
         FN_REVERSE_GRAPHEME_CLUSTERS,
 
+        /**
+         * Support for function {@code CONTAINS}. Done in <a href="https://github.com/elastic/elasticsearch/pull/133016">#133016.</a>
+         */
+        FN_CONTAINS,
+
         /**
          * Support for function {@code CBRT}. Done in #108574.
          */

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

@@ -162,6 +162,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.StYMin;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.BitLength;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.ByteLength;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Concat;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.LTrim;
@@ -369,6 +370,7 @@ public class EsqlFunctionRegistry {
                 def(BitLength.class, BitLength::new, "bit_length"),
                 def(ByteLength.class, ByteLength::new, "byte_length"),
                 def(Concat.class, Concat::new, "concat"),
+                def(Contains.class, Contains::new, "contains"),
                 def(EndsWith.class, EndsWith::new, "ends_with"),
                 def(Hash.class, Hash::new, "hash"),
                 def(LTrim.class, LTrim::new, "ltrim"),

+ 2 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/ScalarFunctionWritables.java

@@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.math.Tau;
 import org.elasticsearch.xpack.esql.expression.function.scalar.nulls.Coalesce;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.BitLength;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Concat;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.Contains;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Hash;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left;
@@ -70,6 +71,7 @@ public class ScalarFunctionWritables {
         entries.add(CIDRMatch.ENTRY);
         entries.add(Coalesce.ENTRY);
         entries.add(Concat.ENTRY);
+        entries.add(Contains.ENTRY);
         entries.add(E.ENTRY);
         entries.add(EndsWith.ENTRY);
         entries.add(FromAggregateMetricDouble.ENTRY);

+ 134 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/Contains.java

@@ -0,0 +1,134 @@
+/*
+ * 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.string;
+
+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.compute.ann.Evaluator;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+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.expression.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.OptionalArgument;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction;
+import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+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.isString;
+
+/**
+ * Contains function, given a string 'a' and a substring 'b', returns true if the substring 'b' is in 'a'.
+ */
+public class Contains extends EsqlScalarFunction implements OptionalArgument {
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Contains", Contains::new);
+
+    private final Expression str;
+    private final Expression substr;
+
+    @FunctionInfo(returnType = "boolean", description = """
+        Returns true if a keyword substring is within another string.
+        Returns false if the substring cannot be found.""", examples = @Example(file = "string", tag = "contains"))
+    public Contains(
+        Source source,
+        @Param(name = "string", type = { "keyword", "text" }, description = "An input string") Expression str,
+        @Param(name = "substring", type = { "keyword", "text" }, description = "A substring to find in the input string") Expression substr
+    ) {
+        super(source, Arrays.asList(str, substr));
+        this.str = str;
+        this.substr = substr;
+    }
+
+    private Contains(StreamInput in) throws IOException {
+        this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class), in.readNamedWriteable(Expression.class));
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        source().writeTo(out);
+        out.writeNamedWriteable(str);
+        out.writeNamedWriteable(substr);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    public DataType dataType() {
+        return DataType.BOOLEAN;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        if (childrenResolved() == false) {
+            return new TypeResolution("Unresolved children");
+        }
+
+        TypeResolution resolution = isString(str, sourceText(), FIRST);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        resolution = isString(substr, sourceText(), SECOND);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+
+        return TypeResolution.TYPE_RESOLVED;
+    }
+
+    @Override
+    public boolean foldable() {
+        return str.foldable() && substr.foldable();
+    }
+
+    @Evaluator
+    static boolean process(BytesRef str, BytesRef substr) {
+        if (str.length < substr.length) {
+            return false;
+        }
+        return str.utf8ToString().contains(substr.utf8ToString());
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new Contains(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, Contains::new, str, substr);
+    }
+
+    @Override
+    public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
+        ExpressionEvaluator.Factory strExpr = toEvaluator.apply(str);
+        ExpressionEvaluator.Factory substrExpr = toEvaluator.apply(substr);
+
+        return new ContainsEvaluator.Factory(source(), strExpr, substrExpr);
+    }
+
+    Expression str() {
+        return str;
+    }
+
+    Expression substr() {
+        return substr;
+    }
+}

+ 167 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/ContainsTests.java

@@ -0,0 +1,167 @@
+/*
+ * 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.string;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.core.Nullable;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * Tests for {@link Locate} function.
+ */
+public class ContainsTests extends AbstractScalarFunctionTestCase {
+    public ContainsTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new ArrayList<>();
+        for (DataType strType : DataType.stringTypes()) {
+            for (DataType substrType : DataType.stringTypes()) {
+                suppliers.add(
+                    supplier(
+                        "",
+                        strType,
+                        substrType,
+                        () -> randomRealisticUnicodeOfCodepointLength(10),
+                        str -> randomRealisticUnicodeOfCodepointLength(2),
+                        String::contains
+                    )
+                );
+                suppliers.add(
+                    supplier(
+                        "exact match ",
+                        strType,
+                        substrType,
+                        () -> randomRealisticUnicodeOfCodepointLength(10),
+                        str -> str,
+                        (str, substr) -> true
+                    )
+                );
+            }
+        }
+
+        // Here follows some non-randomized examples that we want to cover on every run
+        suppliers.add(supplier("a tiger", "a t", true));
+        suppliers.add(supplier("a tiger", "a", true));
+        suppliers.add(supplier("界世", "界", true));
+        suppliers.add(supplier("a tiger", "er", true));
+        suppliers.add(supplier("a tiger", "r", true));
+        suppliers.add(supplier("界世", "世", true));
+        suppliers.add(supplier("a tiger", "ti", true));
+        suppliers.add(supplier("a tiger", "ige", true));
+        suppliers.add(supplier("世界世", "界", true));
+        suppliers.add(supplier("a tiger", "tigers", false));
+        suppliers.add(supplier("a tiger", "ipa", false));
+        suppliers.add(supplier("世界世", "\uD83C\uDF0D", false));
+
+        suppliers.add(supplier("a ti𠜎er", "𠜎er", true));
+        suppliers.add(supplier("a ti𠜎er", "i𠜎e", true));
+        suppliers.add(supplier("a ti𠜎er", "ti𠜎", true));
+        suppliers.add(supplier("a ti𠜎er", "er", true));
+        suppliers.add(supplier("a ti𠜎er", "r", true));
+        suppliers.add(supplier("a ti𠜎er", "a ti𠜎er", true));
+        // prefix
+        suppliers.add(supplier("𠜎abc", "𠜎", true));
+        suppliers.add(supplier("𠜎 abc", "𠜎 ", true));
+        suppliers.add(supplier("𠜎𠜎𠜎abc", "𠜎𠜎𠜎", true));
+        suppliers.add(supplier("𠜎𠜎𠜎 abc", "𠜎𠜎𠜎 ", true));
+        suppliers.add(supplier(" 𠜎𠜎𠜎 abc", " 𠜎𠜎𠜎 ", true));
+        suppliers.add(supplier("𠜎 𠜎 𠜎 abc", "𠜎 𠜎 𠜎 ", true));
+        // suffix
+        suppliers.add(supplier("abc𠜎", "𠜎", true));
+        suppliers.add(supplier("abc 𠜎", " 𠜎", true));
+        suppliers.add(supplier("abc𠜎𠜎𠜎", "𠜎𠜎𠜎", true));
+        suppliers.add(supplier("abc 𠜎𠜎𠜎", " 𠜎𠜎𠜎", true));
+        suppliers.add(supplier("abc𠜎𠜎𠜎 ", "𠜎𠜎𠜎 ", true));
+        // out of range
+        suppliers.add(supplier("𠜎a ti𠜎er", "𠜎a ti𠜎ers", false));
+        suppliers.add(supplier("a ti𠜎er", "aa ti𠜎er", false));
+        suppliers.add(supplier("abc𠜎𠜎", "𠜎𠜎𠜎", false));
+
+        suppliers.add(supplier("🐱Meow!🐶Woof!", "🐱Meow!🐶Woof!", true));
+        suppliers.add(supplier("🐱Meow!🐶Woof!", "Meow!🐶Woof!", true));
+        suppliers.add(supplier("🐱Meow!🐶Woof!", "eow!🐶Woof!", true));
+
+        return parameterSuppliersFromTypedDataWithDefaultChecksNoErrors(true, suppliers);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Contains(source, args.get(0), args.get(1));
+    }
+
+    private static TestCaseSupplier supplier(String str, String substr, @Nullable Boolean expectedValue) {
+        String name = String.format(Locale.ROOT, "\"%s\" in \"%s\"", substr, str);
+        return new TestCaseSupplier(
+            name,
+            types(DataType.KEYWORD, DataType.KEYWORD),
+            () -> testCase(DataType.KEYWORD, DataType.KEYWORD, str, substr, expectedValue)
+        );
+    }
+
+    interface ExpectedValue {
+        boolean expectedValue(String str, String substr);
+    }
+
+    private static TestCaseSupplier supplier(
+        String name,
+        DataType strType,
+        DataType substrType,
+        Supplier<String> strValueSupplier,
+        Function<String, String> substrValueSupplier,
+        ExpectedValue expectedValue
+    ) {
+        List<DataType> types = types(strType, substrType);
+        return new TestCaseSupplier(name + TestCaseSupplier.nameFromTypes(types), types, () -> {
+            String str = strValueSupplier.get();
+            String substr = substrValueSupplier.apply(str);
+            return testCase(strType, substrType, str, substr, expectedValue.expectedValue(str, substr));
+        });
+    }
+
+    private static String expectedToString() {
+        return "ContainsEvaluator[str=Attribute[channel=0], substr=Attribute[channel=1]]";
+    }
+
+    private static List<DataType> types(DataType firstType, DataType secondType) {
+        List<DataType> types = new ArrayList<>();
+        types.add(firstType);
+        types.add(secondType);
+        return types;
+    }
+
+    private static TestCaseSupplier.TestCase testCase(
+        DataType strType,
+        DataType substrType,
+        String str,
+        String substr,
+        Boolean expectedValue
+    ) {
+        List<TestCaseSupplier.TypedData> values = new ArrayList<>();
+        values.add(new TestCaseSupplier.TypedData(str == null ? null : new BytesRef(str), strType, "str"));
+        values.add(new TestCaseSupplier.TypedData(substr == null ? null : new BytesRef(substr), substrType, "substr"));
+        return new TestCaseSupplier.TestCase(values, expectedToString(), DataType.BOOLEAN, equalTo(expectedValue));
+    }
+}