Browse Source

ESQL: Add url_encode function (#133494)

* ESQL: Add url_encode function
Mouhcine Aitounejjar 1 month ago
parent
commit
875e6f3266
17 changed files with 561 additions and 2 deletions
  1. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/url_encode.md
  2. 13 0
      docs/reference/query-languages/esql/_snippets/functions/examples/url_encode.md
  3. 27 0
      docs/reference/query-languages/esql/_snippets/functions/layout/url_encode.md
  4. 7 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/url_encode.md
  5. 9 0
      docs/reference/query-languages/esql/_snippets/functions/types/url_encode.md
  6. 1 0
      docs/reference/query-languages/esql/images/functions/url_encode.svg
  7. 37 0
      docs/reference/query-languages/esql/kibana/definition/functions/url_encode.json
  8. 8 0
      docs/reference/query-languages/esql/kibana/docs/functions/url_encode.md
  9. 36 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
  10. 153 0
      x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeEvaluator.java
  11. 6 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  12. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/ExpressionWritables.java
  13. 3 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  14. 91 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncode.java
  15. 37 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeErrorTests.java
  16. 19 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeSerializationTests.java
  17. 106 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeTests.java

+ 6 - 0
docs/reference/query-languages/esql/_snippets/functions/description/url_encode.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**
+
+URL encodes the input.
+

+ 13 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/url_encode.md

@@ -0,0 +1,13 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Example**
+
+```esql
+ROW u = "https://www.example.com/papers?q=information+retrieval&year=2024&citations=high" | EVAL u = URL_ENCODE(u)
+```
+
+| u:keyword |
+| --- |
+| https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh |
+
+

+ 27 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/url_encode.md

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

+ 7 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/url_encode.md

@@ -0,0 +1,7 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`string`
+:   URL to encode.
+

+ 9 - 0
docs/reference/query-languages/esql/_snippets/functions/types/url_encode.md

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

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="336" height="46" viewbox="0 0 336 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 31h5m140 0h10m32 0h10m92 0h10m32 0h5"/><rect class="s" x="5" y="5" width="140" height="36"/><text class="k" x="15" y="31">URL_ENCODE</text><rect class="s" x="155" y="5" width="32" height="36" rx="7"/><text class="syn" x="165" y="31">(</text><rect class="s" x="197" y="5" width="92" height="36" rx="7"/><text class="k" x="207" y="31">string</text><rect class="s" x="299" y="5" width="32" height="36" rx="7"/><text class="syn" x="309" y="31">)</text></svg>

+ 37 - 0
docs/reference/query-languages/esql/kibana/definition/functions/url_encode.json

@@ -0,0 +1,37 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "scalar",
+  "name" : "url_encode",
+  "description" : "URL encodes the input.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "URL to encode."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "keyword"
+    },
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "text",
+          "optional" : false,
+          "description" : "URL to encode."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "keyword"
+    }
+  ],
+  "examples" : [
+    "ROW u = \"https://www.example.com/papers?q=information+retrieval&year=2024&citations=high\" | EVAL u = URL_ENCODE(u)"
+  ],
+  "preview" : true,
+  "snapshot_only" : true
+}

+ 8 - 0
docs/reference/query-languages/esql/kibana/docs/functions/url_encode.md

@@ -0,0 +1,8 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### URL ENCODE
+URL encodes the input.
+
+```esql
+ROW u = "https://www.example.com/papers?q=information+retrieval&year=2024&citations=high" | EVAL u = URL_ENCODE(u)
+```

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

@@ -2465,3 +2465,39 @@ warning:Line 2:9: java.lang.IllegalArgumentException: single-value function enco
     @timestamp:date     | message:text
 2023-10-23T13:55:01.544Z|Connected to 10.1.0.1
 ;
