Browse Source

ESQL: `MV_FIRST` and `MV_LAST` (#103928)

This creates the `MV_FIRST` and `MV_LAST` functions that return the
first and last values from a multivalue field. They are noops from a
single valued field. They are quite similar to `MV_MIN` and `MV_MAX`
except they work on positional data rather than relative size. That
sounds like a large distinction, but in practice our multivalued fields
are often sorted. And when they operate on sorted arrays `MV_MIN` does
*the same* thing as `MV_FIRST`.

But there are some cases where it really does matter - say you are
`SPLIT`ing something - so `MV_FIRST(SPLIT("foo;bar;baz", ";"))` gets you
`foo` like you'd expect. No sorting needed.

Relates to #103879
Nik Everett 1 year ago
parent
commit
5ef5dca334
30 changed files with 1452 additions and 185 deletions
  1. 5 0
      docs/changelog/103928.yaml
  2. 0 144
      docs/reference/esql/esql-functions.asciidoc
  3. 4 0
      docs/reference/esql/functions/mv-functions.asciidoc
  4. 27 0
      docs/reference/esql/functions/mv_first.asciidoc
  5. 27 0
      docs/reference/esql/functions/mv_last.asciidoc
  6. 1 0
      docs/reference/esql/functions/signature/mv_first.svg
  7. 1 0
      docs/reference/esql/functions/signature/mv_last.svg
  8. 16 0
      docs/reference/esql/functions/types/mv_first.asciidoc
  9. 16 0
      docs/reference/esql/functions/types/mv_last.asciidoc
  10. 22 9
      x-pack/plugin/esql/compute/ann/src/main/java/org/elasticsearch/compute/ann/MvEvaluator.java
  11. 49 30
      x-pack/plugin/esql/compute/gen/src/main/java/org/elasticsearch/compute/gen/MvEvaluatorImplementer.java
  12. 5 1
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/show.csv-spec
  13. 26 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/string.csv-spec
  14. 89 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstBooleanEvaluator.java
  15. 92 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstBytesRefEvaluator.java
  16. 89 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstDoubleEvaluator.java
  17. 88 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstIntEvaluator.java
  18. 88 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstLongEvaluator.java
  19. 89 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastBooleanEvaluator.java
  20. 92 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastBytesRefEvaluator.java
  21. 89 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastDoubleEvaluator.java
  22. 88 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastIntEvaluator.java
  23. 88 0
      x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastLongEvaluator.java
  24. 4 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  25. 111 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirst.java
  26. 111 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLast.java
  27. 1 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java
  28. 6 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/io/stream/PlanNamedTypes.java
  29. 61 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstTests.java
  30. 67 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastTests.java

+ 5 - 0
docs/changelog/103928.yaml

@@ -0,0 +1,5 @@
+pr: 103928
+summary: "ESQL: `MV_FIRST` and `MV_LAST`"
+area: ES|QL
+type: enhancement
+issues: []

+ 0 - 144
docs/reference/esql/esql-functions.asciidoc

@@ -1,144 +0,0 @@
-[[esql-functions]]
-== {esql} functions
-
-++++
-<titleabbrev>Functions</titleabbrev>
-++++
-
-<<esql-row,`ROW`>>, <<esql-eval,`EVAL`>> and <<esql-where,`WHERE`>> support
-these functions:
-
-* <<esql-abs>>
-* <<esql-acos>>
-* <<esql-asin>>
-* <<esql-atan>>
-* <<esql-atan2>>
-* <<esql-auto_bucket>>
-* <<esql-case>>
-* <<esql-ceil>>
-* <<esql-cidr_match>>
-* <<esql-coalesce>>
-* <<esql-concat>>
-* <<esql-cos>>
-* <<esql-cosh>>
-* <<esql-date_extract>>
-* <<esql-date_format>>
-* <<esql-date_parse>>
-* <<esql-date_trunc>>
-* <<esql-e>>
-* <<esql-ends_with>>
-* <<esql-floor>>
-* <<esql-greatest>>
-* <<esql-is_finite>>
-* <<esql-is_infinite>>
-* <<esql-is_nan>>
-* <<esql-least>>
-* <<esql-left>>
-* <<esql-length>>
-* <<esql-log10>>
-* <<esql-ltrim>>
-* <<esql-mv_avg>>
-* <<esql-mv_concat>>
-* <<esql-mv_count>>
-* <<esql-mv_dedupe>>
-* <<esql-mv_max>>
-* <<esql-mv_median>>
-* <<esql-mv_min>>
-* <<esql-mv_sum>>
-* <<esql-now>>
-* <<esql-pi>>
-* <<esql-pow>>
-* <<esql-replace>>
-* <<esql-right>>
-* <<esql-round>>
-* <<esql-rtrim>>
-* <<esql-sin>>
-* <<esql-sinh>>
-* <<esql-split>>
-* <<esql-starts_with>>
-* <<esql-substring>>
-* <<esql-tan>>
-* <<esql-tanh>>
-* <<esql-tau>>
-* <<esql-to_boolean>>
-* <<esql-to_cartesianpoint>>
-* <<esql-to_datetime>>
-* <<esql-to_degrees>>
-* <<esql-to_double>>
-* <<esql-to_geopoint>>
-* <<esql-to_integer>>
-* <<esql-to_ip>>
-* <<esql-to_long>>
-* <<esql-to_radians>>
-* <<esql-to_string>>
-* <<esql-to_unsigned_long>>
-* <<esql-to_version>>
-* <<esql-trim>>
-
-include::functions/abs.asciidoc[]
-include::functions/acos.asciidoc[]
-include::functions/asin.asciidoc[]
-include::functions/atan.asciidoc[]
-include::functions/atan2.asciidoc[]
-include::functions/auto_bucket.asciidoc[]
-include::functions/case.asciidoc[]
-include::functions/ceil.asciidoc[]
-include::functions/cidr_match.asciidoc[]
-include::functions/coalesce.asciidoc[]
-include::functions/concat.asciidoc[]
-include::functions/cos.asciidoc[]
-include::functions/cosh.asciidoc[]
-include::functions/date_extract.asciidoc[]
-include::functions/date_format.asciidoc[]
-include::functions/date_parse.asciidoc[]
-include::functions/date_trunc.asciidoc[]
-include::functions/e.asciidoc[]
-include::functions/ends_with.asciidoc[]
-include::functions/floor.asciidoc[]
-include::functions/greatest.asciidoc[]
-include::functions/is_finite.asciidoc[]
-include::functions/is_infinite.asciidoc[]
-include::functions/is_nan.asciidoc[]
-include::functions/least.asciidoc[]
-include::functions/left.asciidoc[]
-include::functions/length.asciidoc[]
-include::functions/log10.asciidoc[]
-include::functions/ltrim.asciidoc[]
-include::functions/mv_avg.asciidoc[]
-include::functions/mv_concat.asciidoc[]
-include::functions/mv_count.asciidoc[]
-include::functions/mv_dedupe.asciidoc[]
-include::functions/mv_max.asciidoc[]
-include::functions/mv_median.asciidoc[]
-include::functions/mv_min.asciidoc[]
-include::functions/mv_sum.asciidoc[]
-include::functions/now.asciidoc[]
-include::functions/pi.asciidoc[]
-include::functions/pow.asciidoc[]
-include::functions/replace.asciidoc[]
-include::functions/right.asciidoc[]
-include::functions/round.asciidoc[]
-include::functions/rtrim.asciidoc[]
-include::functions/sin.asciidoc[]
-include::functions/sinh.asciidoc[]
-include::functions/split.asciidoc[]
-include::functions/sqrt.asciidoc[]
-include::functions/starts_with.asciidoc[]
-include::functions/substring.asciidoc[]
-include::functions/tan.asciidoc[]
-include::functions/tanh.asciidoc[]
-include::functions/tau.asciidoc[]
-include::functions/to_boolean.asciidoc[]
-include::functions/to_cartesianpoint.asciidoc[]
-include::functions/to_datetime.asciidoc[]
-include::functions/to_degrees.asciidoc[]
-include::functions/to_double.asciidoc[]
-include::functions/to_geopoint.asciidoc[]
-include::functions/to_integer.asciidoc[]
-include::functions/to_ip.asciidoc[]
-include::functions/to_long.asciidoc[]
-include::functions/to_radians.asciidoc[]
-include::functions/to_string.asciidoc[]
-include::functions/to_unsigned_long.asciidoc[]
-include::functions/to_version.asciidoc[]
-include::functions/trim.asciidoc[]

+ 4 - 0
docs/reference/esql/functions/mv-functions.asciidoc

@@ -12,6 +12,8 @@
 * <<esql-mv_concat>>
 * <<esql-mv_count>>
 * <<esql-mv_dedupe>>
+* <<esql-mv_first>>
+* <<esql-mv_last>>
 * <<esql-mv_max>>
 * <<esql-mv_median>>
 * <<esql-mv_min>>
@@ -22,6 +24,8 @@ include::mv_avg.asciidoc[]
 include::mv_concat.asciidoc[]
 include::mv_count.asciidoc[]
 include::mv_dedupe.asciidoc[]
+include::mv_first.asciidoc[]
+include::mv_last.asciidoc[]
 include::mv_max.asciidoc[]
 include::mv_median.asciidoc[]
 include::mv_min.asciidoc[]

+ 27 - 0
docs/reference/esql/functions/mv_first.asciidoc

@@ -0,0 +1,27 @@
+[discrete]
+[[esql-mv_first]]
+=== `MV_FIRST`
+[.text-center]
+image::esql/functions/signature/mv_first.svg[Embedded,opts=inline]
+
+Converts a multivalued field into a single valued field containing the first value. This is most
+useful when reading from a function that emits multivalued fields in a known order like <<esql-split>>:
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/string.csv-spec[tag=mv_first]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/string.csv-spec[tag=mv_first-result]
+|===
+
+The order that <<esql-multivalued-fields, multivalued fields>> are read from underlying storage is not
+guaranteed. It is *frequently* ascending, but don't rely on that. If you need the minimum field value
+use <<esql-mv_min>> instead of `MV_FIRST`. `MV_MIN` has optimizations for sorted values so there isn't
+a performance benefit to `MV_FIRST`. `MV_FIRST` is mostly useful with functions that create multivalued
+fields like `SPLIT`.
+
+Supported types:
+
+include::types/mv_first.asciidoc[]

+ 27 - 0
docs/reference/esql/functions/mv_last.asciidoc

@@ -0,0 +1,27 @@
+[discrete]
+[[esql-mv_last]]
+=== `MV_LAST`
+[.text-center]
+image::esql/functions/signature/mv_last.svg[Embedded,opts=inline]
+
+Converts a multivalued field into a single valued field containing the last value. This is most
+useful when reading from a function that emits multivalued fields in a known order like <<esql-split>>:
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/string.csv-spec[tag=mv_last]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/string.csv-spec[tag=mv_last-result]
+|===
+
+The order that <<esql-multivalued-fields, multivalued fields>> are read from underlying storage is not
+guaranteed. It is *frequently* ascending, but don't rely on that. If you need the maximum field value
+use <<esql-mv_max>> instead of `MV_LAST`. `MV_MAX` has optimizations for sorted values so there isn't
+a performance benefit to `MV_LAST`. `MV_LAST` is mostly useful with functions that create multivalued
+fields like `SPLIT`.
+
+Supported types:
+
+include::types/mv_last.asciidoc[]

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="252" height="46" viewbox="0 0 252 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m116 0h10m32 0h10m32 0h10m32 0h5"/><rect class="s" x="5" y="5" width="116" height="36"/><text class="k" x="15" y="31">MV_FIRST</text><rect class="s" x="131" y="5" width="32" height="36" rx="7"/><text class="syn" x="141" y="31">(</text><rect class="s" x="173" y="5" width="32" height="36" rx="7"/><text class="k" x="183" y="31">v</text><rect class="s" x="215" y="5" width="32" height="36" rx="7"/><text class="syn" x="225" y="31">)</text></svg>

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="240" height="46" viewbox="0 0 240 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .k{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .s{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m104 0h10m32 0h10m32 0h10m32 0h5"/><rect class="s" x="5" y="5" width="104" height="36"/><text class="k" x="15" y="31">MV_LAST</text><rect class="s" x="119" y="5" width="32" height="36" rx="7"/><text class="syn" x="129" y="31">(</text><rect class="s" x="161" y="5" width="32" height="36" rx="7"/><text class="k" x="171" y="31">v</text><rect class="s" x="203" y="5" width="32" height="36" rx="7"/><text class="syn" x="213" y="31">)</text></svg>

+ 16 - 0
docs/reference/esql/functions/types/mv_first.asciidoc

@@ -0,0 +1,16 @@
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+v | result
+boolean | boolean
+cartesian_point | cartesian_point
+datetime | datetime
+double | double
+geo_point | geo_point
+integer | integer
+ip | ip
+keyword | keyword
+long | long
+text | text
+unsigned_long | unsigned_long
+version | version
+|===

+ 16 - 0
docs/reference/esql/functions/types/mv_last.asciidoc

@@ -0,0 +1,16 @@
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+v | result
+boolean | boolean
+cartesian_point | cartesian_point
+datetime | datetime
+double | double
+geo_point | geo_point
+integer | integer
+ip | ip
+keyword | keyword
+long | long
+text | text
+unsigned_long | unsigned_long
+version | version
+|===

+ 22 - 9
x-pack/plugin/esql/compute/ann/src/main/java/org/elasticsearch/compute/ann/MvEvaluator.java

@@ -16,17 +16,30 @@ import java.lang.annotation.Target;
  * Implement an evaluator for a function reducing multivalued fields into a
  * single valued field from a static {@code process} method.
  * <p>
- *     Annotated methods can have two "shapes": pairwise processing and
- *     accumulator processing. Pairwise is <strong>generally</strong>
- *     simpler and looks like {@code int process(int current, int next)}.
- *     Use it when the result is a primitive. Accumulator processing is
- *     a bit more complex and looks like {@code void process(State state, int v)}
- *     and it useful when you need to accumulate more data than fits
- *     in a primitive result. Think Kahan summation.
+ *     Annotated methods can have three "shapes":
  * </p>
+ * <ul>
+ *     <li>pairwise processing</li>
+ *     <li>accumulator processing</li>
+ *     <li>position at a time processing</li>
+ * </ul>
  * <p>
- *     Both method shapes support at {@code finish = "finish_method"} parameter
- *     on the annotation which is used to, well, "finish" processing after
+ *     Pairwise processing is <strong>generally</strong> simpler and looks
+ *     like {@code int process(int current, int next)}. Use it when the result
+ *     is a primitive.</p>
+ * <p>
+ *     Accumulator processing is a bit more complex and looks like
+ *     {@code void process(State state, int v)} and it useful when you need to
+ *     accumulate more data than fits in a primitive result. Think Kahan summation.
+ * </p>
+ * <p>
+ *     Position at a time processing just hands the block, start index, and end index
+ *     to the processor and is useful when none of the others fit. It looks like
+ *     {@code long process(LongBlock block, int start, int end)}.
+ * </p>
+ * <p>
+ *     Pairwise and accumulator processing support a {@code finish = "finish_method"}
+ *     parameter on the annotation which is used to, well, "finish" processing after
  *     all values have been received. Again, think reading the sum from the
  *     Kahan summation. Or doing the division for an "average" operation.
  *     This method is required for accumulator processing.

+ 49 - 30
x-pack/plugin/esql/compute/gen/src/main/java/org/elasticsearch/compute/gen/MvEvaluatorImplementer.java

@@ -92,13 +92,20 @@ public class MvEvaluatorImplementer {
     ) {
         this.declarationType = (TypeElement) processFunction.getEnclosingElement();
         this.processFunction = processFunction;
-        if (processFunction.getParameters().size() != 2) {
-            throw new IllegalArgumentException("process should have exactly two parameters");
+        if (processFunction.getParameters().size() == 2) {
+            this.workType = TypeName.get(processFunction.getParameters().get(0).asType());
+            this.fieldType = TypeName.get(processFunction.getParameters().get(1).asType());
+            this.finishFunction = FinishFunction.from(declarationType, finishMethodName, workType, fieldType);
+            this.resultType = this.finishFunction == null ? this.workType : this.finishFunction.resultType;
+        } else {
+            if (finishMethodName.equals("") == false) {
+                throw new IllegalArgumentException("finish function is only supported for pairwise processing");
+            }
+            this.workType = null;
+            this.fieldType = Types.elementType(TypeName.get(processFunction.getParameters().get(0).asType()));
+            this.finishFunction = null;
+            this.resultType = TypeName.get(processFunction.getReturnType());
         }
-        this.workType = TypeName.get(processFunction.getParameters().get(0).asType());
-        this.fieldType = TypeName.get(processFunction.getParameters().get(1).asType());
-        this.finishFunction = FinishFunction.from(declarationType, finishMethodName, workType, fieldType);
-        this.resultType = this.finishFunction == null ? this.workType : this.finishFunction.resultType;
         this.singleValueFunction = SingleValueFunction.from(declarationType, singleValueMethodName, resultType, fieldType);
         this.ascendingFunction = AscendingFunction.from(this, declarationType, ascendingMethodName);
         this.warnExceptions = warnExceptions;
@@ -208,11 +215,11 @@ public class MvEvaluatorImplementer {
             Methods.buildFromFactory(builderType)
         );
 
-        if (false == workType.equals(fieldType) && workType.isPrimitive() == false) {
+        if (workType != null && false == workType.equals(fieldType) && workType.isPrimitive() == false) {
             builder.addStatement("$T work = new $T()", workType, workType);
         }
         if (fieldType.equals(BYTES_REF)) {
-            if (workType.equals(fieldType)) {
+            if (fieldType.equals(workType)) {
                 builder.addStatement("$T firstScratch = new $T()", BYTES_REF, BYTES_REF);
                 builder.addStatement("$T nextScratch = new $T()", BYTES_REF, BYTES_REF);
             } else {
@@ -270,33 +277,45 @@ public class MvEvaluatorImplementer {
             }
 
             builder.addStatement("int end = first + valueCount");
-            if (workType.equals(fieldType) || workType.isPrimitive()) {
+            if (processFunction.getParameters().size() == 2) {
                 // process function evaluates pairwise
-                fetch(builder, "value", workType, "first", "firstScratch");
-                builder.beginControlFlow("for (int i = first + 1; i < end; i++)");
-                {
-                    if (fieldType.equals(BYTES_REF)) {
-                        fetch(builder, "next", workType, "i", "nextScratch");
-                        builder.addStatement("$T.$L(value, next)", declarationType, processFunction.getSimpleName());
+                if (workType.equals(fieldType) || workType.isPrimitive()) {
+                    fetch(builder, "value", workType, "first", "firstScratch");
+                    builder.beginControlFlow("for (int i = first + 1; i < end; i++)");
+                    {
+                        if (fieldType.equals(BYTES_REF)) {
+                            fetch(builder, "next", workType, "i", "nextScratch");
+                            builder.addStatement("$T.$L(value, next)", declarationType, processFunction.getSimpleName());
+                        } else {
+                            fetch(builder, "next", fieldType, "i", "nextScratch");
+                            builder.addStatement("value = $T.$L(value, next)", declarationType, processFunction.getSimpleName());
+                        }
+                    }
+                    builder.endControlFlow();
+                    if (finishFunction == null) {
+                        builder.addStatement("$T result = value", resultType);
                     } else {
-                        fetch(builder, "next", fieldType, "i", "nextScratch");
-                        builder.addStatement("value = $T.$L(value, next)", declarationType, processFunction.getSimpleName());
+                        finishFunction.call(builder, "value");
                     }
-                }
-                builder.endControlFlow();
-                if (finishFunction == null) {
-                    builder.addStatement("$T result = value", resultType);
                 } else {
-                    finishFunction.call(builder, "value");
+                    builder.beginControlFlow("for (int i = first; i < end; i++)");
+                    {
+                        fetch(builder, "value", fieldType, "i", "valueScratch");
+                        builder.addStatement("$T.$L(work, value)", declarationType, processFunction.getSimpleName());
+                    }
+                    builder.endControlFlow();
+                    finishFunction.call(builder, "work");
                 }
             } else {
-                builder.beginControlFlow("for (int i = first; i < end; i++)");
-                {
-                    fetch(builder, "value", fieldType, "i", "valueScratch");
-                    builder.addStatement("$T.$L(work, value)", declarationType, processFunction.getSimpleName());
-                }
-                builder.endControlFlow();
-                finishFunction.call(builder, "work");
+                // process function evaluates position at a time
+                String scratch = fieldType.equals(BYTES_REF) ? ", valueScratch" : "";
+                builder.addStatement(
+                    "$T result = $T.$L(v, first, end$L)",
+                    resultType,
+                    declarationType,
+                    processFunction.getSimpleName(),
+                    scratch
+                );
             }
             writeResult(builder);
         });
@@ -399,7 +418,7 @@ public class MvEvaluatorImplementer {
     private static class FinishFunction {
         static FinishFunction from(TypeElement declarationType, String name, TypeName workType, TypeName fieldType) {
             if (name.equals("")) {
-                if (false == workType.equals(fieldType)) {
+                if (workType != null && false == workType.equals(fieldType)) {
                     throw new IllegalArgumentException(
                         "the [finish] enum value is required because the first and second arguments differ in type"
                     );

+ 5 - 1
x-pack/plugin/esql/qa/testFixtures/src/main/resources/show.csv-spec

@@ -51,6 +51,8 @@ mv_avg                   |? mv_avg(arg1:?)
 mv_concat                |"keyword mv_concat(v:text|keyword, delim:text|keyword)" |[v, delim]               |["text|keyword", "text|keyword"] |["values to join", "delimiter"]      |keyword              | "Reduce a multivalued string field to a single valued field by concatenating all values." | [false, false]       | false
 mv_count                 |"integer mv_count(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)" |v      | "unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point" | "" | integer | "Reduce a multivalued field to a single valued field containing the count of values."       | false                | false
 mv_dedupe                |"? mv_dedupe(v:boolean|date|double|ip|text|integer|keyword|version|long)" |v | "boolean|date|double|ip|text|integer|keyword|version|long" | "" |?   | "Remove duplicate values from a multivalued field."                      | false                | false
+mv_first                 |"? mv_first(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)" |v | "unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point" | "" |?   | "Reduce a multivalued field to a single valued field containing the first value."                      | false                | false
+mv_last                  |"? mv_last(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)" |v | "unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point" | "" |?   | "Reduce a multivalued field to a single valued field containing the last value."                      | false                | false
 mv_max                   |"? mv_max(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long)" |v | "unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long" | "" |?      | "Reduce a multivalued field to a single valued field containing the maximum value." | false                | false
 mv_median                |? mv_median(arg1:?)                                     |arg1                     |?                 | ""                                                 |?                    | ""                      | false                | false
 mv_min                   |"? mv_min(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long)" |v | "unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long" | "" |?      | "Reduce a multivalued field to a single valued field containing the minimum value." | false                | false
@@ -143,6 +145,8 @@ boolean is_nan(n:double)
 "keyword mv_concat(v:text|keyword, delim:text|keyword)"
 "integer mv_count(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)"
 "? mv_dedupe(v:boolean|date|double|ip|text|integer|keyword|version|long)"
+"? mv_first(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)"
+"? mv_last(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long|geo_point|cartesian_point)"
 "? mv_max(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long)"
 ? mv_median(arg1:?)
 "? mv_min(v:unsigned_long|date|boolean|double|ip|text|integer|keyword|version|long)"
@@ -211,5 +215,5 @@ countFunctions#[skip:-8.12.99]
 show functions |  stats  a = count(*), b = count(*), c = count(*) |  mv_expand c;
 
 a:long | b:long | c:long
-85     | 85     | 85
+87     | 87     | 87
 ;

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

@@ -415,6 +415,32 @@ ROW a=["foo", "zoo", "bar"]
 // end::mv_concat-result[]
 ;
 
+mvFirst#[skip:-8.12.99, reason:Added in 8.13.0]
+// tag::mv_first[]
+ROW a="foo;bar;baz"
+| EVAL first_a = MV_FIRST(SPLIT(a, ";"))
+// end::mv_first[]
+;
+
+// tag::mv_first-result[]
+  a:keyword | first_a:keyword
+foo;bar;baz | "foo"
+// end::mv_first-result[]
+;
+
+mvLast#[skip:-8.12.99, reason:Added in 8.13.0]
+// tag::mv_last[]
+ROW a="foo;bar;baz"
+| EVAL last_a = MV_LAST(SPLIT(a, ";"))
+// end::mv_last[]
+;
+
+// tag::mv_last-result[]
+  a:keyword | last_a:keyword
+foo;bar;baz | "baz"
+// end::mv_last-result[]
+;
+
 mvMax
 // tag::mv_max[]
 ROW a=["foo", "zoo", "bar"]

+ 89 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstBooleanEvaluator.java

@@ -0,0 +1,89 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvFirst}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvFirstBooleanEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvFirstBooleanEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvFirst";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    BooleanBlock v = (BooleanBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BooleanBlock.Builder builder = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        boolean result = MvFirst.process(v, first, end);
+        builder.appendBoolean(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    BooleanBlock v = (BooleanBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BooleanVector.FixedBuilder builder = driverContext.blockFactory().newBooleanVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        boolean result = MvFirst.process(v, first, end);
+        builder.appendBoolean(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvFirstBooleanEvaluator get(DriverContext context) {
+      return new MvFirstBooleanEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvFirst[field=" + field + "]";
+    }
+  }
+}

+ 92 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstBytesRefEvaluator.java

@@ -0,0 +1,92 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvFirst}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvFirstBytesRefEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvFirstBytesRefEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvFirst";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    BytesRefBlock v = (BytesRefBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BytesRefBlock.Builder builder = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+      BytesRef valueScratch = new BytesRef();
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        BytesRef result = MvFirst.process(v, first, end, valueScratch);
+        builder.appendBytesRef(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    BytesRefBlock v = (BytesRefBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BytesRefVector.Builder builder = driverContext.blockFactory().newBytesRefVectorBuilder(positionCount)) {
+      BytesRef valueScratch = new BytesRef();
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        BytesRef result = MvFirst.process(v, first, end, valueScratch);
+        builder.appendBytesRef(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvFirstBytesRefEvaluator get(DriverContext context) {
+      return new MvFirstBytesRefEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvFirst[field=" + field + "]";
+    }
+  }
+}

+ 89 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstDoubleEvaluator.java

@@ -0,0 +1,89 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.DoubleVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvFirst}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvFirstDoubleEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvFirstDoubleEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvFirst";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    DoubleBlock v = (DoubleBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (DoubleBlock.Builder builder = driverContext.blockFactory().newDoubleBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        double result = MvFirst.process(v, first, end);
+        builder.appendDouble(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    DoubleBlock v = (DoubleBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (DoubleVector.FixedBuilder builder = driverContext.blockFactory().newDoubleVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        double result = MvFirst.process(v, first, end);
+        builder.appendDouble(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvFirstDoubleEvaluator get(DriverContext context) {
+      return new MvFirstDoubleEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvFirst[field=" + field + "]";
+    }
+  }
+}

+ 88 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstIntEvaluator.java

@@ -0,0 +1,88 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvFirst}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvFirstIntEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvFirstIntEvaluator(EvalOperator.ExpressionEvaluator field, DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvFirst";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    IntBlock v = (IntBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (IntBlock.Builder builder = driverContext.blockFactory().newIntBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        int result = MvFirst.process(v, first, end);
+        builder.appendInt(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    IntBlock v = (IntBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (IntVector.FixedBuilder builder = driverContext.blockFactory().newIntVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        int result = MvFirst.process(v, first, end);
+        builder.appendInt(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvFirstIntEvaluator get(DriverContext context) {
+      return new MvFirstIntEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvFirst[field=" + field + "]";
+    }
+  }
+}

+ 88 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvFirstLongEvaluator.java

@@ -0,0 +1,88 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.data.LongVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvFirst}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvFirstLongEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvFirstLongEvaluator(EvalOperator.ExpressionEvaluator field, DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvFirst";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    LongBlock v = (LongBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (LongBlock.Builder builder = driverContext.blockFactory().newLongBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        long result = MvFirst.process(v, first, end);
+        builder.appendLong(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    LongBlock v = (LongBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (LongVector.FixedBuilder builder = driverContext.blockFactory().newLongVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        long result = MvFirst.process(v, first, end);
+        builder.appendLong(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvFirstLongEvaluator get(DriverContext context) {
+      return new MvFirstLongEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvFirst[field=" + field + "]";
+    }
+  }
+}

+ 89 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastBooleanEvaluator.java

@@ -0,0 +1,89 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvLast}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvLastBooleanEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvLastBooleanEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvLast";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    BooleanBlock v = (BooleanBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BooleanBlock.Builder builder = driverContext.blockFactory().newBooleanBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        boolean result = MvLast.process(v, first, end);
+        builder.appendBoolean(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    BooleanBlock v = (BooleanBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BooleanVector.FixedBuilder builder = driverContext.blockFactory().newBooleanVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        boolean result = MvLast.process(v, first, end);
+        builder.appendBoolean(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvLastBooleanEvaluator get(DriverContext context) {
+      return new MvLastBooleanEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvLast[field=" + field + "]";
+    }
+  }
+}

+ 92 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastBytesRefEvaluator.java

@@ -0,0 +1,92 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.BytesRefVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvLast}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvLastBytesRefEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvLastBytesRefEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvLast";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    BytesRefBlock v = (BytesRefBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BytesRefBlock.Builder builder = driverContext.blockFactory().newBytesRefBlockBuilder(positionCount)) {
+      BytesRef valueScratch = new BytesRef();
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        BytesRef result = MvLast.process(v, first, end, valueScratch);
+        builder.appendBytesRef(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    BytesRefBlock v = (BytesRefBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (BytesRefVector.Builder builder = driverContext.blockFactory().newBytesRefVectorBuilder(positionCount)) {
+      BytesRef valueScratch = new BytesRef();
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        BytesRef result = MvLast.process(v, first, end, valueScratch);
+        builder.appendBytesRef(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvLastBytesRefEvaluator get(DriverContext context) {
+      return new MvLastBytesRefEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvLast[field=" + field + "]";
+    }
+  }
+}

+ 89 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastDoubleEvaluator.java

@@ -0,0 +1,89 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.DoubleVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvLast}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvLastDoubleEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvLastDoubleEvaluator(EvalOperator.ExpressionEvaluator field,
+      DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvLast";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    DoubleBlock v = (DoubleBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (DoubleBlock.Builder builder = driverContext.blockFactory().newDoubleBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        double result = MvLast.process(v, first, end);
+        builder.appendDouble(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    DoubleBlock v = (DoubleBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (DoubleVector.FixedBuilder builder = driverContext.blockFactory().newDoubleVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        double result = MvLast.process(v, first, end);
+        builder.appendDouble(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvLastDoubleEvaluator get(DriverContext context) {
+      return new MvLastDoubleEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvLast[field=" + field + "]";
+    }
+  }
+}

+ 88 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastIntEvaluator.java

@@ -0,0 +1,88 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvLast}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvLastIntEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvLastIntEvaluator(EvalOperator.ExpressionEvaluator field, DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvLast";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    IntBlock v = (IntBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (IntBlock.Builder builder = driverContext.blockFactory().newIntBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        int result = MvLast.process(v, first, end);
+        builder.appendInt(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    IntBlock v = (IntBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (IntVector.FixedBuilder builder = driverContext.blockFactory().newIntVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        int result = MvLast.process(v, first, end);
+        builder.appendInt(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvLastIntEvaluator get(DriverContext context) {
+      return new MvLastIntEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvLast[field=" + field + "]";
+    }
+  }
+}

+ 88 - 0
x-pack/plugin/esql/src/main/java/generated/org/elasticsearch/xpack/esql/expression/function/scalar/multivalue/MvLastLongEvaluator.java

@@ -0,0 +1,88 @@
+// 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 java.lang.Override;
+import java.lang.String;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.data.LongVector;
+import org.elasticsearch.compute.operator.DriverContext;
+import org.elasticsearch.compute.operator.EvalOperator;
+
+/**
+ * {@link EvalOperator.ExpressionEvaluator} implementation for {@link MvLast}.
+ * This class is generated. Do not edit it.
+ */
+public final class MvLastLongEvaluator extends AbstractMultivalueFunction.AbstractEvaluator {
+  public MvLastLongEvaluator(EvalOperator.ExpressionEvaluator field, DriverContext driverContext) {
+    super(driverContext, field);
+  }
+
+  @Override
+  public String name() {
+    return "MvLast";
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNullable(Block fieldVal) {
+    LongBlock v = (LongBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (LongBlock.Builder builder = driverContext.blockFactory().newLongBlockBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        if (valueCount == 0) {
+          builder.appendNull();
+          continue;
+        }
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        long result = MvLast.process(v, first, end);
+        builder.appendLong(result);
+      }
+      return builder.build();
+    }
+  }
+
+  /**
+   * Evaluate blocks containing at least one multivalued field.
+   */
+  @Override
+  public Block evalNotNullable(Block fieldVal) {
+    LongBlock v = (LongBlock) fieldVal;
+    int positionCount = v.getPositionCount();
+    try (LongVector.FixedBuilder builder = driverContext.blockFactory().newLongVectorFixedBuilder(positionCount)) {
+      for (int p = 0; p < positionCount; p++) {
+        int valueCount = v.getValueCount(p);
+        int first = v.getFirstValueIndex(p);
+        int end = first + valueCount;
+        long result = MvLast.process(v, first, end);
+        builder.appendLong(result);
+      }
+      return builder.build().asBlock();
+    }
+  }
+
+  public static class Factory implements EvalOperator.ExpressionEvaluator.Factory {
+    private final EvalOperator.ExpressionEvaluator.Factory field;
+
+    public Factory(EvalOperator.ExpressionEvaluator.Factory field) {
+      this.field = field;
+    }
+
+    @Override
+    public MvLastLongEvaluator get(DriverContext context) {
+      return new MvLastLongEvaluator(field.get(context), context);
+    }
+
+    @Override
+    public String toString() {
+      return "MvLast[field=" + field + "]";
+    }
+  }
+}

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

@@ -67,6 +67,8 @@ 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.MvCount;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvDedupe;
+import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvFirst;
+import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvLast;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMax;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMedian;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMin;
@@ -194,6 +196,8 @@ public final class EsqlFunctionRegistry extends FunctionRegistry {
                 def(MvConcat.class, MvConcat::new, "mv_concat"),
                 def(MvCount.class, MvCount::new, "mv_count"),
                 def(MvDedupe.class, MvDedupe::new, "mv_dedupe"),
+                def(MvFirst.class, MvFirst::new, "mv_first"),
+                def(MvLast.class, MvLast::new, "mv_last"),
                 def(MvMax.class, MvMax::new, "mv_max"),
                 def(MvMedian.class, MvMedian::new, "mv_median"),
                 def(MvMin.class, MvMin::new, "mv_min"),

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

@@ -0,0 +1,111 @@
+/*
+ * 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.compute.ann.MvEvaluator;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.planner.PlannerUtils;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypes;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.tree.NodeInfo;
+import org.elasticsearch.xpack.ql.tree.Source;
+
+import java.util.List;
+
+import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isType;
+
+/**
+ * Reduce a multivalued field to a single valued field containing the minimum value.
+ */
+public class MvFirst extends AbstractMultivalueFunction {
+    @FunctionInfo(returnType = "?", description = "Reduce a multivalued field to a single valued field containing the first value.")
+    public MvFirst(
+        Source source,
+        @Param(
+            name = "v",
+            type = {
+                "unsigned_long",
+                "date",
+                "boolean",
+                "double",
+                "ip",
+                "text",
+                "integer",
+                "keyword",
+                "version",
+                "long",
+                "geo_point",
+                "cartesian_point" }
+        ) Expression field
+    ) {
+        super(source, field);
+    }
+
+    @Override
+    protected TypeResolution resolveFieldType() {
+        return isType(field(), EsqlDataTypes::isRepresentable, sourceText(), null, "representable");
+    }
+
+    @Override
+    protected ExpressionEvaluator.Factory evaluator(ExpressionEvaluator.Factory fieldEval) {
+        return switch (PlannerUtils.toElementType(field().dataType())) {
+            case BOOLEAN -> new MvFirstBooleanEvaluator.Factory(fieldEval);
+            case BYTES_REF -> new MvFirstBytesRefEvaluator.Factory(fieldEval);
+            case DOUBLE -> new MvFirstDoubleEvaluator.Factory(fieldEval);
+            case INT -> new MvFirstIntEvaluator.Factory(fieldEval);
+            case LONG -> new MvFirstLongEvaluator.Factory(fieldEval);
+            case NULL -> EvalOperator.CONSTANT_NULL_FACTORY;
+            default -> throw EsqlIllegalArgumentException.illegalDataType(field.dataType());
+        };
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new MvFirst(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, MvFirst::new, field());
+    }
+
+    @MvEvaluator(extraName = "Boolean")
+    static boolean process(BooleanBlock block, int start, int end) {
+        return block.getBoolean(start);
+    }
+
+    @MvEvaluator(extraName = "Long")
+    static long process(LongBlock block, int start, int end) {
+        return block.getLong(start);
+    }
+
+    @MvEvaluator(extraName = "Int")
+    static int process(IntBlock block, int start, int end) {
+        return block.getInt(start);
+    }
+
+    @MvEvaluator(extraName = "Double")
+    static double process(DoubleBlock block, int start, int end) {
+        return block.getDouble(start);
+    }
+
+    @MvEvaluator(extraName = "BytesRef")
+    static BytesRef process(BytesRefBlock block, int start, int end, BytesRef scratch) {
+        return block.getBytesRef(start, scratch);
+    }
+}

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

@@ -0,0 +1,111 @@
+/*
+ * 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.compute.ann.MvEvaluator;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BytesRefBlock;
+import org.elasticsearch.compute.data.DoubleBlock;
+import org.elasticsearch.compute.data.IntBlock;
+import org.elasticsearch.compute.data.LongBlock;
+import org.elasticsearch.compute.operator.EvalOperator;
+import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
+import org.elasticsearch.xpack.esql.EsqlIllegalArgumentException;
+import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.planner.PlannerUtils;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypes;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.tree.NodeInfo;
+import org.elasticsearch.xpack.ql.tree.Source;
+
+import java.util.List;
+
+import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isType;
+
+/**
+ * Reduce a multivalued field to a single valued field containing the minimum value.
+ */
+public class MvLast extends AbstractMultivalueFunction {
+    @FunctionInfo(returnType = "?", description = "Reduce a multivalued field to a single valued field containing the last value.")
+    public MvLast(
+        Source source,
+        @Param(
+            name = "v",
+            type = {
+                "unsigned_long",
+                "date",
+                "boolean",
+                "double",
+                "ip",
+                "text",
+                "integer",
+                "keyword",
+                "version",
+                "long",
+                "geo_point",
+                "cartesian_point" }
+        ) Expression field
+    ) {
+        super(source, field);
+    }
+
+    @Override
+    protected TypeResolution resolveFieldType() {
+        return isType(field(), EsqlDataTypes::isRepresentable, sourceText(), null, "representable");
+    }
+
+    @Override
+    protected ExpressionEvaluator.Factory evaluator(ExpressionEvaluator.Factory fieldEval) {
+        return switch (PlannerUtils.toElementType(field().dataType())) {
+            case BOOLEAN -> new MvLastBooleanEvaluator.Factory(fieldEval);
+            case BYTES_REF -> new MvLastBytesRefEvaluator.Factory(fieldEval);
+            case DOUBLE -> new MvLastDoubleEvaluator.Factory(fieldEval);
+            case INT -> new MvLastIntEvaluator.Factory(fieldEval);
+            case LONG -> new MvLastLongEvaluator.Factory(fieldEval);
+            case NULL -> EvalOperator.CONSTANT_NULL_FACTORY;
+            default -> throw EsqlIllegalArgumentException.illegalDataType(field.dataType());
+        };
+    }
+
+    @Override
+    public Expression replaceChildren(List<Expression> newChildren) {
+        return new MvLast(source(), newChildren.get(0));
+    }
+
+    @Override
+    protected NodeInfo<? extends Expression> info() {
+        return NodeInfo.create(this, MvLast::new, field());
+    }
+
+    @MvEvaluator(extraName = "Boolean")
+    static boolean process(BooleanBlock block, int start, int end) {
+        return block.getBoolean(end - 1);
+    }
+
+    @MvEvaluator(extraName = "Long")
+    static long process(LongBlock block, int start, int end) {
+        return block.getLong(end - 1);
+    }
+
+    @MvEvaluator(extraName = "Int")
+    static int process(IntBlock block, int start, int end) {
+        return block.getInt(end - 1);
+    }
+
+    @MvEvaluator(extraName = "Double")
+    static double process(DoubleBlock block, int start, int end) {
+        return block.getDouble(end - 1);
+    }
+
+    @MvEvaluator(extraName = "BytesRef")
+    static BytesRef process(BytesRefBlock block, int start, int end, BytesRef scratch) {
+        return block.getBytesRef(end - 1, scratch);
+    }
+}

+ 1 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/package-info.java

@@ -100,7 +100,7 @@
  *         {@code ./gradlew -p x-pack/plugin/esql/ check}
  *     </li>
  *     <li>
- *         Now it's time to write some docs! Open {@code docs/reference/esql/esql-functions.asciidoc}
+ *         Now it's time to write some docs! Open {@code docs/reference/esql/esql-functions-operators.asciidoc}
  *         and add your function in alphabetical order to the list at the top and then add it to
  *         the includes below.
  *     </li>

+ 6 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/io/stream/PlanNamedTypes.java

@@ -87,6 +87,8 @@ 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.MvCount;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvDedupe;
+import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvFirst;
+import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvLast;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMax;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMedian;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMin;
@@ -392,6 +394,8 @@ public final class PlanNamedTypes {
             of(ScalarFunction.class, MvCount.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
             of(ScalarFunction.class, MvConcat.class, PlanNamedTypes::writeMvConcat, PlanNamedTypes::readMvConcat),
             of(ScalarFunction.class, MvDedupe.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
+            of(ScalarFunction.class, MvFirst.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
+            of(ScalarFunction.class, MvLast.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
             of(ScalarFunction.class, MvMax.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
             of(ScalarFunction.class, MvMedian.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
             of(ScalarFunction.class, MvMin.class, PlanNamedTypes::writeMvFunction, PlanNamedTypes::readMvFunction),
@@ -1544,6 +1548,8 @@ public final class PlanNamedTypes {
         entry(name(MvAvg.class), MvAvg::new),
         entry(name(MvCount.class), MvCount::new),
         entry(name(MvDedupe.class), MvDedupe::new),
+        entry(name(MvFirst.class), MvFirst::new),
+        entry(name(MvLast.class), MvLast::new),
         entry(name(MvMax.class), MvMax::new),
         entry(name(MvMedian.class), MvMedian::new),
         entry(name(MvMin.class), MvMin::new),

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

@@ -0,0 +1,61 @@
+/*
+ * 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.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypes;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.tree.Source;
+import org.elasticsearch.xpack.ql.type.DataType;
+import org.elasticsearch.xpack.ql.type.DataTypes;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class MvFirstTests extends AbstractMultivalueFunctionTestCase {
+    public MvFirstTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> cases = new ArrayList<>();
+        booleans(cases, "mv_first", "MvFirst", DataTypes.BOOLEAN, (size, values) -> equalTo(values.findFirst().get()));
+        bytesRefs(cases, "mv_first", "MvFirst", Function.identity(), (size, values) -> equalTo(values.findFirst().get()));
+        doubles(cases, "mv_first", "MvFirst", DataTypes.DOUBLE, (size, values) -> equalTo(values.findFirst().getAsDouble()));
+        ints(cases, "mv_first", "MvFirst", DataTypes.INTEGER, (size, values) -> equalTo(values.findFirst().getAsInt()));
+        longs(cases, "mv_first", "MvFirst", DataTypes.LONG, (size, values) -> equalTo(values.findFirst().getAsLong()));
+        unsignedLongs(cases, "mv_first", "MvFirst", DataTypes.UNSIGNED_LONG, (size, values) -> equalTo(values.findFirst().get()));
+        dateTimes(cases, "mv_first", "MvFirst", DataTypes.DATETIME, (size, values) -> equalTo(values.findFirst().getAsLong()));
+        geoPoints(cases, "mv_first", "MvFirst", EsqlDataTypes.GEO_POINT, (size, values) -> equalTo(values.findFirst().get()));
+        cartesianPoints(cases, "mv_first", "MvFirst", EsqlDataTypes.CARTESIAN_POINT, (size, values) -> equalTo(values.findFirst().get()));
+        return parameterSuppliersFromTypedData(errorsForCasesWithoutExamples(anyNullIsNull(false, cases)));
+    }
+
+    @Override
+    protected Expression build(Source source, Expression field) {
+        return new MvFirst(source, field);
+    }
+
+    @Override
+    protected DataType[] supportedTypes() {
+        return representableTypes();
+    }
+
+    @Override
+    protected DataType expectedType(List<DataType> argTypes) {
+        return argTypes.get(0);
+    }
+}

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

@@ -0,0 +1,67 @@
+/*
+ * 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.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.elasticsearch.xpack.esql.type.EsqlDataTypes;
+import org.elasticsearch.xpack.ql.expression.Expression;
+import org.elasticsearch.xpack.ql.tree.Source;
+import org.elasticsearch.xpack.ql.type.DataType;
+import org.elasticsearch.xpack.ql.type.DataTypes;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class MvLastTests extends AbstractMultivalueFunctionTestCase {
+    public MvLastTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        List<TestCaseSupplier> cases = new ArrayList<>();
+        booleans(cases, "mv_last", "MvLast", DataTypes.BOOLEAN, (size, values) -> equalTo(values.reduce((f, s) -> s).get()));
+        bytesRefs(cases, "mv_last", "MvLast", Function.identity(), (size, values) -> equalTo(values.reduce((f, s) -> s).get()));
+        doubles(cases, "mv_last", "MvLast", DataTypes.DOUBLE, (size, values) -> equalTo(values.reduce((f, s) -> s).getAsDouble()));
+        ints(cases, "mv_last", "MvLast", DataTypes.INTEGER, (size, values) -> equalTo(values.reduce((f, s) -> s).getAsInt()));
+        longs(cases, "mv_last", "MvLast", DataTypes.LONG, (size, values) -> equalTo(values.reduce((f, s) -> s).getAsLong()));
+        unsignedLongs(cases, "mv_last", "MvLast", DataTypes.UNSIGNED_LONG, (size, values) -> equalTo(values.reduce((f, s) -> s).get()));
+        dateTimes(cases, "mv_last", "MvLast", DataTypes.DATETIME, (size, values) -> equalTo(values.reduce((f, s) -> s).getAsLong()));
+        geoPoints(cases, "mv_last", "MvLast", EsqlDataTypes.GEO_POINT, (size, values) -> equalTo(values.reduce((f, s) -> s).get()));
+        cartesianPoints(
+            cases,
+            "mv_last",
+            "MvLast",
+            EsqlDataTypes.CARTESIAN_POINT,
+            (size, values) -> equalTo(values.reduce((f, s) -> s).get())
+        );
+        return parameterSuppliersFromTypedData(errorsForCasesWithoutExamples(anyNullIsNull(false, cases)));
+    }
+
+    @Override
+    protected Expression build(Source source, Expression field) {
+        return new MvLast(source, field);
+    }
+
+    @Override
+    protected DataType[] supportedTypes() {
+        return representableTypes();
+    }
+
+    @Override
+    protected DataType expectedType(List<DataType> argTypes) {
+        return argTypes.get(0);
+    }
+}