Przeglądaj źródła

ESQL: URL encoding changes (#134503)

- Change URL_ENCODE to encode spaces as +.
- Add a new URL_ENCODE_COMPONENT scalar function, which encodes spaces as %20.
- Both encoding functions encode all characters in the input except for alphanumerics, ., -, _, and ~.
- Allow URL_DECODE to fail gracefully if the input can't be decoded, by returning null and adding a warning in the header, similar to other scalar functions.
- Manually perform percent-encoding directly on the BreakingBytesRefBuilder scratch area, and only if the input was gonna change after encoding.
- Update csv-tests:
  - Test decoding for both + and %20 back to space.
  - Reduce the length of doc lines to less than 76 chars, as mentioned in the contribution guide.
  - Add test cases with fixed strings
- Minor changes to the documentation.
- Update Unit tests:
  - Add fixed test cases that fail decoding
  - Randomize the encoder used in decoding tests
  - Use Apache's PercentEncode as the ground truth during unit testing.
Mouhcine Aitounejjar 2 tygodni temu
rodzic
commit
1af8b3e616
35 zmienionych plików z 933 dodań i 102 usunięć
  1. 1 1
      docs/reference/query-languages/esql/_snippets/functions/description/url_decode.md
  2. 1 1
      docs/reference/query-languages/esql/_snippets/functions/description/url_encode.md
  3. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/url_encode_component.md
  4. 3 2
      docs/reference/query-languages/esql/_snippets/functions/examples/url_decode.md
  5. 2 2
      docs/reference/query-languages/esql/_snippets/functions/examples/url_encode.md
  6. 14 0
      docs/reference/query-languages/esql/_snippets/functions/examples/url_encode_component.md
  7. 27 0
      docs/reference/query-languages/esql/_snippets/functions/layout/url_encode_component.md
  8. 1 1
      docs/reference/query-languages/esql/_snippets/functions/parameters/url_decode.md
  9. 1 1
      docs/reference/query-languages/esql/_snippets/functions/parameters/url_encode.md
  10. 7 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/url_encode_component.md
  11. 9 0
      docs/reference/query-languages/esql/_snippets/functions/types/url_encode_component.md
  12. 1 0
      docs/reference/query-languages/esql/images/functions/url_encode_component.svg
  13. 4 4
      docs/reference/query-languages/esql/kibana/definition/functions/url_decode.json
  14. 4 4
      docs/reference/query-languages/esql/kibana/definition/functions/url_encode.json
  15. 37 0
      docs/reference/query-languages/esql/kibana/definition/functions/url_encode_component.json
  16. 3 2
      docs/reference/query-languages/esql/kibana/docs/functions/url_decode.md
  17. 2 2
      docs/reference/query-languages/esql/kibana/docs/functions/url_encode.md
  18. 9 0
      docs/reference/query-languages/esql/kibana/docs/functions/url_encode_component.md
  19. 67 6
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
  20. 23 27
      x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecodeEvaluator.java
  21. 162 0
      x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentEvaluator.java
  22. 15 6
      x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeEvaluator.java
  23. 5 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  24. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/ExpressionWritables.java
  25. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  26. 3 3
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecode.java
  27. 17 9
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncode.java
  28. 99 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponent.java
  29. 116 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/util/UrlCodecUtils.java
  30. 193 29
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractUrlEncodeDecodeTestCase.java
  31. 1 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecodeTests.java
  32. 37 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentErrorTests.java
  33. 21 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentSerializationTests.java
  34. 37 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentTests.java
  35. 1 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeTests.java

+ 1 - 1
docs/reference/query-languages/esql/_snippets/functions/description/url_decode.md

@@ -2,5 +2,5 @@
 
 **Description**
 
-URL decodes the input.
+URL-decodes the input, or returns `null` and adds a warning header to the response if the input cannot be decoded.
 

+ 1 - 1
docs/reference/query-languages/esql/_snippets/functions/description/url_encode.md

@@ -2,5 +2,5 @@
 
 **Description**
 
-URL encodes the input.
+URL-encodes the input. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `+`.
 

+ 6 - 0
docs/reference/query-languages/esql/_snippets/functions/description/url_encode_component.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. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `%20`.
+

+ 3 - 2
docs/reference/query-languages/esql/_snippets/functions/examples/url_decode.md

@@ -3,11 +3,12 @@
 **Example**
 
 ```esql
-ROW u = "https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh" | EVAL u = URL_DECODE(u)
+ROW u = "https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz"
+| EVAL u = URL_DECODE(u)
 ```
 
 | u:keyword |
 | --- |
-| https://www.example.com/papers?q=information+retrieval&year=2024&citations=high |
+| https://example.com/?x=foo bar&y=baz |
 
 

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

@@ -3,11 +3,11 @@
 **Example**
 
 ```esql
-ROW u = "https://www.example.com/papers?q=information+retrieval&year=2024&citations=high" | EVAL u = URL_ENCODE(u)
+ROW u = "https://example.com/?x=foo bar&y=baz" | EVAL u = URL_ENCODE(u)
 ```
 
 | u:keyword |
 | --- |
-| https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh |
+| https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo+bar%26y%3Dbaz |
 
 

+ 14 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/url_encode_component.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 u = "https://example.com/?x=foo bar&y=baz"
+| EVAL u = URL_ENCODE_COMPONENT(u)
+```
+
+| u:keyword |
+| --- |
+| https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz |
+
+

+ 27 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/url_encode_component.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_COMPONENT` [esql-url_encode_component]
+```{applies_to}
+stack: development
+serverless: preview
+```
+
+**Syntax**
+
+:::{image} ../../../images/functions/url_encode_component.svg
+:alt: Embedded
+:class: text-center
+:::
+
+
+:::{include} ../parameters/url_encode_component.md
+:::
+
+:::{include} ../description/url_encode_component.md
+:::
+
+:::{include} ../types/url_encode_component.md
+:::
+
+:::{include} ../examples/url_encode_component.md
+:::

+ 1 - 1
docs/reference/query-languages/esql/_snippets/functions/parameters/url_decode.md

@@ -3,5 +3,5 @@
 **Parameters**
 
 `string`
-:   URL encoded string to decode.
+:   The URL-encoded string to decode.
 

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

@@ -3,5 +3,5 @@
 **Parameters**
 
 `string`
-:   URL to encode.
+:   The URL to encode.
 

+ 7 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/url_encode_component.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`
+:   The URL to encode.
+

+ 9 - 0
docs/reference/query-languages/esql/_snippets/functions/types/url_encode_component.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_component.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="456" height="46" viewbox="0 0 456 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 31h5m260 0h10m32 0h10m92 0h10m32 0h5"/><rect class="s" x="5" y="5" width="260" height="36"/><text class="k" x="15" y="31">URL_ENCODE_COMPONENT</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="92" height="36" rx="7"/><text class="k" x="327" y="31">string</text><rect class="s" x="419" y="5" width="32" height="36" rx="7"/><text class="syn" x="429" y="31">)</text></svg>

+ 4 - 4
docs/reference/query-languages/esql/kibana/definition/functions/url_decode.json

@@ -2,7 +2,7 @@
   "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
   "type" : "scalar",
   "name" : "url_decode",
-  "description" : "URL decodes the input.",
+  "description" : "URL-decodes the input, or returns `null` and adds a warning header to the response if the input cannot be decoded.",
   "signatures" : [
     {
       "params" : [
@@ -10,7 +10,7 @@
           "name" : "string",
           "type" : "keyword",
           "optional" : false,
-          "description" : "URL encoded string to decode."
+          "description" : "The URL-encoded string to decode."
         }
       ],
       "variadic" : false,
@@ -22,7 +22,7 @@
           "name" : "string",
           "type" : "text",
           "optional" : false,
-          "description" : "URL encoded string to decode."
+          "description" : "The URL-encoded string to decode."
         }
       ],
       "variadic" : false,
@@ -30,7 +30,7 @@
     }
   ],
   "examples" : [
-    "ROW u = \"https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh\" | EVAL u = URL_DECODE(u)"
+    "ROW u = \"https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz\"\n| EVAL u = URL_DECODE(u)"
   ],
   "preview" : true,
   "snapshot_only" : true

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

@@ -2,7 +2,7 @@
   "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.",
+  "description" : "URL-encodes the input. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `+`.",
   "signatures" : [
     {
       "params" : [
@@ -10,7 +10,7 @@
           "name" : "string",
           "type" : "keyword",
           "optional" : false,
-          "description" : "URL to encode."
+          "description" : "The URL to encode."
         }
       ],
       "variadic" : false,
@@ -22,7 +22,7 @@
           "name" : "string",
           "type" : "text",
           "optional" : false,
-          "description" : "URL to encode."
+          "description" : "The URL to encode."
         }
       ],
       "variadic" : false,
@@ -30,7 +30,7 @@
     }
   ],
   "examples" : [
-    "ROW u = \"https://www.example.com/papers?q=information+retrieval&year=2024&citations=high\" | EVAL u = URL_ENCODE(u)"
+    "ROW u = \"https://example.com/?x=foo bar&y=baz\" | EVAL u = URL_ENCODE(u)"
   ],
   "preview" : true,
   "snapshot_only" : true

+ 37 - 0
docs/reference/query-languages/esql/kibana/definition/functions/url_encode_component.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_component",
+  "description" : "URL-encodes the input. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `%20`.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "The URL to encode."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "keyword"
+    },
+    {
+      "params" : [
+        {
+          "name" : "string",
+          "type" : "text",
+          "optional" : false,
+          "description" : "The URL to encode."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "keyword"
+    }
+  ],
+  "examples" : [
+    "ROW u = \"https://example.com/?x=foo bar&y=baz\"\n| EVAL u = URL_ENCODE_COMPONENT(u)"
+  ],
+  "preview" : true,
+  "snapshot_only" : true
+}

+ 3 - 2
docs/reference/query-languages/esql/kibana/docs/functions/url_decode.md

@@ -1,8 +1,9 @@
 % This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
 
 ### URL DECODE
-URL decodes the input.
+URL-decodes the input, or returns `null` and adds a warning header to the response if the input cannot be decoded.
 
 ```esql
-ROW u = "https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh" | EVAL u = URL_DECODE(u)
+ROW u = "https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz"
+| EVAL u = URL_DECODE(u)
 ```

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

@@ -1,8 +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.
+URL-encodes the input. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `+`.
 
 ```esql
-ROW u = "https://www.example.com/papers?q=information+retrieval&year=2024&citations=high" | EVAL u = URL_ENCODE(u)
+ROW u = "https://example.com/?x=foo bar&y=baz" | EVAL u = URL_ENCODE(u)
 ```

+ 9 - 0
docs/reference/query-languages/esql/kibana/docs/functions/url_encode_component.md

@@ -0,0 +1,9 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### URL ENCODE COMPONENT
+URL-encodes the input. All characters are percent-encoded except for alphanumerics, `.`, `-`, `_`, and `~`. Spaces are encoded as `%20`.
+
+```esql
+ROW u = "https://example.com/?x=foo bar&y=baz"
+| EVAL u = URL_ENCODE_COMPONENT(u)
+```

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

@@ -2583,13 +2583,13 @@ 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)
+ROW u = "https://example.com/?x=foo bar&y=baz" | 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
+https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo+bar%26y%3Dbaz
 // end::url_encode-result[]
 ;
 
@@ -2608,24 +2608,26 @@ Georgi    | georgi
 
 url_encode mixed input tests
 required_capability: url_encode
+required_capability: url_encode_component
 
-ROW u = ["hello elastic!", "a+b-c%d", "", "!#$&'()*+,/:;=?@[]"] | EVAL u = URL_ENCODE(u);
+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"]
+["hello+elastic%21", "a%2Bb-c%25d", "", ".-_~", "%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D", "%F0%9F%94%A5%F0%9F%92%A7"]
 ;
 
 url_decode sample for docs
 required_capability: url_decode
 
 // tag::url_decode[]
-ROW u = "https%3A%2F%2Fwww.example.com%2Fpapers%3Fq%3Dinformation%2Bretrieval%26year%3D2024%26citations%3Dhigh" | EVAL u = URL_DECODE(u)
+ROW u = "https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz" 
+| EVAL u = URL_DECODE(u)
 // end::url_decode[]
 ;
 
 // tag::url_decode-result[]
 u:keyword
-https://www.example.com/papers?q=information+retrieval&year=2024&citations=high
+https://example.com/?x=foo bar&y=baz
 // end::url_decode-result[]
 ;
 
@@ -2636,6 +2638,7 @@ FROM employees
 | WHERE emp_no == 10001 
 | EVAL a = TRIM(URL_DECODE(first_name))
 | EVAL b = URL_DECODE(TO_LOWER(first_name))
+| EVAL c = URL_DECODE(TO_LOWER(first_name))
 | KEEP a,b;
 
 a:keyword | b:keyword
@@ -2651,6 +2654,19 @@ u:keyword
 "!#$&'()*+,/:;=?@[]"
 ;
 
+url_decode bad input tests
+required_capability: url_decode
+required_capability: url_encode_component
+
+ROW u = "%#" | EVAL u = URL_DECODE(u);
+
+warning:Line 1:25: evaluation of [URL_DECODE(u)] failed, treating result as null. Only first 20 failures recorded.
+warning:Line 1:25: java.lang.IllegalArgumentException: URLDecoder: Incomplete trailing escape (%25) pattern
+
+u:keyword
+null
+;
+
 combined url encode decode tests with table reads
 required_capability: url_encode
 required_capability: url_decode
@@ -2684,3 +2700,48 @@ ROW u = ["https://www.example.com/papers?q=information+retrieval&year=2024&citat
 u:keyword
 ["https://www.example.com/papers?q=information+retrieval&year=2024&citations=high", "", "!#$&'()+/:;=?@[]", "💨🔥🪨💧"]
 ;
+
+url_encode_component sample for docs
+required_capability: url_encode_component
+
+// tag::url_encode_component[]
+ROW u = "https://example.com/?x=foo bar&y=baz" 
+| EVAL u = URL_ENCODE_COMPONENT(u)
+// end::url_encode_component[]
+;
+
+// tag::url_encode_component-result[]
+u:keyword
+https%3A%2F%2Fexample.com%2F%3Fx%3Dfoo%20bar%26y%3Dbaz
+// end::url_encode_component-result[]
+;
+
+url_encode_component special input tests
+required_capability: url_encode_component
+required_capability: url_decode
+
+ROW a = "+!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", b = ".-_~", c = "❗🐶🐱"
+| EVAL a = URL_ENCODE_COMPONENT(a)
+| EVAL b = URL_ENCODE_COMPONENT(b)
+| EVAL c = URL_DECODE(URL_ENCODE_COMPONENT(c));
+
+a:keyword                                                                                                                                                   | b:keyword | c:keyword
+"%2B%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~" | ".-_~"    |❗🐶🐱
+;
+
+url_encode_component tests with table reads
+required_capability: url_encode_component
+
+FROM books 
+| EVAL author_encoded = URL_ENCODE_COMPONENT(author), 
+        title_encoded = URL_ENCODE_COMPONENT(title)
+| KEEP book_no, author_encoded, title_encoded
+| SORT book_no
+| WHERE book_no IN ("1211", "1463")
+;
+
+book_no:keyword | author_encoded:keyword | title_encoded:keyword
+1211            | Fyodor%20Dostoevsky    | The%20brothers%20Karamazov
+1463            | J.%20R.%20R.%20Tolkien | Realms%20of%20Tolkien%3A%20Images%20of%20Middle-earth
+;
+

+ 23 - 27
x-pack/plugin/esql/src/main/generated/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecodeEvaluator.java

@@ -4,6 +4,7 @@
 // 2.0.
 package org.elasticsearch.xpack.esql.expression.function.scalar.convert;
 
+import java.lang.IllegalArgumentException;
 import java.lang.Override;
 import java.lang.String;
 import org.apache.lucene.util.BytesRef;
@@ -11,8 +12,6 @@ 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;
@@ -42,18 +41,24 @@ public final class UrlDecodeEvaluator extends AbstractConvertFunction.AbstractEv
   @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 {
+        return driverContext.blockFactory().newConstantBytesRefBlockWith(evalValue(vector, 0, scratchPad), positionCount);
+      } catch (IllegalArgumentException  e) {
+        registerException(e);
+        return driverContext.blockFactory().newConstantNullBlock(positionCount);
+      }
     }
     try (BytesRefBlock.Builder builder = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
       for (int p = 0; p < positionCount; p++) {
-        builder.appendBytesRef(evalValue(vector, p, scratchPad));
+        try {
+          builder.appendBytesRef(evalValue(vector, p, scratchPad));
+        } catch (IllegalArgumentException  e) {
+          registerException(e);
+          builder.appendNull();
+        }
       }
       return builder.build();
     }
@@ -77,13 +82,17 @@ public final class UrlDecodeEvaluator extends AbstractConvertFunction.AbstractEv
         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;
+          try {
+            BytesRef value = evalValue(block, i, scratchPad);
+            if (positionOpened == false && valueCount > 1) {
+              builder.beginPositionEntry();
+              positionOpened = true;
+            }
+            builder.appendBytesRef(value);
+            valuesAppended = true;
+          } catch (IllegalArgumentException  e) {
+            registerException(e);
           }
-          builder.appendBytesRef(value);
-          valuesAppended = true;
         }
         if (valuesAppended == false) {
           builder.appendNull();
@@ -100,19 +109,6 @@ public final class UrlDecodeEvaluator extends AbstractConvertFunction.AbstractEv
     return UrlDecode.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 "UrlDecodeEvaluator[" + "val=" + val + "]";

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

@@ -0,0 +1,162 @@
+// 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 java.util.function.Function;
+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.BreakingBytesRefBuilder;
+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 UrlEncodeComponent}.
+ * This class is generated. Edit {@code ConvertEvaluatorImplementer} instead.
+ */
+public final class UrlEncodeComponentEvaluator extends AbstractConvertFunction.AbstractEvaluator {
+  private static final long BASE_RAM_BYTES_USED = RamUsageEstimator.shallowSizeOfInstance(UrlEncodeComponentEvaluator.class);
+
+  private final EvalOperator.ExpressionEvaluator val;
+
+  private final BreakingBytesRefBuilder scratch;
+
+  public UrlEncodeComponentEvaluator(Source source, EvalOperator.ExpressionEvaluator val,
+      BreakingBytesRefBuilder scratch, DriverContext driverContext) {
+    super(driverContext, source);
+    this.val = val;
+    this.scratch = scratch;
+  }
+
+  @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 UrlEncodeComponent.process(value, this.scratch);
+  }
+
+  @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 UrlEncodeComponent.process(value, this.scratch);
+  }
+
+  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 "UrlEncodeComponentEvaluator[" + "val=" + val + "]";
+  }
+
+  @Override
+  public void close() {
+    Releasables.closeExpectNoException(val, scratch);
+  }
+
+  @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;
+
+    private final Function<DriverContext, BreakingBytesRefBuilder> scratch;
+
+    public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val,
+        Function<DriverContext, BreakingBytesRefBuilder> scratch) {
+      this.source = source;
+      this.val = val;
+      this.scratch = scratch;
+    }
+
+    @Override
+    public UrlEncodeComponentEvaluator get(DriverContext context) {
+      return new UrlEncodeComponentEvaluator(source, val.get(context), scratch.apply(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "UrlEncodeComponentEvaluator[" + "val=" + val + "]";
+    }
+  }
+}

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

@@ -6,6 +6,7 @@ package org.elasticsearch.xpack.esql.expression.function.scalar.convert;
 
 import java.lang.Override;
 import java.lang.String;
+import java.util.function.Function;
 import org.apache.lucene.util.BytesRef;
 import org.apache.lucene.util.RamUsageEstimator;
 import org.elasticsearch.compute.data.Block;
@@ -14,6 +15,7 @@ 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.BreakingBytesRefBuilder;
 import org.elasticsearch.compute.operator.DriverContext;
 import org.elasticsearch.compute.operator.EvalOperator;
 import org.elasticsearch.core.Releasables;
@@ -28,10 +30,13 @@ public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEv
 
   private final EvalOperator.ExpressionEvaluator val;
 
+  private final BreakingBytesRefBuilder scratch;
+
   public UrlEncodeEvaluator(Source source, EvalOperator.ExpressionEvaluator val,
-      DriverContext driverContext) {
+      BreakingBytesRefBuilder scratch, DriverContext driverContext) {
     super(driverContext, source);
     this.val = val;
+    this.scratch = scratch;
   }
 
   @Override
@@ -61,7 +66,7 @@ public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEv
 
   private BytesRef evalValue(BytesRefVector container, int index, BytesRef scratchPad) {
     BytesRef value = container.getBytesRef(index, scratchPad);
-    return UrlEncode.process(value);
+    return UrlEncode.process(value, this.scratch);
   }
 
   @Override
@@ -97,7 +102,7 @@ public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEv
 
   private BytesRef evalValue(BytesRefBlock container, int index, BytesRef scratchPad) {
     BytesRef value = container.getBytesRef(index, scratchPad);
-    return UrlEncode.process(value);
+    return UrlEncode.process(value, this.scratch);
   }
 
   private Block evalOrdinals(OrdinalBytesRefVector v) {
@@ -120,7 +125,7 @@ public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEv
 
   @Override
   public void close() {
-    Releasables.closeExpectNoException(val);
+    Releasables.closeExpectNoException(val, scratch);
   }
 
   @Override
@@ -135,14 +140,18 @@ public final class UrlEncodeEvaluator extends AbstractConvertFunction.AbstractEv
 
     private final EvalOperator.ExpressionEvaluator.Factory val;
 
-    public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val) {
+    private final Function<DriverContext, BreakingBytesRefBuilder> scratch;
+
+    public Factory(Source source, EvalOperator.ExpressionEvaluator.Factory val,
+        Function<DriverContext, BreakingBytesRefBuilder> scratch) {
       this.source = source;
       this.val = val;
+      this.scratch = scratch;
     }
 
     @Override
     public UrlEncodeEvaluator get(DriverContext context) {
-      return new UrlEncodeEvaluator(source, val.get(context), context);
+      return new UrlEncodeEvaluator(source, val.get(context), scratch.apply(context), context);
     }
 
     @Override

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

@@ -1460,6 +1460,11 @@ public class EsqlCapabilities {
          */
         URL_ENCODE(Build.current().isSnapshot()),
 
+        /**
+         * URL component encoding function.
+         */
+        URL_ENCODE_COMPONENT(Build.current().isSnapshot()),
+
         /**
          * URL decoding function.
          */

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

@@ -41,6 +41,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsigne
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlDecode;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncode;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncodeComponent;
 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;
@@ -231,6 +232,7 @@ public class ExpressionWritables {
         entries.add(WildcardLikeList.ENTRY);
         entries.add(Delay.ENTRY);
         entries.add(UrlEncode.ENTRY);
+        entries.add(UrlEncodeComponent.ENTRY);
         entries.add(UrlDecode.ENTRY);
         // mv functions
         entries.addAll(MvFunctionWritables.getNamedWriteables());

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

@@ -96,6 +96,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToUnsigne
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.ToVersion;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlDecode;
 import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncode;
+import org.elasticsearch.xpack.esql.expression.function.scalar.convert.UrlEncodeComponent;
 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;
@@ -541,6 +542,7 @@ public class EsqlFunctionRegistry {
                 def(Magnitude.class, Magnitude::new, "v_magnitude"),
                 def(Hamming.class, Hamming::new, "v_hamming"),
                 def(UrlEncode.class, UrlEncode::new, "url_encode"),
+                def(UrlEncodeComponent.class, UrlEncodeComponent::new, "url_encode_component"),
                 def(UrlDecode.class, UrlDecode::new, "url_decode") } };
     }
 

+ 3 - 3
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecode.java

@@ -44,13 +44,13 @@ public final class UrlDecode extends UnaryScalarFunction {
     @FunctionInfo(
         returnType = "keyword",
         preview = true,
-        description = "URL decodes the input.",
+        description = "URL-decodes the input, or returns `null` and adds a warning header to the response if the input cannot be decoded.",
         examples = { @Example(file = "string", tag = "url_decode") },
         appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.DEVELOPMENT) }
     )
     public UrlDecode(
         Source source,
-        @Param(name = "string", type = { "keyword", "text" }, description = "URL encoded string to decode.") Expression str
+        @Param(name = "string", type = { "keyword", "text" }, description = "The URL-encoded string to decode.") Expression str
     ) {
         super(source, str);
     }
@@ -83,7 +83,7 @@ public final class UrlDecode extends UnaryScalarFunction {
         return new UrlDecodeEvaluator.Factory(source(), toEvaluator.apply(field()));
     }
 
-    @ConvertEvaluator()
+    @ConvertEvaluator(warnExceptions = { IllegalArgumentException.class })
     static BytesRef process(final BytesRef val) {
         String input = val.utf8ToString();
         String decoded = URLDecoder.decode(input, StandardCharsets.UTF_8);

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

@@ -11,6 +11,8 @@ 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.ann.Fixed;
+import org.elasticsearch.compute.operator.BreakingBytesRefBuilder;
 import org.elasticsearch.compute.operator.EvalOperator;
 import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
@@ -22,12 +24,12 @@ import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecyc
 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 org.elasticsearch.xpack.esql.expression.function.scalar.util.UrlCodecUtils;
 
 import java.io.IOException;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
 import java.util.List;
 
+import static org.elasticsearch.compute.ann.Fixed.Scope.THREAD_LOCAL;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
 
 public final class UrlEncode extends UnaryScalarFunction {
@@ -45,11 +47,15 @@ public final class UrlEncode extends UnaryScalarFunction {
     @FunctionInfo(
         returnType = "keyword",
         preview = true,
-        description = "URL encodes the input.",
+        description = "URL-encodes the input. All characters are percent-encoded except for alphanumerics, "
+            + "`.`, `-`, `_`, and `~`. Spaces are encoded as `+`.",
         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) {
+    public UrlEncode(
+        Source source,
+        @Param(name = "string", type = { "keyword", "text" }, description = "The URL to encode.") Expression str
+    ) {
         super(source, str);
     }
 
@@ -78,14 +84,16 @@ public final class UrlEncode extends UnaryScalarFunction {
 
     @Override
     public EvalOperator.ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
-        return new UrlEncodeEvaluator.Factory(source(), toEvaluator.apply(field()));
+        return new UrlEncodeEvaluator.Factory(
+            source(),
+            toEvaluator.apply(field()),
+            context -> new BreakingBytesRefBuilder(context.breaker(), "url_encode")
+        );
     }
 
     @ConvertEvaluator()
-    static BytesRef process(final BytesRef val) {
-        String input = val.utf8ToString();
-        String encoded = URLEncoder.encode(input, StandardCharsets.UTF_8);
-        return new BytesRef(encoded);
+    static BytesRef process(final BytesRef val, @Fixed(includeInToString = false, scope = THREAD_LOCAL) BreakingBytesRefBuilder scratch) {
+        return UrlCodecUtils.urlEncode(val, scratch, true);
     }
 
 }

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

@@ -0,0 +1,99 @@
+/*
+ * 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.ann.Fixed;
+import org.elasticsearch.compute.operator.BreakingBytesRefBuilder;
+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 org.elasticsearch.xpack.esql.expression.function.scalar.util.UrlCodecUtils;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.compute.ann.Fixed.Scope.THREAD_LOCAL;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
+
+public class UrlEncodeComponent extends UnaryScalarFunction {
+
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+        Expression.class,
+        "UrlEncodeComponent",
+        UrlEncodeComponent::new
+    );
+
+    private UrlEncodeComponent(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @FunctionInfo(
+        returnType = "keyword",
+        preview = true,
+        description = "URL-encodes the input. All characters are percent-encoded except for alphanumerics, "
+            + "`.`, `-`, `_`, and `~`. Spaces are encoded as `%20`.",
+        examples = { @Example(file = "string", tag = "url_encode_component") },
+        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.DEVELOPMENT) }
+    )
+    public UrlEncodeComponent(
+        Source source,
+        @Param(name = "string", type = { "keyword", "text" }, description = "The URL to encode.") Expression str
+    ) {
+        super(source, str);
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new UrlEncodeComponent(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, UrlEncodeComponent::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 UrlEncodeComponentEvaluator.Factory(
+            source(),
+            toEvaluator.apply(field()),
+            context -> new BreakingBytesRefBuilder(context.breaker(), "url_encode_component")
+        );
+    }
+
+    @ConvertEvaluator()
+    static BytesRef process(final BytesRef val, @Fixed(includeInToString = false, scope = THREAD_LOCAL) BreakingBytesRefBuilder scratch) {
+        return UrlCodecUtils.urlEncode(val, scratch, false);
+    }
+
+}

+ 116 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/util/UrlCodecUtils.java

@@ -0,0 +1,116 @@
+/*
+ * 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.util;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.operator.BreakingBytesRefBuilder;
+
+public final class UrlCodecUtils {
+
+    private UrlCodecUtils() {}
+
+    private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray();
+
+    public static BytesRef urlEncode(final BytesRef val, BreakingBytesRefBuilder scratch, final boolean plusForSpace) {
+        int size = computeSizeAfterEncoding(val, plusForSpace);
+
+        if (size == -1) {
+            // the input doesn't change after encoding so encoding can be skipped
+            return val;
+        }
+
+        scratch.grow(size);
+        scratch.clear();
+
+        int lo = val.offset;
+        int hi = val.offset + val.length;
+
+        for (int i = lo; i < hi; ++i) {
+            byte b = val.bytes[i];
+            char c = (char) (b & 0xFF);
+
+            if (plusForSpace && c == ' ') {
+                scratch.append((byte) '+');
+                continue;
+            }
+
+            if (isRfc3986Safe(c)) {
+                scratch.append(b);
+                continue;
+            }
+
+            // every encoded byte is represented by 3 chars: %XY
+            scratch.append((byte) '%');
+
+            // the X in %XY is the hex value for the high nibble
+            scratch.append((byte) HEX_DIGITS[(c >> 4) & 0x0F]);
+
+            // the Y in %XY is the hex value for the low nibble
+            scratch.append((byte) HEX_DIGITS[c & 0x0F]);
+        }
+
+        return scratch.bytesRefView();
+    }
+
+    /**
+     * Determines whether a character is considered unreserved (or safe) according to RFC3986. Alphanumerics along with ".-_~" are safe,
+     * and therefore not percent-encoded.
+     *
+     * @param c A character
+     * @return Boolean
+     */
+    public static boolean isRfc3986Safe(char c) {
+        return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || ('0' <= c && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~';
+    }
+
+    /**
+     * <p>Computes the size of the input if it were encoded, and tells whether any encoding is needed at all. For example, if the input only
+     * contained alphanumerics and safe characters, then -1 is returned, to mean that no encoding is needed. If the input additionally
+     * contained spaces which can be encoded as '+', then the new size after encoding is returned.</p>
+     *
+     * <p>Examples</p>
+     * <ul>
+     *     <li>"abc" -> -1 (no encoding needed)</li>
+     *     <li>"a b" ->  3 if encoding spaces as "+". The positive value indicates encoding is needed.</li>
+     *     <li>"a b" ->  5 if encoding spaces as "%20". The positive value indicates encoding is needed.</li>
+     *     <li>""    -> -1 (no encoding needed)</li>
+     * </ul>
+     *
+     * @param val
+     * @param plusForSpace Whether spaces are encoded as + or %20.
+     * @return The new size after encoding, or -1 if no encoding is needed.
+     */
+    private static int computeSizeAfterEncoding(final BytesRef val, final boolean plusForSpace) {
+        int size = 0;
+        boolean noEncodingNeeded = true;
+
+        int lo = val.offset;
+        int hi = val.offset + val.length;
+
+        for (int i = lo; i < hi; ++i) {
+            char c = (char) (val.bytes[i] & 0xFF);
+
+            if (plusForSpace && c == ' ') {
+                ++size;
+                noEncodingNeeded = false;
+            } else if (isRfc3986Safe(c)) {
+                ++size;
+            } else {
+                size += 3;
+                noEncodingNeeded = false;
+            }
+        }
+
+        if (noEncodingNeeded) {
+            return -1;
+        }
+
+        return size;
+    }
+
+}

+ 193 - 29
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractUrlEncodeDecodeTestCase.java

@@ -7,99 +7,263 @@
 
 package org.elasticsearch.xpack.esql.expression.function;
 
+import org.apache.commons.codec.EncoderException;
+import org.apache.commons.codec.net.PercentCodec;
 import org.apache.lucene.util.BytesRef;
 import org.elasticsearch.common.lucene.BytesRefs;
+import org.elasticsearch.core.Tuple;
 import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.scalar.util.UrlCodecUtils;
 
-import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.function.Supplier;
 
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
 
 public abstract class AbstractUrlEncodeDecodeTestCase extends AbstractScalarFunctionTestCase {
 
-    private record RandomUrl(String plain, String encoded) {}
+    private static final PercentCodec urlEncodeCodec;
+    private static final PercentCodec urlEncodeComponentCodec;
+
+    public enum PercentCodecTestType {
+        ENCODE("UrlEncodeEvaluator[val=Attribute[channel=0]]"),
+        ENCODE_COMPONENT("UrlEncodeComponentEvaluator[val=Attribute[channel=0]]"),
+        DECODE("UrlDecodeEvaluator[val=Attribute[channel=0]]");
+
+        public final String evaluatorToString;
+
+        PercentCodecTestType(String evaluatorToString) {
+            this.evaluatorToString = evaluatorToString;
+        }
+
+        public PercentCodec getCodec() {
+            return switch (this) {
+                case ENCODE -> urlEncodeCodec;
+                case ENCODE_COMPONENT -> urlEncodeComponentCodec;
 
-    public static Iterable<Object[]> createParameters(boolean isEncoderTest) {
-        String evaluatorToString = isEncoderTest
-            ? "UrlEncodeEvaluator[val=Attribute[channel=0]]"
-            : "UrlDecodeEvaluator[val=Attribute[channel=0]]";
+                // Randomized decoder tests apply a random encoder to the input to make it decodable. Fixed bad cases for the decoder skip
+                // this by design, in order to assert undecodable input is handled gracefully.
+                case DECODE -> randomBoolean() ? urlEncodeCodec : urlEncodeComponentCodec;
+            };
+        }
+    }
 
+    static {
+        // Both codecs percent-encode all characters in the input except for alphanumerics, '-', '.', '_', and '~'. The space character is a
+        // special case, as it can be either percent-encoded or replaced with a '+'.
+        // During testing, the values generated by both encoders are considered as ground truth, so the results of our implementation
+        // must match that.
+
+        // encodes spaces as '+'
+        byte[] b1 = buildUnsafeBytes(Set.of(' '));
+        urlEncodeCodec = new PercentCodec(b1, true);
+
+        // encodes spaces as '%20'
+        byte[] b2 = buildUnsafeBytes(Set.of());
+        urlEncodeComponentCodec = new PercentCodec(b2, false);
+    }
+
+    private record RandomUrl(String plain, String encoded) {}
+
+    public static Iterable<Object[]> createParameters(PercentCodecTestType codecTestType) {
         List<TestCaseSupplier> suppliers = new ArrayList<>();
 
         for (DataType dataType : DataType.stringTypes()) {
-            Supplier<TestCaseSupplier.TestCase> caseSupplier = () -> createTestCaseWithRandomUrl(
-                dataType,
-                evaluatorToString,
-                isEncoderTest
-            );
-
+            // random URL tests
+            Supplier<TestCaseSupplier.TestCase> caseSupplier = () -> createTestCaseWithRandomUrl(dataType, codecTestType);
             suppliers.add(new TestCaseSupplier(List.of(dataType), caseSupplier));
 
+            // random strings tests
             for (TestCaseSupplier.TypedDataSupplier supplier : TestCaseSupplier.stringCases(dataType)) {
                 TestCaseSupplier testCaseSupplier = new TestCaseSupplier(
                     supplier.name(),
                     List.of(supplier.type()),
-                    () -> createTestCaseWithRandomString(dataType, evaluatorToString, isEncoderTest, supplier)
+                    () -> createTestCaseWithRandomString(dataType, codecTestType, supplier)
                 );
                 suppliers.add(testCaseSupplier);
             }
+
+            // fixed input tests
+            String[] fixedInputs = new String[] {
+                // all safe chars plus a space
+                "foo bar",
+
+                // unicode: right-to-left override (U+202E), math symbols, etc.
+                "ab \u202E cd \u202E ef sigma:\u2211 delta:\u2206 tunes:\u266B radioactive:\u2622 hourglass:\u23F3",
+
+                // safe and unsafe chars
+                "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
+
+                // all ASCII chars
+                new String(allAsciiChars(), StandardCharsets.UTF_8) };
+
+            for (String input : fixedInputs) {
+                suppliers.add(createFixedTestCase(dataType, input, codecTestType));
+            }
+
+            if (codecTestType == PercentCodecTestType.DECODE) {
+                // bad inputs for decoder tests aren't encoded first (as they wouldn't be bad then), but are expected to be handled
+                // gracefully by the decoder.
+
+                List<Tuple<String, String>> tuples = List.of(
+                    // incomplete sequence
+                    Tuple.tuple("%1", "Line 1:1: java.lang.IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern"),
+
+                    // missing sequence
+                    Tuple.tuple("%", "Line 1:1: java.lang.IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern"),
+
+                    // invalid hex digits
+                    Tuple.tuple(
+                        "%xy",
+                        "Line 1:1: java.lang.IllegalArgumentException: URLDecoder: Illegal hex characters in escape (%) pattern - "
+                            + "not a hexadecimal digit: \"x\" = 120"
+                    ),
+
+                    // valid and invalid sequences
+                    Tuple.tuple(
+                        "foo+bar%20qux%mn",
+                        "Line 1:1: java.lang.IllegalArgumentException: URLDecoder: Illegal hex characters in escape (%) pattern - "
+                            + "not a hexadecimal digit: \"m\" = 109"
+                    )
+                );
+
+                for (Tuple<String, String> t : tuples) {
+                    String undecodableInput = t.v1();
+                    String expectedErrorMessage = t.v2();
+                    suppliers.add(createBadDecoderTestCase(dataType, undecodableInput, expectedErrorMessage));
+                }
+            }
         }
 
         return parameterSuppliersFromTypedDataWithDefaultChecksNoErrors(false, suppliers);
 
     }
 
-    public static TestCaseSupplier.TestCase createTestCaseWithRandomUrl(
-        DataType dataType,
-        String evaluatorToString,
-        boolean isEncoderTest
-    ) {
-        RandomUrl url = generateRandomUrl();
+    public static TestCaseSupplier.TestCase createTestCaseWithRandomUrl(DataType dataType, PercentCodecTestType codecTestType) {
+        boolean isEncoderTest = (codecTestType != PercentCodecTestType.DECODE);
+        RandomUrl url = generateRandomUrl(codecTestType);
         BytesRef input = new BytesRef(isEncoderTest ? url.plain() : url.encoded());
         BytesRef output = new BytesRef(isEncoderTest ? url.encoded() : url.plain());
         TestCaseSupplier.TypedData fieldTypedData = new TestCaseSupplier.TypedData(input, dataType, "string");
 
-        return new TestCaseSupplier.TestCase(List.of(fieldTypedData), evaluatorToString, dataType, equalTo(output));
+        return new TestCaseSupplier.TestCase(List.of(fieldTypedData), codecTestType.evaluatorToString, dataType, equalTo(output));
     }
 
     public static TestCaseSupplier.TestCase createTestCaseWithRandomString(
         DataType dataType,
-        String evaluatorToString,
-        boolean isEncoderTest,
+        PercentCodecTestType codecTestType,
         TestCaseSupplier.TypedDataSupplier supplier
     ) {
+        boolean isEncoderTest = (codecTestType != PercentCodecTestType.DECODE);
         TestCaseSupplier.TypedData fieldTypedData = supplier.get();
         String plain = BytesRefs.toBytesRef(fieldTypedData.data()).utf8ToString();
-        String encoded = encode(plain);
+        String encoded = encode(plain, codecTestType);
         BytesRef input = new BytesRef(isEncoderTest ? plain : encoded);
         BytesRef output = new BytesRef(isEncoderTest ? encoded : plain);
 
         return new TestCaseSupplier.TestCase(
             List.of(new TestCaseSupplier.TypedData(input, dataType, "string")),
-            evaluatorToString,
+            codecTestType.evaluatorToString,
             dataType,
             equalTo(output)
         );
     }
 
-    private static RandomUrl generateRandomUrl() {
+    private static RandomUrl generateRandomUrl(PercentCodecTestType codecTestType) {
         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 space = " "; // ensure the correct encoding for space (+ or %20)
 
-        String plain = String.format(Locale.ROOT, "%s%s%s%s", protocol, domain, path, query);
-        String encoded = encode(plain);
+        String plain = String.format(Locale.ROOT, "%s%s%s%s%s", protocol, domain, path, query, space);
+        String encoded = encode(plain, codecTestType);
 
         return new RandomUrl(plain, encoded);
     }
 
-    private static String encode(String plain) {
-        return URLEncoder.encode(plain, StandardCharsets.UTF_8);
+    private static String encode(String plain, PercentCodecTestType codecTestType) {
+        byte[] plainBytes = plain.getBytes(StandardCharsets.UTF_8);
+        byte[] encoded = null;
+
+        try {
+            encoded = codecTestType.getCodec().encode(plainBytes);
+        } catch (EncoderException ex) {
+            // Checked exception isn't really thrown, but we must handle it given the signature of PercentCodec.encode().
+            throw new RuntimeException(ex);
+        }
+
+        return new String(encoded, StandardCharsets.UTF_8);
+    }
+
+    /**
+     * Builds the list of individual ASCII bytes that are considered unsafe; must always be percent-encoded. Bytes outside the
+     * ASCII range are always percent-encoded by the codecs are don't need to be included in our list.
+     *
+     * @param additionallySafe
+     * @return unsafe ASCII chars
+     */
+    private static byte[] buildUnsafeBytes(final Set<Character> additionallySafe) {
+        Set<Byte> unsafe = new HashSet<>();
+
+        for (int i = 0; i <= Byte.MAX_VALUE; ++i) {
+            char c = (char) i;
+            if (additionallySafe.contains(c) == false && UrlCodecUtils.isRfc3986Safe(c) == false) {
+                unsafe.add((byte) i);
+            }
+        }
+
+        byte[] bytes = new byte[unsafe.size()];
+
+        int i = 0;
+        for (byte b : unsafe) {
+            bytes[i++] = b;
+        }
+
+        return bytes;
+    }
+
+    private static TestCaseSupplier createFixedTestCase(DataType dataType, String plain, PercentCodecTestType codecTestType) {
+        return new TestCaseSupplier(List.of(dataType), () -> {
+            boolean isEncoderTest = (codecTestType != PercentCodecTestType.DECODE);
+            String encoded = encode(plain, codecTestType);
+            String input = (isEncoderTest) ? plain : encoded;
+            String output = isEncoderTest ? encoded : plain;
+
+            return new TestCaseSupplier.TestCase(
+                List.of(new TestCaseSupplier.TypedData(new BytesRef(input), dataType, "string")),
+                codecTestType.evaluatorToString,
+                dataType,
+                equalTo(new BytesRef(output))
+            );
+        });
+    }
+
+    private static TestCaseSupplier createBadDecoderTestCase(DataType dataType, String undecodable, String exceptionMessage) {
+        return new TestCaseSupplier(
+            List.of(dataType),
+            () -> new TestCaseSupplier.TestCase(
+                List.of(new TestCaseSupplier.TypedData(new BytesRef(undecodable), dataType, "string")),
+                PercentCodecTestType.DECODE.evaluatorToString,
+                dataType,
+                is(nullValue())
+            ).withWarning("Line 1:1: evaluation of [source] failed, treating result as null. Only first 20 failures recorded.")
+                .withWarning(exceptionMessage)
+        );
+    }
+
+    private static byte[] allAsciiChars() {
+        byte[] bytes = new byte[Byte.MAX_VALUE + 1];
+        for (int i = 0; i < bytes.length; ++i) {
+            bytes[i] = (byte) i;
+        }
+        return bytes;
     }
 }

+ 1 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlDecodeTests.java

@@ -26,7 +26,7 @@ public class UrlDecodeTests extends AbstractUrlEncodeDecodeTestCase {
 
     @ParametersFactory
     public static Iterable<Object[]> parameters() {
-        return createParameters(false);
+        return createParameters(PercentCodecTestType.DECODE);
     }
 
     @Override

+ 37 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentErrorTests.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 UrlEncodeComponentErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+    @Override
+    protected List<TestCaseSupplier> cases() {
+        return paramsToSuppliers(UrlEncodeComponentTests.parameters());
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new UrlEncodeComponent(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"));
+    }
+}

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

@@ -0,0 +1,21 @@
+/*
+ * 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 UrlEncodeComponentSerializationTests extends AbstractUnaryScalarSerializationTests<UrlEncodeComponent> {
+
+    @Override
+    protected UrlEncodeComponent create(Source source, Expression child) {
+        return new UrlEncodeComponent(source, child);
+    }
+
+}

+ 37 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/convert/UrlEncodeComponentTests.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 com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.function.AbstractUrlEncodeDecodeTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+public class UrlEncodeComponentTests extends AbstractUrlEncodeDecodeTestCase {
+
+    public UrlEncodeComponentTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        return createParameters(PercentCodecTestType.ENCODE_COMPONENT);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new UrlEncodeComponent(source, args.get(0));
+    }
+
+}

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

@@ -26,7 +26,7 @@ public class UrlEncodeTests extends AbstractUrlEncodeDecodeTestCase {
 
     @ParametersFactory
     public static Iterable<Object[]> parameters() {
-        return createParameters(true);
+        return createParameters(PercentCodecTestType.ENCODE);
     }
 
     @Override