Browse Source

ESQL: Fix Max doubles bug with negatives and add tests for Max and Min (#110586)

`MAX()` currently doesn't work with doubles smaller than
`Double.MIN_VALUE` (Note that `Double.MIN_VALUE` returns the smallest
non-zero positive, not the smallest double).

This PR adds tests for Max and Min, and fixes the bug (Detected by the
tests).

Also, as the tests now generate the docs, replaced the old docs with the
generated ones, and updated the Max&Min examples.
Iván Cea Fontenla 1 year ago
parent
commit
5d3512fb33
25 changed files with 609 additions and 87 deletions
  1. 5 0
      docs/changelog/110586.yaml
  2. 4 4
      docs/reference/esql/functions/aggregation-functions.asciidoc
  3. 5 0
      docs/reference/esql/functions/description/max.asciidoc
  4. 5 0
      docs/reference/esql/functions/description/min.asciidoc
  5. 5 26
      docs/reference/esql/functions/examples/max.asciidoc
  6. 4 25
      docs/reference/esql/functions/examples/min.asciidoc
  7. 60 0
      docs/reference/esql/functions/kibana/definition/max.json
  8. 60 0
      docs/reference/esql/functions/kibana/definition/min.json
  9. 11 0
      docs/reference/esql/functions/kibana/docs/max.md
  10. 11 0
      docs/reference/esql/functions/kibana/docs/min.md
  11. 15 0
      docs/reference/esql/functions/layout/max.asciidoc
  12. 15 0
      docs/reference/esql/functions/layout/min.asciidoc
  13. 6 0
      docs/reference/esql/functions/parameters/max.asciidoc
  14. 6 0
      docs/reference/esql/functions/parameters/min.asciidoc
  15. 1 0
      docs/reference/esql/functions/signature/max.svg
  16. 1 0
      docs/reference/esql/functions/signature/min.svg
  17. 12 0
      docs/reference/esql/functions/types/max.asciidoc
  18. 12 0
      docs/reference/esql/functions/types/min.asciidoc
  19. 1 1
      x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/MaxDoubleAggregator.java
  20. 11 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java
  21. 11 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java
  22. 15 11
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java
  23. 31 18
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java
  24. 151 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java
  25. 151 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java

+ 5 - 0
docs/changelog/110586.yaml

@@ -0,0 +1,5 @@
+pr: 110586
+summary: "ESQL: Fix Max doubles bug with negatives and add tests for Max and Min"
+area: ES|QL
+type: bug
+issues: []

+ 4 - 4
docs/reference/esql/functions/aggregation-functions.asciidoc

@@ -11,10 +11,10 @@ The <<esql-stats-by>> command supports these aggregate functions:
 * <<esql-avg>>
 * <<esql-agg-count>>
 * <<esql-agg-count-distinct>>
-* <<esql-agg-max>>
+* <<esql-max>>
 * <<esql-agg-median>>
 * <<esql-agg-median-absolute-deviation>>
-* <<esql-agg-min>>
+* <<esql-min>>
 * <<esql-agg-percentile>>
 * experimental:[] <<esql-agg-st-centroid>>
 * <<esql-agg-sum>>
@@ -25,14 +25,14 @@ The <<esql-stats-by>> command supports these aggregate functions:
 
 include::count.asciidoc[]
 include::count-distinct.asciidoc[]
-include::max.asciidoc[]
 include::median.asciidoc[]
 include::median-absolute-deviation.asciidoc[]
-include::min.asciidoc[]
 include::percentile.asciidoc[]
 include::st_centroid_agg.asciidoc[]
 include::sum.asciidoc[]
 include::layout/avg.asciidoc[]
+include::layout/max.asciidoc[]
+include::layout/min.asciidoc[]
 include::layout/top.asciidoc[]
 include::values.asciidoc[]
 include::weighted-avg.asciidoc[]

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

@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+The maximum value of a numeric field.

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

@@ -0,0 +1,5 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Description*
+
+The minimum value of a numeric field.

+ 5 - 26
docs/reference/esql/functions/max.asciidoc → docs/reference/esql/functions/examples/max.asciidoc

@@ -1,24 +1,6 @@
-[discrete]
-[[esql-agg-max]]
-=== `MAX`
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
 
-*Syntax*
-
-[source,esql]
-----
-MAX(expression)
-----
-
-*Parameters*
-
-`expression`::
-Expression from which to return the maximum value.
-
-*Description*
-
-Returns the maximum value of a numeric expression.
-
-*Example*
+*Examples*
 
 [source.merge.styled,esql]
 ----
@@ -28,11 +10,7 @@ include::{esql-specs}/stats.csv-spec[tag=max]
 |===
 include::{esql-specs}/stats.csv-spec[tag=max-result]
 |===
-
-The expression can use inline functions. For example, to calculate the maximum
-over an average of a multivalued column, use `MV_AVG` to first average the
-multiple values per row, and use the result with the `MAX` function:
-
+The expression can use inline functions. For example, to calculate the maximum over an average of a multivalued column, use `MV_AVG` to first average the multiple values per row, and use the result with the `MAX` function
 [source.merge.styled,esql]
 ----
 include::{esql-specs}/stats.csv-spec[tag=docsStatsMaxNestedExpression]
@@ -40,4 +18,5 @@ include::{esql-specs}/stats.csv-spec[tag=docsStatsMaxNestedExpression]
 [%header.monospaced.styled,format=dsv,separator=|]
 |===
 include::{esql-specs}/stats.csv-spec[tag=docsStatsMaxNestedExpression-result]
-|===
+|===
+

+ 4 - 25
docs/reference/esql/functions/min.asciidoc → docs/reference/esql/functions/examples/min.asciidoc

@@ -1,24 +1,6 @@
-[discrete]
-[[esql-agg-min]]
-=== `MIN`
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
 
-*Syntax*
-
-[source,esql]
-----
-MIN(expression)
-----
-
-*Parameters*
-
-`expression`::
-Expression from which to return the minimum value.
-
-*Description*
-
-Returns the minimum value of a numeric expression.
-
-*Example*
+*Examples*
 
 [source.merge.styled,esql]
 ----
@@ -28,11 +10,7 @@ include::{esql-specs}/stats.csv-spec[tag=min]
 |===
 include::{esql-specs}/stats.csv-spec[tag=min-result]
 |===
-
-The expression can use inline functions. For example, to calculate the minimum
-over an average of a multivalued column, use `MV_AVG` to first average the
-multiple values per row, and use the result with the `MIN` function:
-
+The expression can use inline functions. For example, to calculate the minimum over an average of a multivalued column, use `MV_AVG` to first average the multiple values per row, and use the result with the `MIN` function
 [source.merge.styled,esql]
 ----
 include::{esql-specs}/stats.csv-spec[tag=docsStatsMinNestedExpression]
@@ -41,3 +19,4 @@ include::{esql-specs}/stats.csv-spec[tag=docsStatsMinNestedExpression]
 |===
 include::{esql-specs}/stats.csv-spec[tag=docsStatsMinNestedExpression-result]
 |===
+

+ 60 - 0
docs/reference/esql/functions/kibana/definition/max.json

@@ -0,0 +1,60 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "agg",
+  "name" : "max",
+  "description" : "The maximum value of a numeric field.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "datetime",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "datetime"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "integer"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "long"
+    }
+  ],
+  "examples" : [
+    "FROM employees\n| STATS MAX(languages)",
+    "FROM employees\n| STATS max_avg_salary_change = MAX(MV_AVG(salary_change))"
+  ]
+}

