diff --git a/docs/reference/query-languages/esql/_snippets/functions/description/present.md b/docs/reference/query-languages/esql/_snippets/functions/description/present.md
new file mode 100644
index 0000000000000..64d0ea09e22ac
--- /dev/null
+++ b/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.
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/examples/present.md b/docs/reference/query-languages/esql/_snippets/functions/examples/present.md
new file mode 100644
index 0000000000000..45c27f180480a
--- /dev/null
+++ b/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 |
+
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/layout/present.md b/docs/reference/query-languages/esql/_snippets/functions/layout/present.md
new file mode 100644
index 0000000000000..6662a790982f3
--- /dev/null
+++ b/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
+:::
diff --git a/docs/reference/query-languages/esql/_snippets/functions/parameters/present.md b/docs/reference/query-languages/esql/_snippets/functions/parameters/present.md
new file mode 100644
index 0000000000000..26d9bb18c9fb5
--- /dev/null
+++ b/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.
+
diff --git a/docs/reference/query-languages/esql/_snippets/functions/types/present.md b/docs/reference/query-languages/esql/_snippets/functions/types/present.md
new file mode 100644
index 0000000000000..626f34b097399
--- /dev/null
+++ b/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 |
+
diff --git a/docs/reference/query-languages/esql/_snippets/lists/aggregation-functions.md b/docs/reference/query-languages/esql/_snippets/lists/aggregation-functions.md
index 3138a6f97ad69..867eb96d13339 100644
--- a/docs/reference/query-languages/esql/_snippets/lists/aggregation-functions.md
+++ b/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)
diff --git a/docs/reference/query-languages/esql/functions-operators/aggregation-functions.md b/docs/reference/query-languages/esql/functions-operators/aggregation-functions.md
index c067d46ec6890..07afc26ea6ed8 100644
--- a/docs/reference/query-languages/esql/functions-operators/aggregation-functions.md
+++ b/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
+:::
diff --git a/docs/reference/query-languages/esql/images/functions/present.svg b/docs/reference/query-languages/esql/images/functions/present.svg
new file mode 100644
index 0000000000000..fac5187f7c198
--- /dev/null
+++ b/docs/reference/query-languages/esql/images/functions/present.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/reference/query-languages/esql/kibana/definition/functions/present.json b/docs/reference/query-languages/esql/kibana/definition/functions/present.json
new file mode 100644
index 0000000000000..584843213c5a4
--- /dev/null
+++ b/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
+}
diff --git a/docs/reference/query-languages/esql/kibana/docs/functions/present.md b/docs/reference/query-languages/esql/kibana/docs/functions/present.md
new file mode 100644
index 0000000000000..7f314a3075862
--- /dev/null
+++ b/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)
+```
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunction.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunction.java
new file mode 100644
index 0000000000000..44ffde34238d1
--- /dev/null
+++ b/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 nonGroupingIntermediateStateDesc() {
+ return PresentAggregatorFunction.intermediateStateDesc();
+ }
+
+ @Override
+ public List groupingIntermediateStateDesc() {
+ return PresentGroupingAggregatorFunction.intermediateStateDesc();
+ }
+
+ @Override
+ public AggregatorFunction aggregator(DriverContext driverContext, List channels) {
+ return PresentAggregatorFunction.create(channels);
+ }
+
+ @Override
+ public GroupingAggregatorFunction groupingAggregator(DriverContext driverContext, List channels) {
+ return PresentGroupingAggregatorFunction.create(driverContext, channels);
+ }
+
+ @Override
+ public String describe() {
+ return "present";
+ }
+ };
+ }
+
+ private static final List INTERMEDIATE_STATE_DESC = List.of(
+ new IntermediateStateDesc("present", ElementType.BOOLEAN)
+ );
+
+ public static List intermediateStateDesc() {
+ return INTERMEDIATE_STATE_DESC;
+ }
+
+ private final List channels;
+
+ private boolean state;
+
+ public static PresentAggregatorFunction create(List inputChannels) {
+ return new PresentAggregatorFunction(inputChannels, false);
+ }
+
+ private PresentAggregatorFunction(List 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.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() {}
+}
diff --git a/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunction.java b/x-pack/plugin/esql/compute/src/main/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunction.java
new file mode 100644
index 0000000000000..3b09e8085828a
--- /dev/null
+++ b/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 INTERMEDIATE_STATE_DESC = List.of(
+ new IntermediateStateDesc("present", ElementType.BOOLEAN)
+ );
+
+ private final BitArray state;
+ private final List channels;
+ private final DriverContext driverContext;
+
+ public static PresentGroupingAggregatorFunction create(DriverContext driverContext, List inputChannels) {
+ return new PresentGroupingAggregatorFunction(inputChannels, new BitArray(1, driverContext.bigArrays()), driverContext);
+ }
+
+ public static List intermediateStateDesc() {
+ return INTERMEDIATE_STATE_DESC;
+ }
+
+ private PresentGroupingAggregatorFunction(List 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.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.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.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();
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunctionTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentAggregatorFunctionTests.java
new file mode 100644
index 0000000000000..979583d69fc8d
--- /dev/null
+++ b/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 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))));
+ }
+}
diff --git a/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunctionTests.java b/x-pack/plugin/esql/compute/src/test/java/org/elasticsearch/compute/aggregation/PresentGroupingAggregatorFunctionTests.java
new file mode 100644
index 0000000000000..f58a8933be3fe
--- /dev/null
+++ b/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 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));
+ }
+ }
+}
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/present.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/present.csv-spec
new file mode 100644
index 0000000000000..42b206fdbcb66
--- /dev/null
+++ b/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
+;
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec
index ddd57a30fc808..6802874d36ad8 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/union_types.csv-spec
+++ b/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
+;
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
index 9132e59e2257a..d9c096ab8a8c7 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
@@ -1452,7 +1452,12 @@ public enum Cap {
/**
* 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;
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
index 08c17be59ba8c..c94d93c0e9be8 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/EsqlFunctionRegistry.java
+++ b/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.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 @@ private static FunctionDefinition[][] functions() {
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"),
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java
index 20979f0748ac4..bc2ddc90591ef 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/AggregateWritables.java
@@ -41,7 +41,8 @@ public static List getNamedWriteables() {
SumOverTime.ENTRY,
CountOverTime.ENTRY,
CountDistinctOverTime.ENTRY,
- WeightedAvg.ENTRY
+ WeightedAvg.ENTRY,
+ Present.ENTRY
);
}
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Present.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/aggregate/Present.java
new file mode 100644
index 0000000000000..7f3ef8f47b917
--- /dev/null
+++ b/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 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 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");
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
index 6288cc9c23d87..1805388d49fd0 100644
--- a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
+++ b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/analysis/VerifierTests.java
@@ -1135,8 +1135,13 @@ public void testAggregateOnCounter() {
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 void testSortByAggregate() {
);
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 void testFilterByAggregate() {
"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 void testGrokByAggregate() {
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() {
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentErrorTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentErrorTests.java
new file mode 100644
index 0000000000000..b394fa803f7ce
--- /dev/null
+++ b/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 cases() {
+ return paramsToSuppliers(PresentTests.parameters());
+ }
+
+ @Override
+ protected Expression build(Source source, List args) {
+ return new Present(source, args.get(0));
+ }
+
+ @Override
+ protected Matcher expectedTypeErrorMatcher(List> validPerPosition, List signature) {
+ assert false : "All checked types must work";
+ return null;
+ }
+
+ @Override
+ protected void assertNumberOfCheckedSignatures(int checked) {
+ assertThat(checked, equalTo(0));
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentSerializationTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentSerializationTests.java
new file mode 100644
index 0000000000000..ab221e43861f6
--- /dev/null
+++ b/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 {
+ @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));
+ }
+}
diff --git a/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentTests.java b/x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/aggregate/PresentTests.java
new file mode 100644
index 0000000000000..8e19759a1b790
--- /dev/null
+++ b/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) {
+ this.testCase = testCaseSupplier.get();
+ }
+
+ @ParametersFactory
+ public static Iterable