Bläddra i källkod

Added Percentile aggregation tests and Kibana docs (#111050)

- Added Percentile aggregation tests and autogen docs
- Added a new "appendix" section to FunctionInfo. Existing Percentile docs had a final, long section with info, and we need this to leep it. We have an "detailedDescription" attribute already, but it's right after the description, and it would make it harder to read the important bits of the function (types, examples...). So I'm not reusing it.
Iván Cea Fontenla 1 år sedan
förälder
incheckning
0e68117935

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

@@ -15,7 +15,7 @@ The <<esql-stats-by>> command supports these aggregate functions:
 * <<esql-agg-median>>
 * <<esql-agg-median-absolute-deviation>>
 * <<esql-min>>
-* <<esql-agg-percentile>>
+* <<esql-percentile>>
 * experimental:[] <<esql-agg-st-centroid>>
 * <<esql-agg-sum>>
 * <<esql-top>>
@@ -27,12 +27,12 @@ include::count.asciidoc[]
 include::count-distinct.asciidoc[]
 include::median.asciidoc[]
 include::median-absolute-deviation.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/percentile.asciidoc[]
 include::layout/top.asciidoc[]
 include::values.asciidoc[]
 include::weighted-avg.asciidoc[]

+ 14 - 0
docs/reference/esql/functions/appendix/percentile.asciidoc

@@ -0,0 +1,14 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+[discrete]
+[[esql-percentile-approximate]]
+==== `PERCENTILE` is (usually) approximate
+
+include::../../../aggregations/metrics/percentile-aggregation.asciidoc[tag=approximate]
+
+[WARNING]
+====
+`PERCENTILE` is also {wikipedia}/Nondeterministic_algorithm[non-deterministic].
+This means you can get slightly different results using the same data.
+====
+

+ 5 - 0
docs/reference/esql/functions/description/percentile.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*
+
+Returns the value at which a certain percentage of observed values occur. For example, the 95th percentile is the value which is greater than 95% of the observed values and the 50th percentile is the `MEDIAN`.

+ 22 - 0
docs/reference/esql/functions/examples/percentile.asciidoc

@@ -0,0 +1,22 @@
+// This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+
+*Examples*
+
+[source.merge.styled,esql]
+----
+include::{esql-specs}/stats_percentile.csv-spec[tag=percentile]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/stats_percentile.csv-spec[tag=percentile-result]
+|===
+The expression can use inline functions. For example, to calculate a percentile of the maximum values of a multivalued column, first use `MV_MAX` to get the maximum value per row, and use the result with the `PERCENTILE` function
+[source.merge.styled,esql]
+----
+include::{esql-specs}/stats_percentile.csv-spec[tag=docsStatsPercentileNestedExpression]
+----
+[%header.monospaced.styled,format=dsv,separator=|]
+|===
+include::{esql-specs}/stats_percentile.csv-spec[tag=docsStatsPercentileNestedExpression-result]
+|===
+

+ 174 - 0
docs/reference/esql/functions/kibana/definition/percentile.json

@@ -0,0 +1,174 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.",
+  "type" : "agg",
+  "name" : "percentile",
+  "description" : "Returns the value at which a certain percentage of observed values occur. For example, the 95th percentile is the value which is greater than 95% of the observed values and the 50th percentile is the `MEDIAN`.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "double",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "integer",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    },
+    {
+      "params" : [
+        {
+          "name" : "number",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        },
+        {
+          "name" : "percentile",
+          "type" : "long",
+          "optional" : false,
+          "description" : ""
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "double"
+    }
+  ],
+  "examples" : [
+    "FROM employees\n| STATS p0 = PERCENTILE(salary,  0)\n     , p50 = PERCENTILE(salary, 50)\n     , p99 = PERCENTILE(salary, 99)",
+    "FROM employees\n| STATS p80_max_salary_change = PERCENTILE(MV_MAX(salary_change), 80)"
+  ]
+}

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

@@ -0,0 +1,13 @@
+<!--
+This is generated by ESQL's AbstractFunctionTestCase. Do no edit it. See ../README.md for how to regenerate it.
+-->
+
+### PERCENTILE
+Returns the value at which a certain percentage of observed values occur. For example, the 95th percentile is the value which is greater than 95% of the observed values and the 50th percentile is the `MEDIAN`.
+
+```
+FROM employees
+| STATS p0 = PERCENTILE(salary,  0)
+     , p50 = PERCENTILE(salary, 50)
+     , p99 = PERCENTILE(salary, 99)
+```

+ 16 - 0
docs/reference/esql/functions/layout/percentile.asciidoc

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

+ 2 - 2
docs/reference/esql/functions/median-absolute-deviation.asciidoc

@@ -25,8 +25,8 @@ It is calculated as the median of each data point's deviation from the median of
 the entire sample. That is, for a random variable `X`, the median absolute
 deviation is `median(|median(X) - X|)`.
 
-NOTE: Like <<esql-agg-percentile>>, `MEDIAN_ABSOLUTE_DEVIATION` is
-      <<esql-agg-percentile-approximate,usually approximate>>.
+NOTE: Like <<esql-percentile>>, `MEDIAN_ABSOLUTE_DEVIATION` is
+      <<esql-percentile-approximate,usually approximate>>.
 
 [WARNING]
 ====

+ 2 - 2
docs/reference/esql/functions/median.asciidoc

@@ -17,9 +17,9 @@ Expression from which to return the median value.
 *Description*
 
 Returns the value that is greater than half of all values and less than half of
-all values, also known as the 50% <<esql-agg-percentile>>.
+all values, also known as the 50% <<esql-percentile>>.
 
-NOTE: Like <<esql-agg-percentile>>, `MEDIAN` is <<esql-agg-percentile-approximate,usually approximate>>.
+NOTE: Like <<esql-percentile>>, `MEDIAN` is <<esql-percentile-approximate,usually approximate>>.
 
 [WARNING]
 ====

+ 9 - 0
docs/reference/esql/functions/parameters/percentile.asciidoc

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

+ 0 - 60
docs/reference/esql/functions/percentile.asciidoc

@@ -1,60 +0,0 @@
-[discrete]
-[[esql-agg-percentile]]
-=== `PERCENTILE`
-
-*Syntax*
-
-[source,esql]
-----
-PERCENTILE(expression, percentile)
-----
-
-*Parameters*
-
-`expression`::
-Expression from which to return a percentile.
-
-`percentile`::
-A constant numeric expression.
-
-*Description*
-
-Returns the value at which a certain percentage of observed values occur. For
-example, the 95th percentile is the value which is greater than 95% of the
-observed values and the 50th percentile is the <<esql-agg-median>>.
-
-*Example*
-
-[source.merge.styled,esql]
-----
-include::{esql-specs}/stats_percentile.csv-spec[tag=percentile]
-----
-[%header.monospaced.styled,format=dsv,separator=|]
-|===
-include::{esql-specs}/stats_percentile.csv-spec[tag=percentile-result]
-|===
-
-The expression can use inline functions. For example, to calculate a percentile
-of the maximum values of a multivalued column, first use `MV_MAX` to get the
-maximum value per row, and use the result with the `PERCENTILE` function:
-
-[source.merge.styled,esql]
-----
-include::{esql-specs}/stats_percentile.csv-spec[tag=docsStatsPercentileNestedExpression]
-----
-[%header.monospaced.styled,format=dsv,separator=|]
-|===
-include::{esql-specs}/stats_percentile.csv-spec[tag=docsStatsPercentileNestedExpression-result]
-|===
-
-[discrete]
-[[esql-agg-percentile-approximate]]
-==== `PERCENTILE` is (usually) approximate
-
-include::../../aggregations/metrics/percentile-aggregation.asciidoc[tag=approximate]
-
-[WARNING]
-====
-`PERCENTILE` is also {wikipedia}/Nondeterministic_algorithm[non-deterministic].
-This means you can get slightly different results using the same data.
-====

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="528" height="46" viewbox="0 0 528 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 31h5m140 0h10m32 0h10m92 0h10m32 0h10m140 0h10m32 0h5"/><rect class="s" x="5" y="5" width="140" height="36"/><text class="k" x="15" y="31">PERCENTILE</text><rect class="s" x="155" y="5" width="32" height="36" rx="7"/><text class="syn" x="165" y="31">(</text><rect class="s" x="197" y="5" width="92" height="36" rx="7"/><text class="k" x="207" y="31">number</text><rect class="s" x="299" y="5" width="32" height="36" rx="7"/><text class="syn" x="309" y="31">,</text><rect class="s" x="341" y="5" width="140" height="36" rx="7"/><text class="k" x="351" y="31">percentile</text><rect class="s" x="491" y="5" width="32" height="36" rx="7"/><text class="syn" x="501" y="31">)</text></svg>

+ 17 - 0
docs/reference/esql/functions/types/percentile.asciidoc

@@ -0,0 +1,17 @@
+// 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 | percentile | result
+double | double | double
+double | integer | double
+double | long | double
+integer | double | double
+integer | integer | double
+integer | long | double
+long | double | double
+long | integer | double
+long | long | double
+|===

+ 3 - 3
x-pack/plugin/esql/qa/testFixtures/src/main/resources/meta.csv-spec

@@ -58,7 +58,7 @@ double e()
 "double|integer|long|unsigned_long mv_sum(number:double|integer|long|unsigned_long)"
 "keyword mv_zip(string1:keyword|text, string2:keyword|text, ?delim:keyword|text)"
 date now()
-"double|integer|long percentile(number:double|integer|long, percentile:double|integer|long)"
+"double percentile(number:double|integer|long, percentile:double|integer|long)"
 double pi()
 "double pow(base:double|integer|long|unsigned_long, exponent:double|integer|long|unsigned_long)"
 "keyword repeat(string:keyword|text, number:integer)"
@@ -301,7 +301,7 @@ mv_sort       |Sorts a multivalued field in lexicographical order.
 mv_sum        |Converts a multivalued field into a single valued field containing the sum of all of the values.
 mv_zip        |Combines the values from two multivalued fields with a delimiter that joins them together.
 now           |Returns current date and time.
-percentile    |The value at which a certain percentage of observed values occur.
+percentile    |Returns the value at which a certain percentage of observed values occur. For example, the 95th percentile is the value which is greater than 95% of the observed values and the 50th percentile is the `MEDIAN`.
 pi            |Returns {wikipedia}/Pi[Pi], the ratio of a circle's circumference to its diameter.
 pow           |Returns the value of `base` raised to the power of `exponent`.
 repeat        |Returns a string constructed by concatenating `string` with itself the specified `number` of times.
@@ -424,7 +424,7 @@ mv_sort       |"boolean|date|double|integer|ip|keyword|long|text|version"
 mv_sum        |"double|integer|long|unsigned_long"                                                                                         |false                       |false           |false
 mv_zip        |keyword                                                                                                                     |[false, false, true]        |false           |false
 now           |date                                                                                                                        |null                        |false           |false
-percentile    |"double|integer|long"                                                                                                       |[false, false]              |false           |true
+percentile    |double                                                                                                                      |[false, false]              |false           |true
 pi            |double                                                                                                                      |null                        |false           |false
 pow           |double                                                                                                                      |[false, false]              |false           |false
 repeat        |keyword                                                                                                                     |[false, false]              |false           |false

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

@@ -39,6 +39,11 @@ public @interface FunctionInfo {
      */
     String note() default "";
 
+    /**
+     * Extra information rendered at the bottom of the function docs.
+     */
+    String appendix() default "";
+
     /**
      * Is this an aggregation (true) or a scalar function (false).
      */

+ 35 - 5
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Percentile.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.tree.NodeInfo;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.Example;
 import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
 import org.elasticsearch.xpack.esql.expression.function.Param;
 import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
@@ -28,7 +29,6 @@ import java.util.List;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.FIRST;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.SECOND;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isFoldable;
-import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isNumeric;
 import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
 
 public class Percentile extends NumericAggregate {
@@ -41,9 +41,33 @@ public class Percentile extends NumericAggregate {
     private final Expression percentile;
 
     @FunctionInfo(
-        returnType = { "double", "integer", "long" },
-        description = "The value at which a certain percentage of observed values occur.",
-        isAggregation = true
+        returnType = "double",
+        description = "Returns the value at which a certain percentage of observed values occur. "
+            + "For example, the 95th percentile is the value which is greater than 95% of the "
+            + "observed values and the 50th percentile is the `MEDIAN`.",
+        appendix = """
+            [discrete]
+            [[esql-percentile-approximate]]
+            ==== `PERCENTILE` is (usually) approximate
+
+            include::../../../aggregations/metrics/percentile-aggregation.asciidoc[tag=approximate]
+
+            [WARNING]
+            ====
+            `PERCENTILE` is also {wikipedia}/Nondeterministic_algorithm[non-deterministic].
+            This means you can get slightly different results using the same data.
+            ====
+            """,
+        isAggregation = true,
+        examples = {
+            @Example(file = "stats_percentile", tag = "percentile"),
+            @Example(
+                description = "The expression can use inline functions. For example, to calculate a percentile "
+                    + "of the maximum values of a multivalued column, first use `MV_MAX` to get the "
+                    + "maximum value per row, and use the result with the `PERCENTILE` function",
+                file = "stats_percentile",
+                tag = "docsStatsPercentileNestedExpression"
+            ), }
     )
     public Percentile(
         Source source,
@@ -101,7 +125,13 @@ public class Percentile extends NumericAggregate {
             return resolution;
         }
 
-        return isNumeric(percentile, sourceText(), SECOND).and(isFoldable(percentile, sourceText(), SECOND));
+        return isType(
+            percentile,
+            dt -> dt.isNumeric() && dt != DataType.UNSIGNED_LONG,
+            sourceText(),
+            SECOND,
+            "numeric except unsigned_long"
+        ).and(isFoldable(percentile, sourceText(), SECOND));
     }
 
     @Override

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

@@ -491,7 +491,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
             FunctionInfo info = EsqlFunctionRegistry.functionInfo(definition);
             renderDescription(description.description(), info.detailedDescription(), info.note());
             boolean hasExamples = renderExamples(info);
-            renderFullLayout(name, hasExamples);
+            boolean hasAppendix = renderAppendix(info.appendix());
+            renderFullLayout(name, hasExamples, hasAppendix);
             renderKibanaInlineDocs(name, info);
             List<EsqlFunctionRegistry.ArgSignature> args = description.args();
             if (name.equals("case")) {
@@ -620,7 +621,19 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
         return true;
     }
 
-    private static void renderFullLayout(String name, boolean hasExamples) throws IOException {
+    private static boolean renderAppendix(String appendix) throws IOException {
+        if (appendix.isEmpty()) {
+            return false;
+        }
+
+        String rendered = DOCS_WARNING + appendix + "\n";
+
+        LogManager.getLogger(getTestClass()).info("Writing appendix for [{}]:\n{}", functionName(), rendered);
+        writeToTempDir("appendix", rendered, "asciidoc");
+        return true;
+    }
+
+    private static void renderFullLayout(String name, boolean hasExamples, boolean hasAppendix) throws IOException {
         String rendered = DOCS_WARNING + """
             [discrete]
             [[esql-$NAME$]]
@@ -638,6 +651,9 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
         if (hasExamples) {
             rendered += "include::../examples/" + name + ".asciidoc[]\n";
         }
+        if (hasAppendix) {
+            rendered += "include::../appendix/" + name + ".asciidoc[]\n";
+        }
         LogManager.getLogger(getTestClass()).info("Writing layout for [{}]:\n{}", functionName(), rendered);
         writeToTempDir("layout", rendered, "asciidoc");
     }

+ 89 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PercentileTests.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.aggregate;
+
+import com.carrotsearch.randomizedtesting.annotations.Name;
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+
+import org.elasticsearch.search.aggregations.metrics.TDigestState;
+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.List;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class PercentileTests extends AbstractAggregationTestCase {
+    public PercentileTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        var suppliers = new ArrayList<TestCaseSupplier>();
+
+        var fieldCases = 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)
+        ).flatMap(List::stream).toList();
+
+        var percentileCases = Stream.of(
+            TestCaseSupplier.intCases(0, 100, true),
+            TestCaseSupplier.longCases(0, 100, true),
+            TestCaseSupplier.doubleCases(0, 100, true)
+        ).flatMap(List::stream).toList();
+
+        for (var field : fieldCases) {
+            for (var percentile : percentileCases) {
+                suppliers.add(makeSupplier(field, percentile));
+            }
+        }
+
+        return parameterSuppliersFromTypedDataWithDefaultChecks(suppliers);
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Percentile(source, args.get(0), args.get(1));
+    }
+
+    private static TestCaseSupplier makeSupplier(
+        TestCaseSupplier.TypedDataSupplier fieldSupplier,
+        TestCaseSupplier.TypedDataSupplier percentileSupplier
+    ) {
+        return new TestCaseSupplier(List.of(fieldSupplier.type(), percentileSupplier.type()), () -> {
+            var fieldTypedData = fieldSupplier.get();
+            var percentileTypedData = percentileSupplier.get().forceLiteral();
+
+            var percentile = ((Number) percentileTypedData.data()).intValue();
+
+            var digest = TDigestState.create(1000);
+
+            for (var value : fieldTypedData.multiRowData()) {
+                digest.add(((Number) value).doubleValue());
+            }
+
+            var expected = digest.size() == 0 ? null : digest.quantile((double) percentile / 100);
+
+            return new TestCaseSupplier.TestCase(
+                List.of(fieldTypedData, percentileTypedData),
+                "Percentile[number=Attribute[channel=0],percentile=Attribute[channel=1]]",
+                DataType.DOUBLE,
+                equalTo(expected)
+            );
+        });
+    }
+}