+ 60 - 0
docs/reference/esql/functions/kibana/definition/min.json

@@ -0,0 +1,60 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "agg",
+  "name" : "min",
+  "description" : "The minimum value of a numeric field.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "datetime",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "datetime"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "integer"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "long"
+    }
+  ],
+  "examples" : [
+    "FROM employees\n| STATS MIN(languages)",
+    "FROM employees\n| STATS min_avg_salary_change = MIN(MV_AVG(salary_change))"
+  ]
+}

+ 11 - 0
docs/reference/esql/functions/kibana/docs/max.md

@@ -0,0 +1,11 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### MAX
+The maximum value of a numeric field.
+
+```
+FROM employees
+| STATS MAX(languages)
+```

+ 11 - 0
docs/reference/esql/functions/kibana/docs/min.md

@@ -0,0 +1,11 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### MIN
+The minimum value of a numeric field.
+
+```
+FROM employees
+| STATS MIN(languages)
+```

+ 15 - 0
docs/reference/esql/functions/layout/max.asciidoc

@@ -0,0 +1,15 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-max]]
+=== `MAX`
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/max.svg[Embedded,opts=inline]
+
+include::../parameters/max.asciidoc[]
+include::../description/max.asciidoc[]
+include::../types/max.asciidoc[]
+include::../examples/max.asciidoc[]

