Browse Source

Add MV_CONTAINS function (#133099)

* Add `mv_contains` function
* Add documentation for `mv_contains` function

---------

Co-authored-by: Liam Thompson <leemthompo@gmail.com>
Co-authored-by: Iván Cea Fontenla <ivancea96@outlook.com>
Michael Bischoff 1 tháng trước cách đây
mục cha
commit
1e584281fd
19 tập tin đã thay đổi với 1701 bổ sung0 xóa
  1. 5 0
      docs/changelog/133099.yaml
  2. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/mv_contains.md
  3. 34 0
      docs/reference/query-languages/esql/_snippets/functions/examples/mv_contains.md
  4. 26 0
      docs/reference/query-languages/esql/_snippets/functions/layout/mv_contains.md
  5. 10 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/mv_contains.md
  6. 24 0
      docs/reference/query-languages/esql/_snippets/functions/types/mv_contains.md
  7. 1 0
      docs/reference/query-languages/esql/_snippets/lists/mv-functions.md
  8. 3 0
      docs/reference/query-languages/esql/functions-operators/mv-functions.md
  9. 1 0
      docs/reference/query-languages/esql/images/functions/mv_contains.svg
  10. 321 0
      docs/reference/query-languages/esql/kibana/definition/functions/mv_contains.json
  11. 9 0
      docs/reference/query-languages/esql/kibana/docs/functions/mv_contains.md
  12. 60 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
  13. 6 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  14. 2 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  15. 742 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContains.java
  16. 1 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFunctionWritables.java
  17. 45 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContainsErrorTests.java
  18. 37 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContainsSerializationTests.java
  19. 368 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContainsTests.java

+ 5 - 0
docs/changelog/133099.yaml

@@ -0,0 +1,5 @@
+pr: 133099
+summary: Add MV_CONTAINS function
+area: ES|QL
+type: enhancement
+issues: []

+ 6 - 0
docs/reference/query-languages/esql/_snippets/functions/description/mv_contains.md

@@ -0,0 +1,6 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+Checks if all values yielded by the second multivalue expression are present in the values yielded by the first multivalue expression. Returns a boolean. Null values are treated as an empty set.
+

+ 34 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/mv_contains.md

@@ -0,0 +1,34 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Examples**
+
+```esql
+ROW set = ["a", "b", "c"], element = "a"
+| EVAL set_contains_element = mv_contains(set, element)
+```
+
+| set:keyword | element:keyword | set_contains_element:boolean |
+| --- | --- | --- |
+| [a, b, c] | a | true |
+
+```esql
+ROW setA = ["a","c"], setB = ["a", "b", "c"]
+| EVAL a_subset_of_b = mv_contains(setB, setA)
+| EVAL b_subset_of_a = mv_contains(setA, setB)
+```
+
+| setA:keyword | setB:keyword | a_subset_of_b:boolean | b_subset_of_a:boolean |
+| --- | --- | --- | --- |
+| [a, c] | [a, b, c] | true | false |
+
+```esql
+FROM airports
+| WHERE mv_contains(type, ["major","military"]) AND scalerank == 9
+| KEEP scalerank, name, country
+```
+
+| scalerank:integer | name:text | country:keyword |
+| --- | --- | --- |
+| 9 | Chandigarh Int'l | India |
+
+

+ 26 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/mv_contains.md

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

+ 10 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/mv_contains.md

@@ -0,0 +1,10 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`superset`
+:   Multivalue expression.
+
+`subset`
+:   Multivalue expression.
+

+ 24 - 0
docs/reference/query-languages/esql/_snippets/functions/types/mv_contains.md

@@ -0,0 +1,24 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Supported types**
+
+| superset | subset | result |
+| --- | --- | --- |
+| boolean | boolean | boolean |
+| cartesian_point | cartesian_point | boolean |
+| cartesian_shape | cartesian_shape | boolean |
+| date | date | boolean |
+| date_nanos | date_nanos | boolean |
+| double | double | boolean |
+| geo_point | geo_point | boolean |
+| geo_shape | geo_shape | boolean |
+| integer | integer | boolean |
+| ip | ip | boolean |
+| keyword | keyword | boolean |
+| keyword | text | boolean |
+| long | long | boolean |
+| text | keyword | boolean |
+| text | text | boolean |
+| unsigned_long | unsigned_long | boolean |
+| version | version | boolean |
+

+ 1 - 0
docs/reference/query-languages/esql/_snippets/lists/mv-functions.md

@@ -1,6 +1,7 @@
 * [`MV_APPEND`](../../functions-operators/mv-functions.md#esql-mv_append)
 * [`MV_AVG`](../../functions-operators/mv-functions.md#esql-mv_avg)
 * [`MV_CONCAT`](../../functions-operators/mv-functions.md#esql-mv_concat)
+* [preview] [`MV_CONTAINS`](../../functions-operators/mv-functions.md#esql-mv_contains)
 * [`MV_COUNT`](../../functions-operators/mv-functions.md#esql-mv_count)
 * [`MV_DEDUPE`](../../functions-operators/mv-functions.md#esql-mv_dedupe)
 * [`MV_FIRST`](../../functions-operators/mv-functions.md#esql-mv_first)

+ 3 - 0
docs/reference/query-languages/esql/functions-operators/mv-functions.md

@@ -21,6 +21,9 @@ mapped_pages:
 :::{include} ../_snippets/functions/layout/mv_concat.md
 :::
 
+:::{include} ../_snippets/functions/layout/mv_contains.md
+:::
+
 :::{include} ../_snippets/functions/layout/mv_count.md
 :::
 

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="516" height="46" viewbox="0 0 516 46"><defs><style type="text/css">.c{fill:none;stroke:#222222;}.k{fill:#000000;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}.s{fill:#e4f4ff;stroke:#222222;}.syn{fill:#8D8D8D;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}</style></defs><path class="c" d="M0 31h5m152 0h10m32 0h10m116 0h10m32 0h10m92 0h10m32 0h5"/><rect class="s" x="5" y="5" width="152" height="36"/><text class="k" x="15" y="31">MV_CONTAINS</text><rect class="s" x="167" y="5" width="32" height="36" rx="7"/><text class="syn" x="177" y="31">(</text><rect class="s" x="209" y="5" width="116" height="36" rx="7"/><text class="k" x="219" y="31">superset</text><rect class="s" x="335" y="5" width="32" height="36" rx="7"/><text class="syn" x="345" y="31">,</text><rect class="s" x="377" y="5" width="92" height="36" rx="7"/><text class="k" x="387" y="31">subset</text><rect class="s" x="479" y="5" width="32" height="36" rx="7"/><text class="syn" x="489" y="31">)</text></svg>

+ 321 - 0
docs/reference/query-languages/esql/kibana/definition/functions/mv_contains.json

@@ -0,0 +1,321 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "scalar",
+  "name" : "mv_contains",
+  "description" : "Checks if all values yielded by the second multivalue expression are present in the values yielded by the first multivalue expression. Returns a boolean. Null values are treated as an empty set.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "boolean",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "boolean",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "cartesian_point",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "cartesian_point",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "cartesian_shape",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "cartesian_shape",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "date",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "date",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "date_nanos",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "date_nanos",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "double",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "double",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "geo_point",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "geo_point",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "geo_shape",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "geo_shape",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "integer",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "integer",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "ip",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "ip",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "long",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "long",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "unsigned_long",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "unsigned_long",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "superset",
+          "type" : "version",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        },
+        {
+          "name" : "subset",
+          "type" : "version",
+          "optional" : false,
+          "description" : "Multivalue expression."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "ROW set = [\"a\", \"b\", \"c\"], element = \"a\"\n| EVAL set_contains_element = mv_contains(set, element)",
+    "ROW setA = [\"a\",\"c\"], setB = [\"a\", \"b\", \"c\"]\n| EVAL a_subset_of_b = mv_contains(setB, setA)\n| EVAL b_subset_of_a = mv_contains(setA, setB)",
+    "FROM airports\n| WHERE mv_contains(type, [\"major\",\"military\"]) AND scalerank == 9\n| KEEP scalerank, name, country"
+  ],
+  "preview" : false,
+  "snapshot_only" : false
+}

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

@@ -0,0 +1,9 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### MV CONTAINS
+Checks if all values yielded by the second multivalue expression are present in the values yielded by the first multivalue expression. Returns a boolean. Null values are treated as an empty set.
+
+```esql
+ROW set = ["a", "b", "c"], element = "a"
+| EVAL set_contains_element = mv_contains(set, element)
+```

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

@@ -2026,6 +2026,66 @@ l1:integer | l2:integer
 null       | 0
 ;
 
+mvContains
+required_capability: fn_mv_contains
+// tag::mv_contains[]
+ROW set = ["a", "b", "c"], element = "a"
+| EVAL set_contains_element = mv_contains(set, element)
+// end::mv_contains[]
+;
+
+// tag::mv_contains-result[]
+set:keyword | element:keyword | set_contains_element:boolean 
+[a, b, c]   | a               | true         
+// end::mv_contains-result[]
+;
+
+mvContains_bothsides
+required_capability: fn_mv_contains
+// tag::mv_contains_bothsides[]
+ROW setA = ["a","c"], setB = ["a", "b", "c"]
+| EVAL a_subset_of_b = mv_contains(setB, setA)
+| EVAL b_subset_of_a = mv_contains(setA, setB)
+// end::mv_contains_bothsides[]
+;
+
+// tag::mv_contains_bothsides-result[]
+setA:keyword | setB:keyword | a_subset_of_b:boolean | b_subset_of_a:boolean 
+[a, c]       | [a, b, c]    | true                  | false
+// end::mv_contains_bothsides-result[]
+;
+
+mvContainsCombinations
+required_capability: fn_mv_contains
+
+ROW a = "a", b = ["a", "b", "c"], n = null
+| EVAL aa = mv_contains(a, a), 
+       bb = mv_contains(b, b), 
+       ab = mv_contains(a, b), 
+       ba = mv_contains(b,a), 
+       na = mv_contains(n, a), 
+       an = mv_contains(a, n), 
+       nn = mv_contains(n,n)
+;
+
+a:keyword | b:keyword | n:null | aa:boolean | bb:boolean   | ab:boolean | ba:boolean | na:boolean | an:boolean | nn:boolean
+a         | [a, b, c] | null   | true       | true         | false      | true       | false      | true       | true
+;
+
+mvContains_where
+required_capability: fn_mv_contains
+// tag::mv_contains_where[]
+FROM airports
+| WHERE mv_contains(type, ["major","military"]) AND scalerank == 9
+| KEEP scalerank, name, country
+// end::mv_contains_where[]
+;
+
+// tag::mv_contains_where-result[]
+scalerank:integer | name:text               | country:keyword  
+9                 | Chandigarh Int'l        | India
+// end::mv_contains_where-result[]
+;
 
 mvAppend
 required_capability: fn_mv_append

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

@@ -250,6 +250,12 @@ public class EsqlCapabilities {
          */
         FN_MONTH_NAME,
 
+        /**
+         * support for MV_CONTAINS function
+         * <a href="https://github.com/elastic/elasticsearch/pull/133099/">Add MV_CONTAINS function #133099</a>
+         */
+        FN_MV_CONTAINS,
+
         /**
          * Fixes for multiple functions not serializing their source, and emitting warnings with wrong line number and text.
          */

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

@@ -123,6 +123,7 @@ import org.elasticsearch.xpack.esql.expression.function.scalar.math.Tau;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvAppend;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvAvg;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvConcat;
+import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvContains;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvCount;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvDedupe;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvFirst;
@@ -452,6 +453,7 @@ public class EsqlFunctionRegistry {
                 def(MvAppend.class, MvAppend::new, "mv_append"),
                 def(MvAvg.class, MvAvg::new, "mv_avg"),
                 def(MvConcat.class, MvConcat::new, "mv_concat"),
+                def(MvContains.class, MvContains::new, "mv_contains"),
                 def(MvCount.class, MvCount::new, "mv_count"),
                 def(MvDedupe.class, MvDedupe::new, "mv_dedupe"),
                 def(MvFirst.class, MvFirst::new, "mv_first"),

+ 742 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContains.java

@@ -0,0 +1,742 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.multivalue;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.ElementType;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.core.Releasables;
+import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.FoldContext;
+import org.elasticsearch.xpack.esql.core.expression.Nullability;
+import org.elasticsearch.xpack.esql.core.expression.function.scalar.BinaryScalarFunction;
+import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.evaluator.mapper.EvaluatorMapper;
+import org.elasticsearch.xpack.esql.expression.function.Example;
+import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesTo;
+import org.elasticsearch.xpack.esql.expression.function.FunctionAppliesToLifecycle;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.planner.PlannerUtils;
+
+import java.io.IOException;
+
+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.isRepresentableExceptCounters;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+
+/**
+ * Function that takes two multivalued expressions and checks if values of one expression are all present(equals) in the other.
+ * <p>
+ * Given Set A = {"a","b","c"} and Set B = {"b","c"}, the relationship between first (row) and second (column) arguments is:
+ * <ul>
+ *     <li>A, B &rArr; true  (A &sube; B)</li>
+ *     <li>B, A &rArr; false (A &#8840; B)</li>
+ *     <li>A, A &rArr; true (A &equiv; A)</li>
+ *     <li>B, B &rArr; true (B &equiv; B)</li>
+ *     <li>A, null &rArr; true (B &sube; &empty;)</li>
+ *     <li>null, A &rArr; false (&empty; &#8840; B)</li>
+ *     <li>B, null &rArr; true (B &sube; &empty;)</li>
+ *     <li>null, B &rArr; false (&empty; &#8840; B)</li>
+ *     <li>null, null &rArr; true (&empty; &equiv; &empty;)</li>
+ * </ul>
+ */
+public class MvContains extends BinaryScalarFunction implements EvaluatorMapper {
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(
+        Expression.class,
+        "MvContains",
+        MvContains::new
+    );
+
+    @FunctionInfo(
+        returnType = "boolean",
+        description = "Checks if all values yielded by the second multivalue expression are present in the values yielded by "
+            + "the first multivalue expression. Returns a boolean. Null values are treated as an empty set.",
+        examples = {
+            @Example(file = "string", tag = "mv_contains"),
+            @Example(file = "string", tag = "mv_contains_bothsides"),
+            @Example(file = "string", tag = "mv_contains_where"), },
+        appliesTo = { @FunctionAppliesTo(lifeCycle = FunctionAppliesToLifecycle.PREVIEW, version = "9.2.0") }
+    )
+    public MvContains(
+        Source source,
+        @Param(
+            name = "superset",
+            type = {
+                "boolean",
+                "cartesian_point",
+                "cartesian_shape",
+                "date",
+                "date_nanos",
+                "double",
+                "geo_point",
+                "geo_shape",
+                "integer",
+                "ip",
+                "keyword",
+                "long",
+                "text",
+                "unsigned_long",
+                "version" },
+            description = "Multivalue expression."
+        ) Expression superset,
+        @Param(
+            name = "subset",
+            type = {
+                "boolean",
+                "cartesian_point",
+                "cartesian_shape",
+                "date",
+                "date_nanos",
+                "double",
+                "geo_point",
+                "geo_shape",
+                "integer",
+                "ip",
+                "keyword",
+                "long",
+                "text",
+                "unsigned_long",
+                "version" },
+            description = "Multivalue expression."
+        ) Expression subset
+    ) {
+        super(source, superset, subset);
+    }
+
+    private MvContains(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        if (childrenResolved() == false) {
+            return new TypeResolution("Unresolved children");
+        }
+
+        TypeResolution resolution = isRepresentableExceptCounters(left(), sourceText(), FIRST);
+        if (resolution.unresolved()) {
+            return resolution;
+        }
+        if (left().dataType() == DataType.NULL) {
+            return isRepresentableExceptCounters(right(), sourceText(), SECOND);
+        }
+        return isType(right(), t -> t.noText() == left().dataType().noText(), sourceText(), SECOND, left().dataType().noText().typeName());
+    }
+
+    @Override
+    public DataType dataType() {
+        return DataType.BOOLEAN;
+    }
+
+    @Override
+    public Nullability nullable() {
+        return Nullability.FALSE;
+    }
+
+    @Override
+    protected MvContains replaceChildren(Expression newLeft, Expression newRight) {
+        return new MvContains(source(), newLeft, newRight);
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, MvContains::new, left(), right());
+    }
+
+    @Override
+    public Object fold(FoldContext ctx) {
+        return EvaluatorMapper.super.fold(source(), ctx);
+    }
+
+    @Override
+    public ExpressionEvaluator.Factory toEvaluator(ToEvaluator toEvaluator) {
+        var supersetType = PlannerUtils.toElementType(left().dataType());
+        var subsetType = PlannerUtils.toElementType(right().dataType());
+        if (supersetType != ElementType.NULL && subsetType != ElementType.NULL && supersetType != subsetType) {
+            throw new EsqlIllegalArgumentException(
+                "Incompatible data types for MvContains, superset type({}) value({}) and subset type({}) value({}) don't match.",
+                supersetType,
+                left(),
+                subsetType,
+                right()
+            );
+        }
+        if (supersetType == ElementType.NULL || subsetType == ElementType.NULL) {
+            return new MvContainsNullEvaluator(toEvaluator.apply(right()));
+        }
+        return switch (supersetType) {
+            case BOOLEAN -> new MvContainsBooleanEvaluator.Factory(source(), toEvaluator.apply(left()), toEvaluator.apply(right()));
+            case BYTES_REF -> new MvContainsBytesRefEvaluator.Factory(source(), toEvaluator.apply(left()), toEvaluator.apply(right()));
+            case DOUBLE -> new MvContainsDoubleEvaluator.Factory(source(), toEvaluator.apply(left()), toEvaluator.apply(right()));
+            case INT -> new MvContainsIntEvaluator.Factory(source(), toEvaluator.apply(left()), toEvaluator.apply(right()));
+            case LONG -> new MvContainsLongEvaluator.Factory(source(), toEvaluator.apply(left()), toEvaluator.apply(right()));
+            default -> throw EsqlIllegalArgumentException.illegalDataType(dataType());
+        };
+    }
+
+    // @Evaluator(extraName = "Int") see end of file.
+    static void process(BooleanBlock.Builder builder, int position, IntBlock field1, IntBlock field2) {
+        appendTo(builder, containsAll(field1, field2, position, IntBlock::getInt));
+    }
+
+    // @Evaluator(extraName = "Boolean") see end of file.
+    static void process(BooleanBlock.Builder builder, int position, BooleanBlock field1, BooleanBlock field2) {
+        appendTo(builder, containsAll(field1, field2, position, BooleanBlock::getBoolean));
+    }
+
+    // @Evaluator(extraName = "Long") see end of file.
+    static void process(BooleanBlock.Builder builder, int position, LongBlock field1, LongBlock field2) {
+        appendTo(builder, containsAll(field1, field2, position, LongBlock::getLong));
+    }
+
+    // @Evaluator(extraName = "Double") see end of file.
+    static void process(BooleanBlock.Builder builder, int position, DoubleBlock field1, DoubleBlock field2) {
+        appendTo(builder, containsAll(field1, field2, position, DoubleBlock::getDouble));
+    }
+
+    // @Evaluator(extraName = "BytesRef") see end of file.
+    static void process(BooleanBlock.Builder builder, int position, BytesRefBlock field1, BytesRefBlock field2) {
+        appendTo(builder, containsAll(field1, field2, position, (block, index) -> {
+            var ref = new BytesRef();
+            // we pass in a reference, but sometimes we only get a return value, see ConstantBytesRefVector.getBytesRef
+            ref = block.getBytesRef(index, ref);
+            // pass empty ref as null
+            if (ref.length == 0) {
+                return null;
+            }
+            return ref;
+        }));
+    }
+
+    static void appendTo(BooleanBlock.Builder builder, Boolean bool) {
+        if (bool == null) {
+            builder.appendNull();
+        } else {
+            builder.beginPositionEntry().appendBoolean(bool).endPositionEntry();
+        }
+    }
+
+    /**
+     * A block is considered a subset if the superset contains values that test equal for all the values in the subset, independent of
+     * order. Duplicates are ignored in the sense that for each duplicate in the subset, we will search/match against the first/any value
+     * in the superset.
+     *
+     * @param superset block to check against
+     * @param subset   block containing values that should be present in the other block.
+     * @return {@code true} if the given blocks are a superset and subset to each other, {@code false} if not.
+     */
+    static <BlockType extends Block, Type> Boolean containsAll(
+        BlockType superset,
+        BlockType subset,
+        final int position,
+        ValueExtractor<BlockType, Type> valueExtractor
+    ) {
+        if (superset == subset) {
+            return true;
+        }
+        if (subset.areAllValuesNull()) {
+            return true;
+        }
+
+        final var subsetCount = subset.getValueCount(position);
+        final var startIndex = subset.getFirstValueIndex(position);
+        for (int subsetIndex = startIndex; subsetIndex < startIndex + subsetCount; subsetIndex++) {
+            var value = valueExtractor.extractValue(subset, subsetIndex);
+            if (hasValue(superset, position, value, valueExtractor) == false) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Check if the block has the value at any of it's positions
+     * @param superset Block to search
+     * @param value to search for
+     * @return true if the supplied long value is in the supplied Block
+     */
+    static <BlockType extends Block, Type> boolean hasValue(
+        BlockType superset,
+        final int position,
+        Type value,
+        ValueExtractor<BlockType, Type> valueExtractor
+    ) {
+        final var supersetCount = superset.getValueCount(position);
+        final var startIndex = superset.getFirstValueIndex(position);
+        for (int supersetIndex = startIndex; supersetIndex < startIndex + supersetCount; supersetIndex++) {
+            var element = valueExtractor.extractValue(superset, supersetIndex);
+            if (element != null && element.equals(value)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    interface ValueExtractor<BlockType extends Block, Type> {
+        Type extractValue(BlockType block, int position);
+    }
+
+    private static final class MvContainsNullEvaluator implements ExpressionEvaluator.Factory {
+        private final ExpressionEvaluator.Factory subsetFieldEvaluator;
+
+        private MvContainsNullEvaluator(ExpressionEvaluator.Factory subsetFieldEvaluator) {
+            this.subsetFieldEvaluator = subsetFieldEvaluator;
+        }
+
+        @Override
+        public ExpressionEvaluator get(DriverContext context) {
+            return new ExpressionEvaluator() {
+                final ExpressionEvaluator subsetField = subsetFieldEvaluator.get(context);
+
+                @Override
+                public Block eval(Page page) {
+                    try (Block block = subsetField.eval(page)) {
+                        var position = page.getPositionCount();
+                        return context.blockFactory().newConstantBooleanBlockWith(block.isNull(position), position);
+                    }
+                }
+
+                @Override
+                public void close() {
+                    Releasables.closeExpectNoException(subsetField);
+                }
+
+                @Override
+                public String toString() {
+                    return "MvContainsNullEvaluator[" + "subsetField=" + subsetFieldEvaluator + "]";
+                }
+            };
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsNullEvaluator[" + "subsetField=" + subsetFieldEvaluator + "]";
+        }
+    }
+
+    /**
+     *  Currently {@code EvaluatorImplementer} generates:
+     *          if (allBlocksAreNulls) {
+     *           result.appendNull();
+     *           continue position;
+     *         }
+     *  when all params are null, this violates our contract of always returning a boolean.
+     *  It should probably also generate the warnings method conditionally - omitted here.
+     *  TODO extend code generation to handle this case
+     */
+    public static class MvContainsBooleanEvaluator implements EvalOperator.ExpressionEvaluator {
+        private final EvalOperator.ExpressionEvaluator field1;
+        private final EvalOperator.ExpressionEvaluator field2;
+        private final DriverContext driverContext;
+
+        public MvContainsBooleanEvaluator(
+            EvalOperator.ExpressionEvaluator field1,
+            EvalOperator.ExpressionEvaluator field2,
+            DriverContext driverContext
+        ) {
+            this.field1 = field1;
+            this.field2 = field2;
+            this.driverContext = driverContext;
+        }
+
+        @Override
+        public Block eval(Page page) {
+            try (BooleanBlock field1Block = (BooleanBlock) field1.eval(page)) {
+                try (BooleanBlock field2Block = (BooleanBlock) field2.eval(page)) {
+                    return eval(page.getPositionCount(), field1Block, field2Block);
+                }
+            }
+        }
+
+        public BooleanBlock eval(int positionCount, BooleanBlock field1Block, BooleanBlock field2Block) {
+            try (BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+                for (int p = 0; p < positionCount; p++) {
+                    MvContains.process(result, p, field1Block, field2Block);
+                }
+                return result.build();
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsBooleanEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+        }
+
+        @Override
+        public void close() {
+            Releasables.closeExpectNoException(field1, field2);
+        }
+
+        public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+            private final Source source;
+            private final EvalOperator.ExpressionEvaluator.Factory field1;
+            private final EvalOperator.ExpressionEvaluator.Factory field2;
+
+            public Factory(
+                Source source,
+                EvalOperator.ExpressionEvaluator.Factory field1,
+                EvalOperator.ExpressionEvaluator.Factory field2
+            ) {
+                this.source = source;
+                this.field1 = field1;
+                this.field2 = field2;
+            }
+
+            @Override
+            public MvContainsBooleanEvaluator get(DriverContext context) {
+                return new MvContainsBooleanEvaluator(field1.get(context), field2.get(context), context);
+            }
+
+            @Override
+            public String toString() {
+                return "MvContainsBooleanEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+            }
+        }
+    }
+
+    /**
+     *  Currently {@code EvaluatorImplementer} generates:
+     *          if (allBlocksAreNulls) {
+     *           result.appendNull();
+     *           continue position;
+     *         }
+     *  when all params are null, this violates our contract of always returning a boolean.
+     *  It should probably also generate the warnings method conditionally - omitted here.
+     *  TODO extend code generation to handle this case
+     */
+    public static class MvContainsBytesRefEvaluator implements EvalOperator.ExpressionEvaluator {
+        private final EvalOperator.ExpressionEvaluator field1;
+        private final EvalOperator.ExpressionEvaluator field2;
+        private final DriverContext driverContext;
+
+        public MvContainsBytesRefEvaluator(
+            EvalOperator.ExpressionEvaluator field1,
+            EvalOperator.ExpressionEvaluator field2,
+            DriverContext driverContext
+        ) {
+            this.field1 = field1;
+            this.field2 = field2;
+            this.driverContext = driverContext;
+        }
+
+        @Override
+        public Block eval(Page page) {
+            try (BytesRefBlock field1Block = (BytesRefBlock) field1.eval(page)) {
+                try (BytesRefBlock field2Block = (BytesRefBlock) field2.eval(page)) {
+                    return eval(page.getPositionCount(), field1Block, field2Block);
+                }
+            }
+        }
+
+        public BooleanBlock eval(int positionCount, BytesRefBlock field1Block, BytesRefBlock field2Block) {
+            try (BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+                for (int p = 0; p < positionCount; p++) {
+                    MvContains.process(result, p, field1Block, field2Block);
+                }
+                return result.build();
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsBytesRefEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+        }
+
+        @Override
+        public void close() {
+            Releasables.closeExpectNoException(field1, field2);
+        }
+
+        public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+            private final Source source;
+            private final EvalOperator.ExpressionEvaluator.Factory field1;
+            private final EvalOperator.ExpressionEvaluator.Factory field2;
+
+            public Factory(
+                Source source,
+                EvalOperator.ExpressionEvaluator.Factory field1,
+                EvalOperator.ExpressionEvaluator.Factory field2
+            ) {
+                this.source = source;
+                this.field1 = field1;
+                this.field2 = field2;
+            }
+
+            @Override
+            public MvContainsBytesRefEvaluator get(DriverContext context) {
+                return new MvContainsBytesRefEvaluator(field1.get(context), field2.get(context), context);
+            }
+
+            @Override
+            public String toString() {
+                return "MvContainsBytesRefEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+            }
+        }
+    }
+
+    /**
+     *  Currently {@code EvaluatorImplementer} generates:
+     *          if (allBlocksAreNulls) {
+     *           result.appendNull();
+     *           continue position;
+     *         }
+     *  when all params are null, this violates our contract of always returning a boolean.
+     *  It should probably also generate the warnings method conditionally - omitted here.
+     *  TODO extend code generation to handle this case
+     */
+    public static class MvContainsDoubleEvaluator implements EvalOperator.ExpressionEvaluator {
+        private final EvalOperator.ExpressionEvaluator field1;
+        private final EvalOperator.ExpressionEvaluator field2;
+        private final DriverContext driverContext;
+
+        public MvContainsDoubleEvaluator(
+            EvalOperator.ExpressionEvaluator field1,
+            EvalOperator.ExpressionEvaluator field2,
+            DriverContext driverContext
+        ) {
+            this.field1 = field1;
+            this.field2 = field2;
+            this.driverContext = driverContext;
+        }
+
+        @Override
+        public Block eval(Page page) {
+            try (DoubleBlock field1Block = (DoubleBlock) field1.eval(page)) {
+                try (DoubleBlock field2Block = (DoubleBlock) field2.eval(page)) {
+                    return eval(page.getPositionCount(), field1Block, field2Block);
+                }
+            }
+        }
+
+        public BooleanBlock eval(int positionCount, DoubleBlock field1Block, DoubleBlock field2Block) {
+            try (BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+                for (int p = 0; p < positionCount; p++) {
+                    MvContains.process(result, p, field1Block, field2Block);
+                }
+                return result.build();
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsDoubleEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+        }
+
+        @Override
+        public void close() {
+            Releasables.closeExpectNoException(field1, field2);
+        }
+
+        public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+            private final Source source;
+            private final EvalOperator.ExpressionEvaluator.Factory field1;
+            private final EvalOperator.ExpressionEvaluator.Factory field2;
+
+            public Factory(
+                Source source,
+                EvalOperator.ExpressionEvaluator.Factory field1,
+                EvalOperator.ExpressionEvaluator.Factory field2
+            ) {
+                this.source = source;
+                this.field1 = field1;
+                this.field2 = field2;
+            }
+
+            @Override
+            public MvContainsDoubleEvaluator get(DriverContext context) {
+                return new MvContainsDoubleEvaluator(field1.get(context), field2.get(context), context);
+            }
+
+            @Override
+            public String toString() {
+                return "MvContainsDoubleEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+            }
+        }
+    }
+
+    /**
+     *  Currently {@code EvaluatorImplementer} generates:
+     *          if (allBlocksAreNulls) {
+     *           result.appendNull();
+     *           continue position;
+     *         }
+     *  when all params are null, this violates our contract of always returning a boolean.
+     *  It should probably also generate the warnings method conditionally - omitted here.
+     *  TODO extend code generation to handle this case
+     */
+    public static class MvContainsIntEvaluator implements EvalOperator.ExpressionEvaluator {
+        private final EvalOperator.ExpressionEvaluator field1;
+        private final EvalOperator.ExpressionEvaluator field2;
+        private final DriverContext driverContext;
+
+        public MvContainsIntEvaluator(
+            EvalOperator.ExpressionEvaluator field1,
+            EvalOperator.ExpressionEvaluator field2,
+            DriverContext driverContext
+        ) {
+            this.field1 = field1;
+            this.field2 = field2;
+            this.driverContext = driverContext;
+        }
+
+        @Override
+        public Block eval(Page page) {
+            try (IntBlock field1Block = (IntBlock) field1.eval(page)) {
+                try (IntBlock field2Block = (IntBlock) field2.eval(page)) {
+                    return eval(page.getPositionCount(), field1Block, field2Block);
+                }
+            }
+        }
+
+        public BooleanBlock eval(int positionCount, IntBlock field1Block, IntBlock field2Block) {
+            try (BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+                for (int p = 0; p < positionCount; p++) {
+                    MvContains.process(result, p, field1Block, field2Block);
+                }
+                return result.build();
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsIntEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+        }
+
+        @Override
+        public void close() {
+            Releasables.closeExpectNoException(field1, field2);
+        }
+
+        public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+            private final Source source;
+            private final EvalOperator.ExpressionEvaluator.Factory field1;
+            private final EvalOperator.ExpressionEvaluator.Factory field2;
+
+            public Factory(
+                Source source,
+                EvalOperator.ExpressionEvaluator.Factory field1,
+                EvalOperator.ExpressionEvaluator.Factory field2
+            ) {
+                this.source = source;
+                this.field1 = field1;
+                this.field2 = field2;
+            }
+
+            @Override
+            public MvContainsIntEvaluator get(DriverContext context) {
+                return new MvContainsIntEvaluator(field1.get(context), field2.get(context), context);
+            }
+
+            @Override
+            public String toString() {
+                return "MvContainsIntEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+            }
+        }
+    }
+
+    /**
+     *  Currently {@code EvaluatorImplementer} generates:
+     *          if (allBlocksAreNulls) {
+     *           result.appendNull();
+     *           continue position;
+     *         }
+     *  when all params are null, this violates our contract of always returning a boolean.
+     *  It should probably also generate the warnings method conditionally - omitted here.
+     *  TODO extend code generation to handle this case
+     */
+    public static class MvContainsLongEvaluator implements EvalOperator.ExpressionEvaluator {
+        private final EvalOperator.ExpressionEvaluator field1;
+        private final EvalOperator.ExpressionEvaluator field2;
+        private final DriverContext driverContext;
+
+        public MvContainsLongEvaluator(
+            EvalOperator.ExpressionEvaluator field1,
+            EvalOperator.ExpressionEvaluator field2,
+            DriverContext driverContext
+        ) {
+            this.field1 = field1;
+            this.field2 = field2;
+            this.driverContext = driverContext;
+        }
+
+        @Override
+        public Block eval(Page page) {
+            try (LongBlock field1Block = (LongBlock) field1.eval(page)) {
+                try (LongBlock field2Block = (LongBlock) field2.eval(page)) {
+                    return eval(page.getPositionCount(), field1Block, field2Block);
+                }
+            }
+        }
+
+        public BooleanBlock eval(int positionCount, LongBlock field1Block, LongBlock field2Block) {
+            try (BooleanBlock.Builder result = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+                for (int p = 0; p < positionCount; p++) {
+                    MvContains.process(result, p, field1Block, field2Block);
+                }
+                return result.build();
+            }
+        }
+
+        @Override
+        public String toString() {
+            return "MvContainsLongEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+        }
+
+        @Override
+        public void close() {
+            Releasables.closeExpectNoException(field1, field2);
+        }
+
+        public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+            private final Source source;
+            private final EvalOperator.ExpressionEvaluator.Factory field1;
+            private final EvalOperator.ExpressionEvaluator.Factory field2;
+
+            public Factory(
+                Source source,
+                EvalOperator.ExpressionEvaluator.Factory field1,
+                EvalOperator.ExpressionEvaluator.Factory field2
+            ) {
+                this.source = source;
+                this.field1 = field1;
+                this.field2 = field2;
+            }
+
+            @Override
+            public MvContainsLongEvaluator get(DriverContext context) {
+                return new MvContainsLongEvaluator(field1.get(context), field2.get(context), context);
+            }
+
+            @Override
+            public String toString() {
+                return "MvContainsLongEvaluator[" + "field1=" + field1 + ", field2=" + field2 + "]";
+            }
+        }
+    }
+}

+ 1 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFunctionWritables.java

@@ -17,6 +17,7 @@ public class MvFunctionWritables {
             MvAppend.ENTRY,
             MvAvg.ENTRY,
             MvConcat.ENTRY,
+            MvContains.ENTRY,
             MvCount.ENTRY,
             MvDedupe.ENTRY,
             MvFirst.ENTRY,

+ 45 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContainsErrorTests.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.multivalue;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class MvContainsErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+    @Override
+    protected List<TestCaseSupplier> cases() {
+        return paramsToSuppliers(MvContainsTests.parameters());
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new MvContains(source, args.get(0), args.get(1));
+    }
+
+    @Override
+    protected Matcher<String> expectedTypeErrorMatcher(List<Set<DataType>> validPerPosition, List<DataType> signature) {
+        return equalTo(
+            "second argument of ["
+                + sourceForSignature(signature)
+                + "] must be ["
+                + signature.get(0).noText().typeName()
+                + "], found value [] type ["
+                + signature.get(1).typeName()
+                + "]"
+        );
+    }
+}

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

@@ -0,0 +1,37 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.multivalue;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests;
+
+import java.io.IOException;
+
+public class MvContainsSerializationTests extends AbstractExpressionSerializationTests<MvContains> {
+    @Override
+    protected MvContains createTestInstance() {
+        Source source = randomSource();
+        Expression field1 = randomChild();
+        Expression field2 = randomChild();
+        return new MvContains(source, field1, field2);
+    }
+
+    @Override
+    protected MvContains mutateInstance(MvContains instance) throws IOException {
+        Source source = randomSource();
+        Expression field1 = randomChild();
+        Expression field2 = randomChild();
+        if (randomBoolean()) {
+            field1 = randomValueOtherThan(field1, AbstractExpressionSerializationTests::randomChild);
+        } else {
+            field2 = randomValueOtherThan(field2, AbstractExpressionSerializationTests::randomChild);
+        }
+        return new MvContains(source, field1, field2);
+    }
+}

+ 368 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvContainsTests.java

@@ -0,0 +1,368 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+package org.elasticsearch.xpack.esql.expression.function.scalar.multivalue;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.geo.GeometryTestUtils;
+import org.elasticsearch.geo.ShapeTestUtils;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static org.elasticsearch.xpack.esql.EsqlTestUtils.randomLiteral;
+import static org.elasticsearch.xpack.esql.core.util.SpatialCoordinateTypes.CARTESIAN;
+import static org.elasticsearch.xpack.esql.core.util.SpatialCoordinateTypes.GEO;
+import static org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier.TypedData.MULTI_ROW_NULL;
+import static org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier.TypedData.NULL;
+import static org.hamcrest.Matchers.anyOf;
+import static org.hamcrest.Matchers.equalTo;
+
+public class MvContainsTests extends AbstractScalarFunctionTestCase {
+    public MvContainsTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> suppliers = new ArrayList<>();
+        booleans(suppliers);
+        ints(suppliers);
+        longs(suppliers);
+        doubles(suppliers);
+        bytesRefs(suppliers);
+
+        return parameterSuppliersFromTypedData(
+            anyNullIsNull(
+                suppliers,
+                (nullPosition, nullValueDataType, original) -> original.expectedType(),
+                (nullPosition, nullData, original) -> original
+            )
+        );
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new MvContains(source, args.get(0), args.get(1));
+    }
+
+    private static void booleans(List<TestCaseSupplier> suppliers) {
+        suppliers.add(new TestCaseSupplier(List.of(DataType.BOOLEAN, DataType.BOOLEAN), () -> {
+            List<Boolean> field1 = randomList(1, 10, ESTestCase::randomBoolean);
+            List<Boolean> field2 = randomList(1, 2, ESTestCase::randomBoolean);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.BOOLEAN, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.BOOLEAN, "field2")
+                ),
+                "MvContainsBooleanEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+    }
+
+    private static void ints(List<TestCaseSupplier> suppliers) {
+        suppliers.add(new TestCaseSupplier(List.of(DataType.INTEGER, DataType.INTEGER), () -> {
+            List<Integer> field1 = randomList(1, 10, ESTestCase::randomInt);
+            List<Integer> field2 = randomList(1, 10, ESTestCase::randomInt);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.INTEGER, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.INTEGER, "field2")
+                ),
+                "MvContainsIntEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+    }
+
+    private static void longs(List<TestCaseSupplier> suppliers) {
+        suppliers.add(new TestCaseSupplier(List.of(DataType.LONG, DataType.LONG), () -> {
+            List<Long> field1 = randomList(1, 10, ESTestCase::randomLong);
+            List<Long> field2 = randomList(1, 10, ESTestCase::randomLong);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.LONG, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.LONG, "field2")
+                ),
+                "MvContainsLongEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+        suppliers.add(new TestCaseSupplier(List.of(DataType.UNSIGNED_LONG, DataType.UNSIGNED_LONG), () -> {
+            List<Long> field1 = randomList(1, 10, ESTestCase::randomLong);
+            List<Long> field2 = randomList(1, 10, ESTestCase::randomLong);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.UNSIGNED_LONG, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.UNSIGNED_LONG, "field2")
+                ),
+                "MvContainsLongEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+        suppliers.add(new TestCaseSupplier(List.of(DataType.DATETIME, DataType.DATETIME), () -> {
+            List<Long> field1 = randomList(1, 10, ESTestCase::randomLong);
+            List<Long> field2 = randomList(1, 10, ESTestCase::randomLong);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.DATETIME, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.DATETIME, "field2")
+                ),
+                "MvContainsLongEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+        suppliers.add(new TestCaseSupplier(List.of(DataType.DATE_NANOS, DataType.DATE_NANOS), () -> {
+            List<Long> field1 = randomList(1, 10, ESTestCase::randomNonNegativeLong);
+            List<Long> field2 = randomList(1, 10, ESTestCase::randomNonNegativeLong);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.DATE_NANOS, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.DATE_NANOS, "field2")
+                ),
+                "MvContainsLongEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+    }
+
+    private static void doubles(List<TestCaseSupplier> suppliers) {
+        suppliers.add(new TestCaseSupplier(List.of(DataType.DOUBLE, DataType.DOUBLE), () -> {
+            List<Double> field1 = randomList(1, 10, ESTestCase::randomDouble);
+            List<Double> field2 = randomList(1, 10, ESTestCase::randomDouble);
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.DOUBLE, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.DOUBLE, "field2")
+                ),
+                "MvContainsDoubleEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+    }
+
+    private static void bytesRefs(List<TestCaseSupplier> suppliers) {
+        for (DataType lhs : new DataType[] { DataType.KEYWORD, DataType.TEXT }) {
+            for (DataType rhs : new DataType[] { DataType.KEYWORD, DataType.TEXT }) {
+                suppliers.add(new TestCaseSupplier(List.of(lhs, rhs), () -> {
+                    List<Object> field1 = randomList(1, 10, () -> randomLiteral(lhs).value());
+                    List<Object> field2 = randomList(1, 10, () -> randomLiteral(rhs).value());
+                    var result = field1.containsAll(field2);
+                    return new TestCaseSupplier.TestCase(
+                        List.of(
+                            new TestCaseSupplier.TypedData(field1, lhs, "field1"),
+                            new TestCaseSupplier.TypedData(field2, rhs, "field2")
+                        ),
+                        "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                        DataType.BOOLEAN,
+                        equalTo(result)
+                    );
+                }));
+            }
+        }
+        suppliers.add(new TestCaseSupplier(List.of(DataType.IP, DataType.IP), () -> {
+            List<Object> field1 = randomList(1, 10, () -> randomLiteral(DataType.IP).value());
+            List<Object> field2 = randomList(1, 10, () -> randomLiteral(DataType.IP).value());
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.IP, "field"),
+                    new TestCaseSupplier.TypedData(field2, DataType.IP, "field")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+
+        suppliers.add(new TestCaseSupplier(List.of(DataType.VERSION, DataType.VERSION), () -> {
+            List<Object> field1 = randomList(1, 10, () -> randomLiteral(DataType.VERSION).value());
+            List<Object> field2 = randomList(1, 10, () -> randomLiteral(DataType.VERSION).value());
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.VERSION, "field"),
+                    new TestCaseSupplier.TypedData(field2, DataType.VERSION, "field")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+
+        suppliers.add(new TestCaseSupplier(List.of(DataType.GEO_POINT, DataType.GEO_POINT), () -> {
+            List<Object> field1 = randomList(1, 10, () -> new BytesRef(GEO.asWkt(GeometryTestUtils.randomPoint())));
+            List<Object> field2 = randomList(1, 10, () -> new BytesRef(GEO.asWkt(GeometryTestUtils.randomPoint())));
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.GEO_POINT, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.GEO_POINT, "field2")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+
+        suppliers.add(new TestCaseSupplier(List.of(DataType.CARTESIAN_POINT, DataType.CARTESIAN_POINT), () -> {
+            List<Object> field1 = randomList(1, 10, () -> new BytesRef(CARTESIAN.asWkt(ShapeTestUtils.randomPoint())));
+            List<Object> field2 = randomList(1, 10, () -> new BytesRef(CARTESIAN.asWkt(ShapeTestUtils.randomPoint())));
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.CARTESIAN_POINT, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.CARTESIAN_POINT, "field2")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+
+        suppliers.add(new TestCaseSupplier(List.of(DataType.GEO_SHAPE, DataType.GEO_SHAPE), () -> {
+            var field1 = randomList(1, 3, () -> new BytesRef(GEO.asWkt(GeometryTestUtils.randomGeometry(randomBoolean(), 500))));
+            var field2 = randomList(1, 3, () -> new BytesRef(GEO.asWkt(GeometryTestUtils.randomGeometry(randomBoolean(), 500))));
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.GEO_SHAPE, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.GEO_SHAPE, "field2")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+
+        suppliers.add(new TestCaseSupplier(List.of(DataType.CARTESIAN_SHAPE, DataType.CARTESIAN_SHAPE), () -> {
+            var field1 = randomList(1, 3, () -> new BytesRef(CARTESIAN.asWkt(ShapeTestUtils.randomGeometry(randomBoolean(), 500))));
+            var field2 = randomList(1, 3, () -> new BytesRef(CARTESIAN.asWkt(ShapeTestUtils.randomGeometry(randomBoolean(), 500))));
+            var result = field1.containsAll(field2);
+            return new TestCaseSupplier.TestCase(
+                List.of(
+                    new TestCaseSupplier.TypedData(field1, DataType.CARTESIAN_SHAPE, "field1"),
+                    new TestCaseSupplier.TypedData(field2, DataType.CARTESIAN_SHAPE, "field2")
+                ),
+                "MvContainsBytesRefEvaluator[field1=Attribute[channel=0], field2=Attribute[channel=1]]",
+                DataType.BOOLEAN,
+                equalTo(result)
+            );
+        }));
+    }
+
+    // Adjusted from static method anyNullIsNull in {@code AbstractScalarFunctionTestCase#}
+    protected static List<TestCaseSupplier> anyNullIsNull(
+        List<TestCaseSupplier> testCaseSuppliers,
+        ExpectedType expectedType,
+        ExpectedEvaluatorToString evaluatorToString
+    ) {
+        List<TestCaseSupplier> suppliers = new ArrayList<>(testCaseSuppliers);
+
+        /*
+         * For each original test case, add as many copies as there were
+         * arguments, replacing one of the arguments with null and keeping
+         * the others.
+         *
+         * Also, if this was the first time we saw the signature we copy it
+         * *again*, replacing the argument with null, but annotating the
+         * argument’s type as `null` explicitly.
+         */
+        Set<List<DataType>> uniqueSignatures = new HashSet<>();
+        for (TestCaseSupplier original : testCaseSuppliers) {
+            boolean firstTimeSeenSignature = uniqueSignatures.add(original.types());
+            for (int typeIndex = 0; typeIndex < original.types().size(); typeIndex++) {
+                int nullPosition = typeIndex;
+
+                suppliers.add(new TestCaseSupplier("G1: " + original.name() + " null in " + nullPosition, original.types(), () -> {
+                    TestCaseSupplier.TestCase originalTestCase = original.get();
+                    List<TestCaseSupplier.TypedData> typeDataWithNull = new ArrayList<>(originalTestCase.getData());
+                    var data = typeDataWithNull.get(nullPosition);
+                    typeDataWithNull.set(nullPosition, data.withData(data.isMultiRow() ? Collections.singletonList(null) : null));
+                    TestCaseSupplier.TypedData nulledData = originalTestCase.getData().get(nullPosition);
+                    return new TestCaseSupplier.TestCase(
+                        typeDataWithNull,
+                        evaluatorToString.evaluatorToString(nullPosition, nulledData, originalTestCase.evaluatorToString()),
+                        expectedType.expectedType(nullPosition, DataType.BOOLEAN, originalTestCase),
+                        equalTo(nullPosition == 1)
+                    );
+                }));
+
+                if (firstTimeSeenSignature) {
+                    var typesWithNull = new ArrayList<>(original.types());
+                    typesWithNull.set(nullPosition, DataType.NULL);
+                    boolean newSignature = uniqueSignatures.add(typesWithNull);
+                    if (newSignature) {
+                        suppliers.add(
+                            new TestCaseSupplier(
+                                "G2: " + toSpaceSeparatedString(typesWithNull) + " null in " + nullPosition,
+                                typesWithNull,
+                                () -> {
+                                    TestCaseSupplier.TestCase originalTestCase = original.get();
+                                    var typeDataWithNull = new ArrayList<>(originalTestCase.getData());
+                                    typeDataWithNull.set(
+                                        nullPosition,
+                                        typeDataWithNull.get(nullPosition).isMultiRow() ? MULTI_ROW_NULL : NULL
+                                    );
+                                    return new TestCaseSupplier.TestCase(
+                                        typeDataWithNull,
+                                        "MvContainsNullEvaluator[subsetField=Attribute[channel=1]]",
+                                        expectedType.expectedType(nullPosition, DataType.BOOLEAN, originalTestCase),
+                                        equalTo(nullPosition == 1)
+                                    );
+                                }
+                            )
+                        );
+                    }
+                }
+            }
+        }
+
+        return suppliers;
+    }
+
+    private static String toSpaceSeparatedString(ArrayList<DataType> typesWithNull) {
+        return typesWithNull.stream().map(Objects::toString).collect(Collectors.joining(" "));
+    }
+
+    // We always return a boolean.
+    @Override
+    protected Matcher<Object> allNullsMatcher() {
+        return anyOf(equalTo(false), equalTo(true));
+    }
+}