Browse Source

Added esql ends_with implementation (#99613)

Added an implementation for `ends_with` function in esql.  `ends_with` -
Returns a boolean that indicates whether a keyword string ends with
another string. Also made sure that the docs look alright: 

<img width="1677" alt="Screenshot 2023-09-16 at 18 10 46"
src="https://github.com/elastic/elasticsearch/assets/91881042/eccd81e1-40a2-4a66-a514-cf3e4205f9da">
gheorghepucea 2 years ago
parent
commit
d58b9ea87d

+ 2 - 0
docs/reference/esql/esql-functions.asciidoc

@@ -26,6 +26,7 @@ these functions:
 * <<esql-date_parse>>
 * <<esql-date_trunc>>
 * <<esql-e>>
+* <<esql-ends_with>>
 * <<esql-floor>>
 * <<esql-greatest>>
 * <<esql-is_finite>>
@@ -89,6 +90,7 @@ include::functions/date_format.asciidoc[]
 include::functions/date_parse.asciidoc[]
 include::functions/date_trunc.asciidoc[]
 include::functions/e.asciidoc[]
+include::functions/ends_with.asciidoc[]
 include::functions/floor.asciidoc[]
 include::functions/greatest.asciidoc[]
 include::functions/is_finite.asciidoc[]

+ 20 - 0
docs/reference/esql/functions/ends_with.asciidoc

@@ -0,0 +1,20 @@
+[[esql-ends_with]]
+=== `ENDS_WITH`
+[.text-center]
+image::esql/functions/signature/ends_with.svg[Embedded,opts=inline]
+
+Returns a boolean that indicates whether a keyword string ends with another
+string:
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/string.csv-spec[tag=endsWith]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/string.csv-spec[tag=endsWith-result]
+|===
+
+Supported types:
+
+include::types/ends_with.asciidoc[]

+ 1 - 0
docs/reference/esql/functions/signature/ends_with.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="420" height="46" viewbox="0 0 420 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m128 0h10m32 0h10m68 0h10m32 0h10m68 0h10m32 0h5"/><rect class="s" x="5" y="5" width="128" height="36"/><text class="k" x="15" y="31">ENDS_WITH</text><rect class="s" x="143" y="5" width="32" height="36" rx="7"/><text class="syn" x="153" y="31">(</text><rect class="s" x="185" y="5" width="68" height="36" rx="7"/><text class="k" x="195" y="31">arg1</text><rect class="s" x="263" y="5" width="32" height="36" rx="7"/><text class="syn" x="273" y="31">,</text><rect class="s" x="305" y="5" width="68" height="36" rx="7"/><text class="k" x="315" y="31">arg2</text><rect class="s" x="383" y="5" width="32" height="36" rx="7"/><text class="syn" x="393" y="31">)</text></svg>

+ 5 - 0
docs/reference/esql/functions/types/ends_with.asciidoc

@@ -0,0 +1,5 @@
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+arg1 | arg2 | result
+keyword | keyword | boolean
+|===

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

@@ -47,6 +47,21 @@ avg(salary):double | always_false:boolean
  48353.72222222222 | false
 ;
 
+statsWithEndsWithAlwaysTrue
+from employees | where first_name is not null | eval always_true = ends_with(first_name, "") | stats avg(salary) by always_true;
+
+avg(salary):double | always_true:boolean
+ 48353.72222222222 | true
+;
+
+statsWithEndsWithAlwaysFalse
+from employees | where first_name is not null | eval always_false = ends_with(first_name, "noneendswiththis") | stats avg(salary) by always_false;
+
+avg(salary):double | always_false:boolean
+ 48353.72222222222 | false
+;
+
+
 in
 from employees | keep emp_no, is_rehired, still_hired | where is_rehired in (still_hired, true) | where is_rehired != still_hired;
 ignoreOrder:true

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

@@ -30,6 +30,7 @@ date_format              |date_format(arg1, arg2)
 date_parse               |date_parse(arg1, arg2)
 date_trunc               |date_trunc(arg1, arg2)
 e                        |e()
+ends_with                |ends_with(arg1, arg2)
 floor                    |floor(n)
 greatest                 |greatest(first, rest...)
 is_finite                |is_finite(arg1)

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

@@ -760,3 +760,74 @@ Bamford           |ord
 Bernatsky         |sky
 // end::right-result[]
 ;
+
+endsWithConstant
+from employees | sort emp_no | limit 10 | eval f_a = ends_with(first_name, "a") | keep emp_no, first_name, f_a;
+
+emp_no:integer | first_name:keyword  | f_a:boolean
+10001 | Georgi    | false
+10002 | Bezalel   | false
+10003 | Parto     | false
+10004 | Chirstian | false
+10005 | Kyoichi   | false
+10006 | Anneke    | false
+10007 | Tzvetan   | false
+10008 | Saniya    | true
+10009 | Sumant    | false
+10010 | Duangkaew | false
+;
+
+endsWithSequence
+from employees | sort emp_no | limit 10 | eval f_a = ends_with(first_name, "an") | keep emp_no, first_name, f_a;
+
+emp_no:integer | first_name:keyword  | f_a:boolean
+10001 | Georgi    | false
+10002 | Bezalel   | false
+10003 | Parto     | false
+10004 | Chirstian | true
+10005 | Kyoichi   | false
+10006 | Anneke    | false
+10007 | Tzvetan   | true
+10008 | Saniya    | false
+10009 | Sumant    | false
+10010 | Duangkaew | false
+;
+
+endsWithExpression
+from employees | sort emp_no | limit 10
+| eval last_name_last_letter = right(last_name, 1)
+| eval same_last_letters  = ends_with(first_name, last_name_last_letter)
+| keep emp_no, first_name, last_name, same_last_letters;
+
+emp_no:integer | first_name:keyword  | last_name:keyword | same_last_letters:boolean
+10001 | Georgi    | Facello   | false
+10002 | Bezalel   | Simmel    | true
+10003 | Parto     | Bamford   | false
+10004 | Chirstian | Koblick   | false
+10005 | Kyoichi   | Maliniak  | false
+10006 | Anneke    | Preusig   | false
+10007 | Tzvetan   | Zielinski | false
+10008 | Saniya    | Kalloufi  | false
+10009 | Sumant    | Peac      | false
+10010 | Duangkaew | Piveteau  | false
+;
+
+docsEndsWith
+// tag::endsWith[]
+FROM employees
+| KEEP last_name
+| EVAL ln_E = ENDS_WITH(last_name, "d")
+// end::endsWith[]
+| SORT last_name ASC
+| LIMIT 5
+;
+
+// tag::endsWith-result[]
+last_name:keyword | ln_E:boolean
+Awdeh          |false
+Azuma          |false
+Baek           |false
+Bamford        |true
+Bernatsky      |false
+// end::endsWith-result[]
+;

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

@@ -0,0 +1,93 @@
+// 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.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;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link EndsWith}.
+ * This class is generated. Do not edit it.
+ */
+public final class EndsWithEvaluator implements EvalOperator.ExpressionEvaluator {
+  private final EvalOperator.ExpressionEvaluator str;
+
+  private final EvalOperator.ExpressionEvaluator suffix;
+
+  private final DriverContext driverContext;
+
+  public EndsWithEvaluator(EvalOperator.ExpressionEvaluator str,
+      EvalOperator.ExpressionEvaluator suffix, DriverContext driverContext) {
+    this.str = str;
+    this.suffix = suffix;
+    this.driverContext = driverContext;
+  }
+
+  @Override
+  public Block eval(Page page) {
+    Block strUncastBlock = str.eval(page);
+    if (strUncastBlock.areAllValuesNull()) {
+      return Block.constantNullBlock(page.getPositionCount());
+    }
+    BytesRefBlock strBlock = (BytesRefBlock) strUncastBlock;
+    Block suffixUncastBlock = suffix.eval(page);
+    if (suffixUncastBlock.areAllValuesNull()) {
+      return Block.constantNullBlock(page.getPositionCount());
+    }
+    BytesRefBlock suffixBlock = (BytesRefBlock) suffixUncastBlock;
+    BytesRefVector strVector = strBlock.asVector();
+    if (strVector == null) {
+      return eval(page.getPositionCount(), strBlock, suffixBlock);
+    }
+    BytesRefVector suffixVector = suffixBlock.asVector();
+    if (suffixVector == null) {
+      return eval(page.getPositionCount(), strBlock, suffixBlock);
+    }
+    return eval(page.getPositionCount(), strVector, suffixVector).asBlock();
+  }
+
+  public BooleanBlock eval(int positionCount, BytesRefBlock strBlock, BytesRefBlock suffixBlock) {
+    BooleanBlock.Builder result = BooleanBlock.newBlockBuilder(positionCount);
+    BytesRef strScratch = new BytesRef();
+    BytesRef suffixScratch = new BytesRef();
+    position: for (int p = 0; p < positionCount; p++) {
+      if (strBlock.isNull(p) || strBlock.getValueCount(p) != 1) {
+        result.appendNull();
+        continue position;
+      }
+      if (suffixBlock.isNull(p) || suffixBlock.getValueCount(p) != 1) {
+        result.appendNull();
+        continue position;
+      }
+      result.appendBoolean(EndsWith.process(strBlock.getBytesRef(strBlock.getFirstValueIndex(p), strScratch), suffixBlock.getBytesRef(suffixBlock.getFirstValueIndex(p), suffixScratch)));
+    }
+    return result.build();
+  }
+
+  public BooleanVector eval(int positionCount, BytesRefVector strVector,
+      BytesRefVector suffixVector) {
+    BooleanVector.Builder result = BooleanVector.newVectorBuilder(positionCount);
+    BytesRef strScratch = new BytesRef();
+    BytesRef suffixScratch = new BytesRef();
+    position: for (int p = 0; p < positionCount; p++) {
+      result.appendBoolean(EndsWith.process(strVector.getBytesRef(p, strScratch), suffixVector.getBytesRef(p, suffixScratch)));
+    }
+    return result.build();
+  }
+
+  @Override
+  public String toString() {
+    return "EndsWithEvaluator[" + "str=" + str + ", suffix=" + suffix + "]";
+  }
+}

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

@@ -70,6 +70,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMin;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvSum;
 import org.elasticsearch.xpack.esql.expression.function.scalar.nulls.Coalesce;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Concat;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.LTrim;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length;
@@ -145,7 +146,8 @@ public class EsqlFunctionRegistry extends FunctionRegistry {
                 def(Trim.class, Trim::new, "trim"),
                 def(Left.class, Left::new, "left"),
                 def(Right.class, Right::new, "right"),
-                def(StartsWith.class, StartsWith::new, "starts_with") },
+                def(StartsWith.class, StartsWith::new, "starts_with"),
+                def(EndsWith.class, EndsWith::new, "ends_with") },
             // date
             new FunctionDefinition[] {
                 def(DateExtract.class, DateExtract::new, "date_extract"),

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

@@ -0,0 +1,105 @@
+/*
+ * 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.compute.ann.Evaluator;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xpack.esql.evaluator.mapper.EvaluatorMapper;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.expression.function.scalar.ScalarFunction;
+import org.elasticsearch.xpack.ql.expression.gen.script.ScriptTemplate;
+import org.elasticsearch.xpack.ql.tree.NodeInfo;
+import org.elasticsearch.xpack.ql.tree.Source;
+import org.elasticsearch.xpack.ql.type.DataType;
+import org.elasticsearch.xpack.ql.type.DataTypes;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+
+import static org.elasticsearch.xpack.ql.expression.TypeResolutions.ParamOrdinal.FIRST;
+import static org.elasticsearch.xpack.ql.expression.TypeResolutions.ParamOrdinal.SECOND;
+import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isString;
+
+public class EndsWith extends ScalarFunction implements EvaluatorMapper {
+
+    private final Expression str;
+    private final Expression suffix;
+
+    public EndsWith(Source source, Expression str, Expression suffix) {
+        super(source, Arrays.asList(str, suffix));
+        this.str = str;
+        this.suffix = suffix;
+    }
+
+    @Override
+    public DataType dataType() {
+        return DataTypes.BOOLEAN;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        if (childrenResolved() == false) {
+            return new TypeResolution("Unresolved children");
+        }
+
+        TypeResolution resolution = isString(str, sourceText(), FIRST);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        return isString(suffix, sourceText(), SECOND);
+    }
+
+    @Override
+    public boolean foldable() {
+        return str.foldable() && suffix.foldable();
+    }
+
+    @Override
+    public Object fold() {
+        return EvaluatorMapper.super.fold();
+    }
+
+    @Evaluator
+    static boolean process(BytesRef str, BytesRef suffix) {
+        if (str.length < suffix.length) {
+            return false;
+        }
+        return Arrays.equals(
+            str.bytes,
+            str.offset + str.length - suffix.length,
+            str.offset + str.length,
+            suffix.bytes,
+            suffix.offset,
+            suffix.offset + suffix.length
+        );
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new EndsWith(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, EndsWith::new, str, suffix);
+    }
+
+    @Override
+    public ScriptTemplate asScript() {
+        throw new UnsupportedOperationException("functions do not support scripting");
+    }
+
+    @Override
+    public ExpressionEvaluator.Factory toEvaluator(Function<Expression, ExpressionEvaluator.Factory> toEvaluator) {
+        var strEval = toEvaluator.apply(str);
+        var suffixEval = toEvaluator.apply(suffix);
+        return dvrCtx -> new EndsWithEvaluator(strEval.get(dvrCtx), suffixEval.get(dvrCtx), dvrCtx);
+    }
+}

+ 13 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/io/stream/PlanNamedTypes.java

@@ -88,6 +88,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMin;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvSum;
 import org.elasticsearch.xpack.esql.expression.function.scalar.nulls.Coalesce;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Concat;
+import org.elasticsearch.xpack.esql.expression.function.scalar.string.EndsWith;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.LTrim;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Left;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.Length;
@@ -345,6 +346,7 @@ public final class PlanNamedTypes {
             of(ScalarFunction.class, Round.class, PlanNamedTypes::writeRound, PlanNamedTypes::readRound),
             of(ScalarFunction.class, Pow.class, PlanNamedTypes::writePow, PlanNamedTypes::readPow),
             of(ScalarFunction.class, StartsWith.class, PlanNamedTypes::writeStartsWith, PlanNamedTypes::readStartsWith),
+            of(ScalarFunction.class, EndsWith.class, PlanNamedTypes::writeEndsWith, PlanNamedTypes::readEndsWith),
             of(ScalarFunction.class, Substring.class, PlanNamedTypes::writeSubstring, PlanNamedTypes::readSubstring),
             of(ScalarFunction.class, Left.class, PlanNamedTypes::writeLeft, PlanNamedTypes::readLeft),
             of(ScalarFunction.class, Right.class, PlanNamedTypes::writeRight, PlanNamedTypes::readRight),
@@ -1283,6 +1285,17 @@ public final class PlanNamedTypes {
         out.writeExpression(fields.get(1));
     }
 
+    static EndsWith readEndsWith(PlanStreamInput in) throws IOException {
+        return new EndsWith(Source.EMPTY, in.readExpression(), in.readExpression());
+    }
+
+    static void writeEndsWith(PlanStreamOutput out, EndsWith endsWith) throws IOException {
+        List<Expression> fields = endsWith.children();
+        assert fields.size() == 2;
+        out.writeExpression(fields.get(0));
+        out.writeExpression(fields.get(1));
+    }
+
     static Substring readSubstring(PlanStreamInput in) throws IOException {
         return new Substring(Source.EMPTY, in.readExpression(), in.readExpression(), in.readOptionalNamed(Expression.class));
     }

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

@@ -0,0 +1,130 @@
+/*
+ * 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.xpack.esql.expression.function.TestCaseSupplier;
+import org.elasticsearch.xpack.esql.expression.function.scalar.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.tree.Source;
+import org.elasticsearch.xpack.ql.type.DataType;
+import org.elasticsearch.xpack.ql.type.DataTypes;
+import org.hamcrest.Matcher;
+
+import java.util.LinkedList;
+import java.util.List;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class EndsWithTests extends AbstractScalarFunctionTestCase {
+    public EndsWithTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new LinkedList<>();
+        suppliers.add(new TestCaseSupplier("ends_with empty suffix", () -> {
+            String str = randomAlphaOfLength(5);
+            String suffix = "";
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(new BytesRef(str), DataTypes.KEYWORD, "str"),
+                    new TestCaseSupplier.TypedData(new BytesRef(suffix), DataTypes.KEYWORD, "suffix")
+                ),
+                "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                DataTypes.BOOLEAN,
+                equalTo(str.endsWith(suffix))
+            );
+        }));
+        suppliers.add(new TestCaseSupplier("ends_with empty str", () -> {
+            String str = "";
+            String suffix = randomAlphaOfLength(5);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(new BytesRef(str), DataTypes.KEYWORD, "str"),
+                    new TestCaseSupplier.TypedData(new BytesRef(suffix), DataTypes.KEYWORD, "suffix")
+                ),
+                "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                DataTypes.BOOLEAN,
+                equalTo(str.endsWith(suffix))
+            );
+        }));
+        suppliers.add(new TestCaseSupplier("ends_with one char suffix", () -> {
+            String str = randomAlphaOfLength(5);
+            String suffix = randomAlphaOfLength(1);
+            str = str + suffix;
+
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(new BytesRef(str), DataTypes.KEYWORD, "str"),
+                    new TestCaseSupplier.TypedData(new BytesRef(suffix), DataTypes.KEYWORD, "suffix")
+                ),
+                "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                DataTypes.BOOLEAN,
+                equalTo(str.endsWith(suffix))
+            );
+        }));
+        suppliers.add(new TestCaseSupplier("ends_with no match suffix", () -> {
+            String str = randomAlphaOfLength(5);
+            String suffix = "no_match_suffix";
+            str = suffix + str;
+
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(new BytesRef(str), DataTypes.KEYWORD, "str"),
+                    new TestCaseSupplier.TypedData(new BytesRef(suffix), DataTypes.KEYWORD, "suffix")
+                ),
+                "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                DataTypes.BOOLEAN,
+                equalTo(str.endsWith(suffix))
+            );
+        }));
+        suppliers.add(new TestCaseSupplier("ends_with randomized test", () -> {
+            String str = randomRealisticUnicodeOfLength(5);
+            String suffix = randomRealisticUnicodeOfLength(5);
+            str = str + suffix;
+
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(new BytesRef(str), DataTypes.KEYWORD, "str"),
+                    new TestCaseSupplier.TypedData(new BytesRef(suffix), DataTypes.KEYWORD, "suffix")
+                ),
+                "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                DataTypes.BOOLEAN,
+                equalTo(str.endsWith(suffix))
+            );
+        }));
+        return parameterSuppliersFromTypedData(suppliers);
+    }
+
+    @Override
+    protected DataType expectedType(List<DataType> argTypes) {
+        return DataTypes.BOOLEAN;
+    }
+
+    private Matcher<Object> resultsMatcher(List<TestCaseSupplier.TypedData> typedData) {
+        String str = ((BytesRef) typedData.get(0).data()).utf8ToString();
+        String prefix = ((BytesRef) typedData.get(1).data()).utf8ToString();
+        return equalTo(str.endsWith(prefix));
+    }
+
+    @Override
+    protected List<ArgumentSpec> argSpec() {
+        return List.of(required(strings()), required(strings()));
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new EndsWith(source, args.get(0), args.get(1));
+    }
+}