diff --git a/docs/reference/esql/functions/description/kql.asciidoc b/docs/reference/esql/functions/description/kql.asciidoc
new file mode 100644
index 0000000000000..e1fe411e6689c
--- /dev/null
+++ b/docs/reference/esql/functions/description/kql.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 KQL query. Returns true if the provided KQL query string matches the row.
diff --git a/docs/reference/esql/functions/examples/kql.asciidoc b/docs/reference/esql/functions/examples/kql.asciidoc
new file mode 100644
index 0000000000000..1f8518aeec394
--- /dev/null
+++ b/docs/reference/esql/functions/examples/kql.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}/kql-function.csv-spec[tag=kql-with-field]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/kql-function.csv-spec[tag=kql-with-field-result]
+|===
+
diff --git a/docs/reference/esql/functions/kibana/definition/kql.json b/docs/reference/esql/functions/kibana/definition/kql.json
new file mode 100644
index 0000000000000..6960681fbbf0d
--- /dev/null
+++ b/docs/reference/esql/functions/kibana/definition/kql.json
@@ -0,0 +1,37 @@
+{
+ "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+ "type" : "eval",
+ "name" : "kql",
+ "description" : "Performs a KQL query. Returns true if the provided KQL query string matches the row.",
+ "signatures" : [
+ {
+ "params" : [
+ {
+ "name" : "query",
+ "type" : "keyword",
+ "optional" : false,
+ "description" : "Query string in KQL query string format."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "boolean"
+ },
+ {
+ "params" : [
+ {
+ "name" : "query",
+ "type" : "text",
+ "optional" : false,
+ "description" : "Query string in KQL query string format."
+ }
+ ],
+ "variadic" : false,
+ "returnType" : "boolean"
+ }
+ ],
+ "examples" : [
+ "FROM books \n| WHERE KQL(\"author: Faulkner\")\n| KEEP book_no, author \n| SORT book_no \n| LIMIT 5;"
+ ],
+ "preview" : true,
+ "snapshot_only" : true
+}
diff --git a/docs/reference/esql/functions/kibana/docs/kql.md b/docs/reference/esql/functions/kibana/docs/kql.md
new file mode 100644
index 0000000000000..0ba419c1cd032
--- /dev/null
+++ b/docs/reference/esql/functions/kibana/docs/kql.md
@@ -0,0 +1,14 @@
+
+
+### KQL
+Performs a KQL query. Returns true if the provided KQL query string matches the row.
+
+```
+FROM books
+| WHERE KQL("author: Faulkner")
+| KEEP book_no, author
+| SORT book_no
+| LIMIT 5;
+```
diff --git a/docs/reference/esql/functions/layout/kql.asciidoc b/docs/reference/esql/functions/layout/kql.asciidoc
new file mode 100644
index 0000000000000..8cf2687b240c1
--- /dev/null
+++ b/docs/reference/esql/functions/layout/kql.asciidoc
@@ -0,0 +1,17 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[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."]
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/kql.svg[Embedded,opts=inline]
+
+include::../parameters/kql.asciidoc[]
+include::../description/kql.asciidoc[]
+include::../types/kql.asciidoc[]
+include::../examples/kql.asciidoc[]
diff --git a/docs/reference/esql/functions/parameters/kql.asciidoc b/docs/reference/esql/functions/parameters/kql.asciidoc
new file mode 100644
index 0000000000000..6fb69323ff73c
--- /dev/null
+++ b/docs/reference/esql/functions/parameters/kql.asciidoc
@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`query`::
+Query string in KQL query string format.
diff --git a/docs/reference/esql/functions/signature/kql.svg b/docs/reference/esql/functions/signature/kql.svg
new file mode 100644
index 0000000000000..3f550f27ccdff
--- /dev/null
+++ b/docs/reference/esql/functions/signature/kql.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/reference/esql/functions/types/kql.asciidoc b/docs/reference/esql/functions/types/kql.asciidoc
new file mode 100644
index 0000000000000..866a39e925665
--- /dev/null
+++ b/docs/reference/esql/functions/types/kql.asciidoc
@@ -0,0 +1,10 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+query | result
+keyword | boolean
+text | boolean
+|===
diff --git a/x-pack/plugin/esql/build.gradle b/x-pack/plugin/esql/build.gradle
index f92c895cc5b7b..02f9752d21e09 100644
--- a/x-pack/plugin/esql/build.gradle
+++ b/x-pack/plugin/esql/build.gradle
@@ -34,6 +34,7 @@ dependencies {
compileOnly project(':modules:lang-painless:spi')
compileOnly project(xpackModule('esql-core'))
compileOnly project(xpackModule('ml'))
+ implementation project(xpackModule('kql'))
implementation project('compute')
implementation project('compute:ann')
implementation project(':libs:dissect')
@@ -50,6 +51,7 @@ dependencies {
testImplementation(testArtifact(project(xpackModule('core'))))
testImplementation project(path: xpackModule('enrich'))
testImplementation project(path: xpackModule('spatial'))
+ testImplementation project(path: xpackModule('kql'))
testImplementation project(path: ':modules:reindex')
testImplementation project(path: ':modules:parent-join')
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec
new file mode 100644
index 0000000000000..02be58efac774
--- /dev/null
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/kql-function.csv-spec
@@ -0,0 +1,153 @@
+###############################################
+# Tests for KQL function
+#
+
+kqlWithField
+required_capability: kql_function
+
+// tag::kql-with-field[]
+FROM books
+| WHERE KQL("author: Faulkner")
+| KEEP book_no, author
+| SORT book_no
+| LIMIT 5;
+// end::kql-with-field[]
+
+// tag::kql-with-field-result[]
+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
+;
+// end::kql-with-field-result[]
+
+kqlWithMultipleFields
+required_capability: kql_function
+
+from books
+| where kql("title:Return* AND author:*Tolkien")
+| keep book_no, title;
+ignoreOrder:true
+
+book_no:keyword | title:text
+2714 | Return of the King Being the Third Part of The Lord of the Rings
+7350 | Return of the Shadow
+;
+
+kqlWithQueryExpressions
+required_capability: kql_function
+
+from books
+| where kql(CONCAT("title:Return*", " AND author:*Tolkien"))
+| keep book_no, title;
+ignoreOrder:true
+
+book_no:keyword | title:text
+2714 | Return of the King Being the Third Part of The Lord of the Rings
+7350 | Return of the Shadow
+;
+
+kqlWithConjunction
+required_capability: kql_function
+
+from books
+| where kql("title: Rings") and ratings > 4.6
+| 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
+7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1)
+;
+
+kqlWithFunctionPushedToLucene
+required_capability: kql_function
+
+from hosts
+| where kql("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
+;
+
+kqlWithNonPushableConjunction
+required_capability: kql_function
+
+from books
+| where kql("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
+;
+
+kqlWithMultipleWhereClauses
+required_capability: kql_function
+
+from books
+| where kql("title: rings")
+| where kql("year > 1 AND year < 2005")
+| 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
+7140 | The Lord of the Rings Poster Collection: Six Paintings by Alan Lee (No. 1)
+;
+
+
+kqlWithMultivaluedTextField
+required_capability: kql_function
+
+from employees
+| where kql("job_positions: Tech Lead AND job_positions:(Reporting Analyst)")
+| keep emp_no, first_name, last_name;
+ignoreOrder:true
+
+emp_no:integer | first_name:keyword | last_name:keyword
+10004 | Chirstian | Koblick
+10010 | Duangkaew | Piveteau
+10011 | Mary | Sluis
+10088 | Jungsoon | Syrzycki
+10093 | Sailaja | Desikan
+10097 | Remzi | Waschkowski
+;
+
+kqlWithMultivaluedNumericField
+required_capability: kql_function
+
+from employees
+| where kql("salary_change > 14")
+| keep emp_no, first_name, last_name, salary_change;
+ignoreOrder:true
+
+emp_no:integer | first_name:keyword | last_name:keyword | salary_change:double
+10003 | Parto | Bamford | [12.82, 14.68]
+10015 | Guoxiang | Nooteboom | [12.4, 14.25]
+10023 | Bojan | Montemayor | [0.8, 14.63]
+10040 | Weiyi | Meriste | [-8.94, 1.92, 6.97, 14.74]
+10061 | Tse | Herber | [-2.58, -0.95, 14.39]
+10065 | Satosi | Awdeh | [-9.81, -1.47, 14.44]
+10099 | Valter | Sullins | [-8.78, -3.98, 10.71, 14.26]
+;
+
+testMultiValuedFieldWithConjunction
+required_capability: kql_function
+
+from employees
+| where (kql("job_positions: (Data Scientist) OR job_positions:(Support Engineer)")) and gender == "F"
+| keep emp_no, first_name, last_name;
+ignoreOrder:true
+
+emp_no:integer | first_name:keyword | last_name:keyword
+10023 | Bojan | Montemayor
+10041 | Uri | Lenart
+10044 | Mingsen | Casley
+10053 | Sanjiv | Zschoche
+10069 | Margareta | Bierman
+;
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec
index 3e92e55928d64..6039dc05b6c44 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/qstr-function.csv-spec
@@ -101,8 +101,8 @@ book_no:keyword | title:text
;
-matchMultivaluedTextField
-required_capability: match_function
+qstrWithMultivaluedTextField
+required_capability: qstr_function
from employees
| where qstr("job_positions: (Tech Lead) AND job_positions:(Reporting Analyst)")
@@ -118,8 +118,8 @@ emp_no:integer | first_name:keyword | last_name:keyword
10097 | Remzi | Waschkowski
;
-matchMultivaluedNumericField
-required_capability: match_function
+qstrWithMultivaluedNumericField
+required_capability: qstr_function
from employees
| where qstr("salary_change: [14 TO *]")
@@ -137,7 +137,7 @@ emp_no:integer | first_name:keyword | last_name:keyword | salary_change:double
;
testMultiValuedFieldWithConjunction
-required_capability: match_function
+required_capability: qstr_function
from employees
| where (qstr("job_positions: (Data Scientist) OR job_positions:(Support Engineer)")) and gender == "F"
diff --git a/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java
new file mode 100644
index 0000000000000..d58637ab52c86
--- /dev/null
+++ b/x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/KqlFunctionIT.java
@@ -0,0 +1,144 @@
+/*
+ * 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.index.query.QueryShardException;
+import org.elasticsearch.xpack.esql.VerificationException;
+import org.elasticsearch.xpack.esql.action.AbstractEsqlIntegTestCase;
+import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
+import org.junit.Before;
+import org.junit.BeforeClass;
+
+import java.util.List;
+
+import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertAcked;
+import static org.hamcrest.CoreMatchers.containsString;
+
+public class KqlFunctionIT extends AbstractEsqlIntegTestCase {
+
+ @BeforeClass
+ protected static void ensureKqlFunctionEnabled() {
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+ }
+
+ @Before
+ public void setupIndex() {
+ createAndPopulateIndex();
+ }
+
+ public void testSimpleKqlQuery() {
+ var query = """
+ FROM test
+ | WHERE kql("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 testMultiFieldKqlQuery() {
+ var query = """
+ FROM test
+ | WHERE kql("dog OR canine")
+ | KEEP id
+ """;
+
+ try (var resp = run(query)) {
+ assertColumnNames(resp.columns(), List.of("id"));
+ assertColumnTypes(resp.columns(), List.of("integer"));
+ assertValuesInAnyOrder(resp.values(), List.of(List.of(1), List.of(2), List.of(3), List.of(4), List.of(5)));
+ }
+ }
+
+ public void testKqlQueryWithinEval() {
+ var query = """
+ FROM test
+ | EVAL matches_query = kql("title: fox")
+ """;
+
+ var error = expectThrows(VerificationException.class, () -> run(query));
+ assertThat(error.getMessage(), containsString("[KQL] function is only supported in WHERE commands"));
+ }
+
+ public void testInvalidKqlQueryEof() {
+ var query = """
+ FROM test
+ | WHERE kql("content: ((((dog")
+ """;
+
+ var error = expectThrows(QueryShardException.class, () -> run(query));
+ assertThat(error.getMessage(), containsString("Failed to parse KQL query [content: ((((dog]"));
+ assertThat(error.getRootCause().getMessage(), containsString("line 1:11: mismatched input '('"));
+ }
+
+ public void testInvalidKqlQueryLexicalError() {
+ var query = """
+ FROM test
+ | WHERE kql(":")
+ """;
+
+ var error = expectThrows(QueryShardException.class, () -> run(query));
+ assertThat(error.getMessage(), containsString("Failed to parse KQL query [:]"));
+ assertThat(error.getRootCause().getMessage(), containsString("line 1:1: extraneous input ':' "));
+ }
+
+ 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);
+ }
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
index d675f772b5a3b..d9ce7fca312b3 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@@ -415,6 +415,11 @@ public enum Cap {
*/
MATCH_FUNCTION,
+ /**
+ * KQL function
+ */
+ KQL_FUNCTION(Build.current().isSnapshot()),
+
/**
* Don't optimize CASE IS NOT NULL function by not requiring the fields to be not null as well.
* https://github.com/elastic/elasticsearch/issues/112704
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java
index 3ebb52641232e..2be13398dab2f 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/analysis/Verifier.java
@@ -34,6 +34,7 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.FilteredExpression;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Rate;
import org.elasticsearch.xpack.esql.expression.function.fulltext.FullTextFunction;
+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.grouping.Categorize;
@@ -793,14 +794,19 @@ private static void checkNotPresentInDisjunctions(
private static void checkFullTextQueryFunctions(LogicalPlan plan, Set failures) {
if (plan instanceof Filter f) {
Expression condition = f.condition();
- checkCommandsBeforeExpression(
- plan,
- condition,
- QueryString.class,
- lp -> (lp instanceof Filter || lp instanceof OrderBy || lp instanceof EsRelation),
- qsf -> "[" + qsf.functionName() + "] " + qsf.functionType(),
- failures
- );
+
+ List.of(QueryString.class, Kql.class).forEach(functionClass -> {
+ // Check for limitations of QSTR and KQL function.
+ checkCommandsBeforeExpression(
+ plan,
+ condition,
+ functionClass,
+ lp -> (lp instanceof Filter || lp instanceof OrderBy || lp instanceof EsRelation),
+ fullTextFunction -> "[" + fullTextFunction.functionName() + "] " + fullTextFunction.functionType(),
+ failures
+ );
+ });
+
checkCommandsBeforeExpression(
plan,
condition,
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
index ea1669ccc7a4f..3d26bc170b723 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
@@ -33,6 +33,7 @@
import org.elasticsearch.xpack.esql.expression.function.aggregate.Top;
import org.elasticsearch.xpack.esql.expression.function.aggregate.Values;
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.grouping.Bucket;
@@ -411,6 +412,7 @@ private static FunctionDefinition[][] snapshotFunctions() {
// This is an experimental function and can be removed without notice.
def(Delay.class, Delay::new, "delay"),
def(Categorize.class, Categorize::new, "categorize"),
+ def(Kql.class, Kql::new, "kql"),
def(Rate.class, Rate::withUnresolvedTimestamp, "rate") } };
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java
index d59c736783172..8804a031de78c 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/FullTextWritables.java
@@ -8,14 +8,28 @@
package org.elasticsearch.xpack.esql.expression.function.fulltext;
import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.xpack.esql.action.EsqlCapabilities;
import org.elasticsearch.xpack.esql.core.expression.predicate.fulltext.MatchQueryPredicate;
import org.elasticsearch.xpack.esql.core.expression.predicate.fulltext.MultiMatchQueryPredicate;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
public class FullTextWritables {
public static List getNamedWriteables() {
- return List.of(MatchQueryPredicate.ENTRY, MultiMatchQueryPredicate.ENTRY, QueryString.ENTRY, Match.ENTRY);
+ List entries = new ArrayList<>();
+
+ entries.add(MatchQueryPredicate.ENTRY);
+ entries.add(MultiMatchQueryPredicate.ENTRY);
+ entries.add(QueryString.ENTRY);
+ entries.add(Match.ENTRY);
+
+ if (EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled()) {
+ entries.add(Kql.ENTRY);
+ }
+
+ return Collections.unmodifiableList(entries);
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java
new file mode 100644
index 0000000000000..c03902373c02e
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Kql.java
@@ -0,0 +1,73 @@
+/*
+ * 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.core.expression.Expression;
+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 org.elasticsearch.xpack.esql.querydsl.query.KqlQuery;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Full text function that performs a {@link KqlQuery} .
+ */
+public class Kql extends FullTextFunction {
+ public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Kql", Kql::new);
+
+ @FunctionInfo(
+ returnType = "boolean",
+ preview = true,
+ description = "Performs a KQL query. Returns true if the provided KQL query string matches the row.",
+ examples = { @Example(file = "kql-function", tag = "kql-with-field") }
+ )
+ public Kql(
+ Source source,
+ @Param(
+ name = "query",
+ type = { "keyword", "text" },
+ description = "Query string in KQL query string format."
+ ) Expression queryString
+ ) {
+ super(source, queryString, List.of(queryString));
+ }
+
+ private Kql(StreamInput in) throws IOException {
+ this(Source.readFrom((PlanStreamInput) in), in.readNamedWriteable(Expression.class));
+ }
+
+ @Override
+ public void writeTo(StreamOutput out) throws IOException {
+ source().writeTo(out);
+ out.writeNamedWriteable(query());
+ }
+
+ @Override
+ public String getWriteableName() {
+ return ENTRY.name;
+ }
+
+ @Override
+ public Expression replaceChildren(List newChildren) {
+ return new Kql(source(), newChildren.get(0));
+ }
+
+ @Override
+ protected NodeInfo extends Expression> info() {
+ return NodeInfo.create(this, Kql::new, query());
+ }
+
+}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
index 9f574ee8005b2..3d6c35e914294 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java
@@ -30,8 +30,8 @@
import org.elasticsearch.xpack.esql.core.type.DataType;
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.QueryString;
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;
@@ -252,10 +252,10 @@ static boolean canPushToSource(Expression exp, LucenePushdownPredicates lucenePu
&& Expressions.foldable(cidrMatch.matches());
} else if (exp instanceof SpatialRelatesFunction spatial) {
return canPushSpatialFunctionToSource(spatial, lucenePushdownPredicates);
- } else if (exp instanceof QueryString) {
- return true;
} else if (exp instanceof Match mf) {
return mf.field() instanceof FieldAttribute && DataType.isString(mf.field().dataType());
+ } else if (exp instanceof FullTextFunction) {
+ return true;
}
return false;
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java
index 6fac7bab2bd80..1580b77931240 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/planner/EsqlExpressionTranslators.java
@@ -34,6 +34,7 @@
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
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.scalar.ip.CIDRMatch;
@@ -47,6 +48,7 @@
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThan;
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual;
import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.NotEquals;
+import org.elasticsearch.xpack.esql.querydsl.query.KqlQuery;
import org.elasticsearch.xpack.esql.querydsl.query.SpatialRelatesQuery;
import org.elasticsearch.xpack.versionfield.Version;
@@ -89,6 +91,7 @@ public final class EsqlExpressionTranslators {
new ExpressionTranslators.MultiMatches(),
new MatchFunctionTranslator(),
new QueryStringFunctionTranslator(),
+ new KqlFunctionTranslator(),
new Scalars()
);
@@ -538,4 +541,11 @@ protected Query asQuery(QueryString queryString, TranslatorHandler handler) {
return new QueryStringQuery(queryString.source(), queryString.queryAsText(), Map.of(), Map.of());
}
}
+
+ public static class KqlFunctionTranslator extends ExpressionTranslator {
+ @Override
+ protected Query asQuery(Kql kqlFunction, TranslatorHandler handler) {
+ return new KqlQuery(kqlFunction.source(), kqlFunction.queryAsText());
+ }
+ }
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java
new file mode 100644
index 0000000000000..c388a131b9ab6
--- /dev/null
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/querydsl/query/KqlQuery.java
@@ -0,0 +1,85 @@
+/*
+ * 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.querydsl.query;
+
+import org.elasticsearch.core.Booleans;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.xpack.esql.core.querydsl.query.Query;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.kql.query.KqlQueryBuilder;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+
+import static java.util.Map.entry;
+
+public class KqlQuery extends Query {
+
+ private static final Map> BUILDER_APPLIERS = Map.ofEntries(
+ entry(KqlQueryBuilder.TIME_ZONE_FIELD.getPreferredName(), KqlQueryBuilder::timeZone),
+ entry(KqlQueryBuilder.DEFAULT_FIELD_FIELD.getPreferredName(), KqlQueryBuilder::defaultField),
+ entry(KqlQueryBuilder.CASE_INSENSITIVE_FIELD.getPreferredName(), (qb, s) -> qb.caseInsensitive(Booleans.parseBoolean(s)))
+ );
+
+ private final String query;
+
+ private final Map options;
+
+ // dedicated constructor for QueryTranslator
+ public KqlQuery(Source source, String query) {
+ this(source, query, null);
+ }
+
+ public KqlQuery(Source source, String query, Map options) {
+ super(source);
+ this.query = query;
+ this.options = options == null ? Collections.emptyMap() : options;
+ }
+
+ @Override
+ public QueryBuilder asBuilder() {
+ final KqlQueryBuilder queryBuilder = new KqlQueryBuilder(query);
+ options.forEach((k, v) -> {
+ if (BUILDER_APPLIERS.containsKey(k)) {
+ BUILDER_APPLIERS.get(k).accept(queryBuilder, v);
+ } else {
+ throw new IllegalArgumentException("illegal kql query option [" + k + "]");
+ }
+ });
+ return queryBuilder;
+ }
+
+ public String query() {
+ return query;
+ }
+
+ public Map options() {
+ return options;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(query, options);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (false == super.equals(obj)) {
+ return false;
+ }
+
+ KqlQuery other = (KqlQuery) obj;
+ return Objects.equals(query, other.query) && Objects.equals(options, other.options);
+ }
+
+ @Override
+ protected String innerToString() {
+ return query;
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
index 012720db9efd9..010a60ef7da15 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CsvTests.java
@@ -257,6 +257,10 @@ public final void test() throws Throwable {
"can't use MATCH function in csv tests",
testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.MATCH_FUNCTION.capabilityName())
);
+ assumeFalse(
+ "can't use KQL function in csv tests",
+ testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.KQL_FUNCTION.capabilityName())
+ );
assumeFalse(
"lookup join disabled for csv tests",
testCase.requiredCapabilities.contains(EsqlCapabilities.Cap.JOIN_LOOKUP.capabilityName())
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
index 7b2f85b80b3b6..f25b19c4e5d1c 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
@@ -1263,11 +1263,74 @@ public void testQueryStringFunctionsNotAllowedAfterCommands() throws Exception {
);
}
+ public void testKqlFunctionsNotAllowedAfterCommands() throws Exception {
+ // Skip test if the kql function is not enabled.
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+
+ // Source commands
+ assertEquals("1:13: [KQL] function cannot be used after SHOW", error("show info | where kql(\"8.16.0\")"));
+ assertEquals("1:17: [KQL] function cannot be used after ROW", error("row a= \"Anna\" | where kql(\"Anna\")"));
+
+ // Processing commands
+ assertEquals(
+ "1:43: [KQL] function cannot be used after DISSECT",
+ error("from test | dissect first_name \"%{foo}\" | where kql(\"Connection\")")
+ );
+ assertEquals("1:27: [KQL] function cannot be used after DROP", error("from test | drop emp_no | where kql(\"Anna\")"));
+ assertEquals(
+ "1:71: [KQL] function cannot be used after ENRICH",
+ error("from test | enrich languages on languages with lang = language_name | where kql(\"Anna\")")
+ );
+ assertEquals("1:26: [KQL] function cannot be used after EVAL", error("from test | eval z = 2 | where kql(\"Anna\")"));
+ assertEquals(
+ "1:44: [KQL] function cannot be used after GROK",
+ error("from test | grok last_name \"%{WORD:foo}\" | where kql(\"Anna\")")
+ );
+ assertEquals("1:27: [KQL] function cannot be used after KEEP", error("from test | keep emp_no | where kql(\"Anna\")"));
+ assertEquals("1:24: [KQL] function cannot be used after LIMIT", error("from test | limit 10 | where kql(\"Anna\")"));
+ assertEquals("1:35: [KQL] function cannot be used after MV_EXPAND", error("from test | mv_expand last_name | where kql(\"Anna\")"));
+ assertEquals(
+ "1:45: [KQL] function cannot be used after RENAME",
+ error("from test | rename last_name as full_name | where kql(\"Anna\")")
+ );
+ assertEquals(
+ "1:52: [KQL] function cannot be used after STATS",
+ error("from test | STATS c = COUNT(emp_no) BY languages | where kql(\"Anna\")")
+ );
+
+ // Some combination of processing commands
+ assertEquals("1:38: [KQL] function cannot be used after LIMIT", error("from test | keep emp_no | limit 10 | where kql(\"Anna\")"));
+ assertEquals(
+ "1:46: [KQL] function cannot be used after MV_EXPAND",
+ error("from test | limit 10 | mv_expand last_name | where kql(\"Anna\")")
+ );
+ assertEquals(
+ "1:52: [KQL] function cannot be used after KEEP",
+ error("from test | mv_expand last_name | keep last_name | where kql(\"Anna\")")
+ );
+ assertEquals(
+ "1:77: [KQL] function cannot be used after RENAME",
+ error("from test | STATS c = COUNT(emp_no) BY languages | rename c as total_emps | where kql(\"Anna\")")
+ );
+ assertEquals(
+ "1:54: [KQL] function cannot be used after DROP",
+ error("from test | rename last_name as name | drop emp_no | where kql(\"Anna\")")
+ );
+ }
+
public void testQueryStringFunctionOnlyAllowedInWhere() throws Exception {
assertEquals("1:9: [QSTR] function is only supported in WHERE commands", error("row a = qstr(\"Anna\")"));
checkFullTextFunctionsOnlyAllowedInWhere("QSTR", "qstr(\"Anna\")", "function");
}
+ public void testKqlFunctionOnlyAllowedInWhere() throws Exception {
+ // Skip test if the kql function is not enabled.
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+
+ assertEquals("1:9: [KQL] function is only supported in WHERE commands", error("row a = kql(\"Anna\")"));
+ checkFullTextFunctionsOnlyAllowedInWhere("KQL", "kql(\"Anna\")", "function");
+ }
+
public void testMatchFunctionOnlyAllowedInWhere() throws Exception {
checkFullTextFunctionsOnlyAllowedInWhere("MATCH", "match(first_name, \"Anna\")", "function");
}
@@ -1309,10 +1372,29 @@ public void testQueryStringFunctionArgNotNullOrConstant() throws Exception {
// Other value types are tested in QueryStringFunctionTests
}
+ public void testKqlFunctionArgNotNullOrConstant() throws Exception {
+ // Skip test if the kql function is not enabled.
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+
+ assertEquals(
+ "1:19: argument of [kql(first_name)] must be a constant, received [first_name]",
+ error("from test | where kql(first_name)")
+ );
+ assertEquals("1:19: argument of [kql(null)] cannot be null, received [null]", error("from test | where kql(null)"));
+ // Other value types are tested in KqlFunctionTests
+ }
+
public void testQueryStringWithDisjunctions() {
checkWithDisjunctions("QSTR", "qstr(\"first_name: Anna\")", "function");
}
+ public void testKqlFunctionWithDisjunctions() {
+ // Skip test if the kql function is not enabled.
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+
+ checkWithDisjunctions("KQL", "kql(\"first_name: Anna\")", "function");
+ }
+
public void testMatchFunctionWithDisjunctions() {
checkWithDisjunctions("MATCH", "match(first_name, \"Anna\")", "function");
}
@@ -1368,6 +1450,13 @@ public void testQueryStringFunctionWithNonBooleanFunctions() {
checkFullTextFunctionsWithNonBooleanFunctions("QSTR", "qstr(\"first_name: Anna\")", "function");
}
+ public void testKqlFunctionWithNonBooleanFunctions() {
+ // Skip test if the kql function is not enabled.
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+
+ checkFullTextFunctionsWithNonBooleanFunctions("KQL", "kql(\"first_name: Anna\")", "function");
+ }
+
public void testMatchFunctionWithNonBooleanFunctions() {
checkFullTextFunctionsWithNonBooleanFunctions("MATCH", "match(first_name, \"Anna\")", "function");
}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java
new file mode 100644
index 0000000000000..d97be6b169eef
--- /dev/null
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/KqlTests.java
@@ -0,0 +1,41 @@
+/*
+ * 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.elasticsearch.xpack.esql.action.EsqlCapabilities;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.junit.BeforeClass;
+
+import java.util.List;
+import java.util.function.Supplier;
+
+public class KqlTests extends NoneFieldFullTextFunctionTestCase {
+ @BeforeClass
+ protected static void ensureKqlFunctionEnabled() {
+ assumeTrue("kql function capability not available", EsqlCapabilities.Cap.KQL_FUNCTION.isEnabled());
+ }
+
+ public KqlTests(@Name("TestCase") Supplier testCaseSupplier) {
+ super(testCaseSupplier);
+ }
+
+ @ParametersFactory
+ public static Iterable