+
+url_encode sample for docs
+required_capability: url_encode
+
+// tag::url_encode[]
+ROW u = "https://www.example.com/papers?q=information+retrieval&year=2024&citations=high" | EVAL u = URL_ENCODE(u)
+// end::url_encode[]
+;
+
+// tag::url_encode-result[]
+u:keyword
+https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh
+// end::url_encode-result[]
+;
+
+url_encode mixed functions tests
+required_capability: url_encode
+
+FROM employees 
+| WHERE emp_no == 10001 
+| EVAL a = TRIM(URL_ENCODE(first_name))
+| EVAL b = URL_ENCODE(TO_LOWER(first_name))
+| KEEP a,b;
+
+a:keyword | b:keyword
+Georgi    | georgi 
+;
+
+url_encode mixed input tests
+required_capability: url_encode
+
+ROW u = ["hello elastic!", "a+b-c%d", "", "!#$&'()*+,/:;=?@[]"] | EVAL u = URL_ENCODE(u);
+
+u:keyword
+["hello+elastic%21", "a%2Bb-c%25d", "", "%21%23%24%26%27%28%29*%2B%2C%2F%3A%3B%3D%3F%40%5B%5D"]
+;

+ 153 - 0
x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeEvaluator.java

@@ -0,0 +1,153 @@
+// 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.convert;
+
+import java.lang.Override;
+import java.lang.String;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.RamUsageEstimator;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.OrdinalBytesRefVector;
+import org.elasticsearch.compute.data.Vector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link UrlEncode}.
+ * This class is generated. Edit {@code ConvertEvaluatorImplementer} instead.
+ */
+public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEvaluator {
+  private static final long BASE_RAM_BYTES_USED = RamUsageEstimator.shallowSizeOfInstance(UrlEncodeEvaluator.class);
+
+  private final EvalOperator.ExpressionEvaluator val;
+
+  public UrlEncodeEvaluator(Source source, EvalOperator.ExpressionEvaluator val,
+      DriverContext driverContext) {
+    super(driverContext, source);
+    this.val = val;
+  }
+
+  @Override
+  public EvalOperator.ExpressionEvaluator next() {
+    return val;
+  }
+
+  @Override
+  public Block evalVector(Vector v) {
+    BytesRefVector vector = (BytesRefVector) v;
+    OrdinalBytesRefVector ordinals = vector.asOrdinals();
+    if (ordinals != null) {
+      return evalOrdinals(ordinals);
+    }
+    int positionCount = v.getPositionCount();
+    BytesRef scratchPad = new BytesRef();
+    if (vector.isConstant()) {
+      return driverContext.blockFactory().newConstantBytesRefBlockWith(evalValue(vector, 0, scratchPad), positionCount);
+    }
+    try (BytesRefBlock.Builder builder = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        builder.appendBytesRef(evalValue(vector, p, scratchPad));
+      }
+      return builder.build();
+    }
+  }
+
+  private BytesRef evalValue(BytesRefVector container, int index, BytesRef scratchPad) {
+    BytesRef value = container.getBytesRef(index, scratchPad);
+    return UrlEncode.process(value);
+  }
+
+  @Override
+  public Block evalBlock(Block b) {
+    BytesRefBlock block = (BytesRefBlock) b;
+    int positionCount = block.getPositionCount();
+    try (BytesRefBlock.Builder builder = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+      BytesRef scratchPad = new BytesRef();
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = block.getValueCount(p);
+        int start = block.getFirstValueIndex(p);
+        int end = start + valueCount;
+        boolean positionOpened = false;
+        boolean valuesAppended = false;
+        for (int i = start; i < end; i++) {
+          BytesRef value = evalValue(block, i, scratchPad);
+          if (positionOpened == false && valueCount > 1) {
+            builder.beginPositionEntry();
+            positionOpened = true;
+          }
+          builder.appendBytesRef(value);
+          valuesAppended = true;
+        }
+        if (valuesAppended == false) {
+          builder.appendNull();
+        } else if (positionOpened) {
+          builder.endPositionEntry();
+        }
+      }
+      return builder.build();
+    }
+  }
+
+  private BytesRef evalValue(BytesRefBlock container, int index, BytesRef scratchPad) {
+    BytesRef value = container.getBytesRef(index, scratchPad);
+    return UrlEncode.process(value);
+  }
+
+  private Block evalOrdinals(OrdinalBytesRefVector v) {
+    int positionCount = v.getDictionaryVector().getPositionCount();
+    BytesRef scratchPad = new BytesRef();
+    try (BytesRefVector.Builder builder = driverContext.blockFactory().newBytesRefVectorBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        builder.appendBytesRef(evalValue(v.getDictionaryVector(), p, scratchPad));
+      }
+      IntVector ordinals = v.getOrdinalsVector();
+      ordinals.incRef();
+      return new OrdinalBytesRefVector(ordinals, builder.build()).asBlock();
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "UrlEncodeEvaluator[" + "val=" + val + "]";
+  }
+
+  @Override
+  public void close() {
+    Releasables.closeExpectNoException(val);
+  }
+
+  @Override
+  public long baseRamBytesUsed() {
+    long baseRamBytesUsed = BASE_RAM_BYTES_USED;
+    baseRamBytesUsed += val.baseRamBytesUsed();
+    return baseRamBytesUsed;
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final Source source;
+
+    private final EvalOperator.ExpressionEvaluator.Factory val;
+
+    public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val) {
+      this.source = source;
+      this.val = val;
+    }
+
+    @Override
+    public UrlEncodeEvaluator get(DriverContext context) {
+      return new UrlEncodeEvaluator(source, val.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "UrlEncodeEvaluator[" + "val=" + val + "]";
+    }
+  }
+}

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

