Browse Source

ES|QL: Add PRESENT ES|QL function (#133986)

* ES|QL: Add PRESENT ES|QL function

Add a new ES|QL function that checks for the presence of a field in the
output result. Presence means that the input expression yields any
non-null value.

Part of #131069

* [CI] Auto commit changes from spotless

* ES|QL: Add PRESENT ES|QL function

Add unit tests and documentation for the PRESENT function.

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Clean-up of the PRESENT function.

Part of #131069

* ES|QL: Add PRESENT ES|QL function

- Change intermediate state for using boolean
- Add unit tests for PresentAggregatorFunctionTests and
PresentGroupingAggregatorFunctionTests

Part of #131069

* ES|QL: Add PRESENT ES|QL function

- Add VerifierTests

Part of #131069

* ES|QL: Add PRESENT ES|QL function

- Add union_types csv tests

Part of #131069

* ES|QL: Add PRESENT ES|QL function

- Fix unit tests

Part of #131069

* [CI] Auto commit changes from spotless

* ES|QL: Add PRESENT_OVER_TIME ES|QL function

- Comment out TestLogging on CsvTests
- Add missing DataTypes to the function

Part of #131069

* [CI] Auto commit changes from spotless

* ES|QL: Add PRESENT_OVER_TIME ES|QL function

- Improve documentation

Part of #131069

* ES|QL: Add PRESENT ES|QL function

- Optimize AggregatorFunctions

Part of #131069

* [CI] Auto commit changes from spotless

* ES|QL: Add PRESENT ES|QL function

- Fix Rest Tests

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Optimize AggregatorFunction

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Optimize PresentGroupingAggregatorFunction

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Add PresentErrorTests

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Add docs

Part of #131069

* ES|QL: Add PRESENT ES|QL function

Add docs

Part of #131069

---------

Co-authored-by: elasticsearchmachine <infra-root+elasticsearchmachine@elastic.co>
Dmitry Leontyev 1 month ago
parent
commit
cce52ddb0c
25 changed files with 1273 additions and 6 deletions
  1. 6 0
      docs/reference/query-languages/esql/_snippets/functions/description/present.md
  2. 30 0
      docs/reference/query-languages/esql/_snippets/functions/examples/present.md
  3. 23 0
      docs/reference/query-languages/esql/_snippets/functions/layout/present.md
  4. 7 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/present.md
  5. 25 0
      docs/reference/query-languages/esql/_snippets/functions/types/present.md
  6. 1 0
      docs/reference/query-languages/esql/_snippets/lists/aggregation-functions.md
  7. 3 0
      docs/reference/query-languages/esql/functions-operators/aggregation-functions.md
  8. 1 0
      docs/reference/query-languages/esql/images/functions/present.svg
  9. 230 0
      docs/reference/query-languages/esql/kibana/definition/functions/present.json
  10. 9 0
      docs/reference/query-languages/esql/kibana/docs/functions/present.md
  11. 133 0
      x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunction.java
  12. 204 0
      x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunction.java
  13. 50 0
      x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunctionTests.java
  14. 72 0
      x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunctionTests.java
  15. 112 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/present.csv-spec
  16. 31 0
      x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec
  17. 6 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
  18. 3 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
  19. 2 1
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java
  20. 130 0
      x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Present.java
  21. 16 2
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
  22. 43 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentErrorTests.java
  23. 24 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentSerializationTests.java
  24. 111 0
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentTests.java
  25. 1 1
      x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

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

@@ -0,0 +1,6 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Description**
+
+Returns true if the input expression yields any non-null values within the current aggregation context. Otherwise it returns false.
+

+ 30 - 0
docs/reference/query-languages/esql/_snippets/functions/examples/present.md

@@ -0,0 +1,30 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Examples**
+
+```esql
+FROM employees
+| STATS is_present = PRESENT(languages)
+```
+
+| is_present:boolean |
+| --- |
+| true |
+
+To check for the presence inside a group use `PRESENT()` and `BY` clauses
+
+```esql
+FROM employees
+| STATS is_present = PRESENT(salary) BY languages
+```
+
+| is_present:boolean | languages:integer |
+| --- | --- |
+| true | 1 |
+| true | 2 |
+| true | 3 |
+| true | 4 |
+| true | 5 |
+| true | null |
+
+

+ 23 - 0
docs/reference/query-languages/esql/_snippets/functions/layout/present.md

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

+ 7 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/present.md

@@ -0,0 +1,7 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+**Parameters**
+
+`field`
+:   Expression that outputs values to be checked for presence.
+

+ 25 - 0
docs/reference/query-languages/esql/_snippets/functions/types/present.md

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

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

@@ -14,3 +14,4 @@
 * [`TOP`](../../functions-operators/aggregation-functions.md#esql-top)
 * [preview] [`VALUES`](../../functions-operators/aggregation-functions.md#esql-values)
 * [`WEIGHTED_AVG`](../../functions-operators/aggregation-functions.md#esql-weighted_avg)
+* [`PRESENT`](../../functions-operators/aggregation-functions.md#esql-present)

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

@@ -59,3 +59,6 @@ The [`STATS`](/reference/query-languages/esql/commands/stats-by.md) command supp
 
 :::{include} ../_snippets/functions/layout/weighted_avg.md
 :::
+
+:::{include} ../_snippets/functions/layout/present.md
+:::

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

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="288" height="46" viewbox="0 0 288 46"><defs><style type="text/css">.c{fill:none;stroke:#222222;}.k{fill:#000000;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}.s{fill:#e4f4ff;stroke:#222222;}.syn{fill:#8D8D8D;font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:20px;}</style></defs><path class="c" d="M0 31h5m104 0h10m32 0h10m80 0h10m32 0h5"/><rect class="s" x="5" y="5" width="104" height="36"/><text class="k" x="15" y="31">PRESENT</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="80" height="36" rx="7"/><text class="k" x="171" y="31">field</text><rect class="s" x="251" y="5" width="32" height="36" rx="7"/><text class="syn" x="261" y="31">)</text></svg>

+ 230 - 0
docs/reference/query-languages/esql/kibana/definition/functions/present.json

@@ -0,0 +1,230 @@
+{
+  "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
+  "type" : "agg",
+  "name" : "present",
+  "description" : "Returns true if the input expression yields any non-null values within the current aggregation context. Otherwise it returns false.",
+  "signatures" : [
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "boolean",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_point",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "cartesian_shape",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "date_nanos",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "double",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_point",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geo_shape",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohash",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geohex",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "geotile",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "integer",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "ip",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "keyword",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "long",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "text",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "unsigned_long",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    },
+    {
+      "params" : [
+        {
+          "name" : "field",
+          "type" : "version",
+          "optional" : false,
+          "description" : "Expression that outputs values to be checked for presence."
+        }
+      ],
+      "variadic" : false,
+      "returnType" : "boolean"
+    }
+  ],
+  "examples" : [
+    "FROM employees\n| STATS is_present = PRESENT(languages)",
+    "FROM employees\n| STATS is_present = PRESENT(salary) BY languages"
+  ],
+  "preview" : false,
+  "snapshot_only" : false
+}

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

@@ -0,0 +1,9 @@
+% This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
+
+### PRESENT
+Returns true if the input expression yields any non-null values within the current aggregation context. Otherwise it returns false.
+
+```esql
+FROM employees
+| STATS is_present = PRESENT(languages)
+```

+ 133 - 0
x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunction.java

@@ -0,0 +1,133 @@
+/*
+ * 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.compute.aggregation;
+
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.data.ElementType;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
+
+import java.util.List;
+
+public class PresentAggregatorFunction implements AggregatorFunction {
+    public static AggregatorFunctionSupplier supplier() {
+        return new AggregatorFunctionSupplier() {
+            @Override
+            public List<IntermediateStateDesc> nonGroupingIntermediateStateDesc() {
+                return PresentAggregatorFunction.intermediateStateDesc();
+            }
+
+            @Override
+            public List<IntermediateStateDesc> groupingIntermediateStateDesc() {
+                return PresentGroupingAggregatorFunction.intermediateStateDesc();
+            }
+
+            @Override
+            public AggregatorFunction aggregator(DriverContext driverContext, List<Integer> channels) {
+                return PresentAggregatorFunction.create(channels);
+            }
+
+            @Override
+            public GroupingAggregatorFunction groupingAggregator(DriverContext driverContext, List<Integer> channels) {
+                return PresentGroupingAggregatorFunction.create(driverContext, channels);
+            }
+
+            @Override
+            public String describe() {
+                return "present";
+            }
+        };
+    }
+
+    private static final List<IntermediateStateDesc> INTERMEDIATE_STATE_DESC = List.of(
+        new IntermediateStateDesc("present", ElementType.BOOLEAN)
+    );
+
+    public static List<IntermediateStateDesc> intermediateStateDesc() {
+        return INTERMEDIATE_STATE_DESC;
+    }
+
+    private final List<Integer> channels;
+
+    private boolean state;
+
+    public static PresentAggregatorFunction create(List<Integer> inputChannels) {
+        return new PresentAggregatorFunction(inputChannels, false);
+    }
+
+    private PresentAggregatorFunction(List<Integer> channels, boolean state) {
+        this.channels = channels;
+        this.state = state;
+    }
+
+    @Override
+    public int intermediateBlockCount() {
+        return intermediateStateDesc().size();
+    }
+
+    private int blockIndex() {
+        return channels.get(0);
+    }
+
+    @Override
+    public void addRawInput(Page page, BooleanVector mask) {
+        if (mask.isConstant() && mask.getBoolean(0) == false) return;
+
+        Block block = page.getBlock(blockIndex());
+        this.state = mask.isConstant() ? block.getTotalValueCount() > 0 : presentMasked(block, mask);
+    }
+
+    private boolean presentMasked(Block block, BooleanVector mask) {
+        for (int p = 0; p < block.getPositionCount(); p++) {
+            if (mask.getBoolean(p)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public void addIntermediateInput(Page page) {
+        assert channels.size() == intermediateBlockCount();
+        var blockIndex = blockIndex();
+        assert page.getBlockCount() >= blockIndex + intermediateStateDesc().size();
+        Block uncastBlock = page.getBlock(channels.get(0));
+        if (uncastBlock.areAllValuesNull()) {
+            return;
+        }
+        BooleanVector present = page.<BooleanBlock>getBlock(channels.get(0)).asVector();
+        assert present.getPositionCount() == 1;
+        if (present.getBoolean(0)) {
+            this.state = true;
+        }
+    }
+
+    @Override
+    public void evaluateIntermediate(Block[] blocks, int offset, DriverContext driverContext) {
+        evaluateFinal(blocks, offset, driverContext);
+    }
+
+    @Override
+    public void evaluateFinal(Block[] blocks, int offset, DriverContext driverContext) {
+        blocks[offset] = driverContext.blockFactory().newConstantBooleanBlockWith(state, 1);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName()).append("[");
+        sb.append("channels=").append(channels);
+        sb.append("]");
+        return sb.toString();
+    }
+
+    @Override
+    public void close() {}
+}

+ 204 - 0
x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunction.java

@@ -0,0 +1,204 @@
+/*
+ * 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.compute.aggregation;
+
+import org.elasticsearch.common.util.BitArray;
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.data.ElementType;
+import org.elasticsearch.compute.data.IntArrayBlock;
+import org.elasticsearch.compute.data.IntBigArrayBlock;
+import org.elasticsearch.compute.data.IntVector;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.DriverContext;
+
+import java.util.List;
+
+public class PresentGroupingAggregatorFunction implements GroupingAggregatorFunction {
+
+    private static final List<IntermediateStateDesc> INTERMEDIATE_STATE_DESC = List.of(
+        new IntermediateStateDesc("present", ElementType.BOOLEAN)
+    );
+
+    private final BitArray state;
+    private final List<Integer> channels;
+    private final DriverContext driverContext;
+
+    public static PresentGroupingAggregatorFunction create(DriverContext driverContext, List<Integer> inputChannels) {
+        return new PresentGroupingAggregatorFunction(inputChannels, new BitArray(1, driverContext.bigArrays()), driverContext);
+    }
+
+    public static List<IntermediateStateDesc> intermediateStateDesc() {
+        return INTERMEDIATE_STATE_DESC;
+    }
+
+    private PresentGroupingAggregatorFunction(List<Integer> channels, BitArray state, DriverContext driverContext) {
+        this.channels = channels;
+        this.state = state;
+        this.driverContext = driverContext;
+    }
+
+    private int blockIndex() {
+        return channels.get(0);
+    }
+
+    @Override
+    public int intermediateBlockCount() {
+        return intermediateStateDesc().size();
+    }
+
+    @Override
+    public AddInput prepareProcessRawInputPage(SeenGroupIds seenGroupIds, Page page) {
+        Block valuesBlock = page.getBlock(blockIndex());
+
+        return new AddInput() {
+            @Override
+            public void add(int positionOffset, IntArrayBlock groupIds) {
+                addRawInput(positionOffset, groupIds, valuesBlock);
+            }
+
+            @Override
+            public void add(int positionOffset, IntBigArrayBlock groupIds) {
+                addRawInput(positionOffset, groupIds, valuesBlock);
+            }
+
+            @Override
+            public void add(int positionOffset, IntVector groupIds) {
+                addRawInput(positionOffset, groupIds, valuesBlock);
+            }
+
+            @Override
+            public void close() {}
+        };
+    }
+
+    private void addRawInput(int positionOffset, IntVector groups, Block values) {
+        int position = positionOffset;
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++, position++) {
+            if (values.isNull(position)) {
+                continue;
+            }
+            state.set(groups.getInt(groupPosition), true);
+        }
+    }
+
+    private void addRawInput(int positionOffset, IntArrayBlock groups, Block values) {
+        int position = positionOffset;
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++, position++) {
+            if (groups.isNull(groupPosition) || values.isNull(position)) {
+                continue;
+            }
+            int groupStart = groups.getFirstValueIndex(groupPosition);
+            int groupEnd = groupStart + groups.getValueCount(groupPosition);
+            for (int g = groupStart; g < groupEnd; g++) {
+                state.set(groups.getInt(g), true);
+            }
+        }
+    }
+
+    private void addRawInput(int positionOffset, IntBigArrayBlock groups, Block values) {
+        int position = positionOffset;
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++, position++) {
+            if (groups.isNull(groupPosition) || values.isNull(position)) {
+                continue;
+            }
+            int groupStart = groups.getFirstValueIndex(groupPosition);
+            int groupEnd = groupStart + groups.getValueCount(groupPosition);
+            for (int g = groupStart; g < groupEnd; g++) {
+                state.set(groups.getInt(g), true);
+            }
+        }
+    }
+
+    @Override
+    public void selectedMayContainUnseenGroups(SeenGroupIds seenGroupIds) {}
+
+    @Override
+    public void addIntermediateInput(int positionOffset, IntArrayBlock groups, Page page) {
+        assert channels.size() == intermediateBlockCount();
+        assert page.getBlockCount() >= blockIndex() + intermediateStateDesc().size();
+        BooleanVector present = page.<BooleanBlock>getBlock(channels.get(0)).asVector();
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++) {
+            if (groups.isNull(groupPosition) || present.getBoolean(groupPosition + positionOffset) == false) {
+                continue;
+            }
+            int groupStart = groups.getFirstValueIndex(groupPosition);
+            int groupEnd = groupStart + groups.getValueCount(groupPosition);
+            for (int g = groupStart; g < groupEnd; g++) {
+                state.set(groups.getInt(g), true);
+            }
+        }
+    }
+
+    @Override
+    public void addIntermediateInput(int positionOffset, IntBigArrayBlock groups, Page page) {
+        assert channels.size() == intermediateBlockCount();
+        assert page.getBlockCount() >= blockIndex() + intermediateStateDesc().size();
+        BooleanVector present = page.<BooleanBlock>getBlock(channels.get(0)).asVector();
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++) {
+            if (groups.isNull(groupPosition) || present.getBoolean(groupPosition + positionOffset) == false) {
+                continue;
+            }
+
+            int groupStart = groups.getFirstValueIndex(groupPosition);
+            int groupEnd = groupStart + groups.getValueCount(groupPosition);
+            for (int g = groupStart; g < groupEnd; g++) {
+                state.set(groups.getInt(g), true);
+            }
+        }
+    }
+
+    @Override
+    public void addIntermediateInput(int positionOffset, IntVector groups, Page page) {
+        assert channels.size() == intermediateBlockCount();
+        assert page.getBlockCount() >= blockIndex() + intermediateStateDesc().size();
+        BooleanVector present = page.<BooleanBlock>getBlock(channels.get(0)).asVector();
+        for (int groupPosition = 0; groupPosition < groups.getPositionCount(); groupPosition++) {
+            if (present.getBoolean(groupPosition + positionOffset)) {
+                state.set(groups.getInt(groupPosition), true);
+            }
+        }
+    }
+
+    @Override
+    public void evaluateIntermediate(Block[] blocks, int offset, IntVector selected) {
+        try (BooleanVector.FixedBuilder builder = driverContext.blockFactory().newBooleanVectorFixedBuilder(selected.getPositionCount())) {
+            for (int i = 0; i < selected.getPositionCount(); i++) {
+                int group = selected.getInt(i);
+                builder.appendBoolean(state.get(group));
+            }
+            blocks[offset] = builder.build().asBlock();
+        }
+    }
+
+    @Override
+    public void evaluateFinal(Block[] blocks, int offset, IntVector selected, GroupingAggregatorEvaluationContext evaluationContext) {
+        try (BooleanVector.Builder builder = evaluationContext.blockFactory().newBooleanVectorFixedBuilder(selected.getPositionCount())) {
+            for (int i = 0; i < selected.getPositionCount(); i++) {
+                int si = selected.getInt(i);
+                builder.appendBoolean(state.get(si));
+            }
+            blocks[offset] = builder.build().asBlock();
+        }
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(this.getClass().getSimpleName()).append("[");
+        sb.append("channels=").append(channels);
+        sb.append("]");
+        return sb.toString();
+    }
+
+    @Override
+    public void close() {
+        state.close();
+    }
+}

+ 50 - 0
x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunctionTests.java

@@ -0,0 +1,50 @@
+/*
+ * 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.compute.aggregation;
+
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.SourceOperator;
+import org.elasticsearch.compute.test.SequenceLongBlockSourceOperator;
+
+import java.util.List;
+import java.util.stream.LongStream;
+
+import static org.elasticsearch.compute.test.BlockTestUtils.valuesAtPositions;
+import static org.hamcrest.Matchers.equalTo;
+
+public class PresentAggregatorFunctionTests extends AggregatorFunctionTestCase {
+    @Override
+    protected SourceOperator simpleInput(BlockFactory blockFactory, int size) {
+        return new SequenceLongBlockSourceOperator(blockFactory, LongStream.range(0, size).map(l -> randomLong()));
+    }
+
+    @Override
+    protected AggregatorFunctionSupplier aggregatorFunction() {
+        return PresentAggregatorFunction.supplier();
+    }
+
+    @Override
+    protected String expectedDescriptionOfAggregator() {
+        return "present";
+    }
+
+    @Override
+    protected void assertSimpleOutput(List<Page> input, Block result) {
+        boolean present = input.stream().flatMapToLong(p -> allLongs(p.getBlock(0))).findAny().isPresent();
+        assertThat(((BooleanBlock) result).getBoolean(0), equalTo(present));
+    }
+
+    @Override
+    protected void assertOutputFromEmpty(Block b) {
+        assertThat(b.getPositionCount(), equalTo(1));
+        assertThat(valuesAtPositions(b, 0, 1), equalTo(List.of(List.of(false))));
+    }
+}

+ 72 - 0
x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunctionTests.java

@@ -0,0 +1,72 @@
+/*
+ * 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.compute.aggregation;
+
+import org.elasticsearch.compute.data.Block;
+import org.elasticsearch.compute.data.BlockFactory;
+import org.elasticsearch.compute.data.BooleanBlock;
+import org.elasticsearch.compute.data.BooleanVector;
+import org.elasticsearch.compute.data.ElementType;
+import org.elasticsearch.compute.data.Page;
+import org.elasticsearch.compute.operator.LongDoubleTupleBlockSourceOperator;
+import org.elasticsearch.compute.operator.SourceOperator;
+import org.elasticsearch.compute.test.TupleLongLongBlockSourceOperator;
+import org.elasticsearch.core.Tuple;
+
+import java.util.List;
+import java.util.stream.LongStream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class PresentGroupingAggregatorFunctionTests extends GroupingAggregatorFunctionTestCase {
+    @Override
+    protected AggregatorFunctionSupplier aggregatorFunction() {
+        return PresentAggregatorFunction.supplier();
+    }
+
+    @Override
+    protected String expectedDescriptionOfAggregator() {
+        return "present";
+    }
+
+    @Override
+    protected SourceOperator simpleInput(BlockFactory blockFactory, int size) {
+        if (randomBoolean()) {
+            return new TupleLongLongBlockSourceOperator(
+                blockFactory,
+                LongStream.range(0, size).mapToObj(l -> Tuple.tuple(randomLongBetween(0, 4), randomLong()))
+            );
+        }
+        return new LongDoubleTupleBlockSourceOperator(
+            blockFactory,
+            LongStream.range(0, size).mapToObj(l -> Tuple.tuple(randomLongBetween(0, 4), randomDouble()))
+        );
+    }
+
+    @Override
+    protected void assertSimpleGroup(List<Page> input, Block result, int position, Long group) {
+        boolean present = input.stream().flatMapToInt(p -> allValueOffsets(p, group)).findAny().isPresent();
+        assertThat(((BooleanBlock) result).getBoolean(position), equalTo(present));
+    }
+
+    @Override
+    protected void assertOutputFromNullOnly(Block b, int position) {
+        assertThat(b.isNull(position), equalTo(false));
+        assertThat(b.getValueCount(position), equalTo(1));
+        assertThat(((BooleanBlock) b).getBoolean(b.getFirstValueIndex(position)), equalTo(false));
+    }
+
+    @Override
+    protected void assertOutputFromAllFiltered(Block b) {
+        assertThat(b.elementType(), equalTo(ElementType.BOOLEAN));
+        BooleanVector v = (BooleanVector) b.asVector();
+        for (int p = 0; p < v.getPositionCount(); p++) {
+            assertThat(v.getBoolean(p), equalTo(false));
+        }
+    }
+}

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

@@ -0,0 +1,112 @@
+// PRESENT-specific tests
+
+PRESENT with no filter
+required_capability: fn_present
+
+// tag::present[]
+FROM employees
+| STATS is_present = PRESENT(languages)
+// end::present[]
+;
+
+// tag::present-result[]
+is_present:boolean
+true
+// end::present-result[]
+;
+
+PRESENT with grouping
+required_capability: fn_present
+
+// tag::present-by[]
+FROM employees
+| STATS is_present = PRESENT(salary) BY languages
+// end::present-by[]
+;
+ignoreOrder:true
+
+// tag::present-by-result[]
+is_present:boolean    | languages:integer
+true                  | 1
+true                  | 2
+true                  | 3
+true                  | 4
+true                  | 5
+true                  | null
+// end::present-by-result[]
+;
+
+PRESENT with filter
+required_capability: fn_present
+
+FROM employees
+| WHERE emp_no IN (10019, 10020)
+| STATS is_present = PRESENT(languages)
+;
+
+is_present:boolean
+true
+;
+
+Not PRESENT with filter
+required_capability: fn_present
+
+FROM employees
+| WHERE emp_no == 10020
+| STATS is_present = PRESENT(languages)
+;
+
+is_present:boolean
+false
+;
+
+Not PRESENT with null evaluated field
+required_capability: fn_present
+
+FROM employees
+| EVAL null_field = null
+| STATS is_present = PRESENT(null_field)
+;
+
+is_present:boolean
+false
+;
+
+PRESENT with an expression
+required_capability: fn_present
+
+FROM employees
+| STATS is_present = PRESENT(salary + 10)
+;
+
+is_present:boolean
+true
+;
+
+Some fields are not PRESENT with grouping
+required_capability: fn_present
+
+FROM employees
+| WHERE gender IS NULL
+| STATS is_present = PRESENT(gender) BY languages
+;
+ignoreOrder:true
+
+is_present:boolean     | languages:integer
+false                  | 4
+false                  | 5
+false                  | 1
+false                  | 2
+;
+
+PRESENT with per-agg filter
+required_capability: fn_present
+
+FROM employees
+| STATS p_true = PRESENT(salary) WHERE gender == "M",
+        p_false = PRESENT(salary) WHERE gender == "X"
+;
+
+p_true:boolean    | p_false:boolean
+true              | false
+;

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

@@ -2518,3 +2518,34 @@ x:date_nanos             | b1d:date_nanos
 2023-10-24T13:00:00.000Z | 2023-10-23T13:00:00.000Z
 2023-10-24T12:00:00.000Z | 2023-10-23T12:00:00.000Z
 ;
+
+multiIndexAggregationUsingPresent
+required_capability: date_nanos_type
+required_capability: fn_present
+
+FROM employees, employees_incompatible
+| WHERE hire_date > DATE_PARSE("yyyy-MM-dd", "1989-01-01") and hire_date < DATE_PARSE("yyyy-MM-dd", "1991-01-01")
+| STATS is_present = present(birth_date) BY b = BUCKET(hire_date, 1 month)
+| SORT b DESC, is_present
+;
+ignoreOrder:true
+
+is_present:boolean | b:date_nanos
+false              | 1989-03-01T00:00:00.000Z
+false              | 1990-10-01T00:00:00.000Z
+true               | 1989-02-01T00:00:00.000Z
+true               | 1989-04-01T00:00:00.000Z
+true               | 1989-06-01T00:00:00.000Z
+true               | 1989-07-01T00:00:00.000Z
+true               | 1989-08-01T00:00:00.000Z
+true               | 1989-09-01T00:00:00.000Z
+true               | 1989-11-01T00:00:00.000Z
+true               | 1989-12-01T00:00:00.000Z
+true               | 1990-01-01T00:00:00.000Z
+true               | 1990-02-01T00:00:00.000Z
+true               | 1990-03-01T00:00:00.000Z
+true               | 1990-06-01T00:00:00.000Z
+true               | 1990-08-01T00:00:00.000Z
+true               | 1990-09-01T00:00:00.000Z
+true               | 1990-12-01T00:00:00.000Z
+;

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

@@ -1452,7 +1452,12 @@ public class EsqlCapabilities {
         /**
          * Implicitly applies last_over_time in time-series aggregations when no specific over_time function is provided.
          */
-        IMPLICIT_LAST_OVER_TIME(Build.current().isSnapshot());
+        IMPLICIT_LAST_OVER_TIME(Build.current().isSnapshot()),
+
+        /**
+         * Support for the Present function
+         */
+        FN_PRESENT;
 
         private final boolean enabled;
 

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

@@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.expression.function.aggregate.MedianAbsolute
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Min;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.MinOverTime;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Percentile;
+import org.elasticsearch.xpack.esql.expression.function.aggregate.Present;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Rate;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.Sample;
 import org.elasticsearch.xpack.esql.expression.function.aggregate.SpatialCentroid;
@@ -341,7 +342,8 @@ public class EsqlFunctionRegistry {
                 def(Sum.class, uni(Sum::new), "sum"),
                 def(Top.class, tri(Top::new), "top"),
                 def(Values.class, uni(Values::new), "values"),
-                def(WeightedAvg.class, bi(WeightedAvg::new), "weighted_avg") },
+                def(WeightedAvg.class, bi(WeightedAvg::new), "weighted_avg"),
+                def(Present.class, uni(Present::new), "present") },
             // math
             new FunctionDefinition[] {
                 def(Abs.class, Abs::new, "abs"),

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

@@ -41,7 +41,8 @@ public class AggregateWritables {
             SumOverTime.ENTRY,
             CountOverTime.ENTRY,
             CountDistinctOverTime.ENTRY,
-            WeightedAvg.ENTRY
+            WeightedAvg.ENTRY,
+            Present.ENTRY
         );
     }
 }

+ 130 - 0
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Present.java

@@ -0,0 +1,130 @@
+/*
+ * 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 org.elasticsearch.common.io.stream.NamedWriteableRegistry;
+import org.elasticsearch.common.io.stream.StreamInput;
+import org.elasticsearch.compute.aggregation.AggregatorFunctionSupplier;
+import org.elasticsearch.compute.aggregation.PresentAggregatorFunction;
+import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.expression.Literal;
+import org.elasticsearch.xpack.esql.core.expression.Nullability;
+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.FunctionType;
+import org.elasticsearch.xpack.esql.expression.function.Param;
+import org.elasticsearch.xpack.esql.planner.ToAggregator;
+
+import java.io.IOException;
+import java.util.List;
+
+import static java.util.Collections.emptyList;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.ParamOrdinal.DEFAULT;
+import static org.elasticsearch.xpack.esql.core.expression.TypeResolutions.isType;
+
+/**
+ * The function that checks for the presence of a field in the output result.
+ * Presence means that the input expression yields any non-null value.
+ */
+public class Present extends AggregateFunction implements ToAggregator {
+    public static final NamedWriteableRegistry.Entry ENTRY = new NamedWriteableRegistry.Entry(Expression.class, "Present", Present::new);
+
+    @FunctionInfo(
+        returnType = "boolean",
+        description = "Returns true if the input expression yields any non-null values within the current aggregation context. "
+            + "Otherwise it returns false.",
+        type = FunctionType.AGGREGATE,
+        examples = {
+            @Example(file = "present", tag = "present"),
+            @Example(
+                description = "To check for the presence inside a group use `PRESENT()` and `BY` clauses",
+                file = "present",
+                tag = "present-by"
+            ) }
+    )
+    public Present(
+        Source source,
+        @Param(
+            name = "field",
+            type = {
+                "aggregate_metric_double",
+                "boolean",
+                "cartesian_point",
+                "cartesian_shape",
+                "date",
+                "date_nanos",
+                "double",
+                "geo_point",
+                "geo_shape",
+                "geohash",
+                "geotile",
+                "geohex",
+                "integer",
+                "ip",
+                "keyword",
+                "long",
+                "text",
+                "unsigned_long",
+                "version" },
+            description = "Expression that outputs values to be checked for presence."
+        ) Expression field
+    ) {
+        this(source, field, Literal.TRUE);
+    }
+
+    public Present(Source source, Expression field, Expression filter) {
+        super(source, field, filter, emptyList());
+    }
+
+    private Present(StreamInput in) throws IOException {
+        super(in);
+    }
+
+    @Override
+    public String getWriteableName() {
+        return ENTRY.name;
+    }
+
+    @Override
+    protected NodeInfo<Present> info() {
+        return NodeInfo.create(this, Present::new, field(), filter());
+    }
+
+    @Override
+    public AggregateFunction withFilter(Expression filter) {
+        return new Present(source(), field(), filter);
+    }
+
+    @Override
+    public Present replaceChildren(List<Expression> newChildren) {
+        return new Present(source(), newChildren.get(0), newChildren.get(1));
+    }
+
+    @Override
+    public DataType dataType() {
+        return DataType.BOOLEAN;
+    }
+
+    @Override
+    public AggregatorFunctionSupplier supplier() {
+        return PresentAggregatorFunction.supplier();
+    }
+
+    @Override
+    public Nullability nullable() {
+        return Nullability.FALSE;
+    }
+
+    @Override
+    protected TypeResolution resolveType() {
+        return isType(field(), dt -> dt.isCounter() == false, sourceText(), DEFAULT, "any type except counter types");
+    }
+}

+ 16 - 2
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java

@@ -1135,8 +1135,13 @@ public class VerifierTests extends ESTestCase {
 
     public void testGroupByCounter() {
         assertThat(
-            error("FROM tests | STATS count(*) BY network.bytes_in", tsdb),
-            equalTo("1:32: cannot group by on [counter_long] type for grouping [network.bytes_in]")
+            error("FROM test | STATS count(*) BY network.bytes_in", tsdb),
+            equalTo("1:31: cannot group by on [counter_long] type for grouping [network.bytes_in]")
+        );
+
+        assertThat(
+            error("FROM test | STATS present(name) BY network.bytes_in", tsdb),
+            equalTo("1:36: cannot group by on [counter_long] type for grouping [network.bytes_in]")
         );
     }
 
@@ -2129,6 +2134,10 @@ public class VerifierTests extends ESTestCase {
         );
         assertEquals("1:22: aggregate function [max(a)] not allowed outside STATS command", error("ROW a = 1 | SORT 1 + max(a)"));
         assertEquals("1:18: aggregate function [count(*)] not allowed outside STATS command", error("FROM test | SORT count(*)"));
+        assertEquals(
+            "1:18: aggregate function [present(gender)] not allowed outside STATS command",
+            error("FROM test | SORT present(gender)")
+        );
     }
 
     public void testFilterByAggregate() {
@@ -2142,6 +2151,10 @@ public class VerifierTests extends ESTestCase {
             "1:24: aggregate function [min(languages)] not allowed outside STATS command",
             error("FROM employees | WHERE min(languages) > 2")
         );
+        assertEquals(
+            "1:19: aggregate function [present(gender)] not allowed outside STATS command",
+            error("FROM test | WHERE present(gender)")
+        );
     }
 
     public void testDissectByAggregate() {
@@ -2169,6 +2182,7 @@ public class VerifierTests extends ESTestCase {
     public void testAggregateInRow() {
         assertEquals("1:13: aggregate function [count(*)] not allowed outside STATS command", error("ROW a = 1 + count(*)"));
         assertEquals("1:9: aggregate function [avg(2)] not allowed outside STATS command", error("ROW a = avg(2)"));
+        assertEquals("1:9: aggregate function [present(123)] not allowed outside STATS command", error("ROW a = present(123)"));
     }
 
     public void testLookupJoinDataTypeMismatch() {

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

@@ -0,0 +1,43 @@
+/*
+ * 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 org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.core.tree.Source;
+import org.elasticsearch.xpack.esql.core.type.DataType;
+import org.elasticsearch.xpack.esql.expression.function.ErrorsForCasesWithoutExamplesTestCase;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class PresentErrorTests extends ErrorsForCasesWithoutExamplesTestCase {
+    @Override
+    protected List<TestCaseSupplier> cases() {
+        return paramsToSuppliers(PresentTests.parameters());
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Present(source, args.get(0));
+    }
+
+    @Override
+    protected Matcher<String> expectedTypeErrorMatcher(List<Set<DataType>> validPerPosition, List<DataType> signature) {
+        assert false : "All checked types must work";
+        return null;
+    }
+
+    @Override
+    protected void assertNumberOfCheckedSignatures(int checked) {
+        assertThat(checked, equalTo(0));
+    }
+}

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

@@ -0,0 +1,24 @@
+/*
+ * 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 org.elasticsearch.xpack.esql.expression.AbstractExpressionSerializationTests;
+
+import java.io.IOException;
+
+public class PresentSerializationTests extends AbstractExpressionSerializationTests<Present> {
+    @Override
+    protected Present createTestInstance() {
+        return new Present(randomSource(), randomChild());
+    }
+
+    @Override
+    protected Present mutateInstance(Present instance) throws IOException {
+        return new Present(instance.source(), randomValueOtherThan(instance.field(), AbstractExpressionSerializationTests::randomChild));
+    }
+}

+ 111 - 0
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentTests.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.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.MultiRowTestCaseSupplier.IncludingAltitude;
+import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class PresentTests extends AbstractAggregationTestCase {
+    public PresentTests(@Name("TestCase") Supplier<TestCaseSupplier.TestCase> testCaseSupplier) {
+        this.testCase = testCaseSupplier.get();
+    }
+
+    @ParametersFactory
+    public static Iterable<Object[]> parameters() {
+        ArrayList<TestCaseSupplier> suppliers = new ArrayList<>();
+
+        Stream.of(
+            MultiRowTestCaseSupplier.nullCases(1, 1000),
+            MultiRowTestCaseSupplier.intCases(1, 1000, Integer.MIN_VALUE, Integer.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.longCases(1, 1000, Long.MIN_VALUE, Long.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.ulongCases(1, 1000, BigInteger.ZERO, UNSIGNED_LONG_MAX, true),
+            MultiRowTestCaseSupplier.doubleCases(1, 1000, -Double.MAX_VALUE, Double.MAX_VALUE, true),
+            MultiRowTestCaseSupplier.dateCases(1, 1000),
+            MultiRowTestCaseSupplier.dateNanosCases(1, 1000),
+            MultiRowTestCaseSupplier.booleanCases(1, 1000),
+            MultiRowTestCaseSupplier.ipCases(1, 1000),
+            MultiRowTestCaseSupplier.versionCases(1, 1000),
+            MultiRowTestCaseSupplier.geoPointCases(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.geoShapeCasesWithoutCircle(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.cartesianShapeCasesWithoutCircle(1, 1000, IncludingAltitude.YES),
+            MultiRowTestCaseSupplier.geohashCases(1, 1000),
+            MultiRowTestCaseSupplier.geotileCases(1, 1000),
+            MultiRowTestCaseSupplier.geohexCases(1, 1000),
+            MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.KEYWORD),
+            MultiRowTestCaseSupplier.stringCases(1, 1000, DataType.TEXT)
+        ).flatMap(List::stream).map(PresentTests::makeSupplier).collect(Collectors.toCollection(() -> suppliers));
+
+        // No rows
+        for (var dataType : List.of(
+            DataType.BOOLEAN,
+            DataType.CARTESIAN_POINT,
+            DataType.CARTESIAN_SHAPE,
+            DataType.DATE_NANOS,
+            DataType.DATETIME,
+            DataType.DATE_NANOS,
+            DataType.DOUBLE,
+            DataType.GEO_POINT,
+            DataType.GEO_SHAPE,
+            DataType.INTEGER,
+            DataType.IP,
+            DataType.KEYWORD,
+            DataType.LONG,
+            DataType.TEXT,
+            DataType.UNSIGNED_LONG,
+            DataType.VERSION
+        )) {
+            suppliers.add(
+                new TestCaseSupplier(
+                    "No rows (" + dataType + ")",
+                    List.of(dataType),
+                    () -> new TestCaseSupplier.TestCase(
+                        List.of(TestCaseSupplier.TypedData.multiRow(List.of(), dataType, "field")),
+                        "Present",
+                        DataType.BOOLEAN,
+                        equalTo(false)
+                    )
+                )
+            );
+        }
+
+        // "No rows" expects 0 here instead of null
+        return parameterSuppliersFromTypedData(randomizeBytesRefsOffset(suppliers));
+    }
+
+    @Override
+    protected Expression build(Source source, List<Expression> args) {
+        return new Present(source, args.getFirst());
+    }
+
+    private static TestCaseSupplier makeSupplier(TestCaseSupplier.TypedDataSupplier fieldSupplier) {
+        return new TestCaseSupplier(fieldSupplier.name(), List.of(fieldSupplier.type()), () -> {
+            TestCaseSupplier.TypedData fieldTypedData = fieldSupplier.get();
+            boolean present = fieldTypedData.multiRowData().stream().anyMatch(Objects::nonNull);
+
+            return new TestCaseSupplier.TestCase(List.of(fieldTypedData), "Present", DataType.BOOLEAN, equalTo(present));
+        });
+    }
+}

+ 1 - 1
x-pack/plugin/src/yamlRestTest/resources/rest-api-spec/test/esql/60_usage.yml

@@ -129,7 +129,7 @@ setup:
   - match: {esql.functions.coalesce: $functions_coalesce}
   - gt: {esql.functions.categorize: $functions_categorize}
   # Testing for the entire function set isn't feasible, so we just check that we return the correct count as an approximation.
-  - length: {esql.functions: 172} # check the "sister" test below for a likely update to the same esql.functions length check
+  - length: {esql.functions: 173} # check the "sister" test below for a likely update to the same esql.functions length check
 
 ---
 "Basic ESQL usage output (telemetry) non-snapshot version":