+ 15 - 0
docs/reference/esql/functions/layout/min.asciidoc

@@ -0,0 +1,15 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-min]]
+=== `MIN`
+
+*Syntax*
+
+[.text-center]
+image::esql/functions/signature/min.svg[Embedded,opts=inline]
+
+include::../parameters/min.asciidoc[]
+include::../description/min.asciidoc[]
+include::../types/min.asciidoc[]
+include::../examples/min.asciidoc[]

+ 6 - 0
docs/reference/esql/functions/parameters/max.asciidoc

@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`number`::
+

+ 6 - 0
docs/reference/esql/functions/parameters/min.asciidoc

@@ -0,0 +1,6 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Parameters*
+
+`number`::
+

+ 1 - 0
docs/reference/esql/functions/signature/max.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 31h5m56 0h10m32 0h10m92 0h10m32 0h5"/><rect class="s" x="5" y="5" width="56" height="36"/><text class="k" x="15" y="31">MAX</text><rect class="s" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">(</text><rect class="s" x="113" y="5" width="92" height="36" rx="7"/><text class="k" x="123" y="31">number</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/min.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 31h5m56 0h10m32 0h10m92 0h10m32 0h5"/><rect class="s" x="5" y="5" width="56" height="36"/><text class="k" x="15" y="31">MIN</text><rect class="s" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">(</text><rect class="s" x="113" y="5" width="92" height="36" rx="7"/><text class="k" x="123" y="31">number</text><rect class="s" x="215" y="5" width="32" height="36" rx="7"/><text class="syn" x="225" y="31">)</text></svg>

+ 12 - 0
docs/reference/esql/functions/types/max.asciidoc

@@ -0,0 +1,12 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+number | result
+datetime | datetime
+double | double
+integer | integer
+long | long
+|===

+ 12 - 0
docs/reference/esql/functions/types/min.asciidoc

@@ -0,0 +1,12 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Supported types*
+
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+number | result
+datetime | datetime
+double | double
+integer | integer
+long | long
+|===

+ 1 - 1
x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/MaxDoubleAggregator.java

@@ -16,7 +16,7 @@ import org.elasticsearch.compute.ann.IntermediateState;
 class MaxDoubleAggregator {
 
     public static double init() {
-        return Double.MIN_VALUE;
+        return -Double.MAX_VALUE;
     }
 
     public static double combine(double current, double v) {

+ 11 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Max.java

@@ -18,6 +18,7 @@ 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.expression.SurrogateExpression;
+import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMax;
@@ -31,7 +32,16 @@ public class Max extends NumericAggregate implements SurrogateExpression {
     @FunctionInfo(
         returnType = { "double", "integer", "long", "date" },
         description = "The maximum value of a numeric field.",
-        isAggregation = true
+        isAggregation = true,
+        examples = {
+            @Example(file = "stats", tag = "max"),
+            @Example(
+                description = "The expression can use inline functions. For example, to calculate the maximum "
+                    + "over an average of a multivalued column, use `MV_AVG` to first average the "
+                    + "multiple values per row, and use the result with the `MAX` function",
+                file = "stats",
+                tag = "docsStatsMaxNestedExpression"
+            ) }
     )
     public Max(Source source, @Param(name = "number", type = { "double", "integer", "long", "date" }) Expression field) {
         super(source, field);

+ 11 - 1
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Min.java

@@ -18,6 +18,7 @@ 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.expression.SurrogateExpression;
+import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.expression.function.scalar.multivalue.MvMin;
@@ -31,7 +32,16 @@ public class Min extends NumericAggregate implements SurrogateExpression {
     @FunctionInfo(
         returnType = { "double", "integer", "long", "date" },
         description = "The minimum value of a numeric field.",
-        isAggregation = true
+        isAggregation = true,
+        examples = {
+            @Example(file = "stats", tag = "min"),
+            @Example(
+                description = "The expression can use inline functions. For example, to calculate the minimum "
+                    + "over an average of a multivalued column, use `MV_AVG` to first average the "
+                    + "multiple values per row, and use the result with the `MIN` function",
+                file = "stats",
+                tag = "docsStatsMinNestedExpression"
+            ) }
     )
     public Min(Source source, @Param(name = "number", type = { "double", "integer", "long", "date" }) Expression field) {
         super(source, field);

+ 15 - 11
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractAggregationTestCase.java

@@ -133,11 +133,12 @@ public abstract class AbstractAggregationTestCase extends AbstractFunctionTestCa
     private void aggregateSingleMode(Expression expression) {
         Object result;
         try (var aggregator = aggregator(expression, initialInputChannels(), AggregatorMode.SINGLE)) {
-            Page inputPage = rows(testCase.getMultiRowFields());
-            try {
-                aggregator.processPage(inputPage);
-            } finally {
-                inputPage.releaseBlocks();
+            for (Page inputPage : rows(testCase.getMultiRowFields())) {
+                try {
+                    aggregator.processPage(inputPage);
+                } finally {
+                    inputPage.releaseBlocks();
+                }
             }
 
             result = extractResultFromAggregator(aggregator, PlannerUtils.toElementType(testCase.expectedType()));
@@ -166,11 +167,12 @@ public abstract class AbstractAggregationTestCase extends AbstractFunctionTestCa
             int intermediateBlockExtraSize = randomIntBetween(0, 10);
             intermediateBlocks = new Block[intermediateBlockOffset + intermediateStates + intermediateBlockExtraSize];
 
-            Page inputPage = rows(testCase.getMultiRowFields());
-            try {
-                aggregator.processPage(inputPage);
-            } finally {
-                inputPage.releaseBlocks();
+            for (Page inputPage : rows(testCase.getMultiRowFields())) {
+                try {
+                    aggregator.processPage(inputPage);
+                } finally {
+                    inputPage.releaseBlocks();
+                }
             }
 
             aggregator.evaluate(intermediateBlocks, intermediateBlockOffset, driverContext());
@@ -197,7 +199,9 @@ public abstract class AbstractAggregationTestCase extends AbstractFunctionTestCa
         ) {
             Page inputPage = new Page(intermediateBlocks);
             try {
-                aggregator.processPage(inputPage);
+                if (inputPage.getPositionCount() > 0) {
+                    aggregator.processPage(inputPage);
+                }
             } finally {
                 inputPage.releaseBlocks();
             }

+ 31 - 18
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java

@@ -215,11 +215,11 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
     }
 
     /**
-     * Creates a page based on a list of multi-row fields.
+     * Creates a list of pages based on a list of multi-row fields.
      */
-    protected final Page rows(List<TestCaseSupplier.TypedData> multirowFields) {
+    protected final List<Page> rows(List<TestCaseSupplier.TypedData> multirowFields) {
         if (multirowFields.isEmpty()) {
-            return new Page(0, BlockUtils.NO_BLOCKS);
+            return List.of();
         }
 
         var rowsCount = multirowFields.get(0).multiRowData().size();
@@ -230,27 +230,40 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
                 field -> assertThat("All multi-row fields must have the same number of rows", field.multiRowData(), hasSize(rowsCount))
             );
 
-        var blocks = new Block[multirowFields.size()];
+        List<Page> pages = new ArrayList<>();
 
-        for (int i = 0; i < multirowFields.size(); i++) {
-            var field = multirowFields.get(i);
-            try (
-                var wrapper = BlockUtils.wrapperFor(
-                    TestBlockFactory.getNonBreakingInstance(),
-                    PlannerUtils.toElementType(field.type()),
-                    rowsCount
-                )
-            ) {
+        int pageSize = randomIntBetween(1, 100);
+        for (int initialRow = 0; initialRow < rowsCount;) {
+            if (pageSize > rowsCount - initialRow) {
+                pageSize = rowsCount - initialRow;
+            }
 
-                for (var row : field.multiRowData()) {
-                    wrapper.accept(row);
-                }
+            var blocks = new Block[multirowFields.size()];
 
-                blocks[i] = wrapper.builder().build();
+            for (int i = 0; i < multirowFields.size(); i++) {
+                var field = multirowFields.get(i);
+                try (
+                    var wrapper = BlockUtils.wrapperFor(
+                        TestBlockFactory.getNonBreakingInstance(),
+                        PlannerUtils.toElementType(field.type()),
+                        pageSize
+                    )
+                ) {
+                    var multiRowData = field.multiRowData();
+                    for (int row = initialRow; row < initialRow + pageSize; row++) {
+                        wrapper.accept(multiRowData.get(row));
+                    }
+
+                    blocks[i] = wrapper.builder().build();
+                }
             }
+
+            pages.add(new Page(pageSize, blocks));
+            initialRow += pageSize;
+            pageSize = randomIntBetween(1, 100);
         }
 
-        return new Page(rowsCount, blocks);
+        return pages;
     }
 
     /**

+ 151 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MaxTests.java

@@ -0,0 +1,151 @@
+/*
+ * 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.aggregate;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractAggregationTestCase;
+import org.elasticsearch.xpack.esql.expression.function.MultiRowTestCaseSupplier;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class MaxTests extends AbstractAggregationTestCase {
+    public MaxTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        var suppliers = new ArrayList<TestCaseSupplier>();
+
+        Stream.of(
+            MultiRowTestCaseSupplier.intCases(1, 1000, Integer.MIN_VALUE, Integer.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.longCases(1, 1000, Long.MIN_VALUE, Long.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.doubleCases(1, 1000, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.dateCases(1, 1000)
+        ).flatMap(List::stream).map(MaxTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers));
+
+        suppliers.addAll(
+            List.of(
+                // Surrogates
+                new TestCaseSupplier(
+                    List.of(DataType.INTEGER),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5, 8, -2, 0, 200), DataType.INTEGER, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.INTEGER,
+                        equalTo(200)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.LONG),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5L, 8L, -2L, 0L, 200L), DataType.LONG, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.LONG,
+                        equalTo(200L)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DOUBLE),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5., 8., -2., 0., 200.), DataType.DOUBLE, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.DOUBLE,
+                        equalTo(200.)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DATETIME),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5L, 8L, 2L, 0L, 200L), DataType.DATETIME, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.DATETIME,
+                        equalTo(200L)
+                    )
+                ),
+
+                // Folding
+                new TestCaseSupplier(
+                    List.of(DataType.INTEGER),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200), DataType.INTEGER, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.INTEGER,
+                        equalTo(200)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.LONG),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200L), DataType.LONG, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.LONG,
+                        equalTo(200L)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DOUBLE),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200.), DataType.DOUBLE, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.DOUBLE,
+                        equalTo(200.)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DATETIME),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200L), DataType.DATETIME, "field")),
+                        "Max[field=Attribute[channel=0]]",
+                        DataType.DATETIME,
+                        equalTo(200L)
+                    )
+                )
+            )
+        );
+
+        return parameterSuppliersFromTypedDataWithDefaultChecks(suppliers);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Max(source, args.get(0));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static TestCaseSupplier makeSupplier(TestCaseSupplier.TypedDataSupplier fieldSupplier) {
+        return new TestCaseSupplier(fieldSupplier.name(), List.of(fieldSupplier.type()), () -> {
+            var fieldTypedData = fieldSupplier.get();
+            var expected = fieldTypedData.multiRowData()
+                .stream()
+                .map(v -> (Comparable<? super Comparable<?>>) v)
+                .max(Comparator.naturalOrder())
+                .orElse(null);
+
+            return new TestCaseSupplier.TestCase(
+                List.of(fieldTypedData),
+                "Max[field=Attribute[channel=0]]",
+                fieldSupplier.type(),
+                equalTo(expected)
+            );
+        });
+    }
+}

+ 151 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/MinTests.java

@@ -0,0 +1,151 @@
+/*
+ * 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.aggregate;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.AbstractAggregationTestCase;
+import org.elasticsearch.xpack.esql.expression.function.MultiRowTestCaseSupplier;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class MinTests extends AbstractAggregationTestCase {
+    public MinTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        var suppliers = new ArrayList<TestCaseSupplier>();
+
+        Stream.of(
+            MultiRowTestCaseSupplier.intCases(1, 1000, Integer.MIN_VALUE, Integer.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.longCases(1, 1000, Long.MIN_VALUE, Long.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.doubleCases(1, 1000, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.dateCases(1, 1000)
+        ).flatMap(List::stream).map(MinTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers));
+
+        suppliers.addAll(
+            List.of(
+                // Surrogates
+                new TestCaseSupplier(
+                    List.of(DataType.INTEGER),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5, 8, -2, 0, 200), DataType.INTEGER, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.INTEGER,
+                        equalTo(-2)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.LONG),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5L, 8L, -2L, 0L, 200L), DataType.LONG, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.LONG,
+                        equalTo(-2L)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DOUBLE),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5., 8., -2., 0., 200.), DataType.DOUBLE, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.DOUBLE,
+                        equalTo(-2.)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DATETIME),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(5L, 8L, 2L, 0L, 200L), DataType.DATETIME, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.DATETIME,
+                        equalTo(0L)
+                    )
+                ),
+
+                // Folding
+                new TestCaseSupplier(
+                    List.of(DataType.INTEGER),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200), DataType.INTEGER, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.INTEGER,
+                        equalTo(200)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.LONG),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200L), DataType.LONG, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.LONG,
+                        equalTo(200L)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DOUBLE),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200.), DataType.DOUBLE, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.DOUBLE,
+                        equalTo(200.)
+                    )
+                ),
+                new TestCaseSupplier(
+                    List.of(DataType.DATETIME),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(200L), DataType.DATETIME, "field")),
+                        "Min[field=Attribute[channel=0]]",
+                        DataType.DATETIME,
+                        equalTo(200L)
+                    )
+                )
+            )
+        );
+
+        return parameterSuppliersFromTypedDataWithDefaultChecks(suppliers);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Min(source, args.get(0));
+    }
+
+    @SuppressWarnings("unchecked")
+    private static TestCaseSupplier makeSupplier(TestCaseSupplier.TypedDataSupplier fieldSupplier) {
+        return new TestCaseSupplier(fieldSupplier.name(), List.of(fieldSupplier.type()), () -> {
+            var fieldTypedData = fieldSupplier.get();
+            var expected = fieldTypedData.multiRowData()
+                .stream()
+                .map(v -> (Comparable<? super Comparable<?>>) v)
+                .min(Comparator.naturalOrder())
+                .orElse(null);
+
+            return new TestCaseSupplier.TestCase(
+                List.of(fieldTypedData),
+                "Min[field=Attribute[channel=0]]",
+                fieldSupplier.type(),
+                equalTo(expected)
+            );
+        });
+    }
+}