@@ -1399,7 +1399,12 @@ public class EsqlCapabilities {
         /**
          * Allow qualifiers in attribute names.
          */
-        NAME_QUALIFIERS(Build.current().isSnapshot());
+        NAME_QUALIFIERS(Build.current().isSnapshot()),
+
+        /**
+         * URL encoding function.
+         */
+        URL_ENCODE(Build.current().isSnapshot());
 
         private final boolean enabled;
 

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

@@ -34,6 +34,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToRadians
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsignedLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncode;
 import org.elasticsearch.xpack.esql.expression.function.scalar.math.Abs;
 import org.elasticsearch.xpack.esql.expression.function.scalar.math.Acos;
 import org.elasticsearch.xpack.esql.expression.function.scalar.math.Asin;
@@ -223,6 +224,7 @@ public class ExpressionWritables {
         entries.add(WildcardLike.ENTRY);
         entries.add(WildcardLikeList.ENTRY);
         entries.add(Delay.ENTRY);
+        entries.add(UrlEncode.ENTRY);
         // mv functions
         entries.addAll(MvFunctionWritables.getNamedWriteables());
         return entries;

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

@@ -83,6 +83,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToString;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToTimeDuration;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsignedLong;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncode;
 import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateDiff;
 import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateExtract;
 import org.elasticsearch.xpack.esql.expression.function.scalar.date.DateFormat;
@@ -513,7 +514,8 @@ public class EsqlFunctionRegistry {
                 def(L1Norm.class, L1Norm::new, "v_l1_norm"),
                 def(L2Norm.class, L2Norm::new, "v_l2_norm"),
                 def(Magnitude.class, Magnitude::new, "v_magnitude"),
-                def(Hamming.class, Hamming::new, "v_hamming") } };
+                def(Hamming.class, Hamming::new, "v_hamming"),
+                def(UrlEncode.class, UrlEncode::new, "url_encode") } };
     }
 
     public EsqlFunctionRegistry snapshotRegistry() {

+ 91 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncode.java

@@ -0,0 +1,91 @@
+/*
+ * 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.convert;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.compute.ann.ConvertEvaluator;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+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.Param;
+import org.elasticsearch.xpack.esql.expression.function.scalar.UnaryScalarFunction;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
+
+public final class UrlEncode extends UnaryScalarFunction {
+
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+        Expression.class,
+        "UrlEncode",
+        UrlEncode::new
+    );
+
+    private UrlEncode(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @FunctionInfo(
+        returnType = "keyword",
+        preview = true,
+        description = "URL encodes the input.",
+        examples = { @Example(file = "string", tag = "url_encode") },
+        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.DEVELOPMENT) }
+    )
+    public UrlEncode(Source source, @Param(name = "string", type = { "keyword", "text" }, description = "URL to encode.") Expression str) {
+        super(source, str);
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new UrlEncode(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, UrlEncode::new, field());
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        if (childrenResolved() == false) {
+            return new TypeResolution("Unresolved children");
+        }
+        return isString(field, sourceText(), TypeResolutions.ParamOrdinal.DEFAULT);
+    }
+
+    @Override
+    public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
+        return new UrlEncodeEvaluator.Factory(source(), toEvaluator.apply(field()));
+    }
+
+    @ConvertEvaluator()
+    static BytesRef process(final BytesRef val) {
+        String input = val.utf8ToString();
+        String encoded = URLEncoder.encode(input, StandardCharsets.UTF_8);
+        return new BytesRef(encoded);
+    }
+
+}

+ 37 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeErrorTests.java

@@ -0,0 +1,37 @@
+/*
+ * 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.convert;
+
+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.ErrorsForCasesWithoutExamplesTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class UrlEncodeErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+    @Override
+    protected List<TestCaseSupplier> cases() {
+        return paramsToSuppliers(UrlEncodeTests.parameters());
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new UrlEncode(source, args.get(0));
+    }
+
+    @Override
+    protected Matcher<String> expectedTypeErrorMatcher(List<Set<DataType>> validPerPosition, List<DataType> signature) {
+        return equalTo(typeErrorMessage(false, validPerPosition, signature, (v, p) -> "string"));
+    }
+}

+ 19 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeSerializationTests.java

@@ -0,0 +1,19 @@
+/*
+ * 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.convert;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.AbstractUnaryScalarSerializationTests;
+
+public class UrlEncodeSerializationTests extends AbstractUnaryScalarSerializationTests<UrlEncode> {
+    @Override
+    protected UrlEncode create(Source source, Expression child) {
+        return new UrlEncode(source, child);
+    }
+}

+ 106 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeTests.java

@@ -0,0 +1,106 @@
+/*
+ * 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.convert;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.lucene.BytesRefs;
+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.FunctionName;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+@FunctionName("url_encode")
+public class UrlEncodeTests extends AbstractScalarFunctionTestCase {
+
+    private record RandomUrl(String plain, String encoded) {}
+
+    public UrlEncodeTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new ArrayList<>();
+
+        for (DataType dataType : DataType.stringTypes()) {
+            suppliers.add(new TestCaseSupplier(List.of(dataType), () -> createTestCaseWithRandomUrl(dataType)));
+
+            for (TestCaseSupplier.TypedDataSupplier supplier : TestCaseSupplier.stringCases(dataType)) {
+                TestCaseSupplier testCaseSupplier = new TestCaseSupplier(
+                    supplier.name(),
+                    List.of(supplier.type()),
+                    () -> createTestCaseWithRandomString(dataType, supplier)
+                );
+                suppliers.add(testCaseSupplier);
+            }
+        }
+
+        return parameterSuppliersFromTypedDataWithDefaultChecksNoErrors(false, suppliers);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new UrlEncode(source, args.get(0));
+    }
+
+    private static TestCaseSupplier.TestCase createTestCaseWithRandomUrl(DataType dataType) {
+        RandomUrl url = generateRandomUrl();
+        BytesRef input = new BytesRef(url.plain());
+        BytesRef output = new BytesRef(url.encoded());
+        TestCaseSupplier.TypedData fieldTypedData = new TestCaseSupplier.TypedData(input, dataType, "string");
+
+        return new TestCaseSupplier.TestCase(
+            List.of(fieldTypedData),
+            "UrlEncodeEvaluator[val=Attribute[channel=0]]",
+            dataType,
+            equalTo(output)
+        );
+    }
+
+    private static TestCaseSupplier.TestCase createTestCaseWithRandomString(
+        DataType dataType,
+        TestCaseSupplier.TypedDataSupplier supplier
+    ) {
+        TestCaseSupplier.TypedData fieldTypedData = supplier.get();
+        BytesRef input = BytesRefs.toBytesRef(fieldTypedData.data());
+        BytesRef output = new BytesRef(URLEncoder.encode(input.utf8ToString(), StandardCharsets.UTF_8));
+
+        return new TestCaseSupplier.TestCase(
+            List.of(fieldTypedData),
+            "UrlEncodeEvaluator[val=Attribute[channel=0]]",
+            dataType,
+            equalTo(output)
+        );
+    }
+
+    private static RandomUrl generateRandomUrl() {
+        String protocol = randomFrom("http://", "https://", "");
+        String domain = String.format(Locale.ROOT, "%s.com", randomAlphaOfLengthBetween(3, 10));
+        String path = randomFrom("", "/" + randomAlphanumericOfLength(5) + "/");
+        String query = randomFrom("", "?" + randomAlphaOfLength(5) + "=" + randomAlphanumericOfLength(5));
+
+        String plain = String.format(Locale.ROOT, "%s%s%s%s", protocol, domain, path, query);
+        String encoded = URLEncoder.encode(plain, StandardCharsets.UTF_8);
+
+        return new RandomUrl(plain, encoded);
+    }
+}