Browse Source

Backport Term query for ES|QL to 8.x (#117359) (#118135)

* Term query for ES|QL (#117359)

This commit adds a `term` function for ES|QL to run `TermQueries`.

For example:
FROM test | WHERE term(content, "dog")

(cherry picked from commit 91605860ee9a112815cb20915d07551cefa4d63f)

* Update docs/changelog/118135.yaml
Tommaso Teofili 10 months ago
parent
commit
af84c6142e
35 changed files with 873 additions and 136 deletions
  1. 5 0
      docs/changelog/117359.yaml
  2. 5 0
      docs/changelog/118135.yaml
  3. 1 1
      docs/reference/esql/functions/description/categorize.asciidoc
  4. 1 1
      docs/reference/esql/functions/description/reverse.asciidoc
  5. 5 0
      docs/reference/esql/functions/description/term.asciidoc
  6. 0 7
      docs/reference/esql/functions/description/to_date_nanos.asciidoc
  7. 13 0
      docs/reference/esql/functions/examples/term.asciidoc
  8. 1 1
      docs/reference/esql/functions/kibana/definition/reverse.json
  9. 85 0
      docs/reference/esql/functions/kibana/definition/term.json
  10. 0 95
      docs/reference/esql/functions/kibana/definition/to_date_nanos.json
  11. 1 1
      docs/reference/esql/functions/kibana/docs/reverse.md
  12. 13 0
      docs/reference/esql/functions/kibana/docs/term.md
  13. 0 8
      docs/reference/esql/functions/kibana/docs/to_date_nanos.md
  14. 1 1
      docs/reference/esql/functions/layout/categorize.asciidoc
  15. 1 1
      docs/reference/esql/functions/layout/kql.asciidoc
  16. 1 1
      docs/reference/esql/functions/layout/match.asciidoc
  17. 7 6
      docs/reference/esql/functions/layout/term.asciidoc
  18. 4 1
      docs/reference/esql/functions/parameters/term.asciidoc
  19. 1 0
      docs/reference/esql/functions/signature/term.svg
  20. 0 1
      docs/reference/esql/functions/signature/to_date_nanos.svg
  21. 5 8
      docs/reference/esql/functions/types/term.asciidoc
  22. 206 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/term-function.csv-spec
  23. 139 0
      x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/TermIT.java
  24. 6 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  25. 9 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java
  26. 3 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  27. 3 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java
  28. 124 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Term.java
  29. 3 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
  30. 10 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java
  31. 4 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
  32. 54 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
  33. 132 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/TermTests.java
  34. 29 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java
  35. 1 1
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

+ 5 - 0
docs/changelog/117359.yaml

@@ -0,0 +1,5 @@
+pr: 117359
+summary: Term query for ES|QL
+area: ES|QL
+type: enhancement
+issues: []

+ 5 - 0
docs/changelog/118135.yaml

@@ -0,0 +1,5 @@
+pr: 118135
+summary: Backport Term query for ES|QL to 8.x
+area: ES|QL
+type: enhancement
+issues: []

+ 1 - 1
docs/reference/esql/functions/description/categorize.asciidoc

@@ -8,4 +8,4 @@ Groups text messages into categories of similarly formatted text values.
 
 * can't be used within other expressions
 * can't be used with multiple groupings
-* can't be used or referenced within aggregations
+* can't be used or referenced within aggregate functions

+ 1 - 1
docs/reference/esql/functions/description/reverse.asciidoc

@@ -5,5 +5,5 @@
 Returns a new string representing the input string in reverse order.
 
 NOTE: If Elasticsearch is running with a JDK version less than 20 then this will not properly reverse Grapheme Clusters.
-Elastic Cloud the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk
+Elastic Cloud and the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk
 then you'll see things like "👍🏽😊" be reversed to  "🏽👍😊" instead of the correct "😊👍🏽".

+ 5 - 0
docs/reference/esql/functions/description/term.asciidoc

@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+Performs a Term query on the specified field. Returns true if the provided term matches the row.

+ 0 - 7
docs/reference/esql/functions/description/to_date_nanos.asciidoc

@@ -1,7 +0,0 @@
-// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
-
-*Description*
-
-Converts an input to a nanosecond-resolution date value (aka date_nanos).
-
-NOTE: The range for date nanos is 1970-01-01T00:00:00.000000000Z to 2262-04-11T23:47:16.854775807Z.  Additionally, integers cannot be converted into date nanos, as the range of integer nanoseconds only covers about 2 seconds after epoch.

+ 13 - 0
docs/reference/esql/functions/examples/term.asciidoc

@@ -0,0 +1,13 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Example*
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/term-function.csv-spec[tag=term-with-field]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/term-function.csv-spec[tag=term-with-field-result]
+|===
+

+ 1 - 1
docs/reference/esql/functions/kibana/definition/reverse.json

@@ -3,7 +3,7 @@
   "type" : "eval",
   "name" : "reverse",
   "description" : "Returns a new string representing the input string in reverse order.",
-  "note" : "If Elasticsearch is running with a JDK version less than 20 then this will not properly reverse Grapheme Clusters.\nElastic Cloud the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk\nthen you'll see things like \"\uD83D\uDC4D\uD83C\uDFFD\uD83D\uDE0A\" be reversed to  \"\uD83C\uDFFD\uD83D\uDC4D\uD83D\uDE0A\" instead of the correct \"\uD83D\uDE0A\uD83D\uDC4D\uD83C\uDFFD\".",
+  "note" : "If Elasticsearch is running with a JDK version less than 20 then this will not properly reverse Grapheme Clusters.\nElastic Cloud and the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk\nthen you'll see things like \"\uD83D\uDC4D\uD83C\uDFFD\uD83D\uDE0A\" be reversed to  \"\uD83C\uDFFD\uD83D\uDC4D\uD83D\uDE0A\" instead of the correct \"\uD83D\uDE0A\uD83D\uDC4D\uD83C\uDFFD\".",
   "signatures" : [
     {
       "params" : [

+ 85 - 0
docs/reference/esql/functions/kibana/definition/term.json

@@ -0,0 +1,85 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "eval",
+  "name" : "term",
+  "description" : "Performs a Term query on the specified field. Returns true if the provided term matches the row.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Field that the query will target."
+        },
+        {
+          "name" : "query",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Term you wish to find in the provided field."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Field that the query will target."
+        },
+        {
+          "name" : "query",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Term you wish to find in the provided field."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Field that the query will target."
+        },
+        {
+          "name" : "query",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Term you wish to find in the provided field."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Field that the query will target."
+        },
+        {
+          "name" : "query",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Term you wish to find in the provided field."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "FROM books \n| WHERE TERM(author, \"gabriel\")  \n| KEEP book_no, title\n| LIMIT 3;"
+  ],
+  "preview" : true,
+  "snapshot_only" : true
+}

+ 0 - 95
docs/reference/esql/functions/kibana/definition/to_date_nanos.json

@@ -1,95 +0,0 @@
-{
-  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
-  "type" : "eval",
-  "name" : "to_date_nanos",
-  "description" : "Converts an input to a nanosecond-resolution date value (aka date_nanos).",
-  "note" : "The range for date nanos is 1970-01-01T00:00:00.000000000Z to 2262-04-11T23:47:16.854775807Z.  Additionally, integers cannot be converted into date nanos, as the range of integer nanoseconds only covers about 2 seconds after epoch.",
-  "signatures" : [
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "date",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "date_nanos",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "double",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "keyword",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "long",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "text",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    },
-    {
-      "params" : [
-        {
-          "name" : "field",
-          "type" : "unsigned_long",
-          "optional" : false,
-          "description" : "Input value. The input can be a single- or multi-valued column or an expression."
-        }
-      ],
-      "variadic" : false,
-      "returnType" : "date_nanos"
-    }
-  ],
-  "preview" : true,
-  "snapshot_only" : false
-}

+ 1 - 1
docs/reference/esql/functions/kibana/docs/reverse.md

@@ -9,5 +9,5 @@ Returns a new string representing the input string in reverse order.
 ROW message = "Some Text" | EVAL message_reversed = REVERSE(message);
 ```
 Note: If Elasticsearch is running with a JDK version less than 20 then this will not properly reverse Grapheme Clusters.
-Elastic Cloud the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk
+Elastic Cloud and the JDK bundled with Elasticsearch all use newer JDKs. But if you've explicitly shifted to an older jdk
 then you'll see things like "👍🏽😊" be reversed to  "🏽👍😊" instead of the correct "😊👍🏽".

+ 13 - 0
docs/reference/esql/functions/kibana/docs/term.md

@@ -0,0 +1,13 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### TERM
+Performs a Term query on the specified field. Returns true if the provided term matches the row.
+
+```
+FROM books 
+| WHERE TERM(author, "gabriel")  
+| KEEP book_no, title
+| LIMIT 3;
+```

+ 0 - 8
docs/reference/esql/functions/kibana/docs/to_date_nanos.md

@@ -1,8 +0,0 @@
-<!--
-This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
--->
-
-### TO_DATE_NANOS
-Converts an input to a nanosecond-resolution date value (aka date_nanos).
-
-Note: The range for date nanos is 1970-01-01T00:00:00.000000000Z to 2262-04-11T23:47:16.854775807Z.  Additionally, integers cannot be converted into date nanos, as the range of integer nanoseconds only covers about 2 seconds after epoch.

+ 1 - 1
docs/reference/esql/functions/layout/categorize.asciidoc

@@ -4,7 +4,7 @@
 [[esql-categorize]]
 === `CATEGORIZE`
 
-preview::["Do not use on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
+preview::["Do not use `VALUES` on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
 
 *Syntax*
 

+ 1 - 1
docs/reference/esql/functions/layout/kql.asciidoc

@@ -4,7 +4,7 @@
 [[esql-kql]]
 === `KQL`
 
-preview::["Do not use on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
+preview::["Do not use `VALUES` on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
 
 *Syntax*
 

+ 1 - 1
docs/reference/esql/functions/layout/match.asciidoc

@@ -4,7 +4,7 @@
 [[esql-match]]
 === `MATCH`
 
-preview::["Do not use on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
+preview::["Do not use `VALUES` on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
 
 *Syntax*
 

+ 7 - 6
docs/reference/esql/functions/layout/to_date_nanos.asciidoc → docs/reference/esql/functions/layout/term.asciidoc

@@ -1,16 +1,17 @@
 // This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
 
 [discrete]
-[[esql-to_date_nanos]]
-=== `TO_DATE_NANOS`
+[[esql-term]]
+=== `TERM`
 
 preview::["Do not use `VALUES` on production environments. This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features."]
 
 *Syntax*
 
 [.text-center]
-image::esql/functions/signature/to_date_nanos.svg[Embedded,opts=inline]
+image::esql/functions/signature/term.svg[Embedded,opts=inline]
 
-include::../parameters/to_date_nanos.asciidoc[]
-include::../description/to_date_nanos.asciidoc[]
-include::../types/to_date_nanos.asciidoc[]
+include::../parameters/term.asciidoc[]
+include::../description/term.asciidoc[]
+include::../types/term.asciidoc[]
+include::../examples/term.asciidoc[]

+ 4 - 1
docs/reference/esql/functions/parameters/to_date_nanos.asciidoc → docs/reference/esql/functions/parameters/term.asciidoc

@@ -3,4 +3,7 @@
 *Parameters*
 
 `field`::
-Input value. The input can be a single- or multi-valued column or an expression.
+Field that the query will target.
+
+`query`::
+Term you wish to find in the provided field.

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="384" height="46" viewbox="0 0 384 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m68 0h10m32 0h10m80 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="68" height="36"/><text class="k" x="15" y="31">TERM</text><rect class="s" x="83" y="5" width="32" height="36" rx="7"/><text class="syn" x="93" y="31">(</text><rect class="s" x="125" y="5" width="80" height="36" rx="7"/><text class="k" x="135" y="31">field</text><rect class="s" x="215" y="5" width="32" height="36" rx="7"/><text class="syn" x="225" y="31">,</text><rect class="s" x="257" y="5" width="80" height="36" rx="7"/><text class="k" x="267" y="31">query</text><rect class="s" x="347" y="5" width="32" height="36" rx="7"/><text class="syn" x="357" y="31">)</text></svg>

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

@@ -1 +0,0 @@
-<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="360" height="46" viewbox="0 0 360 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m176 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="176" height="36"/><text class="k" x="15" y="31">TO_DATE_NANOS</text><rect class="s" x="191" y="5" width="32" height="36" rx="7"/><text class="syn" x="201" y="31">(</text><rect class="s" x="233" y="5" width="80" height="36" rx="7"/><text class="k" x="243" y="31">field</text><rect class="s" x="323" y="5" width="32" height="36" rx="7"/><text class="syn" x="333" y="31">)</text></svg>

+ 5 - 8
docs/reference/esql/functions/types/to_date_nanos.asciidoc → docs/reference/esql/functions/types/term.asciidoc

@@ -4,12 +4,9 @@
 
 [%header.monospaced.styled,format=dsv,separator=|]
 |===
-field | result
-date | date_nanos
-date_nanos | date_nanos
-double | date_nanos
-keyword | date_nanos
-long | date_nanos
-text | date_nanos
-unsigned_long | date_nanos
+field | query | result
+keyword | keyword | boolean
+keyword | text | boolean
+text | keyword | boolean
+text | text | boolean
 |===

+ 206 - 0
x-pack/plugin/esql/qa/testFixtures/src/main/resources/term-function.csv-spec

@@ -0,0 +1,206 @@
+###############################################
+# Tests for Term function
+#
+
+termWithTextField
+required_capability: term_function
+
+// tag::term-with-field[]
+FROM books 
+| WHERE TERM(author, "gabriel")  
+| KEEP book_no, title
+| LIMIT 3;
+// end::term-with-field[]
+ignoreOrder:true
+
+book_no:keyword | title:text
+4814            | El Coronel No Tiene Quien Le Escriba / No One Writes to the Colonel (Spanish Edition)
+4917            | Autumn of the Patriarch
+6380            | La hojarasca (Spanish Edition)
+;
+
+termWithKeywordField
+required_capability: term_function
+
+from employees
+| where term(first_name, "Guoxiang")
+| keep emp_no, first_name;
+
+// tag::term-with-keyword-field-result[]
+emp_no:integer | first_name:keyword
+10015            | Guoxiang
+;
+// end::term-with-keyword-field-result[]
+
+termWithQueryExpressions
+required_capability: term_function
+
+from books 
+| where term(author, CONCAT("gab", "riel"))  
+| keep book_no, title;
+ignoreOrder:true
+
+book_no:keyword | title:text
+4814            | El Coronel No Tiene Quien Le Escriba / No One Writes to the Colonel (Spanish Edition)
+4917            | Autumn of the Patriarch
+6380            | La hojarasca (Spanish Edition)
+;
+
+termAfterKeep
+required_capability: term_function
+
+from books 
+| keep book_no, author 
+| where term(author, "faulkner")
+| sort book_no 
+| limit 5;
+
+book_no:keyword | author:text
+2378            | [Carol Faulkner, Holly Byers Ochoa, Lucretia Mott]
+2713            | William Faulkner
+2847            | Colleen Faulkner
+2883            | William Faulkner
+3293            | Danny Faulkner
+;
+
+termAfterDrop
+required_capability: term_function
+
+from books 
+| drop ratings, description, year, publisher, title, author.keyword
+| where term(author, "william")
+| keep book_no, author
+| sort book_no 
+| limit 2;
+
+book_no:keyword | author:text
+2713            | William Faulkner
+2883            | William Faulkner
+;
+
+termAfterEval
+required_capability: term_function
+
+from books 
+| eval stars = to_long(ratings / 2.0) 
+| where term(author, "colleen")
+| sort book_no 
+| keep book_no, author, stars
+| limit 2;
+
+book_no:keyword | author:text      | stars:long
+2847            | Colleen Faulkner | 3
+4502            | Colleen Faulkner | 3
+;
+
+termWithConjunction
+required_capability: term_function
+
+from books 
+| where term(author, "tolkien") and ratings > 4.95
+| eval author = mv_sort(author)
+| keep book_no, ratings, author;
+ignoreOrder:true
+
+book_no:keyword | ratings:double | author:keyword
+2301            | 5.0            | John Ronald Reuel Tolkien    
+3254            | 5.0            | [Christopher Tolkien, John Ronald Reuel Tolkien]
+7350            | 5.0            | [Christopher Tolkien, John Ronald Reuel Tolkien]
+;
+
+termWithConjunctionAndSort
+required_capability: term_function
+
+from books 
+| where term(author, "tolkien") and ratings > 4.95
+| eval author = mv_sort(author)
+| keep book_no, ratings, author
+| sort book_no;
+
+book_no:keyword | ratings:double | author:keyword
+2301            | 5.0            | John Ronald Reuel Tolkien    
+3254            | 5.0            | [Christopher Tolkien, John Ronald Reuel Tolkien]
+7350            | 5.0            | [Christopher Tolkien, John Ronald Reuel Tolkien]
+;
+
+termWithFunctionPushedToLucene
+required_capability: term_function
+
+from hosts 
+| where term(host, "beta") and cidr_match(ip1, "127.0.0.2/32", "127.0.0.3/32") 
+| keep card, host, ip0, ip1;
+ignoreOrder:true
+
+card:keyword   |host:keyword   |ip0:ip                   |ip1:ip
+eth1           |beta           |127.0.0.1                |127.0.0.2
+;
+
+termWithNonPushableConjunction
+required_capability: term_function
+
+from books 
+| where term(title, "rings") and length(title) > 75
+| keep book_no, title;
+ignoreOrder:true
+
+book_no:keyword | title:text
+4023            | A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings
+;
+
+termWithMultipleWhereClauses
+required_capability: term_function
+
+from books 
+| where term(title, "rings") 
+| where term(title, "lord") 
+| keep book_no, title;
+ignoreOrder:true
+
+book_no:keyword | title:text
+2675            | The Lord of the Rings - Boxed Set           
+2714            | Return of the King Being the Third Part of The Lord of the Rings
+4023            | A Tolkien Compass: Including J. R. R. Tolkien's Guide to the Names in The Lord of the Rings
+7140            | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1)
+;
+
+termWithMultivaluedField
+required_capability: term_function
+
+from employees 
+| where term(job_positions, "Data Scientist") 
+| keep emp_no, first_name, last_name
+| sort emp_no asc
+| limit 2;
+ignoreOrder:true
+
+emp_no:integer | first_name:keyword | last_name:keyword
+10014          | Berni              | Genin    
+10017          | Cristinel          | Bouloucos       
+;
+
+testWithMultiValuedFieldWithConjunction
+required_capability: term_function
+
+from employees 
+| where term(job_positions, "Data Scientist") and term(first_name, "Cristinel")
+| keep emp_no, first_name, last_name
+| limit 1;
+
+emp_no:integer | first_name:keyword | last_name:keyword  
+10017          | Cristinel          | Bouloucos
+;
+
+termWithConjQueryStringFunctions
+required_capability: term_function
+required_capability: qstr_function
+
+from employees 
+| where term(job_positions, "Data Scientist") and qstr("first_name: Cristinel and gender: F")
+| keep emp_no, first_name, last_name
+| sort emp_no ASC
+| limit 1;
+ignoreOrder:true
+
+emp_no:integer | first_name:keyword | last_name:keyword  
+10017          | Cristinel          | Bouloucos
+;

+ 139 - 0
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/TermIT.java

@@ -0,0 +1,139 @@
+/*
+ * 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.plugin;
+
+import org.elasticsearch.action.index.IndexRequest;
+import org.elasticsearch.action.support.WriteRequest;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.xpack.esql.VerificationException;
+import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase;
+import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
+import org.elasticsearch.xpack.esql.action.EsqlQueryRequest;
+import org.elasticsearch.xpack.esql.action.EsqlQueryResponse;
+import org.junit.Before;
+
+import java.util.List;
+
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
+import static org.hamcrest.CoreMatchers.containsString;
+
+public class TermIT extends AbstractEsqlIntegTestCase {
+
+    @Before
+    public void setupIndex() {
+        createAndPopulateIndex();
+    }
+
+    @Override
+    protected EsqlQueryResponse run(EsqlQueryRequest request) {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        return super.run(request);
+    }
+
+    public void testSimpleTermQuery() throws Exception {
+        var query = """
+            FROM test
+            | WHERE term(content,"dog")
+            | KEEP id
+            | SORT id
+            """;
+
+        try (var resp = run(query)) {
+            assertColumnNames(resp.columns(), List.of("id"));
+            assertColumnTypes(resp.columns(), List.of("integer"));
+            assertValues(resp.values(), List.of(List.of(1), List.of(3), List.of(4), List.of(5)));
+        }
+    }
+
+    public void testTermWithinEval() {
+        var query = """
+            FROM test
+            | EVAL term_query = term(title,"fox")
+            """;
+
+        var error = expectThrows(VerificationException.class, () -> run(query));
+        assertThat(error.getMessage(), containsString("[Term] function is only supported in WHERE commands"));
+    }
+
+    public void testMultipleTerm() {
+        var query = """
+            FROM test
+            | WHERE term(content,"fox") AND term(content,"brown")
+            | KEEP id
+            | SORT id
+            """;
+
+        try (var resp = run(query)) {
+            assertColumnNames(resp.columns(), List.of("id"));
+            assertColumnTypes(resp.columns(), List.of("integer"));
+            assertValues(resp.values(), List.of(List.of(2), List.of(4), List.of(5)));
+        }
+    }
+
+    public void testNotWhereTerm() {
+        var query = """
+            FROM test
+            | WHERE NOT term(content,"brown")
+            | KEEP id
+            | SORT id
+            """;
+
+        try (var resp = run(query)) {
+            assertColumnNames(resp.columns(), List.of("id"));
+            assertColumnTypes(resp.columns(), List.of("integer"));
+            assertValues(resp.values(), List.of(List.of(3)));
+        }
+    }
+
+    private void createAndPopulateIndex() {
+        var indexName = "test";
+        var client = client().admin().indices();
+        var CreateRequest = client.prepareCreate(indexName)
+            .setSettings(Settings.builder().put("index.number_of_shards", 1))
+            .setMapping("id", "type=integer", "content", "type=text");
+        assertAcked(CreateRequest);
+        client().prepareBulk()
+            .add(
+                new IndexRequest(indexName).id("1")
+                    .source("id", 1, "content", "The quick brown animal swiftly jumps over a lazy dog", "title", "A Swift Fox's Journey")
+            )
+            .add(
+                new IndexRequest(indexName).id("2")
+                    .source("id", 2, "content", "A speedy brown fox hops effortlessly over a sluggish canine", "title", "The Fox's Leap")
+            )
+            .add(
+                new IndexRequest(indexName).id("3")
+                    .source("id", 3, "content", "Quick and nimble, the fox vaults over the lazy dog", "title", "Brown Fox in Action")
+            )
+            .add(
+                new IndexRequest(indexName).id("4")
+                    .source(
+                        "id",
+                        4,
+                        "content",
+                        "A fox that is quick and brown jumps over a dog that is quite lazy",
+                        "title",
+                        "Speedy Animals"
+                    )
+            )
+            .add(
+                new IndexRequest(indexName).id("5")
+                    .source(
+                        "id",
+                        5,
+                        "content",
+                        "With agility, a quick brown fox bounds over a slow-moving dog",
+                        "title",
+                        "Foxes and Canines"
+                    )
+            )
+            .setRefreshPolicy(WriteRequest.RefreshPolicy.IMMEDIATE)
+            .get();
+        ensureYellow(indexName);
+    }
+}

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

@@ -533,7 +533,12 @@ public class EsqlCapabilities {
         /**
          * Support the "METADATA _score" directive to enable _score column.
          */
-        METADATA_SCORE(Build.current().isSnapshot());
+        METADATA_SCORE(Build.current().isSnapshot()),
+
+        /**
+         * Term function
+         */
+        TERM_FUNCTION(Build.current().isSnapshot());
 
         private final boolean enabled;
 

+ 9 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java

@@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextFunctio
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Match;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString;
+import org.elasticsearch.xpack.esql.expression.function.fulltext.Term;
 import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
 import org.elasticsearch.xpack.esql.expression.function.grouping.GroupingFunction;
 import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.Neg;
@@ -837,6 +838,14 @@ public class Verifier {
                 m -> "[" + m.functionName() + "] " + m.functionType(),
                 failures
             );
+            checkCommandsBeforeExpression(
+                plan,
+                condition,
+                Term.class,
+                lp -> (lp instanceof Limit == false) && (lp instanceof Aggregate == false),
+                m -> "[" + m.functionName() + "] " + m.functionType(),
+                failures
+            );
             checkNotPresentInDisjunctions(condition, ftf -> "[" + ftf.functionName() + "] " + ftf.functionType(), failures);
             checkFullTextFunctionsParents(condition, failures);
         } else {

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

@@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.WeightedAvg;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Match;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString;
+import org.elasticsearch.xpack.esql.expression.function.fulltext.Term;
 import org.elasticsearch.xpack.esql.expression.function.grouping.Bucket;
 import org.elasticsearch.xpack.esql.expression.function.grouping.Categorize;
 import org.elasticsearch.xpack.esql.expression.function.scalar.conditional.Case;
@@ -414,7 +415,8 @@ public class EsqlFunctionRegistry {
                 // This is an experimental function and can be removed without notice.
                 def(Delay.class, Delay::new, "delay"),
                 def(Kql.class, Kql::new, "kql"),
-                def(Rate.class, Rate::withUnresolvedTimestamp, "rate") } };
+                def(Rate.class, Rate::withUnresolvedTimestamp, "rate"),
+                def(Term.class, Term::new, "term") } };
     }
 
     public EsqlFunctionRegistry snapshotRegistry() {

+ 3 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java

@@ -29,6 +29,9 @@ public class FullTextWritables {
         if (EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()) {
             entries.add(Kql.ENTRY);
         }
+        if (EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled()) {
+            entries.add(Term.ENTRY);
+        }
 
         return Collections.unmodifiableList(entries);
     }

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

@@ -0,0 +1,124 @@
+/*
+ * 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.fulltext;
+
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.common.io.stream.StreamOutput;
+import org.elasticsearch.xpack.esql.capabilities.Validatable;
+import org.elasticsearch.xpack.esql.common.Failure;
+import org.elasticsearch.xpack.esql.common.Failures;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.FieldAttribute;
+import org.elasticsearch.xpack.esql.core.expression.TypeResolutions;
+import org.elasticsearch.xpack.esql.core.querydsl.query.TermQuery;
+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.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
+
+import java.io.IOException;
+import java.util.List;
+
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNotNull;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isString;
+
+/**
+ * Full text function that performs a {@link TermQuery} .
+ */
+public class Term extends FullTextFunction implements Validatable {
+
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Term", Term::readFrom);
+
+    private final Expression field;
+
+    @FunctionInfo(
+        returnType = "boolean",
+        preview = true,
+        description = "Performs a Term query on the specified field. Returns true if the provided term matches the row.",
+        examples = { @Example(file = "term-function", tag = "term-with-field") }
+    )
+    public Term(
+        Source source,
+        @Param(name = "field", type = { "keyword", "text" }, description = "Field that the query will target.") Expression field,
+        @Param(
+            name = "query",
+            type = { "keyword", "text" },
+            description = "Term you wish to find in the provided field."
+        ) Expression termQuery
+    ) {
+        super(source, termQuery, List.of(field, termQuery));
+        this.field = field;
+    }
+
+    private static Term readFrom(StreamInput in) throws IOException {
+        Source source = Source.readFrom((PlanStreamInput) in);
+        Expression field = in.readNamedWriteable(Expression.class);
+        Expression query = in.readNamedWriteable(Expression.class);
+        return new Term(source, field, query);
+    }
+
+    @Override
+    public void writeTo(StreamOutput out) throws IOException {
+        source().writeTo(out);
+        out.writeNamedWriteable(field());
+        out.writeNamedWriteable(query());
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    protected TypeResolution resolveNonQueryParamTypes() {
+        return isNotNull(field, sourceText(), FIRST).and(isString(field, sourceText(), FIRST)).and(super.resolveNonQueryParamTypes());
+    }
+
+    @Override
+    public void validate(Failures failures) {
+        if (field instanceof FieldAttribute == false) {
+            failures.add(
+                Failure.fail(
+                    field,
+                    "[{}] {} cannot operate on [{}], which is not a field from an index mapping",
+                    functionName(),
+                    functionType(),
+                    field.sourceText()
+                )
+            );
+        }
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new Term(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, Term::new, field, query());
+    }
+
+    protected TypeResolutions.ParamOrdinal queryParamOrdinal() {
+        return SECOND;
+    }
+
+    public Expression field() {
+        return field;
+    }
+
+    @Override
+    public String functionName() {
+        return ENTRY.name;
+    }
+}

+ 3 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java

@@ -32,6 +32,7 @@ import org.elasticsearch.xpack.esql.core.util.CollectionUtils;
 import org.elasticsearch.xpack.esql.core.util.Queries;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextFunction;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Match;
+import org.elasticsearch.xpack.esql.expression.function.fulltext.Term;
 import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch;
 import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.BinarySpatialFunction;
 import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialRelatesFunction;
@@ -254,6 +255,8 @@ public class PushFiltersToSource extends PhysicalOptimizerRules.ParameterizedOpt
             return canPushSpatialFunctionToSource(spatial, lucenePushdownPredicates);
         } else if (exp instanceof Match mf) {
             return mf.field() instanceof FieldAttribute && DataType.isString(mf.field().dataType());
+        } else if (exp instanceof Term term) {
+            return term.field() instanceof FieldAttribute && DataType.isString(term.field().dataType());
         } else if (exp instanceof FullTextFunction) {
             return true;
         }

+ 10 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java

@@ -37,6 +37,7 @@ import org.elasticsearch.xpack.esql.core.util.Check;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Kql;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.Match;
 import org.elasticsearch.xpack.esql.expression.function.fulltext.QueryString;
+import org.elasticsearch.xpack.esql.expression.function.fulltext.Term;
 import org.elasticsearch.xpack.esql.expression.function.scalar.ip.CIDRMatch;
 import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialRelatesFunction;
 import org.elasticsearch.xpack.esql.expression.function.scalar.spatial.SpatialRelatesUtils;
@@ -92,6 +93,7 @@ public final class EsqlExpressionTranslators {
         new MatchFunctionTranslator(),
         new QueryStringFunctionTranslator(),
         new KqlFunctionTranslator(),
+        new TermFunctionTranslator(),
         new Scalars()
     );
 
@@ -548,4 +550,12 @@ public final class EsqlExpressionTranslators {
             return new KqlQuery(kqlFunction.source(), kqlFunction.queryAsText());
         }
     }
+
+    public static class TermFunctionTranslator extends ExpressionTranslator<Term> {
+        @Override
+        protected Query asQuery(Term term, TranslatorHandler handler) {
+            return new TermQuery(term.source(), ((FieldAttribute) term.field()).name(), term.queryAsText());
+        }
+    }
+
 }

+ 4 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java

@@ -262,6 +262,10 @@ public class CsvTests extends ESTestCase {
                 "lookup join disabled for csv tests",
                 testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP_V4.capabilityName())
             );
+            assumeFalse(
+                "can't use TERM function in csv tests",
+                testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.TERM_FUNCTION.capabilityName())
+            );
             if (Build.current().isSnapshot()) {
                 assertThat(
                     "Capability is not included in the enabled list capabilities on a snapshot build. Spelling mistake?",

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

@@ -1337,6 +1337,11 @@ public class VerifierTests extends ESTestCase {
         checkFullTextFunctionsOnlyAllowedInWhere("MATCH", "match(first_name, \"Anna\")", "function");
     }
 
+    public void testTermFunctionOnlyAllowedInWhere() throws Exception {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        checkFullTextFunctionsOnlyAllowedInWhere("Term", "term(first_name, \"Anna\")", "function");
+    }
+
     public void testMatchOperatornOnlyAllowedInWhere() throws Exception {
         checkFullTextFunctionsOnlyAllowedInWhere(":", "first_name:\"Anna\"", "operator");
     }
@@ -1401,6 +1406,11 @@ public class VerifierTests extends ESTestCase {
         checkWithDisjunctions("MATCH", "match(first_name, \"Anna\")", "function");
     }
 
+    public void testTermFunctionWithDisjunctions() {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        checkWithDisjunctions("Term", "term(first_name, \"Anna\")", "function");
+    }
+
     public void testMatchOperatorWithDisjunctions() {
         checkWithDisjunctions(":", "first_name : \"Anna\"", "operator");
     }
@@ -1463,6 +1473,11 @@ public class VerifierTests extends ESTestCase {
         checkFullTextFunctionsWithNonBooleanFunctions("MATCH", "match(first_name, \"Anna\")", "function");
     }
 
+    public void testTermFunctionWithNonBooleanFunctions() {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        checkFullTextFunctionsWithNonBooleanFunctions("Term", "term(first_name, \"Anna\")", "function");
+    }
+
     public void testMatchOperatorWithNonBooleanFunctions() {
         checkFullTextFunctionsWithNonBooleanFunctions(":", "first_name:\"Anna\"", "operator");
     }
@@ -1563,6 +1578,45 @@ public class VerifierTests extends ESTestCase {
         assertEquals("1:33: Unknown column [first_name]", error("from test | keep emp_no | where first_name : \"Anna\""));
     }
 
+    public void testTermFunctionArgNotConstant() throws Exception {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        assertEquals(
+            "1:19: second argument of [term(first_name, first_name)] must be a constant, received [first_name]",
+            error("from test | where term(first_name, first_name)")
+        );
+        assertEquals(
+            "1:59: second argument of [term(first_name, query)] must be a constant, received [query]",
+            error("from test | eval query = concat(\"first\", \" name\") | where term(first_name, query)")
+        );
+        // Other value types are tested in QueryStringFunctionTests
+    }
+
+    // These should pass eventually once we lift some restrictions on match function
+    public void testTermFunctionCurrentlyUnsupportedBehaviour() throws Exception {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        assertEquals(
+            "1:67: Unknown column [first_name]",
+            error("from test | stats max_salary = max(salary) by emp_no | where term(first_name, \"Anna\")")
+        );
+    }
+
+    public void testTermFunctionNullArgs() throws Exception {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        assertEquals(
+            "1:19: first argument of [term(null, \"query\")] cannot be null, received [null]",
+            error("from test | where term(null, \"query\")")
+        );
+        assertEquals(
+            "1:19: second argument of [term(first_name, null)] cannot be null, received [null]",
+            error("from test | where term(first_name, null)")
+        );
+    }
+
+    public void testTermTargetsExistingField() throws Exception {
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+        assertEquals("1:38: Unknown column [first_name]", error("from test | keep emp_no | where term(first_name, \"Anna\")"));
+    }
+
     public void testCoalesceWithMixedNumericTypes() {
         assertEquals(
             "1:22: second argument of [coalesce(languages, height)] must be [integer], found value [height] type [double]",

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

@@ -0,0 +1,132 @@
+/*
+ * 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.fulltext;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.xpack.core.security.authc.support.mapper.expressiondsl.FieldExpression;
+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.AbstractFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.FunctionName;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+@FunctionName("term")
+public class TermTests extends AbstractFunctionTestCase {
+
+    public TermTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<Set<DataType>> supportedPerPosition = supportedParams();
+        List<TestCaseSupplier> suppliers = new LinkedList<>();
+        for (DataType fieldType : DataType.stringTypes()) {
+            for (DataType queryType : DataType.stringTypes()) {
+                addPositiveTestCase(List.of(fieldType, queryType), suppliers);
+                addNonFieldTestCase(List.of(fieldType, queryType), supportedPerPosition, suppliers);
+            }
+        }
+
+        List<TestCaseSupplier> suppliersWithErrors = errorsForCasesWithoutExamples(suppliers, (v, p) -> "string");
+
+        // Don't test null, as it is not allowed but the expected message is not a type error - so we check it separately in VerifierTests
+        return parameterSuppliersFromTypedData(
+            suppliersWithErrors.stream().filter(s -> s.types().contains(DataType.NULL) == false).toList()
+        );
+    }
+
+    protected static List<Set<DataType>> supportedParams() {
+        Set<DataType> supportedTextParams = Set.of(DataType.KEYWORD, DataType.TEXT);
+        Set<DataType> supportedNumericParams = Set.of(DataType.DOUBLE, DataType.INTEGER);
+        Set<DataType> supportedFuzzinessParams = Set.of(DataType.INTEGER, DataType.KEYWORD, DataType.TEXT);
+        List<Set<DataType>> supportedPerPosition = List.of(
+            supportedTextParams,
+            supportedTextParams,
+            supportedNumericParams,
+            supportedFuzzinessParams
+        );
+        return supportedPerPosition;
+    }
+
+    protected static void addPositiveTestCase(List<DataType> paramDataTypes, List<TestCaseSupplier> suppliers) {
+
+        // Positive case - creates an ES field from the field parameter type
+        suppliers.add(
+            new TestCaseSupplier(
+                getTestCaseName(paramDataTypes, "-ES field"),
+                paramDataTypes,
+                () -> new TestCaseSupplier.TestCase(
+                    getTestParams(paramDataTypes),
+                    "EndsWithEvaluator[str=Attribute[channel=0], suffix=Attribute[channel=1]]",
+                    DataType.BOOLEAN,
+                    equalTo(true)
+                )
+            )
+        );
+    }
+
+    private static void addNonFieldTestCase(
+        List<DataType> paramDataTypes,
+        List<Set<DataType>> supportedPerPosition,
+        List<TestCaseSupplier> suppliers
+    ) {
+        // Negative case - use directly the field parameter type
+        suppliers.add(
+            new TestCaseSupplier(
+                getTestCaseName(paramDataTypes, "-non ES field"),
+                paramDataTypes,
+                typeErrorSupplier(true, supportedPerPosition, paramDataTypes, TermTests::matchTypeErrorSupplier)
+            )
+        );
+    }
+
+    private static List<TestCaseSupplier.TypedData> getTestParams(List<DataType> paramDataTypes) {
+        String fieldName = randomIdentifier();
+        List<TestCaseSupplier.TypedData> params = new ArrayList<>();
+        params.add(
+            new TestCaseSupplier.TypedData(
+                new FieldExpression(fieldName, List.of(new FieldExpression.FieldValue(fieldName))),
+                paramDataTypes.get(0),
+                "field"
+            )
+        );
+        params.add(new TestCaseSupplier.TypedData(new BytesRef(randomAlphaOfLength(10)), paramDataTypes.get(1), "query"));
+        return params;
+    }
+
+    private static String getTestCaseName(List<DataType> paramDataTypes, String fieldType) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("<");
+        sb.append(paramDataTypes.get(0)).append(fieldType).append(", ");
+        sb.append(paramDataTypes.get(1));
+        sb.append(">");
+        return sb.toString();
+    }
+
+    private static String matchTypeErrorSupplier(boolean includeOrdinal, List<Set<DataType>> validPerPosition, List<DataType> types) {
+        return "[] cannot operate on [" + types.get(0).typeName() + "], which is not a field from an index mapping";
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Match(source, args.get(0), args.get(1));
+    }
+}

+ 29 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java

@@ -1342,6 +1342,35 @@ public class LocalPhysicalPlanOptimizerTests extends MapperServiceTestCase {
         assertThat(actualLuceneQuery.toString(), is(expectedLuceneQuery.toString()));
     }
 
+    /**
+     * Expecting
+     * LimitExec[1000[INTEGER]]
+     * \_ExchangeExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na
+     * me{f}#6, long_noidx{f}#11, salary{f}#7],false]
+     *   \_ProjectExec[[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gender{f}#4, job{f}#9, job.raw{f}#10, languages{f}#5, last_na
+     * me{f}#6, long_noidx{f}#11, salary{f}#7]]
+     *     \_FieldExtractExec[_meta_field{f}#8, emp_no{f}#2, first_name{f}#3, gen]
+     *       \_EsQueryExec[test], indexMode[standard], query[{"term":{"last_name":{"query":"Smith"}}}]
+     */
+    public void testTermFunction() {
+        // Skip test if the term function is not enabled.
+        assumeTrue("term function capability not available", EsqlCapabilities.Cap.TERM_FUNCTION.isEnabled());
+
+        var plan = plannerOptimizer.plan("""
+            from test
+            | where term(last_name, "Smith")
+            """, IS_SV_STATS);
+
+        var limit = as(plan, LimitExec.class);
+        var exchange = as(limit.child(), ExchangeExec.class);
+        var project = as(exchange.child(), ProjectExec.class);
+        var field = as(project.child(), FieldExtractExec.class);
+        var query = as(field.child(), EsQueryExec.class);
+        assertThat(query.limit().fold(), is(1000));
+        var expected = QueryBuilders.termQuery("last_name", "Smith");
+        assertThat(query.query().toString(), is(expected.toString()));
+    }
+
     private QueryBuilder wrapWithSingleQuery(String query, QueryBuilder inner, String fieldName, Source source) {
         return FilterTests.singleValueQuery(query, inner, fieldName, source);
     }

+ 1 - 1
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

@@ -92,7 +92,7 @@ setup:
   - gt: {esql.functions.to_long: $functions_to_long}
   - match: {esql.functions.coalesce: $functions_coalesce}
   # Testing for the entire function set isn't feasbile, so we just check that we return the correct count as an approximation.
-  - length: {esql.functions: 122} # check the "sister" test below for a likely update to the same esql.functions length check
+  - length: {esql.functions: 123} # check the "sister" test below for a likely update to the same esql.functions length check
 
 ---
 "Basic ESQL usage output (telemetry) non-snapshot version":