From 64b2c0599aca3bc320c70f4742fd173a2711332a Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 20 Aug 2025 17:07:32 -0700
Subject: [PATCH 01/26] Add earliest/latest aggregate function to PPL

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../calcite/udf/udaf/MaxByAggFunction.java    |  78 +++++
 .../calcite/udf/udaf/MinByAggFunction.java    |  78 +++++
 .../function/BuiltinFunctionName.java         |  10 +-
 .../expression/function/PPLFuncImpTable.java  | 297 ++++++------------
 .../udf/udaf/MaxByMinByAggFunctionTest.java   | 107 +++++++
 .../function/AggFunctionTestBase.java         |  92 ++++++
 .../EarliestLatestAggFunctionTest.java        |  93 ++++++
 ppl/src/main/antlr/OpenSearchPPLParser.g4     |   2 +-
 .../sql/ppl/parser/AstExpressionBuilder.java  | 100 +++++-
 .../CalcitePPLAggregationTestBase.java        |  41 +++
 .../calcite/CalcitePPLEarliestLatestTest.java | 257 +++++++++++++++
 11 files changed, 929 insertions(+), 226 deletions(-)
 create mode 100644 core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
 create mode 100644 core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
 create mode 100644 core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
 create mode 100644 core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
 create mode 100644 core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
 create mode 100644 ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
 create mode 100644 ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java

diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
new file mode 100644
index 00000000000..0bea7cd5c9b
--- /dev/null
+++ b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.calcite.udf.udaf;
+
+import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
+
+/**
+ * MAX_BY aggregate function implementation.
+ * Returns the value from the first argument corresponding to the maximum value of the second argument.
+ * Follows Spark SQL semantics: MAX_BY(value_field, order_field)
+ */
+public class MaxByAggFunction implements UserDefinedAggFunction<MaxByAggFunction.MaxByAccumulator> {
+
+  @Override
+  public MaxByAccumulator init() {
+    return new MaxByAccumulator();
+  }
+
+  @Override
+  public Object result(MaxByAccumulator accumulator) {
+    return accumulator.value();
+  }
+
+  @Override
+  public MaxByAccumulator add(MaxByAccumulator acc, Object... values) {
+    if (values.length < 2) {
+      throw new IllegalArgumentException("MAX_BY requires exactly 2 arguments: value_field and order_field");
+    }
+    
+    Object candidateValue = values[0];
+    Object candidateOrderValue = values[1];
+    
+    // Skip null order values
+    if (candidateOrderValue == null) {
+      return acc;
+    }
+    
+    acc.update(candidateValue, candidateOrderValue);
+    return acc;
+  }
+
+  public static class MaxByAccumulator implements UserDefinedAggFunction.Accumulator {
+    private Object maxValue;
+    private Comparable<Object> maxOrderValue;
+
+    public MaxByAccumulator() {
+      this.maxValue = null;
+      this.maxOrderValue = null;
+    }
+
+    @Override
+    public Object value(Object... argList) {
+      return maxValue;
+    }
+
+    @SuppressWarnings("unchecked")
+    public void update(Object value, Object orderValue) {
+      if (orderValue == null) {
+        return;
+      }
+      
+      try {
+        Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
+        
+        if (maxOrderValue == null || comparableOrderValue.compareTo(maxOrderValue) > 0) {
+          maxValue = value;
+          maxOrderValue = comparableOrderValue;
+        }
+      } catch (ClassCastException e) {
+        throw new IllegalArgumentException(
+            "MAX_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(), e);
+      }
+    }
+  }
+}
diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
new file mode 100644
index 00000000000..fb6480a39f8
--- /dev/null
+++ b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.calcite.udf.udaf;
+
+import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
+
+/**
+ * MIN_BY aggregate function implementation.
+ * Returns the value from the first argument corresponding to the minimum value of the second argument.
+ * Follows Spark SQL semantics: MIN_BY(value_field, order_field)
+ */
+public class MinByAggFunction implements UserDefinedAggFunction<MinByAggFunction.MinByAccumulator> {
+
+  @Override
+  public MinByAccumulator init() {
+    return new MinByAccumulator();
+  }
+
+  @Override
+  public Object result(MinByAccumulator accumulator) {
+    return accumulator.value();
+  }
+
+  @Override
+  public MinByAccumulator add(MinByAccumulator acc, Object... values) {
+    if (values.length < 2) {
+      throw new IllegalArgumentException("MIN_BY requires exactly 2 arguments: value_field and order_field");
+    }
+    
+    Object candidateValue = values[0];
+    Object candidateOrderValue = values[1];
+    
+    // Skip null order values
+    if (candidateOrderValue == null) {
+      return acc;
+    }
+    
+    acc.update(candidateValue, candidateOrderValue);
+    return acc;
+  }
+
+  public static class MinByAccumulator implements UserDefinedAggFunction.Accumulator {
+    private Object minValue;
+    private Comparable<Object> minOrderValue;
+
+    public MinByAccumulator() {
+      this.minValue = null;
+      this.minOrderValue = null;
+    }
+
+    @Override
+    public Object value(Object... argList) {
+      return minValue;
+    }
+
+    @SuppressWarnings("unchecked")
+    public void update(Object value, Object orderValue) {
+      if (orderValue == null) {
+        return;
+      }
+      
+      try {
+        Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
+        
+        if (minOrderValue == null || comparableOrderValue.compareTo(minOrderValue) < 0) {
+          minValue = value;
+          minOrderValue = comparableOrderValue;
+        }
+      } catch (ClassCastException e) {
+        throw new IllegalArgumentException(
+            "MIN_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(), e);
+      }
+    }
+  }
+}
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
index e3f5a32d3e6..55255d1ad58 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
@@ -5,11 +5,13 @@
 
 package org.opensearch.sql.expression.function;
 
-import com.google.common.collect.ImmutableMap;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+
+import com.google.common.collect.ImmutableMap;
+
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
@@ -200,6 +202,8 @@ public enum BuiltinFunctionName {
   PERCENTILE_APPROX(FunctionName.of("percentile_approx")),
   EARLIEST(FunctionName.of("earliest")),
   LATEST(FunctionName.of("latest")),
+  MAX_BY(FunctionName.of("max_by")),
+  MIN_BY(FunctionName.of("min_by")),
   DISTINCT_COUNT_APPROX(FunctionName.of("distinct_count_approx")),
   // Not always an aggregation query
   NESTED(FunctionName.of("nested")),
@@ -344,8 +348,8 @@ public enum BuiltinFunctionName {
           .put("take", BuiltinFunctionName.TAKE)
           .put("percentile", BuiltinFunctionName.PERCENTILE_APPROX)
           .put("percentile_approx", BuiltinFunctionName.PERCENTILE_APPROX)
-          // .put("earliest", BuiltinFunctionName.EARLIEST)
-          // .put("latest", BuiltinFunctionName.LATEST)
+          .put("earliest", BuiltinFunctionName.EARLIEST)
+          .put("latest", BuiltinFunctionName.LATEST)
           .put("distinct_count_approx", BuiltinFunctionName.DISTINCT_COUNT_APPROX)
           .put("pattern", BuiltinFunctionName.INTERNAL_PATTERN)
           .build();
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index 6e2ac15b5b8..c228a35c86f 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -13,215 +13,8 @@
 import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.TYPE_FACTORY;
 import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.getLegacyTypeName;
 import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.createAggregateFunction;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ABS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ACOS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADD;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADDDATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADDFUNCTION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADDTIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.AND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ARRAY_LENGTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ASCII;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ASIN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ATAN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ATAN2;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.AVG;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CBRT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CEIL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CEILING;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CIDRMATCH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.COALESCE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CONCAT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CONCAT_WS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CONV;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CONVERT_TZ;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.COS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.COSH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.COT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.COUNT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CRC32;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CURDATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CURRENT_DATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CURRENT_TIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CURRENT_TIMESTAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.CURTIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATEDIFF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATETIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_ADD;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_FORMAT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DATE_SUB;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAYNAME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAYOFMONTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAYOFWEEK;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAYOFYEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAY_OF_MONTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAY_OF_WEEK;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DAY_OF_YEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DEGREES;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DIVIDE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.DIVIDEFUNCTION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.E;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EARLIEST;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EQUAL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EXISTS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EXP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EXPM1;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.EXTRACT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.FILTER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.FLOOR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.FORALL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.FROM_DAYS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.FROM_UNIXTIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.GET_FORMAT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.GREATER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.GTE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.HOUR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.HOUR_OF_DAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IFNULL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_GROK;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_ITEM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_PATTERN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_PATTERN_PARSER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_REGEXP_EXTRACT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.INTERNAL_REGEXP_REPLACE_3;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_BLANK;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_EMPTY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_NOT_NULL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_NULL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.IS_PRESENT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_APPEND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_ARRAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_ARRAY_LENGTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_DELETE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_EXTEND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_EXTRACT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_KEYS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_OBJECT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_SET;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.JSON_VALID;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LAST_DAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LATEST;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LEFT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LENGTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LESS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LIKE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOCALTIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOCALTIMESTAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOCATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOG;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOG10;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOG2;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LOWER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LTE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.LTRIM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MAKEDATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MAKETIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MATCH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MATCH_BOOL_PREFIX;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MATCH_PHRASE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MATCH_PHRASE_PREFIX;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MAX;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MD5;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MICROSECOND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MIN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MINUTE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MINUTE_OF_DAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MINUTE_OF_HOUR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MOD;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MODULUS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MODULUSFUNCTION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MONTH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MONTHNAME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MONTH_OF_YEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIPLY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTIPLYFUNCTION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.MULTI_MATCH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.NOT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.NOTEQUAL;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.NOW;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.NULLIF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.OR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.PERCENTILE_APPROX;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.PERIOD_ADD;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.PERIOD_DIFF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.PI;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.POSITION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.POW;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.POWER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.QUARTER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.QUERY_STRING;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.RADIANS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.RAND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.REDUCE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.REGEXP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.REGEX_MATCH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.REPLACE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.REVERSE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.RIGHT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.RINT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.ROUND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.RTRIM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SECOND;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SECOND_OF_MINUTE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SEC_TO_TIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SHA1;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SHA2;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SIGN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SIGNUM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SIMPLE_QUERY_STRING;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SIN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SINH;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SPAN;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SQRT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.STDDEV_POP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.STDDEV_SAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.STRCMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.STR_TO_DATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBDATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBSTR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBSTRING;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBTIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBTRACT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBTRACTFUNCTION;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.SYSDATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TAKE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMEDIFF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMESTAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMESTAMPADD;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIMESTAMPDIFF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIME_FORMAT;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TIME_TO_SEC;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TO_DAYS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TO_SECONDS;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TRANSFORM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TRIM;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TRUNCATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.TYPEOF;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.UNIX_TIMESTAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.UPPER;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.UTC_DATE;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.UTC_TIME;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.UTC_TIMESTAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.VARPOP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.VARSAMP;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.WEEK;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.WEEKDAY;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.WEEKOFYEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.WEEK_OF_YEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.XOR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.YEAR;
-import static org.opensearch.sql.expression.function.BuiltinFunctionName.YEARWEEK;
+import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -234,7 +27,9 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+
 import javax.annotation.Nullable;
+
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLambda;
@@ -260,6 +55,8 @@
 import org.apache.logging.log4j.Logger;
 import org.opensearch.sql.calcite.CalcitePlanContext;
 import org.opensearch.sql.calcite.udf.udaf.LogPatternAggFunction;
+import org.opensearch.sql.calcite.udf.udaf.MaxByAggFunction;
+import org.opensearch.sql.calcite.udf.udaf.MinByAggFunction;
 import org.opensearch.sql.calcite.udf.udaf.PercentileApproxFunction;
 import org.opensearch.sql.calcite.udf.udaf.TakeAggFunction;
 import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory;
@@ -268,6 +65,9 @@
 import org.opensearch.sql.exception.ExpressionEvaluationException;
 import org.opensearch.sql.executor.QueryType;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
 public class PPLFuncImpTable {
   private static final Logger logger = LogManager.getLogger(PPLFuncImpTable.class);
 
@@ -1064,6 +864,21 @@ void registerOperator(BuiltinFunctionName functionName, SqlAggFunction aggFuncti
       register(functionName, handler, typeChecker);
     }
 
+    private static RexNode resolveTimeField(List<RexNode> argList, CalcitePlanContext ctx) {
+      if (argList.isEmpty()) {
+        try {
+          return ctx.rexBuilder.makeInputRef(
+              ctx.relBuilder.peek().getRowType().getField("@timestamp", false, false).getType(),
+              ctx.relBuilder.peek().getRowType().getField("@timestamp", false, false).getIndex());
+        } catch (Exception e) {
+          throw new IllegalArgumentException(
+              "Default @timestamp field not found. Please specify a time field explicitly.", e);
+        }
+      } else {
+        return PlanUtils.derefMapCall(argList.get(0));
+      }
+    }
+
     void populate() {
       registerOperator(MAX, SqlStdOperatorTable.MAX);
       registerOperator(MIN, SqlStdOperatorTable.MIN);
@@ -1160,6 +975,72 @@ void populate() {
                   argList,
                   ctx.relBuilder),
           null);
+
+      register(
+          MAX_BY,
+          (distinct, field, argList, ctx) -> {
+            if (argList.isEmpty()) {
+              throw new IllegalArgumentException("MAX_BY requires exactly 2 arguments: value_field and order_field");
+            }
+            List<RexNode> newArgList =
+                argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
+            return createAggregateFunction(
+                MaxByAggFunction.class,
+                "MAX_BY",
+                ReturnTypes.ARG0_FORCE_NULLABLE,
+                List.of(field),
+                newArgList,
+                ctx.relBuilder);
+          },
+          null);
+
+      register(
+          MIN_BY,
+          (distinct, field, argList, ctx) -> {
+            if (argList.isEmpty()) {
+              throw new IllegalArgumentException("MIN_BY requires exactly 2 arguments: value_field and order_field");
+            }
+            List<RexNode> newArgList =
+                argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
+            return createAggregateFunction(
+                MinByAggFunction.class,
+                "MIN_BY",
+                ReturnTypes.ARG0_FORCE_NULLABLE,
+                List.of(field),
+                newArgList,
+                ctx.relBuilder);
+          },
+          null);
+
+      register(
+          EARLIEST,
+          (distinct, field, argList, ctx) -> {
+            RexNode timeField = resolveTimeField(argList, ctx);
+            
+            return createAggregateFunction(
+                MinByAggFunction.class,
+                "MIN_BY",
+                ReturnTypes.ARG0_FORCE_NULLABLE,
+                List.of(field),
+                List.of(timeField),
+                ctx.relBuilder);
+          },
+          null);
+
+      register(
+          LATEST,
+          (distinct, field, argList, ctx) -> {
+            RexNode timeField = resolveTimeField(argList, ctx);
+            
+            return createAggregateFunction(
+                MaxByAggFunction.class,
+                "MAX_BY",
+                ReturnTypes.ARG0_FORCE_NULLABLE,
+                List.of(field),
+                List.of(timeField),
+                ctx.relBuilder);
+          },
+          null);
     }
   }
 
diff --git a/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
new file mode 100644
index 00000000000..d9d3352b02f
--- /dev/null
+++ b/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.calcite.udf.udaf;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+public class MaxByMinByAggFunctionTest {
+
+  @Test
+  public void testMaxByAggFunction() {
+    MaxByAggFunction maxByFunction = new MaxByAggFunction();
+    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
+
+    maxByFunction.add(accumulator, "value1", 10);
+    maxByFunction.add(accumulator, "value2", 20);
+    maxByFunction.add(accumulator, "value3", 15);
+
+    assertEquals("value2", maxByFunction.result(accumulator));
+  }
+
+  @Test
+  public void testMaxByWithNullOrderValue() {
+    MaxByAggFunction maxByFunction = new MaxByAggFunction();
+    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
+
+    maxByFunction.add(accumulator, "value1", null);
+    maxByFunction.add(accumulator, "value2", 20);
+    maxByFunction.add(accumulator, "value3", null);
+
+    assertEquals("value2", maxByFunction.result(accumulator));
+  }
+
+  @Test
+  public void testMaxByWithInsufficientArguments() {
+    MaxByAggFunction maxByFunction = new MaxByAggFunction();
+    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
+
+    assertThrows(IllegalArgumentException.class, () -> {
+      maxByFunction.add(accumulator, "value1");
+    });
+  }
+
+  @Test
+  public void testMinByAggFunction() {
+    MinByAggFunction minByFunction = new MinByAggFunction();
+    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
+
+    minByFunction.add(accumulator, "value1", 10);
+    minByFunction.add(accumulator, "value2", 5);
+    minByFunction.add(accumulator, "value3", 15);
+
+    assertEquals("value2", minByFunction.result(accumulator));
+  }
+
+  @Test
+  public void testMinByWithNullOrderValue() {
+    MinByAggFunction minByFunction = new MinByAggFunction();
+    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
+
+    minByFunction.add(accumulator, "value1", null);
+    minByFunction.add(accumulator, "value2", 5);
+    minByFunction.add(accumulator, "value3", null);
+
+    assertEquals("value2", minByFunction.result(accumulator));
+  }
+
+  @Test
+  public void testMinByWithInsufficientArguments() {
+    MinByAggFunction minByFunction = new MinByAggFunction();
+    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
+
+    assertThrows(IllegalArgumentException.class, () -> {
+      minByFunction.add(accumulator, "value1");
+    });
+  }
+
+  @Test
+  public void testEmptyAccumulator() {
+    MaxByAggFunction maxByFunction = new MaxByAggFunction();
+    MaxByAggFunction.MaxByAccumulator maxAccumulator = maxByFunction.init();
+    
+    MinByAggFunction minByFunction = new MinByAggFunction();
+    MinByAggFunction.MinByAccumulator minAccumulator = minByFunction.init();
+
+    assertNull(maxByFunction.result(maxAccumulator));
+    assertNull(minByFunction.result(minAccumulator));
+  }
+
+  @Test
+  public void testWithStringOrderValues() {
+    MaxByAggFunction maxByFunction = new MaxByAggFunction();
+    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
+
+    maxByFunction.add(accumulator, "apple", "zebra");
+    maxByFunction.add(accumulator, "banana", "alpha");
+    maxByFunction.add(accumulator, "cherry", "beta");
+
+    assertEquals("apple", maxByFunction.result(accumulator));
+  }
+}
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java b/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
new file mode 100644
index 00000000000..78ce4539838
--- /dev/null
+++ b/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.expression.function;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.Set;
+
+import org.opensearch.sql.expression.function.PPLFuncImpTable.AggHandler;
+
+public abstract class AggFunctionTestBase {
+
+  @SuppressWarnings("unchecked")
+  protected Map<BuiltinFunctionName, AggHandler> getAggFunctionRegistry() {
+    try {
+      PPLFuncImpTable funcTable = PPLFuncImpTable.INSTANCE;
+      Field field = PPLFuncImpTable.class.getDeclaredField("aggFunctionRegistry");
+      field.setAccessible(true);
+      return (Map<BuiltinFunctionName, AggHandler>) field.get(funcTable);
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to access aggFunctionRegistry", e);
+    }
+  }
+
+  protected void assertFunctionIsRegistered(BuiltinFunctionName functionName) {
+    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
+    assertTrue(registry.containsKey(functionName), 
+        functionName.getName().getFunctionName() + " function should be registered in aggregate function registry");
+    assertNotNull(registry.get(functionName), 
+        functionName.getName().getFunctionName() + " function handler should not be null");
+  }
+
+  protected void assertFunctionsAreRegistered(BuiltinFunctionName... functionNames) {
+    for (BuiltinFunctionName functionName : functionNames) {
+      assertFunctionIsRegistered(functionName);
+    }
+  }
+
+
+  protected void assertFunctionHandlerTypes(BuiltinFunctionName... functionNames) {
+    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
+    for (BuiltinFunctionName functionName : functionNames) {
+      AggHandler handler = registry.get(functionName);
+      assertNotNull(handler, functionName.getName().getFunctionName() + " handler should not be null");
+      assertTrue(handler instanceof AggHandler, 
+          functionName.getName().getFunctionName() + " handler should implement AggHandler interface");
+    }
+  }
+
+  protected void assertRegistryMinimumSize(int expectedMinimumSize) {
+    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
+    assertTrue(registry.size() >= expectedMinimumSize, 
+        "Registry should contain at least " + expectedMinimumSize + " aggregate functions");
+  }
+
+  protected void assertKnownFunctionsPresent(Set<BuiltinFunctionName> knownFunctions) {
+    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
+    long foundFunctions = registry.keySet().stream()
+        .filter(knownFunctions::contains)
+        .count();
+    
+    assertTrue(foundFunctions >= knownFunctions.size(), 
+        "Should have at least " + knownFunctions.size() + " known aggregate functions registered");
+  }
+
+  protected void assertFunctionNameResolution(String functionName, BuiltinFunctionName expectedEnum) {
+    assertTrue(BuiltinFunctionName.of(functionName).isPresent(), 
+        "Should be able to resolve '" + functionName + "' function name");
+    assertTrue(BuiltinFunctionName.of(functionName).get() == expectedEnum,
+        "Resolved function should match expected enum value");
+  }
+
+  protected void assertFunctionNamesInEnum(BuiltinFunctionName... functionNames) {
+    Set<BuiltinFunctionName> enumValues = Set.of(BuiltinFunctionName.values());
+    
+    for (BuiltinFunctionName functionName : functionNames) {
+      assertTrue(enumValues.contains(functionName), 
+          functionName.getName().getFunctionName() + " should be defined in BuiltinFunctionName enum");
+    }
+  }
+
+  protected void assertFunctionNameMapping(BuiltinFunctionName functionEnum, String expectedName) {
+    assertTrue(functionEnum.getName().getFunctionName().equals(expectedName),
+        "Function enum should map to expected name: " + expectedName);
+  }
+}
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
new file mode 100644
index 00000000000..7566731401c
--- /dev/null
+++ b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.expression.function;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+public class EarliestLatestAggFunctionTest extends AggFunctionTestBase {
+
+  @Test
+  void testEarliestFunctionIsRegistered() {
+    assertFunctionIsRegistered(BuiltinFunctionName.EARLIEST);
+  }
+
+  @Test
+  void testLatestFunctionIsRegistered() {
+    assertFunctionIsRegistered(BuiltinFunctionName.LATEST);
+  }
+
+  @Test
+  void testBuiltinFunctionNameMapping() {
+    assertFunctionNameMapping(BuiltinFunctionName.EARLIEST, "earliest");
+    assertFunctionNameMapping(BuiltinFunctionName.LATEST, "latest");
+  }
+
+  @Test
+  void testFunctionNameResolution() {
+    assertFunctionNameResolution("earliest", BuiltinFunctionName.EARLIEST);
+    assertFunctionNameResolution("latest", BuiltinFunctionName.LATEST);
+  }
+
+  @Test
+  void testResolveAggWithValidFunctions() {
+    try {
+      java.lang.reflect.Method method = PPLFuncImpTable.class.getDeclaredMethod(
+          "resolveAgg", BuiltinFunctionName.class, boolean.class, 
+          org.apache.calcite.rex.RexNode.class, java.util.List.class, 
+          org.opensearch.sql.calcite.CalcitePlanContext.class);
+      
+      assertNotNull(method, "resolveAgg method should exist");
+    } catch (NoSuchMethodException e) {
+      throw new RuntimeException("resolveAgg method not found", e);
+    }
+  }
+
+  @Test
+  void testFunctionRegistryIntegrity() {
+    assertFunctionsAreRegistered(
+        BuiltinFunctionName.MAX_BY,
+        BuiltinFunctionName.MIN_BY,
+        BuiltinFunctionName.EARLIEST,
+        BuiltinFunctionName.LATEST
+    );
+  }
+
+  @Test
+  void testFunctionHandlerTypes() {
+    assertFunctionHandlerTypes(
+        BuiltinFunctionName.EARLIEST,
+        BuiltinFunctionName.LATEST
+    );
+  }
+
+  @Test
+  void testFunctionRegistrySize() {
+    assertRegistryMinimumSize(10);
+    
+    Set<BuiltinFunctionName> knownFunctions = Set.of(
+        BuiltinFunctionName.COUNT,
+        BuiltinFunctionName.SUM,
+        BuiltinFunctionName.AVG,
+        BuiltinFunctionName.MAX,
+        BuiltinFunctionName.MIN,
+        BuiltinFunctionName.MAX_BY,
+        BuiltinFunctionName.MIN_BY,
+        BuiltinFunctionName.EARLIEST,
+        BuiltinFunctionName.LATEST
+    );
+    
+    assertKnownFunctionsPresent(knownFunctions);
+  }
+
+  @Test
+  void testEarliestLatestFunctionNamesInEnum() {
+    assertFunctionNamesInEnum(BuiltinFunctionName.EARLIEST, BuiltinFunctionName.LATEST);
+  }
+}
diff --git a/ppl/src/main/antlr/OpenSearchPPLParser.g4 b/ppl/src/main/antlr/OpenSearchPPLParser.g4
index 56db568ba80..5def5a9a007 100644
--- a/ppl/src/main/antlr/OpenSearchPPLParser.g4
+++ b/ppl/src/main/antlr/OpenSearchPPLParser.g4
@@ -458,7 +458,7 @@ statsAggTerm
 
 // aggregation functions
 statsFunction
-   : statsFunctionName LT_PRTHS valueExpression RT_PRTHS        # statsFunctionCall
+   : statsFunctionName LT_PRTHS valueExpression (COMMA valueExpression)? RT_PRTHS        # statsFunctionCall
    | COUNT LT_PRTHS RT_PRTHS                                    # countAllFunctionCall
    | PERCENTILE_SHORTCUT LT_PRTHS valueExpression RT_PRTHS      # percentileShortcutFunctionCall
    | (DISTINCT_COUNT | DC | DISTINCT_COUNT_APPROX) LT_PRTHS valueExpression RT_PRTHS    # distinctCountFunctionCall
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index 63793a0d429..a36aee28aef 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -19,10 +19,39 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
+
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.antlr.v4.runtime.RuleContext;
 import org.opensearch.sql.ast.dsl.AstDSL;
-import org.opensearch.sql.ast.expression.*;
+import org.opensearch.sql.ast.expression.AggregateFunction;
+import org.opensearch.sql.ast.expression.Alias;
+import org.opensearch.sql.ast.expression.AllFields;
+import org.opensearch.sql.ast.expression.And;
+import org.opensearch.sql.ast.expression.Between;
+import org.opensearch.sql.ast.expression.Case;
+import org.opensearch.sql.ast.expression.Cast;
+import org.opensearch.sql.ast.expression.Compare;
+import org.opensearch.sql.ast.expression.DataType;
+import org.opensearch.sql.ast.expression.EqualTo;
+import org.opensearch.sql.ast.expression.Field;
+import org.opensearch.sql.ast.expression.Function;
+import org.opensearch.sql.ast.expression.In;
+import org.opensearch.sql.ast.expression.Interval;
+import org.opensearch.sql.ast.expression.IntervalUnit;
+import org.opensearch.sql.ast.expression.LambdaFunction;
+import org.opensearch.sql.ast.expression.Let;
+import org.opensearch.sql.ast.expression.Literal;
+import org.opensearch.sql.ast.expression.Not;
+import org.opensearch.sql.ast.expression.Or;
+import org.opensearch.sql.ast.expression.QualifiedName;
+import org.opensearch.sql.ast.expression.RelevanceFieldList;
+import org.opensearch.sql.ast.expression.Span;
+import org.opensearch.sql.ast.expression.SpanUnit;
+import org.opensearch.sql.ast.expression.UnresolvedArgument;
+import org.opensearch.sql.ast.expression.UnresolvedExpression;
+import org.opensearch.sql.ast.expression.When;
+import org.opensearch.sql.ast.expression.WindowFunction;
+import org.opensearch.sql.ast.expression.Xor;
 import org.opensearch.sql.ast.expression.subquery.ExistsSubquery;
 import org.opensearch.sql.ast.expression.subquery.InSubquery;
 import org.opensearch.sql.ast.expression.subquery.ScalarSubquery;
@@ -83,13 +112,11 @@ public AstExpressionBuilder(AstBuilder astBuilder) {
     this.astBuilder = astBuilder;
   }
 
-  /** Eval clause. */
   @Override
   public UnresolvedExpression visitEvalClause(EvalClauseContext ctx) {
     return new Let((Field) visit(ctx.fieldExpression()), visit(ctx.logicalExpression()));
   }
 
-  /** Trendline clause. */
   @Override
   public Trendline.TrendlineComputation visitTrendlineClause(
       OpenSearchPPLParser.TrendlineClauseContext ctx) {
@@ -111,7 +138,6 @@ public Trendline.TrendlineComputation visitTrendlineClause(
         numberOfDataPoints, dataField, alias, computationType);
   }
 
-  /** Logical expression excluding boolean, comparison. */
   @Override
   public UnresolvedExpression visitLogicalNot(LogicalNotContext ctx) {
     return new Not(visit(ctx.logicalExpression()));
@@ -132,7 +158,6 @@ public UnresolvedExpression visitLogicalXor(LogicalXorContext ctx) {
     return new Xor(visit(ctx.left), visit(ctx.right));
   }
 
-  /** lambda expression */
   @Override
   public UnresolvedExpression visitLambda(OpenSearchPPLParser.LambdaContext ctx) {
     List<QualifiedName> arguments =
@@ -143,7 +168,6 @@ public UnresolvedExpression visitLambda(OpenSearchPPLParser.LambdaContext ctx) {
     return new LambdaFunction(function, arguments);
   }
 
-  /** Comparison expression. */
   @Override
   public UnresolvedExpression visitCompareExpr(CompareExprContext ctx) {
     String operator = ctx.comparisonOperator().getText();
@@ -166,7 +190,6 @@ public UnresolvedExpression visitInExpr(InExprContext ctx) {
     return ctx.NOT() != null ? new Not(expr) : expr;
   }
 
-  /** Value Expression. */
   @Override
   public UnresolvedExpression visitBinaryArithmetic(BinaryArithmeticContext ctx) {
     return new Function(ctx.binaryOperator.getText(), buildArguments(ctx.left, ctx.right));
@@ -184,10 +207,9 @@ private List<UnresolvedExpression> buildArguments(
 
   @Override
   public UnresolvedExpression visitNestedValueExpr(OpenSearchPPLParser.NestedValueExprContext ctx) {
-    return visit(ctx.logicalExpression()); // Discard parenthesis around
+    return visit(ctx.logicalExpression());
   }
 
-  /** Field expression. */
   @Override
   public UnresolvedExpression visitFieldExpression(FieldExpressionContext ctx) {
     return new Field((QualifiedName) visit(ctx.qualifiedName()));
@@ -224,10 +246,42 @@ public UnresolvedExpression visitSortField(SortFieldContext ctx) {
     return new Field(fieldExpression, ArgumentFactory.getArgumentList(ctx));
   }
 
-  /** Aggregation function. */
   @Override
   public UnresolvedExpression visitStatsFunctionCall(StatsFunctionCallContext ctx) {
-    return new AggregateFunction(ctx.statsFunctionName().getText(), visit(ctx.valueExpression()));
+    String functionName = ctx.statsFunctionName().getText();
+    List<UnresolvedExpression> arguments = ctx.valueExpression().stream()
+        .map(this::visit)
+        .collect(Collectors.toList());
+    
+    // Handle EARLIEST and LATEST functions which can take 1 or 2 parameters
+    if ("EARLIEST".equalsIgnoreCase(functionName) || "LATEST".equalsIgnoreCase(functionName)) {
+      return buildEarliestLatestFunction(functionName, arguments);
+    } else {
+      // For other aggregate functions, expect exactly 1 parameter
+      if (arguments.size() != 1) {
+        throw new IllegalArgumentException(
+            String.format("Function %s expects exactly 1 argument, got %d", functionName, arguments.size()));
+      }
+      return new AggregateFunction(functionName, arguments.get(0));
+    }
+  }
+
+  /**
+   * Build EARLIEST or LATEST aggregate function with proper argument handling.
+   * Supports both single parameter (uses default @timestamp) and two parameters (custom time field).
+   */
+  private UnresolvedExpression buildEarliestLatestFunction(String functionName, List<UnresolvedExpression> arguments) {
+    if (arguments.size() == 1) {
+      // Single parameter: earliest(field) or latest(field) - uses default @timestamp
+      return new AggregateFunction(functionName, arguments.get(0));
+    } else if (arguments.size() == 2) {
+      // Two parameters: earliest(field, time_field) or latest(field, time_field)
+      return new AggregateFunction(functionName, arguments.get(0), 
+          Collections.singletonList(new UnresolvedArgument("time_field", arguments.get(1))));
+    } else {
+      throw new IllegalArgumentException(
+          String.format("Function %s expects 1 or 2 arguments, got %d", functionName, arguments.size()));
+    }
   }
 
   @Override
@@ -289,6 +343,27 @@ public UnresolvedExpression visitPercentileShortcutFunctionCall(
             new UnresolvedArgument("percent", AstDSL.doubleLiteral(percent))));
   }
 
+  @Override
+  public UnresolvedExpression visitPercentileShortcutFunctionCall(
+      OpenSearchPPLParser.PercentileShortcutFunctionCallContext ctx) {
+    String functionName = ctx.getStart().getText();
+
+    int prefixLength = functionName.toLowerCase().startsWith("perc") ? 4 : 1;
+    String percentileValue = functionName.substring(prefixLength);
+
+    double percent = Double.parseDouble(percentileValue);
+    if (percent < 0.0 || percent > 100.0) {
+      throw new SyntaxCheckException(
+          String.format("Percentile value must be between 0 and 100, got: %s", percent));
+    }
+
+    return new AggregateFunction(
+        "percentile",
+        visit(ctx.valueExpression()),
+        Collections.singletonList(
+            new UnresolvedArgument("percent", AstDSL.doubleLiteral(percent))));
+  }
+
   /** Case function. */
   @Override
   public UnresolvedExpression visitCaseFunctionCall(
@@ -309,7 +384,6 @@ public UnresolvedExpression visitCaseFunctionCall(
     return new Case(null, whens, Optional.ofNullable(elseValue));
   }
 
-  /** Eval function. */
   @Override
   public UnresolvedExpression visitEvalFunctionCall(EvalFunctionCallContext ctx) {
     final String functionName = ctx.evalFunctionName().getText();
@@ -325,7 +399,6 @@ public UnresolvedExpression visitEvalFunctionCall(EvalFunctionCallContext ctx) {
     return buildFunction(mappedName, ctx.functionArgs().functionArg());
   }
 
-  /** Cast function. */
   @Override
   public UnresolvedExpression visitDataTypeFunctionCall(DataTypeFunctionCallContext ctx) {
     return new Cast(visit(ctx.logicalExpression()), visit(ctx.convertedDataType()));
@@ -470,7 +543,6 @@ private List<UnresolvedExpression> timestampFunctionArguments(
     return args;
   }
 
-  /** Literal and value. */
   @Override
   public UnresolvedExpression visitIdentsAsQualifiedName(IdentsAsQualifiedNameContext ctx) {
     return visitIdentifiers(ctx.ident());
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
new file mode 100644
index 00000000000..bcc3a846d2c
--- /dev/null
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.ppl.calcite;
+
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.test.CalciteAssert;
+
+public abstract class CalcitePPLAggregationTestBase extends CalcitePPLAbstractTest {
+
+  public CalcitePPLAggregationTestBase() {
+    super(CalciteAssert.SchemaSpec.SCOTT_WITH_TEMPORAL);
+  }
+
+  public CalcitePPLAggregationTestBase(CalciteAssert.SchemaSpec... schemaSpecs) {
+    super(schemaSpecs);
+  }
+
+  protected void testAggregationWithAlias(String functionCall, String alias,
+                                        String expectedLogicalPattern, String expectedResult, 
+                                        String expectedSparkSql) {
+    String ppl = "source=EMP | stats " + functionCall + " as " + alias;
+    RelNode root = getRelNode(ppl);
+    verifyLogical(root, expectedLogicalPattern);
+    verifyResult(root, expectedResult);
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  protected String createSimpleAggLogicalPattern(String aggFunction, String fieldProjection) {
+    return "LogicalAggregate(group=[{}], " + aggFunction + ")\n"
+        + "  " + fieldProjection + "\n"
+        + "    LogicalTableScan(table=[[scott, EMP]])\n";
+  }
+
+  protected String createSimpleAggSparkSql(String selectClause) {
+    return "SELECT " + selectClause + "\n"
+        + "FROM `scott`.`EMP`";
+  }
+}
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
new file mode 100644
index 00000000000..1793484fb48
--- /dev/null
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.opensearch.sql.ppl.calcite;
+
+import org.apache.calcite.rel.RelNode;
+import org.junit.Test;
+
+public class CalcitePPLEarliestLatestTest extends CalcitePPLAggregationTestBase {
+
+  @Test
+  public void testEarliestFunction() {
+    testAggregationWithAlias(
+        "earliest(ENAME, HIREDATE)",
+        "earliest_name",
+        createSimpleAggLogicalPattern(
+            "earliest_name=[MIN_BY($0, $1)]",
+            "LogicalProject(ENAME=[$1], HIREDATE=[$4])"
+        ),
+        "earliest_name=SMITH\n",
+        createSimpleAggSparkSql("`MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`")
+    );
+  }
+
+  @Test
+  public void testLatestFunction() {
+    testAggregationWithAlias(
+        "latest(ENAME, HIREDATE)",
+        "latest_name",
+        createSimpleAggLogicalPattern(
+            "latest_name=[MAX_BY($0, $1)]",
+            "LogicalProject(ENAME=[$1], HIREDATE=[$4])"
+        ),
+        "latest_name=ADAMS\n",
+        createSimpleAggSparkSql("`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`")
+    );
+  }
+
+  @Test
+  public void testEarliestWithCustomTimeField() {
+    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalAggregate(group=[{}], earliest_name=[MIN_BY($0, $1)])\n"
+            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
+            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = "earliest_name=SMITH\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`\n"
+        + "FROM `scott`.`EMP`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testLatestWithCustomTimeField() {
+    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as latest_name";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalAggregate(group=[{}], latest_name=[MAX_BY($0, $1)])\n"
+            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
+            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = "latest_name=ADAMS\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`\n"
+        + "FROM `scott`.`EMP`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testEarliestByDepartment() {
+    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name by DEPTNO";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(earliest_name=[$1], DEPTNO=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)])\n"
+            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    // Results should show earliest hired employee per department
+    String expectedResult = 
+        "earliest_name=SMITH; DEPTNO=20\n"
+        + "earliest_name=CLARK; DEPTNO=10\n"
+        + "earliest_name=BLAKE; DEPTNO=30\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `DEPTNO`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testLatestByDepartment() {
+    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as latest_name by DEPTNO";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(latest_name=[$1], DEPTNO=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], latest_name=[MAX_BY($1, $2)])\n"
+            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = 
+        "latest_name=ADAMS; DEPTNO=20\n"
+        + "latest_name=MILLER; DEPTNO=10\n"
+        + "latest_name=JAMES; DEPTNO=30\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `DEPTNO`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testEarliestLatestCombined() {
+    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, latest(ENAME, HIREDATE) as latest_name by DEPTNO";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(earliest_name=[$1], latest_name=[$2], DEPTNO=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n"
+            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = 
+        "earliest_name=SMITH; latest_name=ADAMS; DEPTNO=20\n"
+        + "earliest_name=CLARK; latest_name=MILLER; DEPTNO=10\n"
+        + "earliest_name=BLAKE; latest_name=JAMES; DEPTNO=30\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
+        + "`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `DEPTNO`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testEarliestWithOtherAggregates() {
+    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, count() as cnt, avg(SAL) as avg_sal by DEPTNO";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(earliest_name=[$1], cnt=[$2], avg_sal=[$3], DEPTNO=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], cnt=[COUNT()], avg_sal=[AVG($3)])\n"
+            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4], SAL=[$5])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = 
+        "earliest_name=SMITH; cnt=5; avg_sal=2175.; DEPTNO=20\n"
+        + "earliest_name=CLARK; cnt=3; avg_sal=2916.666666; DEPTNO=10\n"
+        + "earliest_name=BLAKE; cnt=6; avg_sal=1566.666666; DEPTNO=30\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
+        + "COUNT(*) `cnt`, AVG(`SAL`) `avg_sal`, `DEPTNO`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `DEPTNO`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testEarliestSalaryByJob() {
+    String ppl = "source=EMP | stats earliest(SAL, HIREDATE) as earliest_salary by JOB";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(earliest_salary=[$1], JOB=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_salary=[MIN_BY($1, $2)])\n"
+            + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = 
+        "earliest_salary=1600.00; JOB=SALESMAN\n"
+        + "earliest_salary=3000.00; JOB=ANALYST\n"
+        + "earliest_salary=800.00; JOB=CLERK\n"
+        + "earliest_salary=5000.00; JOB=PRESIDENT\n"
+        + "earliest_salary=2850.00; JOB=MANAGER\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `JOB`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testLatestSalaryByJob() {
+    String ppl = "source=EMP | stats latest(SAL, HIREDATE) as latest_salary by JOB";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalProject(latest_salary=[$1], JOB=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], latest_salary=[MAX_BY($1, $2)])\n"
+            + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
+            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = 
+        "latest_salary=1250.00; JOB=SALESMAN\n"
+        + "latest_salary=3000.00; JOB=ANALYST\n"
+        + "latest_salary=1100.00; JOB=CLERK\n"
+        + "latest_salary=5000.00; JOB=PRESIDENT\n"
+        + "latest_salary=2450.00; JOB=MANAGER\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MAX_BY`(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
+        + "FROM `scott`.`EMP`\n"
+        + "GROUP BY `JOB`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testEarliestWithAlias() {
+    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as first_hired";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalAggregate(group=[{}], first_hired=[MIN_BY($0, $1)])\n"
+            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
+            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = "first_hired=SMITH\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `first_hired`\n"
+        + "FROM `scott`.`EMP`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+
+  @Test
+  public void testLatestWithAlias() {
+    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as last_hired";
+    RelNode root = getRelNode(ppl);
+    String expectedLogical =
+        "LogicalAggregate(group=[{}], last_hired=[MAX_BY($0, $1)])\n"
+            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
+            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    verifyLogical(root, expectedLogical);
+    
+    String expectedResult = "last_hired=ADAMS\n";
+    verifyResult(root, expectedResult);
+
+    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `last_hired`\n"
+        + "FROM `scott`.`EMP`";
+    verifyPPLToSparkSQL(root, expectedSparkSql);
+  }
+}

From 7c0a7c6274e1da6b383348aeccb5859b49c513ae Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Thu, 21 Aug 2025 11:04:18 -0700
Subject: [PATCH 02/26] Reformat

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../calcite/udf/udaf/MaxByAggFunction.java    |  22 +--
 .../calcite/udf/udaf/MinByAggFunction.java    |  22 +--
 .../function/BuiltinFunctionName.java         |   4 +-
 .../expression/function/PPLFuncImpTable.java  |  17 +-
 .../udf/udaf/MaxByMinByAggFunctionTest.java   |  18 +-
 .../function/AggFunctionTestBase.java         |  78 +++++----
 .../EarliestLatestAggFunctionTest.java        |  49 +++---
 .../sql/ppl/parser/AstExpressionBuilder.java  |  25 +--
 .../CalcitePPLAggregationTestBase.java        |  20 ++-
 .../calcite/CalcitePPLEarliestLatestTest.java | 154 +++++++++---------
 10 files changed, 225 insertions(+), 184 deletions(-)

diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
index 0bea7cd5c9b..b122bc300de 100644
--- a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
+++ b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
@@ -8,9 +8,9 @@
 import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
 
 /**
- * MAX_BY aggregate function implementation.
- * Returns the value from the first argument corresponding to the maximum value of the second argument.
- * Follows Spark SQL semantics: MAX_BY(value_field, order_field)
+ * MAX_BY aggregate function implementation. Returns the value from the first argument corresponding
+ * to the maximum value of the second argument. Follows Spark SQL semantics: MAX_BY(value_field,
+ * order_field)
  */
 public class MaxByAggFunction implements UserDefinedAggFunction<MaxByAggFunction.MaxByAccumulator> {
 
@@ -27,17 +27,18 @@ public Object result(MaxByAccumulator accumulator) {
   @Override
   public MaxByAccumulator add(MaxByAccumulator acc, Object... values) {
     if (values.length < 2) {
-      throw new IllegalArgumentException("MAX_BY requires exactly 2 arguments: value_field and order_field");
+      throw new IllegalArgumentException(
+          "MAX_BY requires exactly 2 arguments: value_field and order_field");
     }
-    
+
     Object candidateValue = values[0];
     Object candidateOrderValue = values[1];
-    
+
     // Skip null order values
     if (candidateOrderValue == null) {
       return acc;
     }
-    
+
     acc.update(candidateValue, candidateOrderValue);
     return acc;
   }
@@ -61,17 +62,18 @@ public void update(Object value, Object orderValue) {
       if (orderValue == null) {
         return;
       }
-      
+
       try {
         Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
-        
+
         if (maxOrderValue == null || comparableOrderValue.compareTo(maxOrderValue) > 0) {
           maxValue = value;
           maxOrderValue = comparableOrderValue;
         }
       } catch (ClassCastException e) {
         throw new IllegalArgumentException(
-            "MAX_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(), e);
+            "MAX_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(),
+            e);
       }
     }
   }
diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
index fb6480a39f8..dba31bf2470 100644
--- a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
+++ b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
@@ -8,9 +8,9 @@
 import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
 
 /**
- * MIN_BY aggregate function implementation.
- * Returns the value from the first argument corresponding to the minimum value of the second argument.
- * Follows Spark SQL semantics: MIN_BY(value_field, order_field)
+ * MIN_BY aggregate function implementation. Returns the value from the first argument corresponding
+ * to the minimum value of the second argument. Follows Spark SQL semantics: MIN_BY(value_field,
+ * order_field)
  */
 public class MinByAggFunction implements UserDefinedAggFunction<MinByAggFunction.MinByAccumulator> {
 
@@ -27,17 +27,18 @@ public Object result(MinByAccumulator accumulator) {
   @Override
   public MinByAccumulator add(MinByAccumulator acc, Object... values) {
     if (values.length < 2) {
-      throw new IllegalArgumentException("MIN_BY requires exactly 2 arguments: value_field and order_field");
+      throw new IllegalArgumentException(
+          "MIN_BY requires exactly 2 arguments: value_field and order_field");
     }
-    
+
     Object candidateValue = values[0];
     Object candidateOrderValue = values[1];
-    
+
     // Skip null order values
     if (candidateOrderValue == null) {
       return acc;
     }
-    
+
     acc.update(candidateValue, candidateOrderValue);
     return acc;
   }
@@ -61,17 +62,18 @@ public void update(Object value, Object orderValue) {
       if (orderValue == null) {
         return;
       }
-      
+
       try {
         Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
-        
+
         if (minOrderValue == null || comparableOrderValue.compareTo(minOrderValue) < 0) {
           minValue = value;
           minOrderValue = comparableOrderValue;
         }
       } catch (ClassCastException e) {
         throw new IllegalArgumentException(
-            "MIN_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(), e);
+            "MIN_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(),
+            e);
       }
     }
   }
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
index 55255d1ad58..db872b1016d 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
@@ -5,13 +5,11 @@
 
 package org.opensearch.sql.expression.function;
 
+import com.google.common.collect.ImmutableMap;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-
-import com.google.common.collect.ImmutableMap;
-
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index c228a35c86f..c911a975164 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -15,6 +15,8 @@
 import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.createAggregateFunction;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -27,9 +29,7 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-
 import javax.annotation.Nullable;
-
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLambda;
@@ -65,9 +65,6 @@
 import org.opensearch.sql.exception.ExpressionEvaluationException;
 import org.opensearch.sql.executor.QueryType;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-
 public class PPLFuncImpTable {
   private static final Logger logger = LogManager.getLogger(PPLFuncImpTable.class);
 
@@ -980,7 +977,8 @@ void populate() {
           MAX_BY,
           (distinct, field, argList, ctx) -> {
             if (argList.isEmpty()) {
-              throw new IllegalArgumentException("MAX_BY requires exactly 2 arguments: value_field and order_field");
+              throw new IllegalArgumentException(
+                  "MAX_BY requires exactly 2 arguments: value_field and order_field");
             }
             List<RexNode> newArgList =
                 argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
@@ -998,7 +996,8 @@ void populate() {
           MIN_BY,
           (distinct, field, argList, ctx) -> {
             if (argList.isEmpty()) {
-              throw new IllegalArgumentException("MIN_BY requires exactly 2 arguments: value_field and order_field");
+              throw new IllegalArgumentException(
+                  "MIN_BY requires exactly 2 arguments: value_field and order_field");
             }
             List<RexNode> newArgList =
                 argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
@@ -1016,7 +1015,7 @@ void populate() {
           EARLIEST,
           (distinct, field, argList, ctx) -> {
             RexNode timeField = resolveTimeField(argList, ctx);
-            
+
             return createAggregateFunction(
                 MinByAggFunction.class,
                 "MIN_BY",
@@ -1031,7 +1030,7 @@ void populate() {
           LATEST,
           (distinct, field, argList, ctx) -> {
             RexNode timeField = resolveTimeField(argList, ctx);
-            
+
             return createAggregateFunction(
                 MaxByAggFunction.class,
                 "MAX_BY",
diff --git a/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
index d9d3352b02f..2cf0abfd3fd 100644
--- a/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
+++ b/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
@@ -42,9 +42,11 @@ public void testMaxByWithInsufficientArguments() {
     MaxByAggFunction maxByFunction = new MaxByAggFunction();
     MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
 
-    assertThrows(IllegalArgumentException.class, () -> {
-      maxByFunction.add(accumulator, "value1");
-    });
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          maxByFunction.add(accumulator, "value1");
+        });
   }
 
   @Test
@@ -76,16 +78,18 @@ public void testMinByWithInsufficientArguments() {
     MinByAggFunction minByFunction = new MinByAggFunction();
     MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
 
-    assertThrows(IllegalArgumentException.class, () -> {
-      minByFunction.add(accumulator, "value1");
-    });
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          minByFunction.add(accumulator, "value1");
+        });
   }
 
   @Test
   public void testEmptyAccumulator() {
     MaxByAggFunction maxByFunction = new MaxByAggFunction();
     MaxByAggFunction.MaxByAccumulator maxAccumulator = maxByFunction.init();
-    
+
     MinByAggFunction minByFunction = new MinByAggFunction();
     MinByAggFunction.MinByAccumulator minAccumulator = minByFunction.init();
 
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java b/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
index 78ce4539838..d20841a2cee 100644
--- a/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
+++ b/core/src/test/java/org/opensearch/sql/expression/function/AggFunctionTestBase.java
@@ -11,28 +11,33 @@
 import java.lang.reflect.Field;
 import java.util.Map;
 import java.util.Set;
-
 import org.opensearch.sql.expression.function.PPLFuncImpTable.AggHandler;
 
 public abstract class AggFunctionTestBase {
 
   @SuppressWarnings("unchecked")
-  protected Map<BuiltinFunctionName, AggHandler> getAggFunctionRegistry() {
+  protected Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>>
+      getAggFunctionRegistry() {
     try {
       PPLFuncImpTable funcTable = PPLFuncImpTable.INSTANCE;
       Field field = PPLFuncImpTable.class.getDeclaredField("aggFunctionRegistry");
       field.setAccessible(true);
-      return (Map<BuiltinFunctionName, AggHandler>) field.get(funcTable);
+      return (Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>>)
+          field.get(funcTable);
     } catch (Exception e) {
       throw new RuntimeException("Failed to access aggFunctionRegistry", e);
     }
   }
 
   protected void assertFunctionIsRegistered(BuiltinFunctionName functionName) {
-    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
-    assertTrue(registry.containsKey(functionName), 
-        functionName.getName().getFunctionName() + " function should be registered in aggregate function registry");
-    assertNotNull(registry.get(functionName), 
+    Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>> registry =
+        getAggFunctionRegistry();
+    assertTrue(
+        registry.containsKey(functionName),
+        functionName.getName().getFunctionName()
+            + " function should be registered in aggregate function registry");
+    assertNotNull(
+        registry.get(functionName),
         functionName.getName().getFunctionName() + " function handler should not be null");
   }
 
@@ -42,51 +47,68 @@ protected void assertFunctionsAreRegistered(BuiltinFunctionName... functionNames
     }
   }
 
-
   protected void assertFunctionHandlerTypes(BuiltinFunctionName... functionNames) {
-    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
+    Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>> registry =
+        getAggFunctionRegistry();
     for (BuiltinFunctionName functionName : functionNames) {
-      AggHandler handler = registry.get(functionName);
-      assertNotNull(handler, functionName.getName().getFunctionName() + " handler should not be null");
-      assertTrue(handler instanceof AggHandler, 
-          functionName.getName().getFunctionName() + " handler should implement AggHandler interface");
+      org.apache.commons.lang3.tuple.Pair<?, AggHandler> registryEntry = registry.get(functionName);
+      assertNotNull(
+          registryEntry, functionName.getName().getFunctionName() + " should be registered");
+
+      // Extract the AggHandler from the pair
+      AggHandler handler = registryEntry.getRight();
+
+      assertNotNull(
+          handler, functionName.getName().getFunctionName() + " handler should not be null");
+      assertTrue(
+          handler instanceof AggHandler,
+          functionName.getName().getFunctionName()
+              + " handler should implement AggHandler interface");
     }
   }
 
   protected void assertRegistryMinimumSize(int expectedMinimumSize) {
-    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
-    assertTrue(registry.size() >= expectedMinimumSize, 
+    Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>> registry =
+        getAggFunctionRegistry();
+    assertTrue(
+        registry.size() >= expectedMinimumSize,
         "Registry should contain at least " + expectedMinimumSize + " aggregate functions");
   }
 
   protected void assertKnownFunctionsPresent(Set<BuiltinFunctionName> knownFunctions) {
-    Map<BuiltinFunctionName, AggHandler> registry = getAggFunctionRegistry();
-    long foundFunctions = registry.keySet().stream()
-        .filter(knownFunctions::contains)
-        .count();
-    
-    assertTrue(foundFunctions >= knownFunctions.size(), 
+    Map<BuiltinFunctionName, org.apache.commons.lang3.tuple.Pair<?, AggHandler>> registry =
+        getAggFunctionRegistry();
+    long foundFunctions = registry.keySet().stream().filter(knownFunctions::contains).count();
+
+    assertTrue(
+        foundFunctions >= knownFunctions.size(),
         "Should have at least " + knownFunctions.size() + " known aggregate functions registered");
   }
 
-  protected void assertFunctionNameResolution(String functionName, BuiltinFunctionName expectedEnum) {
-    assertTrue(BuiltinFunctionName.of(functionName).isPresent(), 
+  protected void assertFunctionNameResolution(
+      String functionName, BuiltinFunctionName expectedEnum) {
+    assertTrue(
+        BuiltinFunctionName.of(functionName).isPresent(),
         "Should be able to resolve '" + functionName + "' function name");
-    assertTrue(BuiltinFunctionName.of(functionName).get() == expectedEnum,
+    assertTrue(
+        BuiltinFunctionName.of(functionName).get() == expectedEnum,
         "Resolved function should match expected enum value");
   }
 
   protected void assertFunctionNamesInEnum(BuiltinFunctionName... functionNames) {
     Set<BuiltinFunctionName> enumValues = Set.of(BuiltinFunctionName.values());
-    
+
     for (BuiltinFunctionName functionName : functionNames) {
-      assertTrue(enumValues.contains(functionName), 
-          functionName.getName().getFunctionName() + " should be defined in BuiltinFunctionName enum");
+      assertTrue(
+          enumValues.contains(functionName),
+          functionName.getName().getFunctionName()
+              + " should be defined in BuiltinFunctionName enum");
     }
   }
 
   protected void assertFunctionNameMapping(BuiltinFunctionName functionEnum, String expectedName) {
-    assertTrue(functionEnum.getName().getFunctionName().equals(expectedName),
+    assertTrue(
+        functionEnum.getName().getFunctionName().equals(expectedName),
         "Function enum should map to expected name: " + expectedName);
   }
 }
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
index 7566731401c..efb29f8040d 100644
--- a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
+++ b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
@@ -8,7 +8,6 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
 import java.util.Set;
-
 import org.junit.jupiter.api.Test;
 
 public class EarliestLatestAggFunctionTest extends AggFunctionTestBase {
@@ -38,11 +37,15 @@ void testFunctionNameResolution() {
   @Test
   void testResolveAggWithValidFunctions() {
     try {
-      java.lang.reflect.Method method = PPLFuncImpTable.class.getDeclaredMethod(
-          "resolveAgg", BuiltinFunctionName.class, boolean.class, 
-          org.apache.calcite.rex.RexNode.class, java.util.List.class, 
-          org.opensearch.sql.calcite.CalcitePlanContext.class);
-      
+      java.lang.reflect.Method method =
+          PPLFuncImpTable.class.getDeclaredMethod(
+              "resolveAgg",
+              BuiltinFunctionName.class,
+              boolean.class,
+              org.apache.calcite.rex.RexNode.class,
+              java.util.List.class,
+              org.opensearch.sql.calcite.CalcitePlanContext.class);
+
       assertNotNull(method, "resolveAgg method should exist");
     } catch (NoSuchMethodException e) {
       throw new RuntimeException("resolveAgg method not found", e);
@@ -55,34 +58,30 @@ void testFunctionRegistryIntegrity() {
         BuiltinFunctionName.MAX_BY,
         BuiltinFunctionName.MIN_BY,
         BuiltinFunctionName.EARLIEST,
-        BuiltinFunctionName.LATEST
-    );
+        BuiltinFunctionName.LATEST);
   }
 
   @Test
   void testFunctionHandlerTypes() {
-    assertFunctionHandlerTypes(
-        BuiltinFunctionName.EARLIEST,
-        BuiltinFunctionName.LATEST
-    );
+    assertFunctionHandlerTypes(BuiltinFunctionName.EARLIEST, BuiltinFunctionName.LATEST);
   }
 
   @Test
   void testFunctionRegistrySize() {
     assertRegistryMinimumSize(10);
-    
-    Set<BuiltinFunctionName> knownFunctions = Set.of(
-        BuiltinFunctionName.COUNT,
-        BuiltinFunctionName.SUM,
-        BuiltinFunctionName.AVG,
-        BuiltinFunctionName.MAX,
-        BuiltinFunctionName.MIN,
-        BuiltinFunctionName.MAX_BY,
-        BuiltinFunctionName.MIN_BY,
-        BuiltinFunctionName.EARLIEST,
-        BuiltinFunctionName.LATEST
-    );
-    
+
+    Set<BuiltinFunctionName> knownFunctions =
+        Set.of(
+            BuiltinFunctionName.COUNT,
+            BuiltinFunctionName.SUM,
+            BuiltinFunctionName.AVG,
+            BuiltinFunctionName.MAX,
+            BuiltinFunctionName.MIN,
+            BuiltinFunctionName.MAX_BY,
+            BuiltinFunctionName.MIN_BY,
+            BuiltinFunctionName.EARLIEST,
+            BuiltinFunctionName.LATEST);
+
     assertKnownFunctionsPresent(knownFunctions);
   }
 
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index a36aee28aef..8b2229c79f5 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -19,7 +19,6 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
-
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.antlr.v4.runtime.RuleContext;
 import org.opensearch.sql.ast.dsl.AstDSL;
@@ -249,10 +248,9 @@ public UnresolvedExpression visitSortField(SortFieldContext ctx) {
   @Override
   public UnresolvedExpression visitStatsFunctionCall(StatsFunctionCallContext ctx) {
     String functionName = ctx.statsFunctionName().getText();
-    List<UnresolvedExpression> arguments = ctx.valueExpression().stream()
-        .map(this::visit)
-        .collect(Collectors.toList());
-    
+    List<UnresolvedExpression> arguments =
+        ctx.valueExpression().stream().map(this::visit).collect(Collectors.toList());
+
     // Handle EARLIEST and LATEST functions which can take 1 or 2 parameters
     if ("EARLIEST".equalsIgnoreCase(functionName) || "LATEST".equalsIgnoreCase(functionName)) {
       return buildEarliestLatestFunction(functionName, arguments);
@@ -260,27 +258,32 @@ public UnresolvedExpression visitStatsFunctionCall(StatsFunctionCallContext ctx)
       // For other aggregate functions, expect exactly 1 parameter
       if (arguments.size() != 1) {
         throw new IllegalArgumentException(
-            String.format("Function %s expects exactly 1 argument, got %d", functionName, arguments.size()));
+            String.format(
+                "Function %s expects exactly 1 argument, got %d", functionName, arguments.size()));
       }
       return new AggregateFunction(functionName, arguments.get(0));
     }
   }
 
   /**
-   * Build EARLIEST or LATEST aggregate function with proper argument handling.
-   * Supports both single parameter (uses default @timestamp) and two parameters (custom time field).
+   * Build EARLIEST or LATEST aggregate function with proper argument handling. Supports both single
+   * parameter (uses default @timestamp) and two parameters (custom time field).
    */
-  private UnresolvedExpression buildEarliestLatestFunction(String functionName, List<UnresolvedExpression> arguments) {
+  private UnresolvedExpression buildEarliestLatestFunction(
+      String functionName, List<UnresolvedExpression> arguments) {
     if (arguments.size() == 1) {
       // Single parameter: earliest(field) or latest(field) - uses default @timestamp
       return new AggregateFunction(functionName, arguments.get(0));
     } else if (arguments.size() == 2) {
       // Two parameters: earliest(field, time_field) or latest(field, time_field)
-      return new AggregateFunction(functionName, arguments.get(0), 
+      return new AggregateFunction(
+          functionName,
+          arguments.get(0),
           Collections.singletonList(new UnresolvedArgument("time_field", arguments.get(1))));
     } else {
       throw new IllegalArgumentException(
-          String.format("Function %s expects 1 or 2 arguments, got %d", functionName, arguments.size()));
+          String.format(
+              "Function %s expects 1 or 2 arguments, got %d", functionName, arguments.size()));
     }
   }
 
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
index bcc3a846d2c..679f40047c4 100644
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
@@ -18,9 +18,12 @@ public CalcitePPLAggregationTestBase(CalciteAssert.SchemaSpec... schemaSpecs) {
     super(schemaSpecs);
   }
 
-  protected void testAggregationWithAlias(String functionCall, String alias,
-                                        String expectedLogicalPattern, String expectedResult, 
-                                        String expectedSparkSql) {
+  protected void testAggregationWithAlias(
+      String functionCall,
+      String alias,
+      String expectedLogicalPattern,
+      String expectedResult,
+      String expectedSparkSql) {
     String ppl = "source=EMP | stats " + functionCall + " as " + alias;
     RelNode root = getRelNode(ppl);
     verifyLogical(root, expectedLogicalPattern);
@@ -29,13 +32,16 @@ protected void testAggregationWithAlias(String functionCall, String alias,
   }
 
   protected String createSimpleAggLogicalPattern(String aggFunction, String fieldProjection) {
-    return "LogicalAggregate(group=[{}], " + aggFunction + ")\n"
-        + "  " + fieldProjection + "\n"
+    return "LogicalAggregate(group=[{}], "
+        + aggFunction
+        + ")\n"
+        + "  "
+        + fieldProjection
+        + "\n"
         + "    LogicalTableScan(table=[[scott, EMP]])\n";
   }
 
   protected String createSimpleAggSparkSql(String selectClause) {
-    return "SELECT " + selectClause + "\n"
-        + "FROM `scott`.`EMP`";
+    return "SELECT " + selectClause + "\n" + "FROM `scott`.`EMP`";
   }
 }
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
index 1793484fb48..8f7c7d823d3 100644
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
@@ -16,12 +16,9 @@ public void testEarliestFunction() {
         "earliest(ENAME, HIREDATE)",
         "earliest_name",
         createSimpleAggLogicalPattern(
-            "earliest_name=[MIN_BY($0, $1)]",
-            "LogicalProject(ENAME=[$1], HIREDATE=[$4])"
-        ),
+            "earliest_name=[MIN_BY($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
         "earliest_name=SMITH\n",
-        createSimpleAggSparkSql("`MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`")
-    );
+        createSimpleAggSparkSql("`MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`"));
   }
 
   @Test
@@ -30,12 +27,9 @@ public void testLatestFunction() {
         "latest(ENAME, HIREDATE)",
         "latest_name",
         createSimpleAggLogicalPattern(
-            "latest_name=[MAX_BY($0, $1)]",
-            "LogicalProject(ENAME=[$1], HIREDATE=[$4])"
-        ),
+            "latest_name=[MAX_BY($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
         "latest_name=ADAMS\n",
-        createSimpleAggSparkSql("`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`")
-    );
+        createSimpleAggSparkSql("`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`"));
   }
 
   @Test
@@ -47,12 +41,12 @@ public void testEarliestWithCustomTimeField() {
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
+
     String expectedResult = "earliest_name=SMITH\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`\n"
-        + "FROM `scott`.`EMP`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -65,12 +59,12 @@ public void testLatestWithCustomTimeField() {
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
+
     String expectedResult = "latest_name=ADAMS\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`\n"
-        + "FROM `scott`.`EMP`";
+    String expectedSparkSql =
+        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -84,17 +78,18 @@ public void testEarliestByDepartment() {
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
+
     // Results should show earliest hired employee per department
-    String expectedResult = 
+    String expectedResult =
         "earliest_name=SMITH; DEPTNO=20\n"
-        + "earliest_name=CLARK; DEPTNO=10\n"
-        + "earliest_name=BLAKE; DEPTNO=30\n";
+            + "earliest_name=CLARK; DEPTNO=10\n"
+            + "earliest_name=BLAKE; DEPTNO=30\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `DEPTNO`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -108,64 +103,73 @@ public void testLatestByDepartment() {
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
-    String expectedResult = 
+
+    String expectedResult =
         "latest_name=ADAMS; DEPTNO=20\n"
-        + "latest_name=MILLER; DEPTNO=10\n"
-        + "latest_name=JAMES; DEPTNO=30\n";
+            + "latest_name=MILLER; DEPTNO=10\n"
+            + "latest_name=JAMES; DEPTNO=30\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `DEPTNO`";
+    String expectedSparkSql =
+        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
   public void testEarliestLatestCombined() {
-    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, latest(ENAME, HIREDATE) as latest_name by DEPTNO";
+    String ppl =
+        "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, latest(ENAME, HIREDATE) as"
+            + " latest_name by DEPTNO";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_name=[$1], latest_name=[$2], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)],"
+            + " latest_name=[MAX_BY($1, $2)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
-    String expectedResult = 
+
+    String expectedResult =
         "earliest_name=SMITH; latest_name=ADAMS; DEPTNO=20\n"
-        + "earliest_name=CLARK; latest_name=MILLER; DEPTNO=10\n"
-        + "earliest_name=BLAKE; latest_name=JAMES; DEPTNO=30\n";
+            + "earliest_name=CLARK; latest_name=MILLER; DEPTNO=10\n"
+            + "earliest_name=BLAKE; latest_name=JAMES; DEPTNO=30\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
-        + "`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `DEPTNO`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
+            + "`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
   public void testEarliestWithOtherAggregates() {
-    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, count() as cnt, avg(SAL) as avg_sal by DEPTNO";
+    String ppl =
+        "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, count() as cnt, avg(SAL) as"
+            + " avg_sal by DEPTNO";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_name=[$1], cnt=[$2], avg_sal=[$3], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], cnt=[COUNT()], avg_sal=[AVG($3)])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], cnt=[COUNT()],"
+            + " avg_sal=[AVG($3)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4], SAL=[$5])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
-    String expectedResult = 
+
+    String expectedResult =
         "earliest_name=SMITH; cnt=5; avg_sal=2175.; DEPTNO=20\n"
-        + "earliest_name=CLARK; cnt=3; avg_sal=2916.666666; DEPTNO=10\n"
-        + "earliest_name=BLAKE; cnt=6; avg_sal=1566.666666; DEPTNO=30\n";
+            + "earliest_name=CLARK; cnt=3; avg_sal=2916.666666; DEPTNO=10\n"
+            + "earliest_name=BLAKE; cnt=6; avg_sal=1566.666666; DEPTNO=30\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
-        + "COUNT(*) `cnt`, AVG(`SAL`) `avg_sal`, `DEPTNO`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `DEPTNO`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
+            + "COUNT(*) `cnt`, AVG(`SAL`) `avg_sal`, `DEPTNO`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -179,18 +183,19 @@ public void testEarliestSalaryByJob() {
             + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
-    String expectedResult = 
+
+    String expectedResult =
         "earliest_salary=1600.00; JOB=SALESMAN\n"
-        + "earliest_salary=3000.00; JOB=ANALYST\n"
-        + "earliest_salary=800.00; JOB=CLERK\n"
-        + "earliest_salary=5000.00; JOB=PRESIDENT\n"
-        + "earliest_salary=2850.00; JOB=MANAGER\n";
+            + "earliest_salary=3000.00; JOB=ANALYST\n"
+            + "earliest_salary=800.00; JOB=CLERK\n"
+            + "earliest_salary=5000.00; JOB=PRESIDENT\n"
+            + "earliest_salary=2850.00; JOB=MANAGER\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `JOB`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `JOB`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -204,18 +209,19 @@ public void testLatestSalaryByJob() {
             + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
-    String expectedResult = 
+
+    String expectedResult =
         "latest_salary=1250.00; JOB=SALESMAN\n"
-        + "latest_salary=3000.00; JOB=ANALYST\n"
-        + "latest_salary=1100.00; JOB=CLERK\n"
-        + "latest_salary=5000.00; JOB=PRESIDENT\n"
-        + "latest_salary=2450.00; JOB=MANAGER\n";
+            + "latest_salary=3000.00; JOB=ANALYST\n"
+            + "latest_salary=1100.00; JOB=CLERK\n"
+            + "latest_salary=5000.00; JOB=PRESIDENT\n"
+            + "latest_salary=2450.00; JOB=MANAGER\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MAX_BY`(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
-        + "FROM `scott`.`EMP`\n"
-        + "GROUP BY `JOB`";
+    String expectedSparkSql =
+        "SELECT `MAX_BY`(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
+            + "FROM `scott`.`EMP`\n"
+            + "GROUP BY `JOB`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -228,12 +234,12 @@ public void testEarliestWithAlias() {
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
+
     String expectedResult = "first_hired=SMITH\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `first_hired`\n"
-        + "FROM `scott`.`EMP`";
+    String expectedSparkSql =
+        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `first_hired`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -246,12 +252,12 @@ public void testLatestWithAlias() {
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
-    
+
     String expectedResult = "last_hired=ADAMS\n";
     verifyResult(root, expectedResult);
 
-    String expectedSparkSql = "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `last_hired`\n"
-        + "FROM `scott`.`EMP`";
+    String expectedSparkSql =
+        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `last_hired`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 }

From aa67cbb28d1c72ce2ff2638d1c87df26461308df Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Fri, 22 Aug 2025 09:32:55 -0700
Subject: [PATCH 03/26] Add IT

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/user/ppl/cmd/stats.rst                   | 71 +++++++++++++-
 .../org/opensearch/sql/ppl/ExplainIT.java     | 32 +++++++
 .../sql/ppl/NewAddedCommandsIT.java           | 32 +++++++
 ppl/src/main/antlr/OpenSearchPPLParser.g4     |  9 +-
 .../sql/ppl/parser/AstExpressionBuilder.java  | 95 ++++++++-----------
 5 files changed, 182 insertions(+), 57 deletions(-)

diff --git a/docs/user/ppl/cmd/stats.rst b/docs/user/ppl/cmd/stats.rst
index a2a6885c744..54df5a8a7f4 100644
--- a/docs/user/ppl/cmd/stats.rst
+++ b/docs/user/ppl/cmd/stats.rst
@@ -302,6 +302,76 @@ Example::
     | 36                  | M      |
     +---------------------+--------+
 
+EARLIEST
+--------
+
+Description
+>>>>>>>>>>>
+
+Version: 3.1.0
+
+Usage: EARLIEST(field [, time_field]). Return the earliest value of a field based on timestamp ordering.
+
+* field: mandatory. The field to return the earliest value for.
+* time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
+
+Example::
+
+    os> source=logs | stats earliest(message) by host;
+    fetched rows / total rows = 2/2
+    +------------------+----------+
+    | earliest(message)| host     |
+    |------------------+----------|
+    | "Starting up"    | server1  |
+    | "Initializing"   | server2  |
+    +------------------+----------+
+
+Example with custom time field::
+
+    os> source=events | stats earliest(status, event_time) by category;
+    fetched rows / total rows = 2/2
+    +----------------+----------+
+    | earliest(status)| category |
+    |----------------+----------|
+    | "pending"      | orders   |
+    | "active"       | users    |
+    +----------------+----------+
+
+LATEST
+------
+
+Description
+>>>>>>>>>>>
+
+Version: 3.1.0
+
+Usage: LATEST(field [, time_field]). Return the latest value of a field based on timestamp ordering.
+
+* field: mandatory. The field to return the latest value for.
+* time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
+
+Example::
+
+    os> source=logs | stats latest(message) by host;
+    fetched rows / total rows = 2/2
+    +----------------+----------+
+    | latest(message)| host     |
+    |----------------+----------|
+    | "Shutting down"| server1  |
+    | "Ready"        | server2  |
+    +----------------+----------+
+
+Example with custom time field::
+
+    os> source=events | stats latest(status, event_time) by category;
+    fetched rows / total rows = 2/2
+    +--------------+----------+
+    | latest(status)| category |
+    |--------------+----------|
+    | "completed"  | orders   |
+    | "inactive"   | users    |
+    +--------------+----------+
+
 Example 1: Calculate the count of events
 ========================================
 
@@ -524,4 +594,3 @@ PPL query::
     | 28  | 20       | F      |
     | 36  | 30       | M      |
     +-----+----------+--------+
-
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
index faf1b8784ad..2c8e6434618 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
@@ -623,6 +623,38 @@ public void testExplainOnAggregationWithFunction() throws IOException {
                 TEST_INDEX_BANK)));
   }
 
+  @Test
+  public void testExplainOnEarliestLatest() throws IOException {
+    String expected = loadExpectedPlan("explain_earliest_latest.json");
+    assertJsonEqualsIgnoreId(
+        expected,
+        explainQueryToString(
+            "source=opensearch-sql_test_index_account | stats earliest(firstname) as earliest_name,"
+                + " latest(firstname) as latest_name by state"));
+  }
+
+  @Test
+  public void testExplainOnEarliestLatestWithCustomTimeField() throws IOException {
+    String expected = loadExpectedPlan("explain_earliest_latest_custom_time.json");
+    assertJsonEqualsIgnoreId(
+        expected,
+        explainQueryToString(
+            String.format(
+                "source=%s | stats earliest(firstname, birthdate) as earliest_name,"
+                    + " latest(firstname, birthdate) as latest_name by gender",
+                TEST_INDEX_BANK)));
+  }
+
+  @Test
+  public void testExplainOnMaxByMinBy() throws IOException {
+    String expected = loadExpectedPlan("explain_max_by_min_by.json");
+    assertJsonEqualsIgnoreId(
+        expected,
+        explainQueryToString(
+            "source=opensearch-sql_test_index_account | stats max_by(firstname, age) as"
+                + " oldest_name, min_by(firstname, age) as youngest_name by state"));
+  }
+
   protected String loadExpectedPlan(String fileName) throws IOException {
     String prefix;
     if (isCalciteEnabled()) {
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index ab2d4113f80..3bd57e60cb8 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -149,6 +149,38 @@ public void testRegexMatch() throws IOException {
     }
   }
 
+  @Test
+  public void testEarliestLatestAggregates() throws IOException {
+    JSONObject result;
+    try {
+      result =
+          executeQuery(
+              String.format(
+                  "search source=%s | stats earliest(firstname) as earliest_name,"
+                      + " latest(firstname) as latest_name by gender",
+                  TEST_INDEX_BANK));
+    } catch (ResponseException e) {
+      result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
+    }
+    verifyQuery(result);
+  }
+
+  @Test
+  public void testMaxByMinByAggregates() throws IOException {
+    JSONObject result;
+    try {
+      result =
+          executeQuery(
+              String.format(
+                  "search source=%s | stats max_by(firstname, age) as oldest_name,"
+                      + " min_by(firstname, age) as youngest_name by gender",
+                  TEST_INDEX_BANK));
+    } catch (ResponseException e) {
+      result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
+    }
+    verifyQuery(result);
+  }
+
   private void verifyQuery(JSONObject result) throws IOException {
     if (isCalciteEnabled()) {
       assertFalse(result.getJSONArray("datarows").isEmpty());
diff --git a/ppl/src/main/antlr/OpenSearchPPLParser.g4 b/ppl/src/main/antlr/OpenSearchPPLParser.g4
index 5def5a9a007..1e389a56c90 100644
--- a/ppl/src/main/antlr/OpenSearchPPLParser.g4
+++ b/ppl/src/main/antlr/OpenSearchPPLParser.g4
@@ -458,12 +458,13 @@ statsAggTerm
 
 // aggregation functions
 statsFunction
-   : statsFunctionName LT_PRTHS valueExpression (COMMA valueExpression)? RT_PRTHS        # statsFunctionCall
+   : statsFunctionName LT_PRTHS valueExpression RT_PRTHS        # statsFunctionCall
    | COUNT LT_PRTHS RT_PRTHS                                    # countAllFunctionCall
    | PERCENTILE_SHORTCUT LT_PRTHS valueExpression RT_PRTHS      # percentileShortcutFunctionCall
    | (DISTINCT_COUNT | DC | DISTINCT_COUNT_APPROX) LT_PRTHS valueExpression RT_PRTHS    # distinctCountFunctionCall
    | takeAggFunction                                            # takeAggFunctionCall
    | percentileApproxFunction                                   # percentileApproxFunctionCall
+   | earliestLatestFunction                                     # earliestLatestFunctionCall
    ;
 
 statsFunctionName
@@ -478,8 +479,10 @@ statsFunctionName
    | STDDEV_POP
    | PERCENTILE
    | PERCENTILE_APPROX
-   | EARLIEST
-   | LATEST
+   ;
+
+earliestLatestFunction
+   : (EARLIEST | LATEST) LT_PRTHS valueExpression (COMMA timeField = valueExpression)? RT_PRTHS
    ;
 
 
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index 8b2229c79f5..d4813f2a0b0 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -69,7 +69,6 @@
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.DistinctCountFunctionCallContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.DoubleLiteralContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.EvalClauseContext;
-import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.EvalFunctionCallContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.FieldExpressionContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.FloatLiteralContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.IdentsAsQualifiedNameContext;
@@ -248,43 +247,8 @@ public UnresolvedExpression visitSortField(SortFieldContext ctx) {
   @Override
   public UnresolvedExpression visitStatsFunctionCall(StatsFunctionCallContext ctx) {
     String functionName = ctx.statsFunctionName().getText();
-    List<UnresolvedExpression> arguments =
-        ctx.valueExpression().stream().map(this::visit).collect(Collectors.toList());
-
-    // Handle EARLIEST and LATEST functions which can take 1 or 2 parameters
-    if ("EARLIEST".equalsIgnoreCase(functionName) || "LATEST".equalsIgnoreCase(functionName)) {
-      return buildEarliestLatestFunction(functionName, arguments);
-    } else {
-      // For other aggregate functions, expect exactly 1 parameter
-      if (arguments.size() != 1) {
-        throw new IllegalArgumentException(
-            String.format(
-                "Function %s expects exactly 1 argument, got %d", functionName, arguments.size()));
-      }
-      return new AggregateFunction(functionName, arguments.get(0));
-    }
-  }
-
-  /**
-   * Build EARLIEST or LATEST aggregate function with proper argument handling. Supports both single
-   * parameter (uses default @timestamp) and two parameters (custom time field).
-   */
-  private UnresolvedExpression buildEarliestLatestFunction(
-      String functionName, List<UnresolvedExpression> arguments) {
-    if (arguments.size() == 1) {
-      // Single parameter: earliest(field) or latest(field) - uses default @timestamp
-      return new AggregateFunction(functionName, arguments.get(0));
-    } else if (arguments.size() == 2) {
-      // Two parameters: earliest(field, time_field) or latest(field, time_field)
-      return new AggregateFunction(
-          functionName,
-          arguments.get(0),
-          Collections.singletonList(new UnresolvedArgument("time_field", arguments.get(1))));
-    } else {
-      throw new IllegalArgumentException(
-          String.format(
-              "Function %s expects 1 or 2 arguments, got %d", functionName, arguments.size()));
-    }
+    UnresolvedExpression argument = visit(ctx.valueExpression());
+    return new AggregateFunction(functionName, argument);
   }
 
   @Override
@@ -368,6 +332,46 @@ public UnresolvedExpression visitPercentileShortcutFunctionCall(
   }
 
   /** Case function. */
+  public UnresolvedExpression visitEarliestLatestFunctionCall(
+      OpenSearchPPLParser.EarliestLatestFunctionCallContext ctx) {
+    return visit(ctx.earliestLatestFunction());
+  }
+
+  @Override
+  public UnresolvedExpression visitEarliestLatestFunction(
+      OpenSearchPPLParser.EarliestLatestFunctionContext ctx) {
+    String functionName = ctx.EARLIEST() != null ? "earliest" : "latest";
+    UnresolvedExpression valueField = visit(ctx.valueExpression(0));
+
+    if (ctx.timeField != null) {
+      // Two parameters: earliest(field, time_field) or latest(field, time_field)
+      UnresolvedExpression timeField = visit(ctx.timeField);
+      return new AggregateFunction(
+          functionName,
+          valueField,
+          Collections.singletonList(new UnresolvedArgument("time_field", timeField)));
+    } else {
+      // Single parameter: earliest(field) or latest(field) - uses default @timestamp
+      return new AggregateFunction(functionName, valueField);
+    }
+  }
+
+  @Override
+  public UnresolvedExpression visitEvalFunctionCall(
+      OpenSearchPPLParser.EvalFunctionCallContext ctx) {
+    final String functionName = ctx.evalFunctionName().getText();
+    final String mappedName =
+        FUNCTION_NAME_MAPPING.getOrDefault(functionName.toLowerCase(Locale.ROOT), functionName);
+
+    // Rewrite sum and avg functions to arithmetic expressions
+    if (SUM.getName().getFunctionName().equalsIgnoreCase(mappedName)
+        || AVG.getName().getFunctionName().equalsIgnoreCase(mappedName)) {
+      return rewriteSumAvgFunction(mappedName, ctx.functionArgs().functionArg());
+    }
+
+    return buildFunction(mappedName, ctx.functionArgs().functionArg());
+  }
+
   @Override
   public UnresolvedExpression visitCaseFunctionCall(
       OpenSearchPPLParser.CaseFunctionCallContext ctx) {
@@ -387,21 +391,6 @@ public UnresolvedExpression visitCaseFunctionCall(
     return new Case(null, whens, Optional.ofNullable(elseValue));
   }
 
-  @Override
-  public UnresolvedExpression visitEvalFunctionCall(EvalFunctionCallContext ctx) {
-    final String functionName = ctx.evalFunctionName().getText();
-    final String mappedName =
-        FUNCTION_NAME_MAPPING.getOrDefault(functionName.toLowerCase(Locale.ROOT), functionName);
-
-    // Rewrite sum and avg functions to arithmetic expressions
-    if (SUM.getName().getFunctionName().equalsIgnoreCase(mappedName)
-        || AVG.getName().getFunctionName().equalsIgnoreCase(mappedName)) {
-      return rewriteSumAvgFunction(mappedName, ctx.functionArgs().functionArg());
-    }
-
-    return buildFunction(mappedName, ctx.functionArgs().functionArg());
-  }
-
   @Override
   public UnresolvedExpression visitDataTypeFunctionCall(DataTypeFunctionCallContext ctx) {
     return new Cast(visit(ctx.logicalExpression()), visit(ctx.convertedDataType()));

From 0f99bf743d645722c950ce68a6ec87199fe80928 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 11:41:34 -0700
Subject: [PATCH 04/26] Fix integ test

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../expression/function/PPLFuncImpTable.java  | 12 ++--
 docs/user/ppl/cmd/stats.rst                   | 56 +++++++++----------
 doctest/test_data/events.json                 |  8 +++
 doctest/test_mapping/events.json              | 29 ++++++++++
 .../sql/calcite/remote/CalciteExplainIT.java  | 25 +++++++++
 .../remote/CalcitePPLAggregationIT.java       |  8 +--
 .../sql/legacy/SQLIntegTestCase.java          |  5 ++
 .../opensearch/sql/legacy/TestsConstants.java |  2 +
 .../org/opensearch/sql/ppl/ExplainIT.java     | 32 -----------
 .../resources/accounts_with_timestamp.json    | 12 ++++
 .../calcite/explain_earliest_latest.json      |  6 ++
 .../explain_earliest_latest_custom_time.json  |  6 ++
 .../explain_earliest_latest.json              |  6 ++
 .../explain_earliest_latest_custom_time.json  |  6 ++
 .../ppl/explain_earliest_latest.json          | 33 +++++++++++
 .../explain_earliest_latest_custom_time.json  | 33 +++++++++++
 16 files changed, 209 insertions(+), 70 deletions(-)
 create mode 100644 doctest/test_data/events.json
 create mode 100644 doctest/test_mapping/events.json
 create mode 100644 integ-test/src/test/resources/accounts_with_timestamp.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
 create mode 100644 integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json

diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index c911a975164..23c24717e41 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -863,14 +863,14 @@ void registerOperator(BuiltinFunctionName functionName, SqlAggFunction aggFuncti
 
     private static RexNode resolveTimeField(List<RexNode> argList, CalcitePlanContext ctx) {
       if (argList.isEmpty()) {
-        try {
-          return ctx.rexBuilder.makeInputRef(
-              ctx.relBuilder.peek().getRowType().getField("@timestamp", false, false).getType(),
-              ctx.relBuilder.peek().getRowType().getField("@timestamp", false, false).getIndex());
-        } catch (Exception e) {
+        // Try to find @timestamp field
+        var timestampField =
+            ctx.relBuilder.peek().getRowType().getField("@timestamp", false, false);
+        if (timestampField == null) {
           throw new IllegalArgumentException(
-              "Default @timestamp field not found. Please specify a time field explicitly.", e);
+              "Default @timestamp field not found. Please specify a time field explicitly.");
         }
+        return ctx.rexBuilder.makeInputRef(timestampField.getType(), timestampField.getIndex());
       } else {
         return PlanUtils.derefMapCall(argList.get(0));
       }
diff --git a/docs/user/ppl/cmd/stats.rst b/docs/user/ppl/cmd/stats.rst
index 54df5a8a7f4..1bc79ab4933 100644
--- a/docs/user/ppl/cmd/stats.rst
+++ b/docs/user/ppl/cmd/stats.rst
@@ -317,25 +317,25 @@ Usage: EARLIEST(field [, time_field]). Return the earliest value of a field base
 
 Example::
 
-    os> source=logs | stats earliest(message) by host;
+    os> source=events | stats earliest(message, event_time) by host | sort host;
     fetched rows / total rows = 2/2
-    +------------------+----------+
-    | earliest(message)| host     |
-    |------------------+----------|
-    | "Starting up"    | server1  |
-    | "Initializing"   | server2  |
-    +------------------+----------+
+    +-----------------------------+--------+
+    | earliest(message, event_time) | host   |
+    |-----------------------------+--------|
+    | Starting up                 | server1|
+    | Initializing                | server2|
+    +-----------------------------+--------+
 
 Example with custom time field::
 
-    os> source=events | stats earliest(status, event_time) by category;
+    os> source=events | stats earliest(status, event_time) by category | sort category;
     fetched rows / total rows = 2/2
-    +----------------+----------+
-    | earliest(status)| category |
-    |----------------+----------|
-    | "pending"      | orders   |
-    | "active"       | users    |
-    +----------------+----------+
+    +----------------------------+----------+
+    | earliest(status, event_time)| category |
+    |----------------------------+----------|
+    | pending                    | orders   |
+    | active                     | users    |
+    +----------------------------+----------+
 
 LATEST
 ------
@@ -352,25 +352,25 @@ Usage: LATEST(field [, time_field]). Return the latest value of a field based on
 
 Example::
 
-    os> source=logs | stats latest(message) by host;
+    os> source=events | stats latest(message, event_time) by host | sort host;
     fetched rows / total rows = 2/2
-    +----------------+----------+
-    | latest(message)| host     |
-    |----------------+----------|
-    | "Shutting down"| server1  |
-    | "Ready"        | server2  |
-    +----------------+----------+
+    +---------------------------+--------+
+    | latest(message, event_time) | host   |
+    |---------------------------+--------|
+    | Shutting down             | server1|
+    | Maintenance mode          | server2|
+    +---------------------------+--------+
 
 Example with custom time field::
 
-    os> source=events | stats latest(status, event_time) by category;
+    os> source=events | stats latest(status, event_time) by category | sort category;
     fetched rows / total rows = 2/2
-    +--------------+----------+
-    | latest(status)| category |
-    |--------------+----------|
-    | "completed"  | orders   |
-    | "inactive"   | users    |
-    +--------------+----------+
+    +--------------------------+----------+
+    | latest(status, event_time)| category |
+    |--------------------------+----------|
+    | cancelled                | orders   |
+    | inactive                 | users    |
+    +--------------------------+----------+
 
 Example 1: Calculate the count of events
 ========================================
diff --git a/doctest/test_data/events.json b/doctest/test_data/events.json
new file mode 100644
index 00000000000..e873691fb90
--- /dev/null
+++ b/doctest/test_data/events.json
@@ -0,0 +1,8 @@
+{"@timestamp":"2023-01-01T10:00:00Z","event_time":"2023-01-01T09:55:00Z","host":"server1","message":"Starting up","level":"INFO","category":"orders","status":"pending"}
+{"@timestamp":"2023-01-01T10:05:00Z","event_time":"2023-01-01T10:00:00Z","host":"server2","message":"Initializing","level":"INFO","category":"users","status":"active"}
+{"@timestamp":"2023-01-01T10:10:00Z","event_time":"2023-01-01T10:05:00Z","host":"server1","message":"Ready to serve","level":"INFO","category":"orders","status":"processing"}
+{"@timestamp":"2023-01-01T10:15:00Z","event_time":"2023-01-01T10:10:00Z","host":"server2","message":"Ready","level":"INFO","category":"users","status":"inactive"}
+{"@timestamp":"2023-01-01T10:20:00Z","event_time":"2023-01-01T10:15:00Z","host":"server1","message":"Processing requests","level":"INFO","category":"orders","status":"completed"}
+{"@timestamp":"2023-01-01T10:25:00Z","event_time":"2023-01-01T10:20:00Z","host":"server2","message":"Handling connections","level":"INFO","category":"users","status":"pending"}
+{"@timestamp":"2023-01-01T10:30:00Z","event_time":"2023-01-01T10:25:00Z","host":"server1","message":"Shutting down","level":"WARN","category":"orders","status":"cancelled"}
+{"@timestamp":"2023-01-01T10:35:00Z","event_time":"2023-01-01T10:30:00Z","host":"server2","message":"Maintenance mode","level":"WARN","category":"users","status":"inactive"}
diff --git a/doctest/test_mapping/events.json b/doctest/test_mapping/events.json
new file mode 100644
index 00000000000..664f042324b
--- /dev/null
+++ b/doctest/test_mapping/events.json
@@ -0,0 +1,29 @@
+{
+  "mappings": {
+    "properties": {
+      "@timestamp": {
+        "type": "date",
+        "format": "strict_date_optional_time||epoch_millis"
+      },
+      "event_time": {
+        "type": "date",
+        "format": "strict_date_optional_time||epoch_millis"
+      },
+      "host": {
+        "type": "keyword"
+      },
+      "message": {
+        "type": "text"
+      },
+      "level": {
+        "type": "keyword"
+      },
+      "category": {
+        "type": "keyword"
+      },
+      "status": {
+        "type": "keyword"
+      }
+    }
+  }
+}
diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
index 7193467fd86..dceb0b24190 100644
--- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
@@ -12,6 +12,7 @@
 
 import java.io.IOException;
 import java.util.Locale;
+
 import org.junit.Assume;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -25,6 +26,7 @@ public void init() throws Exception {
     loadIndex(Index.BANK_WITH_STRING_VALUES);
     loadIndex(Index.NESTED_SIMPLE);
     loadIndex(Index.TIME_TEST_DATA);
+    loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
   }
 
   @Override
@@ -337,6 +339,29 @@ public void testExplainRegexMatchInEvalWithOutScriptPushdown() throws IOExceptio
     assertJsonEqualsIgnoreId(expected, result);
   }
 
+  // Only for Calcite
+  @Test
+  public void testExplainOnEarliestLatest() throws IOException {
+    String expected = loadExpectedPlan("explain_earliest_latest.json");
+    assertJsonEqualsIgnoreId(
+        expected,
+        explainQueryToString(
+            "source=opensearch-sql_test_index_account_with_timestamp | stats earliest(firstname) as"
+                + " earliest_name, latest(firstname) as latest_name by state"));
+  }
+
+  // Only for Calcite
+  @Test
+  public void testExplainOnEarliestLatestWithCustomTimeField() throws IOException {
+    String expected = loadExpectedPlan("explain_earliest_latest_custom_time.json");
+    assertJsonEqualsIgnoreId(
+        expected,
+        explainQueryToString(
+            "source=opensearch-sql_test_index_account_with_timestamp | stats earliest(firstname,"
+                + " created_time) as earliest_name, latest(firstname, created_time) as latest_name"
+                + " by gender"));
+  }
+
   /**
    * Executes the PPL query and returns the result as a string with windows-style line breaks
    * replaced with Unix-style ones.
diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
index 9b0557ea737..f6d7f7f26a8 100644
--- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
@@ -556,18 +556,18 @@ public void testCountDistinctWithAlias() throws IOException {
     verifyDataRows(actual, rows(3, "F"), rows(4, "M"));
   }
 
-  @Ignore
   @Test
   public void testEarliestAndLatest() throws IOException {
     JSONObject actual =
         executeQuery(
             String.format(
-                "source=%s | stats latest(datetime0), earliest(datetime0)", TEST_INDEX_CALCS));
+                "source=%s | stats latest(datetime0, datetime0), earliest(datetime0, datetime0)",
+                TEST_INDEX_CALCS));
 
     verifySchema(
         actual,
-        schema("latest(datetime0)", "timestamp"),
-        schema("earliest(datetime0)", "timestamp"));
+        schema("latest(datetime0, datetime0)", "timestamp"),
+        schema("earliest(datetime0, datetime0)", "timestamp"));
     verifyDataRows(actual, rows("2004-08-02 07:59:23", "2004-07-04 22:49:28"));
   }
 
diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
index f867a69e953..08509a9d6bc 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
@@ -560,6 +560,11 @@ public enum Index {
         "account",
         getAccountIndexMapping(),
         "src/test/resources/accounts.json"),
+    ACCOUNT_WITH_TIMESTAMP(
+        TestsConstants.TEST_INDEX_ACCOUNT_WITH_TIMESTAMP,
+        "account",
+        getAccountIndexMapping(),
+        "src/test/resources/accounts_with_timestamp.json"),
     PHRASE(
         TestsConstants.TEST_INDEX_PHRASE,
         "phrase",
diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java b/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
index 9e9e0698c93..0ba373f90a8 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
@@ -15,6 +15,8 @@ public class TestsConstants {
 
   public static final String TEST_INDEX_ONLINE = TEST_INDEX + "_online";
   public static final String TEST_INDEX_ACCOUNT = TEST_INDEX + "_account";
+  public static final String TEST_INDEX_ACCOUNT_WITH_TIMESTAMP =
+      TEST_INDEX + "_account_with_timestamp";
   public static final String TEST_INDEX_PHRASE = TEST_INDEX + "_phrase";
   public static final String TEST_INDEX_DOG = TEST_INDEX + "_dog";
   public static final String TEST_INDEX_DOG2 = TEST_INDEX + "_dog2";
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
index 2c8e6434618..faf1b8784ad 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/ExplainIT.java
@@ -623,38 +623,6 @@ public void testExplainOnAggregationWithFunction() throws IOException {
                 TEST_INDEX_BANK)));
   }
 
-  @Test
-  public void testExplainOnEarliestLatest() throws IOException {
-    String expected = loadExpectedPlan("explain_earliest_latest.json");
-    assertJsonEqualsIgnoreId(
-        expected,
-        explainQueryToString(
-            "source=opensearch-sql_test_index_account | stats earliest(firstname) as earliest_name,"
-                + " latest(firstname) as latest_name by state"));
-  }
-
-  @Test
-  public void testExplainOnEarliestLatestWithCustomTimeField() throws IOException {
-    String expected = loadExpectedPlan("explain_earliest_latest_custom_time.json");
-    assertJsonEqualsIgnoreId(
-        expected,
-        explainQueryToString(
-            String.format(
-                "source=%s | stats earliest(firstname, birthdate) as earliest_name,"
-                    + " latest(firstname, birthdate) as latest_name by gender",
-                TEST_INDEX_BANK)));
-  }
-
-  @Test
-  public void testExplainOnMaxByMinBy() throws IOException {
-    String expected = loadExpectedPlan("explain_max_by_min_by.json");
-    assertJsonEqualsIgnoreId(
-        expected,
-        explainQueryToString(
-            "source=opensearch-sql_test_index_account | stats max_by(firstname, age) as"
-                + " oldest_name, min_by(firstname, age) as youngest_name by state"));
-  }
-
   protected String loadExpectedPlan(String fileName) throws IOException {
     String prefix;
     if (isCalciteEnabled()) {
diff --git a/integ-test/src/test/resources/accounts_with_timestamp.json b/integ-test/src/test/resources/accounts_with_timestamp.json
new file mode 100644
index 00000000000..cd41f1b329b
--- /dev/null
+++ b/integ-test/src/test/resources/accounts_with_timestamp.json
@@ -0,0 +1,12 @@
+{"index":{"_id":"1"}}
+{"account_number":1,"balance":39225,"firstname":"Amber","lastname":"Duke","age":32,"gender":"M","address":"880 Holmes Lane","employer":"Pyrami","email":"amberduke@pyrami.com","city":"Brogan","state":"IL","@timestamp":"2023-01-01T10:00:00.000Z","created_time":"2023-01-03T08:00:00.000Z"}
+{"index":{"_id":"2"}}
+{"account_number":2,"balance":28838,"firstname":"Roberta","lastname":"Bender","age":22,"gender":"F","address":"560 Kingsway Place","employer":"Chillium","email":"robertabender@chillium.com","city":"Bennett","state":"LA","@timestamp":"2023-01-01T11:00:00.000Z","created_time":"2023-01-03T07:00:00.000Z"}
+{"index":{"_id":"3"}}
+{"account_number":3,"balance":44947,"firstname":"Levine","lastname":"Burks","age":26,"gender":"F","address":"328 Wilson Avenue","employer":"Amtap","email":"levineburks@amtap.com","city":"Cochranville","state":"HI","@timestamp":"2023-01-01T12:00:00.000Z","created_time":"2023-01-03T06:00:00.000Z"}
+{"index":{"_id":"4"}}
+{"account_number":4,"balance":27658,"firstname":"Rodriquez","lastname":"Flores","age":31,"gender":"F","address":"986 Wyckoff Avenue","employer":"Tourmania","email":"rodriquezflores@tourmania.com","city":"Eastvale","state":"HI","@timestamp":"2023-01-02T10:00:00.000Z","created_time":"2023-01-03T05:00:00.000Z"}
+{"index":{"_id":"5"}}
+{"account_number":5,"balance":29342,"firstname":"Leola","lastname":"Stewart","age":30,"gender":"F","address":"311 Elm Place","employer":"Diginetic","email":"leolastewart@diginetic.com","city":"Fairview","state":"NJ","@timestamp":"2023-01-02T11:00:00.000Z","created_time":"2023-01-03T04:00:00.000Z"}
+{"index":{"_id":"6"}}
+{"account_number":6,"balance":5686,"firstname":"Hattie","lastname":"Bond","age":36,"gender":"M","address":"671 Bristol Street","employer":"Netagy","email":"hattiebond@netagy.com","city":"Dante","state":"TN","@timestamp":"2023-01-02T12:00:00.000Z","created_time":"2023-01-03T03:00:00.000Z"}
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
new file mode 100644
index 00000000000..eb99e38d7c7
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
@@ -0,0 +1,6 @@
+{
+  "calcite": {
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[state, firstname, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"state\",\"firstname\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+  }
+}
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
new file mode 100644
index 00000000000..c82043b9720
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
@@ -0,0 +1,6 @@
+{
+  "calcite": {
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[gender, firstname, created_time]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"gender\",\"firstname\",\"created_time\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+  }
+}
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
new file mode 100644
index 00000000000..d63ef44c60e
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
@@ -0,0 +1,6 @@
+{
+  "calcite": {
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{10}], earliest_name=[MIN_BY($2, $7)], latest_name=[MAX_BY($2, $7)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+  }
+}
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
new file mode 100644
index 00000000000..44d954e1e0e
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
@@ -0,0 +1,6 @@
+{
+  "calcite": {
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{4}], earliest_name=[MIN_BY($2, $1)], latest_name=[MAX_BY($2, $1)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+  }
+}
diff --git a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
new file mode 100644
index 00000000000..ef3b864e3ba
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
@@ -0,0 +1,33 @@
+{
+  "root": {
+    "name": "ProjectOperator",
+    "description": {
+      "fields": "[earliest_name, latest_name, state]"
+    },
+    "children": [
+      {
+        "name": "WindowOperator",
+        "description": {
+          "function": "[earliest(firstname) OVER (PARTITION BY state ORDER BY @timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), latest(firstname) OVER (PARTITION BY state ORDER BY @timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)]"
+        },
+        "children": [
+          {
+            "name": "SortOperator",
+            "description": {
+              "sortList": "[state ASC]"
+            },
+            "children": [
+              {
+                "name": "OpenSearchIndexScan",
+                "description": {
+                  "request": "OpenSearchQueryRequest(indexName=opensearch-sql_test_index_account, sourceBuilder={\"from\":0,\"size\":10000,\"timeout\":\"1m\"}, searchDone=false)"
+                },
+                "children": []
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}
diff --git a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json
new file mode 100644
index 00000000000..fee6e8b18fb
--- /dev/null
+++ b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json
@@ -0,0 +1,33 @@
+{
+  "root": {
+    "name": "ProjectOperator",
+    "description": {
+      "fields": "[earliest_name, latest_name, gender]"
+    },
+    "children": [
+      {
+        "name": "WindowOperator",
+        "description": {
+          "function": "[earliest(firstname, birthdate) OVER (PARTITION BY gender ORDER BY birthdate ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), latest(firstname, birthdate) OVER (PARTITION BY gender ORDER BY birthdate DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)]"
+        },
+        "children": [
+          {
+            "name": "SortOperator",
+            "description": {
+              "sortList": "[gender ASC]"
+            },
+            "children": [
+              {
+                "name": "OpenSearchIndexScan",
+                "description": {
+                  "request": "OpenSearchQueryRequest(indexName=opensearch-sql_test_index_bank, sourceBuilder={\"from\":0,\"size\":10000,\"timeout\":\"1m\"}, searchDone=false)"
+                },
+                "children": []
+              }
+            ]
+          }
+        ]
+      }
+    ]
+  }
+}

From 84be4c181ec57d1f5186cc2a832978a1d0387073 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 12:48:08 -0700
Subject: [PATCH 05/26] Use ARG_MAX and ARG_MIN instead

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../calcite/udf/udaf/MaxByAggFunction.java    |  80 -------------
 .../calcite/udf/udaf/MinByAggFunction.java    |  80 -------------
 .../expression/function/PPLFuncImpTable.java  |  55 +++------
 .../udf/udaf/MaxByMinByAggFunctionTest.java   | 111 ------------------
 .../sql/ppl/NewAddedCommandsIT.java           |   8 +-
 .../calcite/explain_earliest_latest.json      |   4 +-
 .../explain_earliest_latest_custom_time.json  |   4 +-
 .../calcite/CalcitePPLEarliestLatestTest.java |  52 ++++----
 8 files changed, 49 insertions(+), 345 deletions(-)
 delete mode 100644 core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
 delete mode 100644 core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
 delete mode 100644 core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java

diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
deleted file mode 100644
index b122bc300de..00000000000
--- a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MaxByAggFunction.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package org.opensearch.sql.calcite.udf.udaf;
-
-import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
-
-/**
- * MAX_BY aggregate function implementation. Returns the value from the first argument corresponding
- * to the maximum value of the second argument. Follows Spark SQL semantics: MAX_BY(value_field,
- * order_field)
- */
-public class MaxByAggFunction implements UserDefinedAggFunction<MaxByAggFunction.MaxByAccumulator> {
-
-  @Override
-  public MaxByAccumulator init() {
-    return new MaxByAccumulator();
-  }
-
-  @Override
-  public Object result(MaxByAccumulator accumulator) {
-    return accumulator.value();
-  }
-
-  @Override
-  public MaxByAccumulator add(MaxByAccumulator acc, Object... values) {
-    if (values.length < 2) {
-      throw new IllegalArgumentException(
-          "MAX_BY requires exactly 2 arguments: value_field and order_field");
-    }
-
-    Object candidateValue = values[0];
-    Object candidateOrderValue = values[1];
-
-    // Skip null order values
-    if (candidateOrderValue == null) {
-      return acc;
-    }
-
-    acc.update(candidateValue, candidateOrderValue);
-    return acc;
-  }
-
-  public static class MaxByAccumulator implements UserDefinedAggFunction.Accumulator {
-    private Object maxValue;
-    private Comparable<Object> maxOrderValue;
-
-    public MaxByAccumulator() {
-      this.maxValue = null;
-      this.maxOrderValue = null;
-    }
-
-    @Override
-    public Object value(Object... argList) {
-      return maxValue;
-    }
-
-    @SuppressWarnings("unchecked")
-    public void update(Object value, Object orderValue) {
-      if (orderValue == null) {
-        return;
-      }
-
-      try {
-        Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
-
-        if (maxOrderValue == null || comparableOrderValue.compareTo(maxOrderValue) > 0) {
-          maxValue = value;
-          maxOrderValue = comparableOrderValue;
-        }
-      } catch (ClassCastException e) {
-        throw new IllegalArgumentException(
-            "MAX_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(),
-            e);
-      }
-    }
-  }
-}
diff --git a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java b/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
deleted file mode 100644
index dba31bf2470..00000000000
--- a/core/src/main/java/org/opensearch/sql/calcite/udf/udaf/MinByAggFunction.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package org.opensearch.sql.calcite.udf.udaf;
-
-import org.opensearch.sql.calcite.udf.UserDefinedAggFunction;
-
-/**
- * MIN_BY aggregate function implementation. Returns the value from the first argument corresponding
- * to the minimum value of the second argument. Follows Spark SQL semantics: MIN_BY(value_field,
- * order_field)
- */
-public class MinByAggFunction implements UserDefinedAggFunction<MinByAggFunction.MinByAccumulator> {
-
-  @Override
-  public MinByAccumulator init() {
-    return new MinByAccumulator();
-  }
-
-  @Override
-  public Object result(MinByAccumulator accumulator) {
-    return accumulator.value();
-  }
-
-  @Override
-  public MinByAccumulator add(MinByAccumulator acc, Object... values) {
-    if (values.length < 2) {
-      throw new IllegalArgumentException(
-          "MIN_BY requires exactly 2 arguments: value_field and order_field");
-    }
-
-    Object candidateValue = values[0];
-    Object candidateOrderValue = values[1];
-
-    // Skip null order values
-    if (candidateOrderValue == null) {
-      return acc;
-    }
-
-    acc.update(candidateValue, candidateOrderValue);
-    return acc;
-  }
-
-  public static class MinByAccumulator implements UserDefinedAggFunction.Accumulator {
-    private Object minValue;
-    private Comparable<Object> minOrderValue;
-
-    public MinByAccumulator() {
-      this.minValue = null;
-      this.minOrderValue = null;
-    }
-
-    @Override
-    public Object value(Object... argList) {
-      return minValue;
-    }
-
-    @SuppressWarnings("unchecked")
-    public void update(Object value, Object orderValue) {
-      if (orderValue == null) {
-        return;
-      }
-
-      try {
-        Comparable<Object> comparableOrderValue = (Comparable<Object>) orderValue;
-
-        if (minOrderValue == null || comparableOrderValue.compareTo(minOrderValue) < 0) {
-          minValue = value;
-          minOrderValue = comparableOrderValue;
-        }
-      } catch (ClassCastException e) {
-        throw new IllegalArgumentException(
-            "MIN_BY order field must be comparable. Got: " + orderValue.getClass().getSimpleName(),
-            e);
-      }
-    }
-  }
-}
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index 23c24717e41..c04b4741fa9 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -55,8 +55,6 @@
 import org.apache.logging.log4j.Logger;
 import org.opensearch.sql.calcite.CalcitePlanContext;
 import org.opensearch.sql.calcite.udf.udaf.LogPatternAggFunction;
-import org.opensearch.sql.calcite.udf.udaf.MaxByAggFunction;
-import org.opensearch.sql.calcite.udf.udaf.MinByAggFunction;
 import org.opensearch.sql.calcite.udf.udaf.PercentileApproxFunction;
 import org.opensearch.sql.calcite.udf.udaf.TakeAggFunction;
 import org.opensearch.sql.calcite.utils.OpenSearchTypeFactory;
@@ -973,6 +971,7 @@ void populate() {
                   ctx.relBuilder),
           null);
 
+      // Use Calcite's built-in ARG_MAX and ARG_MIN functions
       register(
           MAX_BY,
           (distinct, field, argList, ctx) -> {
@@ -980,17 +979,11 @@ void populate() {
               throw new IllegalArgumentException(
                   "MAX_BY requires exactly 2 arguments: value_field and order_field");
             }
-            List<RexNode> newArgList =
-                argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
-            return createAggregateFunction(
-                MaxByAggFunction.class,
-                "MAX_BY",
-                ReturnTypes.ARG0_FORCE_NULLABLE,
-                List.of(field),
-                newArgList,
-                ctx.relBuilder);
+            RexNode orderField = PlanUtils.derefMapCall(argList.get(0));
+            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MAX, field, orderField);
           },
-          null);
+          wrapSqlOperandTypeChecker(
+              SqlStdOperatorTable.ARG_MAX.getOperandTypeChecker(), MAX_BY.name(), false));
 
       register(
           MIN_BY,
@@ -999,47 +992,29 @@ void populate() {
               throw new IllegalArgumentException(
                   "MIN_BY requires exactly 2 arguments: value_field and order_field");
             }
-            List<RexNode> newArgList =
-                argList.stream().map(PlanUtils::derefMapCall).collect(Collectors.toList());
-            return createAggregateFunction(
-                MinByAggFunction.class,
-                "MIN_BY",
-                ReturnTypes.ARG0_FORCE_NULLABLE,
-                List.of(field),
-                newArgList,
-                ctx.relBuilder);
+            RexNode orderField = PlanUtils.derefMapCall(argList.get(0));
+            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MIN, field, orderField);
           },
-          null);
+          wrapSqlOperandTypeChecker(
+              SqlStdOperatorTable.ARG_MIN.getOperandTypeChecker(), MIN_BY.name(), false));
 
       register(
           EARLIEST,
           (distinct, field, argList, ctx) -> {
             RexNode timeField = resolveTimeField(argList, ctx);
-
-            return createAggregateFunction(
-                MinByAggFunction.class,
-                "MIN_BY",
-                ReturnTypes.ARG0_FORCE_NULLABLE,
-                List.of(field),
-                List.of(timeField),
-                ctx.relBuilder);
+            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MIN, field, timeField);
           },
-          null);
+          wrapSqlOperandTypeChecker(
+              SqlStdOperatorTable.ARG_MIN.getOperandTypeChecker(), EARLIEST.name(), false));
 
       register(
           LATEST,
           (distinct, field, argList, ctx) -> {
             RexNode timeField = resolveTimeField(argList, ctx);
-
-            return createAggregateFunction(
-                MaxByAggFunction.class,
-                "MAX_BY",
-                ReturnTypes.ARG0_FORCE_NULLABLE,
-                List.of(field),
-                List.of(timeField),
-                ctx.relBuilder);
+            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MAX, field, timeField);
           },
-          null);
+          wrapSqlOperandTypeChecker(
+              SqlStdOperatorTable.ARG_MAX.getOperandTypeChecker(), LATEST.name(), false));
     }
   }
 
diff --git a/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
deleted file mode 100644
index 2cf0abfd3fd..00000000000
--- a/core/src/test/java/org/opensearch/sql/calcite/udf/udaf/MaxByMinByAggFunctionTest.java
+++ /dev/null
@@ -1,111 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package org.opensearch.sql.calcite.udf.udaf;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-import org.junit.jupiter.api.Test;
-
-public class MaxByMinByAggFunctionTest {
-
-  @Test
-  public void testMaxByAggFunction() {
-    MaxByAggFunction maxByFunction = new MaxByAggFunction();
-    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
-
-    maxByFunction.add(accumulator, "value1", 10);
-    maxByFunction.add(accumulator, "value2", 20);
-    maxByFunction.add(accumulator, "value3", 15);
-
-    assertEquals("value2", maxByFunction.result(accumulator));
-  }
-
-  @Test
-  public void testMaxByWithNullOrderValue() {
-    MaxByAggFunction maxByFunction = new MaxByAggFunction();
-    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
-
-    maxByFunction.add(accumulator, "value1", null);
-    maxByFunction.add(accumulator, "value2", 20);
-    maxByFunction.add(accumulator, "value3", null);
-
-    assertEquals("value2", maxByFunction.result(accumulator));
-  }
-
-  @Test
-  public void testMaxByWithInsufficientArguments() {
-    MaxByAggFunction maxByFunction = new MaxByAggFunction();
-    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
-
-    assertThrows(
-        IllegalArgumentException.class,
-        () -> {
-          maxByFunction.add(accumulator, "value1");
-        });
-  }
-
-  @Test
-  public void testMinByAggFunction() {
-    MinByAggFunction minByFunction = new MinByAggFunction();
-    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
-
-    minByFunction.add(accumulator, "value1", 10);
-    minByFunction.add(accumulator, "value2", 5);
-    minByFunction.add(accumulator, "value3", 15);
-
-    assertEquals("value2", minByFunction.result(accumulator));
-  }
-
-  @Test
-  public void testMinByWithNullOrderValue() {
-    MinByAggFunction minByFunction = new MinByAggFunction();
-    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
-
-    minByFunction.add(accumulator, "value1", null);
-    minByFunction.add(accumulator, "value2", 5);
-    minByFunction.add(accumulator, "value3", null);
-
-    assertEquals("value2", minByFunction.result(accumulator));
-  }
-
-  @Test
-  public void testMinByWithInsufficientArguments() {
-    MinByAggFunction minByFunction = new MinByAggFunction();
-    MinByAggFunction.MinByAccumulator accumulator = minByFunction.init();
-
-    assertThrows(
-        IllegalArgumentException.class,
-        () -> {
-          minByFunction.add(accumulator, "value1");
-        });
-  }
-
-  @Test
-  public void testEmptyAccumulator() {
-    MaxByAggFunction maxByFunction = new MaxByAggFunction();
-    MaxByAggFunction.MaxByAccumulator maxAccumulator = maxByFunction.init();
-
-    MinByAggFunction minByFunction = new MinByAggFunction();
-    MinByAggFunction.MinByAccumulator minAccumulator = minByFunction.init();
-
-    assertNull(maxByFunction.result(maxAccumulator));
-    assertNull(minByFunction.result(minAccumulator));
-  }
-
-  @Test
-  public void testWithStringOrderValues() {
-    MaxByAggFunction maxByFunction = new MaxByAggFunction();
-    MaxByAggFunction.MaxByAccumulator accumulator = maxByFunction.init();
-
-    maxByFunction.add(accumulator, "apple", "zebra");
-    maxByFunction.add(accumulator, "banana", "alpha");
-    maxByFunction.add(accumulator, "cherry", "beta");
-
-    assertEquals("apple", maxByFunction.result(accumulator));
-  }
-}
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 3bd57e60cb8..94174bccc18 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -24,6 +24,7 @@ public void init() throws Exception {
     loadIndex(Index.BANK);
     loadIndex(Index.DOG);
     loadIndex(Index.BANK_WITH_STRING_VALUES);
+    loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
   }
 
   @Test
@@ -155,10 +156,9 @@ public void testEarliestLatestAggregates() throws IOException {
     try {
       result =
           executeQuery(
-              String.format(
-                  "search source=%s | stats earliest(firstname) as earliest_name,"
-                      + " latest(firstname) as latest_name by gender",
-                  TEST_INDEX_BANK));
+              "search source=opensearch-sql_test_index_account_with_timestamp | stats"
+                  + " earliest(firstname) as earliest_name, latest(firstname) as latest_name by"
+                  + " gender");
     } catch (ResponseException e) {
       result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
     }
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
index eb99e38d7c7..f29df521bab 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[state, firstname, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"state\",\"firstname\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[state, firstname, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"state\",\"firstname\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
index c82043b9720..318b37a5bf6 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[gender, firstname, created_time]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"gender\",\"firstname\",\"created_time\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[gender, firstname, created_time]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"gender\",\"firstname\",\"created_time\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
index 8f7c7d823d3..4ebe73363be 100644
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
@@ -16,9 +16,9 @@ public void testEarliestFunction() {
         "earliest(ENAME, HIREDATE)",
         "earliest_name",
         createSimpleAggLogicalPattern(
-            "earliest_name=[MIN_BY($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
+            "earliest_name=[ARG_MIN($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
         "earliest_name=SMITH\n",
-        createSimpleAggSparkSql("`MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`"));
+        createSimpleAggSparkSql("ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`"));
   }
 
   @Test
@@ -27,9 +27,9 @@ public void testLatestFunction() {
         "latest(ENAME, HIREDATE)",
         "latest_name",
         createSimpleAggLogicalPattern(
-            "latest_name=[MAX_BY($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
+            "latest_name=[ARG_MAX($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
         "latest_name=ADAMS\n",
-        createSimpleAggSparkSql("`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`"));
+        createSimpleAggSparkSql("ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`"));
   }
 
   @Test
@@ -37,7 +37,7 @@ public void testEarliestWithCustomTimeField() {
     String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], earliest_name=[MIN_BY($0, $1)])\n"
+        "LogicalAggregate(group=[{}], earliest_name=[ARG_MIN($0, $1)])\n"
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -46,7 +46,7 @@ public void testEarliestWithCustomTimeField() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -55,7 +55,7 @@ public void testLatestWithCustomTimeField() {
     String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as latest_name";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], latest_name=[MAX_BY($0, $1)])\n"
+        "LogicalAggregate(group=[{}], latest_name=[ARG_MAX($0, $1)])\n"
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -64,7 +64,7 @@ public void testLatestWithCustomTimeField() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -74,7 +74,7 @@ public void testEarliestByDepartment() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_name=[$1], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -87,7 +87,7 @@ public void testEarliestByDepartment() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
+        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
@@ -99,7 +99,7 @@ public void testLatestByDepartment() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(latest_name=[$1], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], latest_name=[MAX_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], latest_name=[ARG_MAX($1, $2)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -111,7 +111,7 @@ public void testLatestByDepartment() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
@@ -125,8 +125,8 @@ public void testEarliestLatestCombined() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_name=[$1], latest_name=[$2], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)],"
-            + " latest_name=[MAX_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)],"
+            + " latest_name=[ARG_MAX($1, $2)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -138,8 +138,8 @@ public void testEarliestLatestCombined() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
-            + "`MAX_BY`(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
+        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, "
+            + "ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `DEPTNO`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
@@ -153,7 +153,7 @@ public void testEarliestWithOtherAggregates() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_name=[$1], cnt=[$2], avg_sal=[$3], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], cnt=[COUNT()],"
+            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], cnt=[COUNT()],"
             + " avg_sal=[AVG($3)])\n"
             + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4], SAL=[$5])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
@@ -166,7 +166,7 @@ public void testEarliestWithOtherAggregates() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `earliest_name`, "
+        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, "
             + "COUNT(*) `cnt`, AVG(`SAL`) `avg_sal`, `DEPTNO`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `DEPTNO`";
@@ -179,7 +179,7 @@ public void testEarliestSalaryByJob() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(earliest_salary=[$1], JOB=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_salary=[MIN_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_salary=[ARG_MIN($1, $2)])\n"
             + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -193,7 +193,7 @@ public void testEarliestSalaryByJob() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
+        "SELECT ARG_MIN(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `JOB`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
@@ -205,7 +205,7 @@ public void testLatestSalaryByJob() {
     RelNode root = getRelNode(ppl);
     String expectedLogical =
         "LogicalProject(latest_salary=[$1], JOB=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], latest_salary=[MAX_BY($1, $2)])\n"
+            + "  LogicalAggregate(group=[{0}], latest_salary=[ARG_MAX($1, $2)])\n"
             + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
             + "      LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -219,7 +219,7 @@ public void testLatestSalaryByJob() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MAX_BY`(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
+        "SELECT ARG_MAX(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
             + "FROM `scott`.`EMP`\n"
             + "GROUP BY `JOB`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
@@ -230,7 +230,7 @@ public void testEarliestWithAlias() {
     String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as first_hired";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], first_hired=[MIN_BY($0, $1)])\n"
+        "LogicalAggregate(group=[{}], first_hired=[ARG_MIN($0, $1)])\n"
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -239,7 +239,7 @@ public void testEarliestWithAlias() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MIN_BY`(`ENAME`, `HIREDATE`) `first_hired`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `first_hired`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
@@ -248,7 +248,7 @@ public void testLatestWithAlias() {
     String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as last_hired";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], last_hired=[MAX_BY($0, $1)])\n"
+        "LogicalAggregate(group=[{}], last_hired=[ARG_MAX($0, $1)])\n"
             + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
             + "    LogicalTableScan(table=[[scott, EMP]])\n";
     verifyLogical(root, expectedLogical);
@@ -257,7 +257,7 @@ public void testLatestWithAlias() {
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT `MAX_BY`(`ENAME`, `HIREDATE`) `last_hired`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `last_hired`\n" + "FROM `scott`.`EMP`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 }

From 988bcb606efa58f5da83e84fcb1cecefe8794dcd Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 13:24:56 -0700
Subject: [PATCH 06/26] Revert comment deletion

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../sql/ppl/parser/AstExpressionBuilder.java  | 56 +++++++++++--------
 1 file changed, 34 insertions(+), 22 deletions(-)

diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index d4813f2a0b0..2aa14f8cc89 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -7,8 +7,6 @@
 
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.Arrays;
 import java.util.Collections;
@@ -19,6 +17,7 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
+
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.antlr.v4.runtime.RuleContext;
 import org.opensearch.sql.ast.dsl.AstDSL;
@@ -69,6 +68,7 @@
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.DistinctCountFunctionCallContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.DoubleLiteralContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.EvalClauseContext;
+import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.EvalFunctionCallContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.FieldExpressionContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.FloatLiteralContext;
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParser.IdentsAsQualifiedNameContext;
@@ -92,6 +92,9 @@
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParserBaseVisitor;
 import org.opensearch.sql.ppl.utils.ArgumentFactory;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
 /** Class of building AST Expression nodes. */
 public class AstExpressionBuilder extends OpenSearchPPLParserBaseVisitor<UnresolvedExpression> {
 
@@ -110,11 +113,13 @@ public AstExpressionBuilder(AstBuilder astBuilder) {
     this.astBuilder = astBuilder;
   }
 
+  /** Eval clause. */
   @Override
   public UnresolvedExpression visitEvalClause(EvalClauseContext ctx) {
     return new Let((Field) visit(ctx.fieldExpression()), visit(ctx.logicalExpression()));
   }
 
+  /** Trendline clause. */
   @Override
   public Trendline.TrendlineComputation visitTrendlineClause(
       OpenSearchPPLParser.TrendlineClauseContext ctx) {
@@ -136,6 +141,7 @@ public Trendline.TrendlineComputation visitTrendlineClause(
         numberOfDataPoints, dataField, alias, computationType);
   }
 
+  /** Logical expression excluding boolean, comparison. */
   @Override
   public UnresolvedExpression visitLogicalNot(LogicalNotContext ctx) {
     return new Not(visit(ctx.logicalExpression()));
@@ -156,6 +162,7 @@ public UnresolvedExpression visitLogicalXor(LogicalXorContext ctx) {
     return new Xor(visit(ctx.left), visit(ctx.right));
   }
 
+  /** lambda expression */
   @Override
   public UnresolvedExpression visitLambda(OpenSearchPPLParser.LambdaContext ctx) {
     List<QualifiedName> arguments =
@@ -166,6 +173,7 @@ public UnresolvedExpression visitLambda(OpenSearchPPLParser.LambdaContext ctx) {
     return new LambdaFunction(function, arguments);
   }
 
+  /** Comparison expression. */
   @Override
   public UnresolvedExpression visitCompareExpr(CompareExprContext ctx) {
     String operator = ctx.comparisonOperator().getText();
@@ -188,6 +196,7 @@ public UnresolvedExpression visitInExpr(InExprContext ctx) {
     return ctx.NOT() != null ? new Not(expr) : expr;
   }
 
+  /** Value Expression. */
   @Override
   public UnresolvedExpression visitBinaryArithmetic(BinaryArithmeticContext ctx) {
     return new Function(ctx.binaryOperator.getText(), buildArguments(ctx.left, ctx.right));
@@ -205,9 +214,10 @@ private List<UnresolvedExpression> buildArguments(
 
   @Override
   public UnresolvedExpression visitNestedValueExpr(OpenSearchPPLParser.NestedValueExprContext ctx) {
-    return visit(ctx.logicalExpression());
+    return visit(ctx.logicalExpression()); // Discard parenthesis around
   }
 
+  /** Field expression. */
   @Override
   public UnresolvedExpression visitFieldExpression(FieldExpressionContext ctx) {
     return new Field((QualifiedName) visit(ctx.qualifiedName()));
@@ -244,11 +254,10 @@ public UnresolvedExpression visitSortField(SortFieldContext ctx) {
     return new Field(fieldExpression, ArgumentFactory.getArgumentList(ctx));
   }
 
+  /** Aggregation function. */
   @Override
   public UnresolvedExpression visitStatsFunctionCall(StatsFunctionCallContext ctx) {
-    String functionName = ctx.statsFunctionName().getText();
-    UnresolvedExpression argument = visit(ctx.valueExpression());
-    return new AggregateFunction(functionName, argument);
+    return new AggregateFunction(ctx.statsFunctionName().getText(), visit(ctx.valueExpression()));
   }
 
   @Override
@@ -356,22 +365,7 @@ public UnresolvedExpression visitEarliestLatestFunction(
     }
   }
 
-  @Override
-  public UnresolvedExpression visitEvalFunctionCall(
-      OpenSearchPPLParser.EvalFunctionCallContext ctx) {
-    final String functionName = ctx.evalFunctionName().getText();
-    final String mappedName =
-        FUNCTION_NAME_MAPPING.getOrDefault(functionName.toLowerCase(Locale.ROOT), functionName);
-
-    // Rewrite sum and avg functions to arithmetic expressions
-    if (SUM.getName().getFunctionName().equalsIgnoreCase(mappedName)
-        || AVG.getName().getFunctionName().equalsIgnoreCase(mappedName)) {
-      return rewriteSumAvgFunction(mappedName, ctx.functionArgs().functionArg());
-    }
-
-    return buildFunction(mappedName, ctx.functionArgs().functionArg());
-  }
-
+  /** Case function. */
   @Override
   public UnresolvedExpression visitCaseFunctionCall(
       OpenSearchPPLParser.CaseFunctionCallContext ctx) {
@@ -391,6 +385,23 @@ public UnresolvedExpression visitCaseFunctionCall(
     return new Case(null, whens, Optional.ofNullable(elseValue));
   }
 
+  /** Eval function. */
+  @Override
+  public UnresolvedExpression visitEvalFunctionCall(EvalFunctionCallContext ctx) {
+    final String functionName = ctx.evalFunctionName().getText();
+    final String mappedName =
+        FUNCTION_NAME_MAPPING.getOrDefault(functionName.toLowerCase(Locale.ROOT), functionName);
+
+    // Rewrite sum and avg functions to arithmetic expressions
+    if (SUM.getName().getFunctionName().equalsIgnoreCase(mappedName)
+        || AVG.getName().getFunctionName().equalsIgnoreCase(mappedName)) {
+      return rewriteSumAvgFunction(mappedName, ctx.functionArgs().functionArg());
+    }
+
+    return buildFunction(mappedName, ctx.functionArgs().functionArg());
+  }
+
+  /** Cast function. */
   @Override
   public UnresolvedExpression visitDataTypeFunctionCall(DataTypeFunctionCallContext ctx) {
     return new Cast(visit(ctx.logicalExpression()), visit(ctx.convertedDataType()));
@@ -535,6 +546,7 @@ private List<UnresolvedExpression> timestampFunctionArguments(
     return args;
   }
 
+  /** Literal and value. */
   @Override
   public UnresolvedExpression visitIdentsAsQualifiedName(IdentsAsQualifiedNameContext ctx) {
     return visitIdentifiers(ctx.ident());

From 462d2d54f826a7a74f16ecb3428793082eafa7e8 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 13:36:45 -0700
Subject: [PATCH 07/26] Remove min_by and max_by

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../function/BuiltinFunctionName.java         |  6 ++--
 .../expression/function/PPLFuncImpTable.java  | 34 +++----------------
 .../EarliestLatestAggFunctionTest.java        |  5 +--
 .../sql/ppl/NewAddedCommandsIT.java           | 17 +---------
 .../explain_earliest_latest.json              |  4 +--
 .../explain_earliest_latest_custom_time.json  |  4 +--
 6 files changed, 14 insertions(+), 56 deletions(-)

diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
index db872b1016d..b7a0222b5da 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
@@ -5,11 +5,13 @@
 
 package org.opensearch.sql.expression.function;
 
-import com.google.common.collect.ImmutableMap;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+
+import com.google.common.collect.ImmutableMap;
+
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
@@ -200,8 +202,6 @@ public enum BuiltinFunctionName {
   PERCENTILE_APPROX(FunctionName.of("percentile_approx")),
   EARLIEST(FunctionName.of("earliest")),
   LATEST(FunctionName.of("latest")),
-  MAX_BY(FunctionName.of("max_by")),
-  MIN_BY(FunctionName.of("min_by")),
   DISTINCT_COUNT_APPROX(FunctionName.of("distinct_count_approx")),
   // Not always an aggregation query
   NESTED(FunctionName.of("nested")),
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index c04b4741fa9..924703ea030 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -15,8 +15,6 @@
 import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.createAggregateFunction;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -29,7 +27,9 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+
 import javax.annotation.Nullable;
+
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLambda;
@@ -63,6 +63,9 @@
 import org.opensearch.sql.exception.ExpressionEvaluationException;
 import org.opensearch.sql.executor.QueryType;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
 public class PPLFuncImpTable {
   private static final Logger logger = LogManager.getLogger(PPLFuncImpTable.class);
 
@@ -971,33 +974,6 @@ void populate() {
                   ctx.relBuilder),
           null);
 
-      // Use Calcite's built-in ARG_MAX and ARG_MIN functions
-      register(
-          MAX_BY,
-          (distinct, field, argList, ctx) -> {
-            if (argList.isEmpty()) {
-              throw new IllegalArgumentException(
-                  "MAX_BY requires exactly 2 arguments: value_field and order_field");
-            }
-            RexNode orderField = PlanUtils.derefMapCall(argList.get(0));
-            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MAX, field, orderField);
-          },
-          wrapSqlOperandTypeChecker(
-              SqlStdOperatorTable.ARG_MAX.getOperandTypeChecker(), MAX_BY.name(), false));
-
-      register(
-          MIN_BY,
-          (distinct, field, argList, ctx) -> {
-            if (argList.isEmpty()) {
-              throw new IllegalArgumentException(
-                  "MIN_BY requires exactly 2 arguments: value_field and order_field");
-            }
-            RexNode orderField = PlanUtils.derefMapCall(argList.get(0));
-            return ctx.relBuilder.aggregateCall(SqlStdOperatorTable.ARG_MIN, field, orderField);
-          },
-          wrapSqlOperandTypeChecker(
-              SqlStdOperatorTable.ARG_MIN.getOperandTypeChecker(), MIN_BY.name(), false));
-
       register(
           EARLIEST,
           (distinct, field, argList, ctx) -> {
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
index efb29f8040d..24d30692271 100644
--- a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
+++ b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
@@ -8,6 +8,7 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
 import java.util.Set;
+
 import org.junit.jupiter.api.Test;
 
 public class EarliestLatestAggFunctionTest extends AggFunctionTestBase {
@@ -55,8 +56,6 @@ void testResolveAggWithValidFunctions() {
   @Test
   void testFunctionRegistryIntegrity() {
     assertFunctionsAreRegistered(
-        BuiltinFunctionName.MAX_BY,
-        BuiltinFunctionName.MIN_BY,
         BuiltinFunctionName.EARLIEST,
         BuiltinFunctionName.LATEST);
   }
@@ -77,8 +76,6 @@ void testFunctionRegistrySize() {
             BuiltinFunctionName.AVG,
             BuiltinFunctionName.MAX,
             BuiltinFunctionName.MIN,
-            BuiltinFunctionName.MAX_BY,
-            BuiltinFunctionName.MIN_BY,
             BuiltinFunctionName.EARLIEST,
             BuiltinFunctionName.LATEST);
 
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 94174bccc18..f34784e5bc4 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -12,6 +12,7 @@
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
 
 import java.io.IOException;
+
 import org.json.JSONObject;
 import org.junit.jupiter.api.Test;
 import org.opensearch.client.ResponseException;
@@ -165,22 +166,6 @@ public void testEarliestLatestAggregates() throws IOException {
     verifyQuery(result);
   }
 
-  @Test
-  public void testMaxByMinByAggregates() throws IOException {
-    JSONObject result;
-    try {
-      result =
-          executeQuery(
-              String.format(
-                  "search source=%s | stats max_by(firstname, age) as oldest_name,"
-                      + " min_by(firstname, age) as youngest_name by gender",
-                  TEST_INDEX_BANK));
-    } catch (ResponseException e) {
-      result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
-    }
-    verifyQuery(result);
-  }
-
   private void verifyQuery(JSONObject result) throws IOException {
     if (isCalciteEnabled()) {
       assertFalse(result.getJSONArray("datarows").isEmpty());
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
index d63ef44c60e..ec50ef3fa2d 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{10}], earliest_name=[MIN_BY($2, $7)], latest_name=[MAX_BY($2, $7)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{10}], earliest_name=[ARG_MIN($2, $7)], latest_name=[ARG_MAX($2, $7)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
   }
 }
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
index 44d954e1e0e..f5b3a3e8310 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[MIN_BY($1, $2)], latest_name=[MAX_BY($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{4}], earliest_name=[MIN_BY($2, $1)], latest_name=[MAX_BY($2, $1)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{4}], earliest_name=[ARG_MIN($2, $1)], latest_name=[ARG_MAX($2, $1)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
   }
 }

From 52691d8f310d22a8af78c73e9d5d52ca8beef813 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 13:38:35 -0700
Subject: [PATCH 08/26] Update doc

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/user/ppl/cmd/stats.rst | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/user/ppl/cmd/stats.rst b/docs/user/ppl/cmd/stats.rst
index 1bc79ab4933..05ebed49345 100644
--- a/docs/user/ppl/cmd/stats.rst
+++ b/docs/user/ppl/cmd/stats.rst
@@ -308,7 +308,7 @@ EARLIEST
 Description
 >>>>>>>>>>>
 
-Version: 3.1.0
+Version: 3.3.0
 
 Usage: EARLIEST(field [, time_field]). Return the earliest value of a field based on timestamp ordering.
 
@@ -343,7 +343,7 @@ LATEST
 Description
 >>>>>>>>>>>
 
-Version: 3.1.0
+Version: 3.3.0
 
 Usage: LATEST(field [, time_field]). Return the latest value of a field based on timestamp ordering.
 

From 68c10c4c77ac6c2057b4b984b6e53cec9926f20b Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 14:27:34 -0700
Subject: [PATCH 09/26] Fix tests

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../calcite/CalcitePPLEarliestLatestTest.java | 330 +++++++++---------
 1 file changed, 166 insertions(+), 164 deletions(-)

diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
index 4ebe73363be..61eedf66ae4 100644
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
@@ -5,259 +5,261 @@
 
 package org.opensearch.sql.ppl.calcite;
 
+import java.sql.Date;
+import java.util.List;
+
+import org.apache.calcite.DataContext;
+import org.apache.calcite.config.CalciteConnectionConfig;
+import org.apache.calcite.linq4j.Enumerable;
+import org.apache.calcite.linq4j.Linq4j;
+import org.apache.calcite.plan.RelTraitDef;
+import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rel.type.RelProtoDataType;
+import org.apache.calcite.schema.ScannableTable;
+import org.apache.calcite.schema.Schema;
+import org.apache.calcite.schema.SchemaPlus;
+import org.apache.calcite.schema.Statistic;
+import org.apache.calcite.schema.Statistics;
+import org.apache.calcite.sql.SqlCall;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.parser.SqlParser;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.calcite.test.CalciteAssert;
+import org.apache.calcite.tools.Frameworks;
+import org.apache.calcite.tools.Programs;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Test;
 
-public class CalcitePPLEarliestLatestTest extends CalcitePPLAggregationTestBase {
+import com.google.common.collect.ImmutableList;
 
-  @Test
-  public void testEarliestFunction() {
-    testAggregationWithAlias(
-        "earliest(ENAME, HIREDATE)",
-        "earliest_name",
-        createSimpleAggLogicalPattern(
-            "earliest_name=[ARG_MIN($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
-        "earliest_name=SMITH\n",
-        createSimpleAggSparkSql("ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`"));
-  }
+import lombok.RequiredArgsConstructor;
 
-  @Test
-  public void testLatestFunction() {
-    testAggregationWithAlias(
-        "latest(ENAME, HIREDATE)",
-        "latest_name",
-        createSimpleAggLogicalPattern(
-            "latest_name=[ARG_MAX($0, $1)]", "LogicalProject(ENAME=[$1], HIREDATE=[$4])"),
-        "latest_name=ADAMS\n",
-        createSimpleAggSparkSql("ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`"));
+/** Unit tests for {@code earliest/latest} functions with @timestamp field in PPL. */
+public class CalcitePPLEarliestLatestTest extends CalcitePPLAbstractTest {
+  public CalcitePPLEarliestLatestTest() {
+    super(CalciteAssert.SchemaSpec.POST);
   }
 
-  @Test
-  public void testEarliestWithCustomTimeField() {
-    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name";
-    RelNode root = getRelNode(ppl);
-    String expectedLogical =
-        "LogicalAggregate(group=[{}], earliest_name=[ARG_MIN($0, $1)])\n"
-            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
-            + "    LogicalTableScan(table=[[scott, EMP]])\n";
-    verifyLogical(root, expectedLogical);
-
-    String expectedResult = "earliest_name=SMITH\n";
-    verifyResult(root, expectedResult);
-
-    String expectedSparkSql =
-        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`\n" + "FROM `scott`.`EMP`";
-    verifyPPLToSparkSQL(root, expectedSparkSql);
+  @Override
+  protected Frameworks.ConfigBuilder config(CalciteAssert.SchemaSpec... schemaSpecs) {
+    final SchemaPlus rootSchema = Frameworks.createRootSchema(true);
+    final SchemaPlus schema = CalciteAssert.addSchema(rootSchema, schemaSpecs);
+    
+    // Add a test table with @timestamp and created_at fields
+    // Note: @timestamp and created_at have different orderings to test explicit field usage
+    ImmutableList<Object[]> rows =
+        ImmutableList.of(
+            new Object[] {"server1", "ERROR", "Database connection failed", Date.valueOf("2023-01-01"), Date.valueOf("2023-01-05")},
+            new Object[] {"server2", "INFO", "Service started", Date.valueOf("2023-01-02"), Date.valueOf("2023-01-04")},
+            new Object[] {"server1", "WARN", "High memory usage", Date.valueOf("2023-01-03"), Date.valueOf("2023-01-03")},
+            new Object[] {"server3", "ERROR", "Disk space low", Date.valueOf("2023-01-04"), Date.valueOf("2023-01-02")},
+            new Object[] {"server2", "INFO", "Backup completed", Date.valueOf("2023-01-05"), Date.valueOf("2023-01-01")});
+    schema.add("LOGS", new LogsTable(rows));
+    
+    return Frameworks.newConfigBuilder()
+        .parserConfig(SqlParser.Config.DEFAULT)
+        .defaultSchema(schema)
+        .traitDefs((List<RelTraitDef>) null)
+        .programs(Programs.heuristicJoinOrder(Programs.RULE_SET, true, 2));
   }
 
   @Test
-  public void testLatestWithCustomTimeField() {
-    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as latest_name";
+  public void testEarliestWithoutSecondArgument() {
+    String ppl = "source=LOGS | stats earliest(message) as earliest_message";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], latest_name=[ARG_MAX($0, $1)])\n"
-            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
-            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalAggregate(group=[{}], earliest_message=[ARG_MIN($0, $1)])\n"
+            + "  LogicalProject(message=[$2], @timestamp=[$3])\n"
+            + "    LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
-    String expectedResult = "latest_name=ADAMS\n";
+    String expectedResult = "earliest_message=Database connection failed\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MIN(`message`, `@timestamp`) `earliest_message`\n" + "FROM `POST`.`LOGS`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testEarliestByDepartment() {
-    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name by DEPTNO";
+  public void testLatestWithoutSecondArgument() {
+    String ppl = "source=LOGS | stats latest(message) as latest_message";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalProject(earliest_name=[$1], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)])\n"
-            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalAggregate(group=[{}], latest_message=[ARG_MAX($0, $1)])\n"
+            + "  LogicalProject(message=[$2], @timestamp=[$3])\n"
+            + "    LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
-    // Results should show earliest hired employee per department
-    String expectedResult =
-        "earliest_name=SMITH; DEPTNO=20\n"
-            + "earliest_name=CLARK; DEPTNO=10\n"
-            + "earliest_name=BLAKE; DEPTNO=30\n";
+    String expectedResult = "latest_message=Backup completed\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, `DEPTNO`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `DEPTNO`";
+        "SELECT ARG_MAX(`message`, `@timestamp`) `latest_message`\n" + "FROM `POST`.`LOGS`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testLatestByDepartment() {
-    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as latest_name by DEPTNO";
+  public void testEarliestByServerWithoutSecondArgument() {
+    String ppl = "source=LOGS | stats earliest(message) as earliest_message by server";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalProject(latest_name=[$1], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], latest_name=[ARG_MAX($1, $2)])\n"
-            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalProject(earliest_message=[$1], server=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)])\n"
+            + "    LogicalProject(server=[$0], message=[$2], @timestamp=[$3])\n"
+            + "      LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
     String expectedResult =
-        "latest_name=ADAMS; DEPTNO=20\n"
-            + "latest_name=MILLER; DEPTNO=10\n"
-            + "latest_name=JAMES; DEPTNO=30\n";
+        "earliest_message=Disk space low; server=server3\n"
+            + "earliest_message=Service started; server=server2\n"
+            + "earliest_message=Database connection failed; server=server1\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `DEPTNO`";
+        "SELECT ARG_MIN(`message`, `@timestamp`) `earliest_message`, `server`\n"
+            + "FROM `POST`.`LOGS`\n"
+            + "GROUP BY `server`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testEarliestLatestCombined() {
-    String ppl =
-        "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, latest(ENAME, HIREDATE) as"
-            + " latest_name by DEPTNO";
+  public void testLatestByServerWithoutSecondArgument() {
+    String ppl = "source=LOGS | stats latest(message) as latest_message by server";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalProject(earliest_name=[$1], latest_name=[$2], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)],"
-            + " latest_name=[ARG_MAX($1, $2)])\n"
-            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalProject(latest_message=[$1], server=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], latest_message=[ARG_MAX($1, $2)])\n"
+            + "    LogicalProject(server=[$0], message=[$2], @timestamp=[$3])\n"
+            + "      LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
     String expectedResult =
-        "earliest_name=SMITH; latest_name=ADAMS; DEPTNO=20\n"
-            + "earliest_name=CLARK; latest_name=MILLER; DEPTNO=10\n"
-            + "earliest_name=BLAKE; latest_name=JAMES; DEPTNO=30\n";
+        "latest_message=Disk space low; server=server3\n"
+            + "latest_message=Backup completed; server=server2\n"
+            + "latest_message=High memory usage; server=server1\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, "
-            + "ARG_MAX(`ENAME`, `HIREDATE`) `latest_name`, `DEPTNO`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `DEPTNO`";
+        "SELECT ARG_MAX(`message`, `@timestamp`) `latest_message`, `server`\n"
+            + "FROM `POST`.`LOGS`\n"
+            + "GROUP BY `server`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testEarliestWithOtherAggregates() {
+  public void testEarliestWithOtherAggregatesWithoutSecondArgument() {
     String ppl =
-        "source=EMP | stats earliest(ENAME, HIREDATE) as earliest_name, count() as cnt, avg(SAL) as"
-            + " avg_sal by DEPTNO";
-    RelNode root = getRelNode(ppl);
-    String expectedLogical =
-        "LogicalProject(earliest_name=[$1], cnt=[$2], avg_sal=[$3], DEPTNO=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], cnt=[COUNT()],"
-            + " avg_sal=[AVG($3)])\n"
-            + "    LogicalProject(DEPTNO=[$7], ENAME=[$1], HIREDATE=[$4], SAL=[$5])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
-    verifyLogical(root, expectedLogical);
-
-    String expectedResult =
-        "earliest_name=SMITH; cnt=5; avg_sal=2175.; DEPTNO=20\n"
-            + "earliest_name=CLARK; cnt=3; avg_sal=2916.666666; DEPTNO=10\n"
-            + "earliest_name=BLAKE; cnt=6; avg_sal=1566.666666; DEPTNO=30\n";
-    verifyResult(root, expectedResult);
-
-    String expectedSparkSql =
-        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `earliest_name`, "
-            + "COUNT(*) `cnt`, AVG(`SAL`) `avg_sal`, `DEPTNO`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `DEPTNO`";
-    verifyPPLToSparkSQL(root, expectedSparkSql);
-  }
-
-  @Test
-  public void testEarliestSalaryByJob() {
-    String ppl = "source=EMP | stats earliest(SAL, HIREDATE) as earliest_salary by JOB";
+        "source=LOGS | stats earliest(message) as earliest_message, count() as cnt by server";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalProject(earliest_salary=[$1], JOB=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], earliest_salary=[ARG_MIN($1, $2)])\n"
-            + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalProject(earliest_message=[$1], cnt=[$2], server=[$0])\n"
+            + "  LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], cnt=[COUNT()])\n"
+            + "    LogicalProject(server=[$0], message=[$2], @timestamp=[$3])\n"
+            + "      LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
     String expectedResult =
-        "earliest_salary=1600.00; JOB=SALESMAN\n"
-            + "earliest_salary=3000.00; JOB=ANALYST\n"
-            + "earliest_salary=800.00; JOB=CLERK\n"
-            + "earliest_salary=5000.00; JOB=PRESIDENT\n"
-            + "earliest_salary=2850.00; JOB=MANAGER\n";
+        "earliest_message=Disk space low; cnt=1; server=server3\n"
+            + "earliest_message=Service started; cnt=2; server=server2\n"
+            + "earliest_message=Database connection failed; cnt=2; server=server1\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MIN(`SAL`, `HIREDATE`) `earliest_salary`, `JOB`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `JOB`";
+        "SELECT ARG_MIN(`message`, `@timestamp`) `earliest_message`, "
+            + "COUNT(*) `cnt`, `server`\n"
+            + "FROM `POST`.`LOGS`\n"
+            + "GROUP BY `server`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testLatestSalaryByJob() {
-    String ppl = "source=EMP | stats latest(SAL, HIREDATE) as latest_salary by JOB";
+  public void testEarliestWithExplicitTimestampField() {
+    String ppl = "source=LOGS | stats earliest(message, created_at) as earliest_message";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalProject(latest_salary=[$1], JOB=[$0])\n"
-            + "  LogicalAggregate(group=[{0}], latest_salary=[ARG_MAX($1, $2)])\n"
-            + "    LogicalProject(JOB=[$2], SAL=[$5], HIREDATE=[$4])\n"
-            + "      LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalAggregate(group=[{}], earliest_message=[ARG_MIN($0, $1)])\n"
+            + "  LogicalProject(message=[$2], created_at=[$4])\n"
+            + "    LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
-    String expectedResult =
-        "latest_salary=1250.00; JOB=SALESMAN\n"
-            + "latest_salary=3000.00; JOB=ANALYST\n"
-            + "latest_salary=1100.00; JOB=CLERK\n"
-            + "latest_salary=5000.00; JOB=PRESIDENT\n"
-            + "latest_salary=2450.00; JOB=MANAGER\n";
+    String expectedResult = "earliest_message=Backup completed\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MAX(`SAL`, `HIREDATE`) `latest_salary`, `JOB`\n"
-            + "FROM `scott`.`EMP`\n"
-            + "GROUP BY `JOB`";
+        "SELECT ARG_MIN(`message`, `created_at`) `earliest_message`\n" + "FROM `POST`.`LOGS`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
   @Test
-  public void testEarliestWithAlias() {
-    String ppl = "source=EMP | stats earliest(ENAME, HIREDATE) as first_hired";
+  public void testLatestWithExplicitTimestampField() {
+    String ppl = "source=LOGS | stats latest(message, created_at) as latest_message";
     RelNode root = getRelNode(ppl);
     String expectedLogical =
-        "LogicalAggregate(group=[{}], first_hired=[ARG_MIN($0, $1)])\n"
-            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
-            + "    LogicalTableScan(table=[[scott, EMP]])\n";
+        "LogicalAggregate(group=[{}], latest_message=[ARG_MAX($0, $1)])\n"
+            + "  LogicalProject(message=[$2], created_at=[$4])\n"
+            + "    LogicalTableScan(table=[[POST, LOGS]])\n";
     verifyLogical(root, expectedLogical);
 
-    String expectedResult = "first_hired=SMITH\n";
+    String expectedResult = "latest_message=Database connection failed\n";
     verifyResult(root, expectedResult);
 
     String expectedSparkSql =
-        "SELECT ARG_MIN(`ENAME`, `HIREDATE`) `first_hired`\n" + "FROM `scott`.`EMP`";
+        "SELECT ARG_MAX(`message`, `created_at`) `latest_message`\n" + "FROM `POST`.`LOGS`";
     verifyPPLToSparkSQL(root, expectedSparkSql);
   }
 
-  @Test
-  public void testLatestWithAlias() {
-    String ppl = "source=EMP | stats latest(ENAME, HIREDATE) as last_hired";
-    RelNode root = getRelNode(ppl);
-    String expectedLogical =
-        "LogicalAggregate(group=[{}], last_hired=[ARG_MAX($0, $1)])\n"
-            + "  LogicalProject(ENAME=[$1], HIREDATE=[$4])\n"
-            + "    LogicalTableScan(table=[[scott, EMP]])\n";
-    verifyLogical(root, expectedLogical);
-
-    String expectedResult = "last_hired=ADAMS\n";
-    verifyResult(root, expectedResult);
-
-    String expectedSparkSql =
-        "SELECT ARG_MAX(`ENAME`, `HIREDATE`) `last_hired`\n" + "FROM `scott`.`EMP`";
-    verifyPPLToSparkSQL(root, expectedSparkSql);
+  // Custom table implementation with @timestamp field
+  @RequiredArgsConstructor
+  public static class LogsTable implements ScannableTable {
+    private final ImmutableList<Object[]> rows;
+
+    protected final RelProtoDataType protoRowType =
+        factory ->
+            factory
+                .builder()
+                .add("server", SqlTypeName.VARCHAR)
+                .add("level", SqlTypeName.VARCHAR)
+                .add("message", SqlTypeName.VARCHAR)
+                .add("@timestamp", SqlTypeName.DATE)
+                .add("created_at", SqlTypeName.DATE)
+                .build();
+
+    @Override
+    public Enumerable<@Nullable Object[]> scan(DataContext root) {
+      return Linq4j.asEnumerable(rows);
+    }
+
+    @Override
+    public RelDataType getRowType(RelDataTypeFactory typeFactory) {
+      return protoRowType.apply(typeFactory);
+    }
+
+    @Override
+    public Statistic getStatistic() {
+      return Statistics.of(0d, ImmutableList.of(), RelCollations.createSingleton(0));
+    }
+
+    @Override
+    public Schema.TableType getJdbcTableType() {
+      return Schema.TableType.TABLE;
+    }
+
+    @Override
+    public boolean isRolledUp(String column) {
+      return false;
+    }
+
+    @Override
+    public boolean rolledUpColumnValidInsideAgg(
+        String column,
+        SqlCall call,
+        @Nullable SqlNode parent,
+        @Nullable CalciteConnectionConfig config) {
+      return false;
+    }
   }
 }

From cc47e60364042d2095baaac4e37d9199c011677c Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 14:53:13 -0700
Subject: [PATCH 10/26] Fix tests

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../function/BuiltinFunctionName.java         |  4 +-
 .../expression/function/PPLFuncImpTable.java  |  7 +--
 .../EarliestLatestAggFunctionTest.java        |  5 +-
 .../sql/calcite/remote/CalciteExplainIT.java  | 17 ++++---
 .../sql/legacy/SQLIntegTestCase.java          |  6 +--
 .../opensearch/sql/legacy/TestsConstants.java |  3 +-
 .../sql/ppl/NewAddedCommandsIT.java           | 13 +++--
 .../resources/accounts_with_timestamp.json    | 12 -----
 integ-test/src/test/resources/logs.json       | 10 ++++
 .../sql/ppl/parser/AstExpressionBuilder.java  |  6 +--
 .../CalcitePPLAggregationTestBase.java        | 47 -----------------
 .../calcite/CalcitePPLEarliestLatestTest.java | 51 ++++++++++++++-----
 12 files changed, 75 insertions(+), 106 deletions(-)
 delete mode 100644 integ-test/src/test/resources/accounts_with_timestamp.json
 create mode 100644 integ-test/src/test/resources/logs.json
 delete mode 100644 ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java

diff --git a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
index b7a0222b5da..db7919e4de2 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java
@@ -5,13 +5,11 @@
 
 package org.opensearch.sql.expression.function;
 
+import com.google.common.collect.ImmutableMap;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-
-import com.google.common.collect.ImmutableMap;
-
 import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index 924703ea030..df4449d658c 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -15,6 +15,8 @@
 import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.createAggregateFunction;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -27,9 +29,7 @@
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-
 import javax.annotation.Nullable;
-
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLambda;
@@ -63,9 +63,6 @@
 import org.opensearch.sql.exception.ExpressionEvaluationException;
 import org.opensearch.sql.executor.QueryType;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-
 public class PPLFuncImpTable {
   private static final Logger logger = LogManager.getLogger(PPLFuncImpTable.class);
 
diff --git a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
index 24d30692271..3e1ca244a65 100644
--- a/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
+++ b/core/src/test/java/org/opensearch/sql/expression/function/EarliestLatestAggFunctionTest.java
@@ -8,7 +8,6 @@
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
 import java.util.Set;
-
 import org.junit.jupiter.api.Test;
 
 public class EarliestLatestAggFunctionTest extends AggFunctionTestBase {
@@ -55,9 +54,7 @@ void testResolveAggWithValidFunctions() {
 
   @Test
   void testFunctionRegistryIntegrity() {
-    assertFunctionsAreRegistered(
-        BuiltinFunctionName.EARLIEST,
-        BuiltinFunctionName.LATEST);
+    assertFunctionsAreRegistered(BuiltinFunctionName.EARLIEST, BuiltinFunctionName.LATEST);
   }
 
   @Test
diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
index dceb0b24190..264a76d1b12 100644
--- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalciteExplainIT.java
@@ -6,13 +6,13 @@
 package org.opensearch.sql.calcite.remote;
 
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_LOGS;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_NESTED_SIMPLE;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
 import static org.opensearch.sql.util.MatcherUtils.assertJsonEqualsIgnoreId;
 
 import java.io.IOException;
 import java.util.Locale;
-
 import org.junit.Assume;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -26,7 +26,7 @@ public void init() throws Exception {
     loadIndex(Index.BANK_WITH_STRING_VALUES);
     loadIndex(Index.NESTED_SIMPLE);
     loadIndex(Index.TIME_TEST_DATA);
-    loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
+    loadIndex(Index.LOGS);
   }
 
   @Override
@@ -346,8 +346,10 @@ public void testExplainOnEarliestLatest() throws IOException {
     assertJsonEqualsIgnoreId(
         expected,
         explainQueryToString(
-            "source=opensearch-sql_test_index_account_with_timestamp | stats earliest(firstname) as"
-                + " earliest_name, latest(firstname) as latest_name by state"));
+            String.format(
+                "source=%s | stats earliest(message) as earliest_message, latest(message) as"
+                    + " latest_message by server",
+                TEST_INDEX_LOGS)));
   }
 
   // Only for Calcite
@@ -357,9 +359,10 @@ public void testExplainOnEarliestLatestWithCustomTimeField() throws IOException
     assertJsonEqualsIgnoreId(
         expected,
         explainQueryToString(
-            "source=opensearch-sql_test_index_account_with_timestamp | stats earliest(firstname,"
-                + " created_time) as earliest_name, latest(firstname, created_time) as latest_name"
-                + " by gender"));
+            String.format(
+                "source=%s | stats earliest(message, created_at) as earliest_message,"
+                    + " latest(message, created_at) as latest_message by level",
+                TEST_INDEX_LOGS)));
   }
 
   /**
diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
index 08509a9d6bc..b14655a226b 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
@@ -560,11 +560,6 @@ public enum Index {
         "account",
         getAccountIndexMapping(),
         "src/test/resources/accounts.json"),
-    ACCOUNT_WITH_TIMESTAMP(
-        TestsConstants.TEST_INDEX_ACCOUNT_WITH_TIMESTAMP,
-        "account",
-        getAccountIndexMapping(),
-        "src/test/resources/accounts_with_timestamp.json"),
     PHRASE(
         TestsConstants.TEST_INDEX_PHRASE,
         "phrase",
@@ -900,6 +895,7 @@ public enum Index {
         "hdfs_logs",
         getHdfsLogsIndexMapping(),
         "src/test/resources/hdfs_logs.json"),
+    LOGS(TestsConstants.TEST_INDEX_LOGS, "logs", null, "src/test/resources/logs.json"),
     TIME_TEST_DATA(
         "opensearch-sql_test_index_time_data",
         "time_data",
diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java b/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
index 0ba373f90a8..fd017d3ee61 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/TestsConstants.java
@@ -15,8 +15,6 @@ public class TestsConstants {
 
   public static final String TEST_INDEX_ONLINE = TEST_INDEX + "_online";
   public static final String TEST_INDEX_ACCOUNT = TEST_INDEX + "_account";
-  public static final String TEST_INDEX_ACCOUNT_WITH_TIMESTAMP =
-      TEST_INDEX + "_account_with_timestamp";
   public static final String TEST_INDEX_PHRASE = TEST_INDEX + "_phrase";
   public static final String TEST_INDEX_DOG = TEST_INDEX + "_dog";
   public static final String TEST_INDEX_DOG2 = TEST_INDEX + "_dog2";
@@ -81,6 +79,7 @@ public class TestsConstants {
   public static final String TEST_INDEX_MERGE_TEST_WILDCARD = TEST_INDEX + "_merge_test_*";
   public static final String TEST_INDEX_ARRAY = TEST_INDEX + "_array";
   public static final String TEST_INDEX_HDFS_LOGS = TEST_INDEX + "_hdfs_logs";
+  public static final String TEST_INDEX_LOGS = TEST_INDEX + "_logs";
 
   public static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
   public static final String TS_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss.SSS";
diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index f34784e5bc4..8361dcf7739 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -8,11 +8,12 @@
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.opensearch.sql.common.setting.Settings.Key.CALCITE_ENGINE_ENABLED;
-import static org.opensearch.sql.legacy.TestsConstants.*;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DOG;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_LOGS;
 
 import java.io.IOException;
-
 import org.json.JSONObject;
 import org.junit.jupiter.api.Test;
 import org.opensearch.client.ResponseException;
@@ -26,6 +27,7 @@ public void init() throws Exception {
     loadIndex(Index.DOG);
     loadIndex(Index.BANK_WITH_STRING_VALUES);
     loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
+    loadIndex(Index.LOGS);
   }
 
   @Test
@@ -157,9 +159,10 @@ public void testEarliestLatestAggregates() throws IOException {
     try {
       result =
           executeQuery(
-              "search source=opensearch-sql_test_index_account_with_timestamp | stats"
-                  + " earliest(firstname) as earliest_name, latest(firstname) as latest_name by"
-                  + " gender");
+              String.format(
+                  "search source=%s | stats earliest(message) as earliest_message, latest(message)"
+                      + " as latest_message by server",
+                  TEST_INDEX_LOGS));
     } catch (ResponseException e) {
       result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
     }
diff --git a/integ-test/src/test/resources/accounts_with_timestamp.json b/integ-test/src/test/resources/accounts_with_timestamp.json
deleted file mode 100644
index cd41f1b329b..00000000000
--- a/integ-test/src/test/resources/accounts_with_timestamp.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{"index":{"_id":"1"}}
-{"account_number":1,"balance":39225,"firstname":"Amber","lastname":"Duke","age":32,"gender":"M","address":"880 Holmes Lane","employer":"Pyrami","email":"amberduke@pyrami.com","city":"Brogan","state":"IL","@timestamp":"2023-01-01T10:00:00.000Z","created_time":"2023-01-03T08:00:00.000Z"}
-{"index":{"_id":"2"}}
-{"account_number":2,"balance":28838,"firstname":"Roberta","lastname":"Bender","age":22,"gender":"F","address":"560 Kingsway Place","employer":"Chillium","email":"robertabender@chillium.com","city":"Bennett","state":"LA","@timestamp":"2023-01-01T11:00:00.000Z","created_time":"2023-01-03T07:00:00.000Z"}
-{"index":{"_id":"3"}}
-{"account_number":3,"balance":44947,"firstname":"Levine","lastname":"Burks","age":26,"gender":"F","address":"328 Wilson Avenue","employer":"Amtap","email":"levineburks@amtap.com","city":"Cochranville","state":"HI","@timestamp":"2023-01-01T12:00:00.000Z","created_time":"2023-01-03T06:00:00.000Z"}
-{"index":{"_id":"4"}}
-{"account_number":4,"balance":27658,"firstname":"Rodriquez","lastname":"Flores","age":31,"gender":"F","address":"986 Wyckoff Avenue","employer":"Tourmania","email":"rodriquezflores@tourmania.com","city":"Eastvale","state":"HI","@timestamp":"2023-01-02T10:00:00.000Z","created_time":"2023-01-03T05:00:00.000Z"}
-{"index":{"_id":"5"}}
-{"account_number":5,"balance":29342,"firstname":"Leola","lastname":"Stewart","age":30,"gender":"F","address":"311 Elm Place","employer":"Diginetic","email":"leolastewart@diginetic.com","city":"Fairview","state":"NJ","@timestamp":"2023-01-02T11:00:00.000Z","created_time":"2023-01-03T04:00:00.000Z"}
-{"index":{"_id":"6"}}
-{"account_number":6,"balance":5686,"firstname":"Hattie","lastname":"Bond","age":36,"gender":"M","address":"671 Bristol Street","employer":"Netagy","email":"hattiebond@netagy.com","city":"Dante","state":"TN","@timestamp":"2023-01-02T12:00:00.000Z","created_time":"2023-01-03T03:00:00.000Z"}
diff --git a/integ-test/src/test/resources/logs.json b/integ-test/src/test/resources/logs.json
new file mode 100644
index 00000000000..8431a185172
--- /dev/null
+++ b/integ-test/src/test/resources/logs.json
@@ -0,0 +1,10 @@
+{"index":{"_id":"1"}}
+{"server":"server1","level":"ERROR","message":"Database connection failed","@timestamp":"2023-01-01T00:00:00.000Z","created_at":"2023-01-05T00:00:00.000Z"}
+{"index":{"_id":"2"}}
+{"server":"server2","level":"INFO","message":"Service started","@timestamp":"2023-01-02T00:00:00.000Z","created_at":"2023-01-04T00:00:00.000Z"}
+{"index":{"_id":"3"}}
+{"server":"server1","level":"WARN","message":"High memory usage","@timestamp":"2023-01-03T00:00:00.000Z","created_at":"2023-01-03T00:00:00.000Z"}
+{"index":{"_id":"4"}}
+{"server":"server3","level":"ERROR","message":"Disk space low","@timestamp":"2023-01-04T00:00:00.000Z","created_at":"2023-01-02T00:00:00.000Z"}
+{"index":{"_id":"5"}}
+{"server":"server2","level":"INFO","message":"Backup completed","@timestamp":"2023-01-05T00:00:00.000Z","created_at":"2023-01-01T00:00:00.000Z"}
diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index 2aa14f8cc89..33653dfb370 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -7,6 +7,8 @@
 
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.*;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import java.math.BigDecimal;
 import java.util.Arrays;
 import java.util.Collections;
@@ -17,7 +19,6 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
-
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.antlr.v4.runtime.RuleContext;
 import org.opensearch.sql.ast.dsl.AstDSL;
@@ -92,9 +93,6 @@
 import org.opensearch.sql.ppl.antlr.parser.OpenSearchPPLParserBaseVisitor;
 import org.opensearch.sql.ppl.utils.ArgumentFactory;
 
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-
 /** Class of building AST Expression nodes. */
 public class AstExpressionBuilder extends OpenSearchPPLParserBaseVisitor<UnresolvedExpression> {
 
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
deleted file mode 100644
index 679f40047c4..00000000000
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLAggregationTestBase.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-package org.opensearch.sql.ppl.calcite;
-
-import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.test.CalciteAssert;
-
-public abstract class CalcitePPLAggregationTestBase extends CalcitePPLAbstractTest {
-
-  public CalcitePPLAggregationTestBase() {
-    super(CalciteAssert.SchemaSpec.SCOTT_WITH_TEMPORAL);
-  }
-
-  public CalcitePPLAggregationTestBase(CalciteAssert.SchemaSpec... schemaSpecs) {
-    super(schemaSpecs);
-  }
-
-  protected void testAggregationWithAlias(
-      String functionCall,
-      String alias,
-      String expectedLogicalPattern,
-      String expectedResult,
-      String expectedSparkSql) {
-    String ppl = "source=EMP | stats " + functionCall + " as " + alias;
-    RelNode root = getRelNode(ppl);
-    verifyLogical(root, expectedLogicalPattern);
-    verifyResult(root, expectedResult);
-    verifyPPLToSparkSQL(root, expectedSparkSql);
-  }
-
-  protected String createSimpleAggLogicalPattern(String aggFunction, String fieldProjection) {
-    return "LogicalAggregate(group=[{}], "
-        + aggFunction
-        + ")\n"
-        + "  "
-        + fieldProjection
-        + "\n"
-        + "    LogicalTableScan(table=[[scott, EMP]])\n";
-  }
-
-  protected String createSimpleAggSparkSql(String selectClause) {
-    return "SELECT " + selectClause + "\n" + "FROM `scott`.`EMP`";
-  }
-}
diff --git a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
index 61eedf66ae4..16a57bacb57 100644
--- a/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
+++ b/ppl/src/test/java/org/opensearch/sql/ppl/calcite/CalcitePPLEarliestLatestTest.java
@@ -5,9 +5,10 @@
 
 package org.opensearch.sql.ppl.calcite;
 
+import com.google.common.collect.ImmutableList;
 import java.sql.Date;
 import java.util.List;
-
+import lombok.RequiredArgsConstructor;
 import org.apache.calcite.DataContext;
 import org.apache.calcite.config.CalciteConnectionConfig;
 import org.apache.calcite.linq4j.Enumerable;
@@ -33,10 +34,6 @@
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Test;
 
-import com.google.common.collect.ImmutableList;
-
-import lombok.RequiredArgsConstructor;
-
 /** Unit tests for {@code earliest/latest} functions with @timestamp field in PPL. */
 public class CalcitePPLEarliestLatestTest extends CalcitePPLAbstractTest {
   public CalcitePPLEarliestLatestTest() {
@@ -47,18 +44,48 @@ public CalcitePPLEarliestLatestTest() {
   protected Frameworks.ConfigBuilder config(CalciteAssert.SchemaSpec... schemaSpecs) {
     final SchemaPlus rootSchema = Frameworks.createRootSchema(true);
     final SchemaPlus schema = CalciteAssert.addSchema(rootSchema, schemaSpecs);
-    
+
     // Add a test table with @timestamp and created_at fields
     // Note: @timestamp and created_at have different orderings to test explicit field usage
     ImmutableList<Object[]> rows =
         ImmutableList.of(
-            new Object[] {"server1", "ERROR", "Database connection failed", Date.valueOf("2023-01-01"), Date.valueOf("2023-01-05")},
-            new Object[] {"server2", "INFO", "Service started", Date.valueOf("2023-01-02"), Date.valueOf("2023-01-04")},
-            new Object[] {"server1", "WARN", "High memory usage", Date.valueOf("2023-01-03"), Date.valueOf("2023-01-03")},
-            new Object[] {"server3", "ERROR", "Disk space low", Date.valueOf("2023-01-04"), Date.valueOf("2023-01-02")},
-            new Object[] {"server2", "INFO", "Backup completed", Date.valueOf("2023-01-05"), Date.valueOf("2023-01-01")});
+            new Object[] {
+              "server1",
+              "ERROR",
+              "Database connection failed",
+              Date.valueOf("2023-01-01"),
+              Date.valueOf("2023-01-05")
+            },
+            new Object[] {
+              "server2",
+              "INFO",
+              "Service started",
+              Date.valueOf("2023-01-02"),
+              Date.valueOf("2023-01-04")
+            },
+            new Object[] {
+              "server1",
+              "WARN",
+              "High memory usage",
+              Date.valueOf("2023-01-03"),
+              Date.valueOf("2023-01-03")
+            },
+            new Object[] {
+              "server3",
+              "ERROR",
+              "Disk space low",
+              Date.valueOf("2023-01-04"),
+              Date.valueOf("2023-01-02")
+            },
+            new Object[] {
+              "server2",
+              "INFO",
+              "Backup completed",
+              Date.valueOf("2023-01-05"),
+              Date.valueOf("2023-01-01")
+            });
     schema.add("LOGS", new LogsTable(rows));
-    
+
     return Frameworks.newConfigBuilder()
         .parserConfig(SqlParser.Config.DEFAULT)
         .defaultSchema(schema)

From c9efe68b0361a3b84b652580d2085425fe31560f Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 15:58:51 -0700
Subject: [PATCH 11/26] Fix tests

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../sql/legacy/SQLIntegTestCase.java          |  7 ++++-
 .../org/opensearch/sql/legacy/TestUtils.java  |  5 ++++
 .../calcite/explain_earliest_latest.json      |  4 +--
 .../explain_earliest_latest_custom_time.json  |  4 +--
 .../indexDefinitions/logs_index_mapping.json  | 29 +++++++++++++++++++
 5 files changed, 44 insertions(+), 5 deletions(-)
 create mode 100644 integ-test/src/test/resources/indexDefinitions/logs_index_mapping.json

diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
index b14655a226b..01a6a7d14e8 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/SQLIntegTestCase.java
@@ -32,6 +32,7 @@
 import static org.opensearch.sql.legacy.TestUtils.getJoinTypeIndexMapping;
 import static org.opensearch.sql.legacy.TestUtils.getJsonTestIndexMapping;
 import static org.opensearch.sql.legacy.TestUtils.getLocationIndexMapping;
+import static org.opensearch.sql.legacy.TestUtils.getLogsIndexMapping;
 import static org.opensearch.sql.legacy.TestUtils.getMappingFile;
 import static org.opensearch.sql.legacy.TestUtils.getNestedSimpleIndexMapping;
 import static org.opensearch.sql.legacy.TestUtils.getNestedTypeIndexMapping;
@@ -895,7 +896,11 @@ public enum Index {
         "hdfs_logs",
         getHdfsLogsIndexMapping(),
         "src/test/resources/hdfs_logs.json"),
-    LOGS(TestsConstants.TEST_INDEX_LOGS, "logs", null, "src/test/resources/logs.json"),
+    LOGS(
+        TestsConstants.TEST_INDEX_LOGS,
+        "logs",
+        getLogsIndexMapping(),
+        "src/test/resources/logs.json"),
     TIME_TEST_DATA(
         "opensearch-sql_test_index_time_data",
         "time_data",
diff --git a/integ-test/src/test/java/org/opensearch/sql/legacy/TestUtils.java b/integ-test/src/test/java/org/opensearch/sql/legacy/TestUtils.java
index 37a35a7c052..6f605837196 100644
--- a/integ-test/src/test/java/org/opensearch/sql/legacy/TestUtils.java
+++ b/integ-test/src/test/java/org/opensearch/sql/legacy/TestUtils.java
@@ -305,6 +305,11 @@ public static String getHdfsLogsIndexMapping() {
     return getMappingFile(mappingFile);
   }
 
+  public static String getLogsIndexMapping() {
+    String mappingFile = "logs_index_mapping.json";
+    return getMappingFile(mappingFile);
+  }
+
   public static void loadBulk(Client client, String jsonPath, String defaultIndex)
       throws Exception {
     System.out.println(String.format("Loading file %s into opensearch cluster", jsonPath));
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
index f29df521bab..46143e0c429 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[state, firstname, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"state\",\"firstname\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], server=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(server=[$1], message=[$3], @timestamp=[$2])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], server=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[server, message, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"server\",\"message\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }
diff --git a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
index 318b37a5bf6..a3d2d3a634c 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite/explain_earliest_latest_custom_time.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]], PushDownContext=[[PROJECT->[gender, firstname, created_time]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"gender\",\"firstname\",\"created_time\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], level=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(level=[$4], message=[$3], created_at=[$0])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], level=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[level, message, created_at]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"level\",\"message\",\"created_at\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }
diff --git a/integ-test/src/test/resources/indexDefinitions/logs_index_mapping.json b/integ-test/src/test/resources/indexDefinitions/logs_index_mapping.json
new file mode 100644
index 00000000000..3259ebaefd5
--- /dev/null
+++ b/integ-test/src/test/resources/indexDefinitions/logs_index_mapping.json
@@ -0,0 +1,29 @@
+{
+  "mappings": {
+    "properties": {
+      "server": {
+        "type": "keyword"
+      },
+      "level": {
+        "type": "keyword"
+      },
+      "message": {
+        "type": "text",
+        "fields": {
+          "keyword": {
+            "type": "keyword",
+            "ignore_above": 256
+          }
+        }
+      },
+      "@timestamp": {
+        "type": "date",
+        "format": "strict_date_optional_time||epoch_millis"
+      },
+      "created_at": {
+        "type": "date",
+        "format": "strict_date_optional_time||epoch_millis"
+      }
+    }
+  }
+}

From deabbfa45183f0341f48e178d66d456f43548397 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 17:01:36 -0700
Subject: [PATCH 12/26] Fit IT

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../remote/CalcitePPLAggregationIT.java       | 53 ++++++-------------
 .../explain_earliest_latest.json              |  4 +-
 .../explain_earliest_latest_custom_time.json  |  4 +-
 3 files changed, 19 insertions(+), 42 deletions(-)

diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
index f6d7f7f26a8..dbae1e6b3a5 100644
--- a/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/calcite/remote/CalcitePPLAggregationIT.java
@@ -10,6 +10,7 @@
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_CALCS;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DATATYPE_NUMERIC;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DATE_FORMATS;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_LOGS;
 import static org.opensearch.sql.util.MatcherUtils.assertJsonEquals;
 import static org.opensearch.sql.util.MatcherUtils.rows;
 import static org.opensearch.sql.util.MatcherUtils.schema;
@@ -21,7 +22,6 @@
 import java.util.Arrays;
 import java.util.List;
 import org.json.JSONObject;
-import org.junit.Ignore;
 import org.junit.jupiter.api.Test;
 import org.opensearch.client.Request;
 import org.opensearch.sql.ppl.PPLIntegTestCase;
@@ -38,6 +38,7 @@ public void init() throws Exception {
     loadIndex(Index.CALCS);
     loadIndex(Index.DATE_FORMATS);
     loadIndex(Index.DATA_TYPE_NUMERIC);
+    loadIndex(Index.LOGS);
   }
 
   @Test
@@ -560,63 +561,39 @@ public void testCountDistinctWithAlias() throws IOException {
   public void testEarliestAndLatest() throws IOException {
     JSONObject actual =
         executeQuery(
-            String.format(
-                "source=%s | stats latest(datetime0, datetime0), earliest(datetime0, datetime0)",
-                TEST_INDEX_CALCS));
+            String.format("source=%s | stats latest(server), earliest(server)", TEST_INDEX_LOGS));
 
-    verifySchema(
-        actual,
-        schema("latest(datetime0, datetime0)", "timestamp"),
-        schema("earliest(datetime0, datetime0)", "timestamp"));
-    verifyDataRows(actual, rows("2004-08-02 07:59:23", "2004-07-04 22:49:28"));
+    verifySchema(actual, schema("latest(server)", "string"), schema("earliest(server)", "string"));
+    verifyDataRows(actual, rows("server2", "server1"));
   }
 
-  @Ignore
   @Test
   public void testEarliestAndLatestWithAlias() throws IOException {
     JSONObject actual =
         executeQuery(
             String.format(
-                "source=%s | stats latest(datetime0) as late, earliest(datetime0) as early",
-                TEST_INDEX_CALCS));
+                "source=%s | stats latest(server) as late, earliest(server) as early",
+                TEST_INDEX_LOGS));
 
-    verifySchema(actual, schema("late", "timestamp"), schema("early", "timestamp"));
-    verifyDataRows(actual, rows("2004-08-02 07:59:23", "2004-07-04 22:49:28"));
+    verifySchema(actual, schema("late", "string"), schema("early", "string"));
+    verifyDataRows(actual, rows("server2", "server1"));
   }
 
-  @Ignore
   @Test
   public void testEarliestAndLatestWithBy() throws IOException {
     JSONObject actual =
         executeQuery(
             String.format(
-                "source=%s | stats latest(datetime0) as late, earliest(datetime0) as early by"
-                    + " bool2",
-                TEST_INDEX_CALCS));
+                "source=%s | stats latest(server) as late, earliest(server) as early by" + " level",
+                TEST_INDEX_LOGS));
 
     verifySchema(
-        actual,
-        schema("late", "timestamp"),
-        schema("early", "timestamp"),
-        schema("bool2", "boolean"));
+        actual, schema("late", "string"), schema("early", "string"), schema("level", "string"));
     verifyDataRows(
         actual,
-        rows("2004-07-31 11:57:52", "2004-07-12 17:30:16", true),
-        rows("2004-08-02 07:59:23", "2004-07-04 22:49:28", false));
-  }
-
-  @Ignore
-  @Test
-  public void testEarliestAndLatestWithTimeBy() throws IOException {
-    JSONObject actual =
-        executeQuery(
-            String.format(
-                "source=%s | stats latest(time1) as late, earliest(time1) as early by" + " bool2",
-                TEST_INDEX_CALCS));
-
-    verifySchema(
-        actual, schema("late", "time"), schema("early", "time"), schema("bool2", "boolean"));
-    verifyDataRows(actual, rows("19:57:33", "04:40:49", true), rows("22:50:16", "00:05:57", false));
+        rows("server3", "server1", "ERROR"),
+        rows("server2", "server2", "INFO"),
+        rows("server1", "server1", "WARN"));
   }
 
   @Test
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
index ec50ef3fa2d..46143e0c429 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], state=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(state=[$10], firstname=[$2], @timestamp=[$7])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], state=[$t0])\n    EnumerableAggregate(group=[{10}], earliest_name=[ARG_MIN($2, $7)], latest_name=[ARG_MAX($2, $7)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], server=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(server=[$1], message=[$3], @timestamp=[$2])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], server=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[server, message, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"server\",\"message\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
index f5b3a3e8310..a3d2d3a634c 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
-    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_name=[$1], latest_name=[$2], gender=[$0])\n    LogicalAggregate(group=[{0}], earliest_name=[ARG_MIN($1, $2)], latest_name=[ARG_MAX($1, $2)])\n      LogicalProject(gender=[$4], firstname=[$2], created_time=[$1])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_name=[$t1], latest_name=[$t2], gender=[$t0])\n    EnumerableAggregate(group=[{4}], earliest_name=[ARG_MIN($2, $1)], latest_name=[ARG_MAX($2, $1)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_account_with_timestamp]])\n"
+    "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], level=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(level=[$4], message=[$3], created_at=[$0])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], level=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[level, message, created_at]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"level\",\"message\",\"created_at\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
   }
 }

From 5419800d74074c2ec4f6296f0ae88d0d59dc4626 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Mon, 25 Aug 2025 17:27:06 -0700
Subject: [PATCH 13/26] Fix doctest

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 doctest/test_docs.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index c1a3e889fb9..13a96ea3559 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -37,6 +37,7 @@
 OCCUPATION = "occupation"
 WORKER = "worker"
 WORK_INFORMATION = "work_information"
+EVENTS = "events"
 
 class DocTestConnection(OpenSearchConnection):
 

From 1441e716b5f87fa528fff856eb35d43e6f154a8b Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Tue, 26 Aug 2025 08:43:36 -0700
Subject: [PATCH 14/26] Fix doctest

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/user/ppl/cmd/stats.rst | 69 +++++++++++++++++++++++--------------
 doctest/test_docs.py        |  3 +-
 2 files changed, 45 insertions(+), 27 deletions(-)

diff --git a/docs/user/ppl/cmd/stats.rst b/docs/user/ppl/cmd/stats.rst
index 05ebed49345..8dabed6416a 100644
--- a/docs/user/ppl/cmd/stats.rst
+++ b/docs/user/ppl/cmd/stats.rst
@@ -71,6 +71,19 @@ stats <aggregation>... [by-clause]
 | year (y)                   |
 +----------------------------+
 
+Calcite-Enabled Functions
+
+Some aggregation functions require Calcite to be enabled for proper functionality. These include advanced functions like EARLIEST and LATEST. To enable Calcite, use the following command:
+
+.. code-block::
+
+    PUT /_cluster/settings
+    {
+      "persistent":{
+          "plugins.calcite.enabled": true
+      }
+    }
+
 Aggregation Functions
 =====================
 
@@ -315,27 +328,29 @@ Usage: EARLIEST(field [, time_field]). Return the earliest value of a field base
 * field: mandatory. The field to return the earliest value for.
 * time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
 
+Note: This function requires Calcite to be enabled (see `Calcite-Enabled Functions`_ section above).
+
 Example::
 
-    os> source=events | stats earliest(message, event_time) by host | sort host;
+    os> source=events | stats earliest(message) by host | sort host;
     fetched rows / total rows = 2/2
-    +-----------------------------+--------+
-    | earliest(message, event_time) | host   |
-    |-----------------------------+--------|
-    | Starting up                 | server1|
-    | Initializing                | server2|
-    +-----------------------------+--------+
+    +-------------------+---------+
+    | earliest(message) | host    |
+    |-------------------+---------|
+    | Starting up       | server1 |
+    | Initializing      | server2 |
+    +-------------------+---------+
 
 Example with custom time field::
 
     os> source=events | stats earliest(status, event_time) by category | sort category;
     fetched rows / total rows = 2/2
-    +----------------------------+----------+
-    | earliest(status, event_time)| category |
-    |----------------------------+----------|
-    | pending                    | orders   |
-    | active                     | users    |
-    +----------------------------+----------+
+    +------------------------------+----------+
+    | earliest(status, event_time) | category |
+    |------------------------------+----------|
+    | pending                      | orders   |
+    | active                       | users    |
+    +------------------------------+----------+
 
 LATEST
 ------
@@ -350,27 +365,29 @@ Usage: LATEST(field [, time_field]). Return the latest value of a field based on
 * field: mandatory. The field to return the latest value for.
 * time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
 
+Note: This function requires Calcite to be enabled (see `Calcite-Enabled Functions`_ section above).
+
 Example::
 
-    os> source=events | stats latest(message, event_time) by host | sort host;
+    os> source=events | stats latest(message) by host | sort host;
     fetched rows / total rows = 2/2
-    +---------------------------+--------+
-    | latest(message, event_time) | host   |
-    |---------------------------+--------|
-    | Shutting down             | server1|
-    | Maintenance mode          | server2|
-    +---------------------------+--------+
+    +------------------+---------+
+    | latest(message)  | host    |
+    |------------------+---------|
+    | Shutting down    | server1 |
+    | Maintenance mode | server2 |
+    +------------------+---------+
 
 Example with custom time field::
 
     os> source=events | stats latest(status, event_time) by category | sort category;
     fetched rows / total rows = 2/2
-    +--------------------------+----------+
-    | latest(status, event_time)| category |
-    |--------------------------+----------|
-    | cancelled                | orders   |
-    | inactive                 | users    |
-    +--------------------------+----------+
+    +----------------------------+----------+
+    | latest(status, event_time) | category |
+    |----------------------------+----------|
+    | cancelled                  | orders   |
+    | inactive                   | users    |
+    +----------------------------+----------+
 
 Example 1: Calculate the count of events
 ========================================
diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index 13a96ea3559..ac58e11070e 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -137,6 +137,7 @@ def set_up_test_indices(test):
     load_file("occupation.json", index_name=OCCUPATION)
     load_file("worker.json", index_name=WORKER)
     load_file("work_information.json", index_name=WORK_INFORMATION)
+    load_file("events.json", index_name=EVENTS)
 
 
 def load_file(filename, index_name):
@@ -165,7 +166,7 @@ def set_up(test):
 
 def tear_down(test):
     # drop leftover tables after each test
-    test_data_client.indices.delete(index=[ACCOUNTS, EMPLOYEES, PEOPLE, ACCOUNT2, NYC_TAXI, BOOKS, APACHE, WILDCARD, NESTED, WEBLOGS, JSON_TEST, STATE_COUNTRY, OCCUPATION, WORKER, WORK_INFORMATION], ignore_unavailable=True)
+    test_data_client.indices.delete(index=[ACCOUNTS, EMPLOYEES, PEOPLE, ACCOUNT2, NYC_TAXI, BOOKS, APACHE, WILDCARD, NESTED, WEBLOGS, JSON_TEST, STATE_COUNTRY, OCCUPATION, WORKER, WORK_INFORMATION, EVENTS], ignore_unavailable=True)
 
 
 docsuite = partial(doctest.DocFileSuite,

From 61ccaf98744a744f3c06b578a23a49e6cdb22440 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Tue, 26 Aug 2025 08:54:42 -0700
Subject: [PATCH 15/26] Remove unneeded test

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../opensearch/sql/ppl/NewAddedCommandsIT.java | 18 ------------------
 1 file changed, 18 deletions(-)

diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 8361dcf7739..1545f2d93b1 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -11,7 +11,6 @@
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DOG;
-import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_LOGS;
 
 import java.io.IOException;
 import org.json.JSONObject;
@@ -27,7 +26,6 @@ public void init() throws Exception {
     loadIndex(Index.DOG);
     loadIndex(Index.BANK_WITH_STRING_VALUES);
     loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
-    loadIndex(Index.LOGS);
   }
 
   @Test
@@ -153,22 +151,6 @@ public void testRegexMatch() throws IOException {
     }
   }
 
-  @Test
-  public void testEarliestLatestAggregates() throws IOException {
-    JSONObject result;
-    try {
-      result =
-          executeQuery(
-              String.format(
-                  "search source=%s | stats earliest(message) as earliest_message, latest(message)"
-                      + " as latest_message by server",
-                  TEST_INDEX_LOGS));
-    } catch (ResponseException e) {
-      result = new JSONObject(TestUtils.getResponseBody(e.getResponse()));
-    }
-    verifyQuery(result);
-  }
-
   private void verifyQuery(JSONObject result) throws IOException {
     if (isCalciteEnabled()) {
       assertFalse(result.getJSONArray("datarows").isEmpty());

From 02d6fc81008d6cfba2190b33df7517c407ea2808 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Tue, 26 Aug 2025 10:50:11 -0700
Subject: [PATCH 16/26] Delete unneeded files

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../ppl/explain_earliest_latest.json          | 33 -------------------
 .../explain_earliest_latest_custom_time.json  | 33 -------------------
 2 files changed, 66 deletions(-)
 delete mode 100644 integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
 delete mode 100644 integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json

diff --git a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
deleted file mode 100644
index ef3b864e3ba..00000000000
--- a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
-  "root": {
-    "name": "ProjectOperator",
-    "description": {
-      "fields": "[earliest_name, latest_name, state]"
-    },
-    "children": [
-      {
-        "name": "WindowOperator",
-        "description": {
-          "function": "[earliest(firstname) OVER (PARTITION BY state ORDER BY @timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), latest(firstname) OVER (PARTITION BY state ORDER BY @timestamp DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)]"
-        },
-        "children": [
-          {
-            "name": "SortOperator",
-            "description": {
-              "sortList": "[state ASC]"
-            },
-            "children": [
-              {
-                "name": "OpenSearchIndexScan",
-                "description": {
-                  "request": "OpenSearchQueryRequest(indexName=opensearch-sql_test_index_account, sourceBuilder={\"from\":0,\"size\":10000,\"timeout\":\"1m\"}, searchDone=false)"
-                },
-                "children": []
-              }
-            ]
-          }
-        ]
-      }
-    ]
-  }
-}
diff --git a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json
deleted file mode 100644
index fee6e8b18fb..00000000000
--- a/integ-test/src/test/resources/expectedOutput/ppl/explain_earliest_latest_custom_time.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
-  "root": {
-    "name": "ProjectOperator",
-    "description": {
-      "fields": "[earliest_name, latest_name, gender]"
-    },
-    "children": [
-      {
-        "name": "WindowOperator",
-        "description": {
-          "function": "[earliest(firstname, birthdate) OVER (PARTITION BY gender ORDER BY birthdate ASC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), latest(firstname, birthdate) OVER (PARTITION BY gender ORDER BY birthdate DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)]"
-        },
-        "children": [
-          {
-            "name": "SortOperator",
-            "description": {
-              "sortList": "[gender ASC]"
-            },
-            "children": [
-              {
-                "name": "OpenSearchIndexScan",
-                "description": {
-                  "request": "OpenSearchQueryRequest(indexName=opensearch-sql_test_index_bank, sourceBuilder={\"from\":0,\"size\":10000,\"timeout\":\"1m\"}, searchDone=false)"
-                },
-                "children": []
-              }
-            ]
-          }
-        ]
-      }
-    ]
-  }
-}

From 24a930ba004b1730dde2db55e4452135898f4b0e Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 09:16:12 -0700
Subject: [PATCH 17/26] Minor fix

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java | 1 -
 1 file changed, 1 deletion(-)

diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 1545f2d93b1..25150827ac3 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -25,7 +25,6 @@ public void init() throws Exception {
     loadIndex(Index.BANK);
     loadIndex(Index.DOG);
     loadIndex(Index.BANK_WITH_STRING_VALUES);
-    loadIndex(Index.ACCOUNT_WITH_TIMESTAMP);
   }
 
   @Test

From a970b62ffa69004bc2715933028eb763f537ac3b Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 09:33:39 -0700
Subject: [PATCH 18/26] Fix issue caused by conflict

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../sql/ppl/parser/AstExpressionBuilder.java  | 21 -------------------
 1 file changed, 21 deletions(-)

diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index 33653dfb370..9478116d9be 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -317,27 +317,6 @@ public UnresolvedExpression visitPercentileShortcutFunctionCall(
             new UnresolvedArgument("percent", AstDSL.doubleLiteral(percent))));
   }
 
-  @Override
-  public UnresolvedExpression visitPercentileShortcutFunctionCall(
-      OpenSearchPPLParser.PercentileShortcutFunctionCallContext ctx) {
-    String functionName = ctx.getStart().getText();
-
-    int prefixLength = functionName.toLowerCase().startsWith("perc") ? 4 : 1;
-    String percentileValue = functionName.substring(prefixLength);
-
-    double percent = Double.parseDouble(percentileValue);
-    if (percent < 0.0 || percent > 100.0) {
-      throw new SyntaxCheckException(
-          String.format("Percentile value must be between 0 and 100, got: %s", percent));
-    }
-
-    return new AggregateFunction(
-        "percentile",
-        visit(ctx.valueExpression()),
-        Collections.singletonList(
-            new UnresolvedArgument("percent", AstDSL.doubleLiteral(percent))));
-  }
-
   /** Case function. */
   public UnresolvedExpression visitEarliestLatestFunctionCall(
       OpenSearchPPLParser.EarliestLatestFunctionCallContext ctx) {

From 986cde4bde3615485d52d5ec16e48b5f2e647929 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 10:07:24 -0700
Subject: [PATCH 19/26] reformat

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
index 25150827ac3..ac5c9f0ce56 100644
--- a/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
+++ b/integ-test/src/test/java/org/opensearch/sql/ppl/NewAddedCommandsIT.java
@@ -8,9 +8,9 @@
 import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.equalTo;
 import static org.opensearch.sql.common.setting.Settings.Key.CALCITE_ENGINE_ENABLED;
-import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_BANK;
 import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_DOG;
+import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_STRINGS;
 
 import java.io.IOException;
 import org.json.JSONObject;

From 037bff753b1452fbbaa20407a1944dd8d2c9546c Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 11:30:35 -0700
Subject: [PATCH 20/26] Fix doctest issue

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/user/ppl/cmd/stats.rst | 21 ++++++++---------
 doctest/build.gradle        |  6 +++++
 doctest/test_docs.py        | 46 +++++++++++++++++++++++++++++++++++++
 3 files changed, 62 insertions(+), 11 deletions(-)

diff --git a/docs/user/ppl/cmd/stats.rst b/docs/user/ppl/cmd/stats.rst
index 8dabed6416a..5fd66daaa4b 100644
--- a/docs/user/ppl/cmd/stats.rst
+++ b/docs/user/ppl/cmd/stats.rst
@@ -71,18 +71,17 @@ stats <aggregation>... [by-clause]
 | year (y)                   |
 +----------------------------+
 
-Calcite-Enabled Functions
-
-Some aggregation functions require Calcite to be enabled for proper functionality. These include advanced functions like EARLIEST and LATEST. To enable Calcite, use the following command:
+Configuration
+=============
+Some aggregation functions require Calcite to be enabled for proper functionality. To enable Calcite, use the following command:
 
-.. code-block::
+Enable Calcite::
 
-    PUT /_cluster/settings
-    {
-      "persistent":{
-          "plugins.calcite.enabled": true
+    >> curl -H 'Content-Type: application/json' -X PUT localhost:9200/_plugins/_query/settings -d '{
+      "persistent" : {
+        "plugins.calcite.enabled" : true
       }
-    }
+    }'
 
 Aggregation Functions
 =====================
@@ -328,7 +327,7 @@ Usage: EARLIEST(field [, time_field]). Return the earliest value of a field base
 * field: mandatory. The field to return the earliest value for.
 * time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
 
-Note: This function requires Calcite to be enabled (see `Calcite-Enabled Functions`_ section above).
+Note: This function requires Calcite to be enabled (see `Configuration`_ section above).
 
 Example::
 
@@ -365,7 +364,7 @@ Usage: LATEST(field [, time_field]). Return the latest value of a field based on
 * field: mandatory. The field to return the latest value for.
 * time_field: optional. The field to use for time-based ordering. Defaults to @timestamp if not specified.
 
-Note: This function requires Calcite to be enabled (see `Calcite-Enabled Functions`_ section above).
+Note: This function requires Calcite to be enabled (see `Configuration`_ section above).
 
 Example::
 
diff --git a/doctest/build.gradle b/doctest/build.gradle
index 4a72ebe4679..e4d6babf985 100644
--- a/doctest/build.gradle
+++ b/doctest/build.gradle
@@ -81,6 +81,12 @@ task doctest(type: Exec, dependsOn: ['bootstrap']) {
     def docs = project.findProperty('docs')
     def verbose = project.findProperty('verbose')
     def endpoint = project.findProperty('endpoint')
+    def debug = project.findProperty('debug')
+    
+    // Set environment variables for debug mode
+    if (debug == 'true') {
+        environment 'DOCTEST_DEBUG', 'true'
+    }
     
     if (docs) {
         // Single file or multiple files mode
diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index ac58e11070e..1ad584654c5 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -39,6 +39,9 @@
 WORK_INFORMATION = "work_information"
 EVENTS = "events"
 
+DEBUG_MODE = os.environ.get('DOCTEST_DEBUG', 'false').lower() == 'true'
+
+
 class DocTestConnection(OpenSearchConnection):
 
     def __init__(self, query_language="sql"):
@@ -49,7 +52,17 @@ def __init__(self, query_language="sql"):
         self.formatter = Formatter(settings)
 
     def process(self, statement):
+        debug(f"Executing {self.query_language.upper()} query: {statement}")
+        
         data = self.execute_query(statement, use_console=False)
+        
+        debug(f"Query result: {data}")
+        
+        if data is None:
+            debug("Query returned None - this may indicate an error or unsupported function")
+            print("Error: Query returned no data")
+            return
+            
         output = self.formatter.format_output(data)
         output = "\n".join(output)
 
@@ -94,6 +107,37 @@ def pretty_print(s):
 test_data_client = OpenSearch([ENDPOINT], verify_certs=True)
 
 
+def debug(message):
+    if DEBUG_MODE:
+        print(f"[DEBUG] {message}", file=sys.stderr)
+
+
+def enable_calcite():
+    """Enable Calcite for functions that require it (like EARLIEST/LATEST)"""
+    try:
+        import requests
+        calcite_settings = {
+            "transient": {
+                "plugins.calcite.enabled": True
+            }
+        }
+        response = requests.put(f"{ENDPOINT}/_plugins/_query/settings", 
+                              json=calcite_settings, 
+                              timeout=10)
+        debug(f"Calcite enable response: {response.status_code} - {response.text}")
+        
+        if response.status_code == 200:
+            debug("Calcite enabled successfully")
+            return True
+        else:
+            debug(f"Failed to enable Calcite: HTTP {response.status_code}")
+            return False
+            
+    except Exception as e:
+        debug(f"Failed to enable Calcite: {e}")
+        return False
+
+
 def sql_cli_transform(s):
     return u'sql_cmd.process({0})'.format(repr(s.strip().rstrip(';')))
 
@@ -122,6 +166,8 @@ def bash_transform(s):
 
 def set_up_test_indices(test):
     set_up(test)
+    enable_calcite()
+    
     load_file("accounts.json", index_name=ACCOUNTS)
     load_file("people.json", index_name=PEOPLE)
     load_file("account2.json", index_name=ACCOUNT2)

From 5548137df8d60eaf316efe96455327f1eaf88955 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 13:28:32 -0700
Subject: [PATCH 21/26] Minor fix

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java | 1 -
 1 file changed, 1 deletion(-)

diff --git a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
index 9478116d9be..dec1383a2b9 100644
--- a/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
+++ b/ppl/src/main/java/org/opensearch/sql/ppl/parser/AstExpressionBuilder.java
@@ -317,7 +317,6 @@ public UnresolvedExpression visitPercentileShortcutFunctionCall(
             new UnresolvedArgument("percent", AstDSL.doubleLiteral(percent))));
   }
 
-  /** Case function. */
   public UnresolvedExpression visitEarliestLatestFunctionCall(
       OpenSearchPPLParser.EarliestLatestFunctionCallContext ctx) {
     return visit(ctx.earliestLatestFunction());

From 904e9980a18929b29a35ff784bdbc367d2c542c4 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 15:47:13 -0700
Subject: [PATCH 22/26] Fix tests

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/user/ppl/cmd/grok.rst                                    | 2 +-
 .../calcite_no_pushdown/explain_earliest_latest.json          | 4 ++--
 .../explain_earliest_latest_custom_time.json                  | 4 ++--
 3 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/docs/user/ppl/cmd/grok.rst b/docs/user/ppl/cmd/grok.rst
index 35f3b0c8461..210fa64b2ea 100644
--- a/docs/user/ppl/cmd/grok.rst
+++ b/docs/user/ppl/cmd/grok.rst
@@ -40,7 +40,7 @@ PPL query::
     |-----------------------+------------|
     | amberduke@pyrami.com  | pyrami.com |
     | hattiebond@netagy.com | netagy.com |
-    | null                  |            |
+    | null                  | null       |
     | daleadams@boink.com   | boink.com  |
     +-----------------------+------------+
 
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
index 46143e0c429..ad2df524707 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
     "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], server=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(server=[$1], message=[$3], @timestamp=[$2])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], server=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[server, message, @timestamp]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"server\",\"message\",\"@timestamp\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], server=[$t0])\n    EnumerableAggregate(group=[{1}], earliest_message=[ARG_MIN($3, $2)], latest_message=[ARG_MAX($3, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n"
   }
-}
+}
\ No newline at end of file
diff --git a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
index a3d2d3a634c..ef5ebfe9e1c 100644
--- a/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
+++ b/integ-test/src/test/resources/expectedOutput/calcite_no_pushdown/explain_earliest_latest_custom_time.json
@@ -1,6 +1,6 @@
 {
   "calcite": {
     "logical": "LogicalSystemLimit(fetch=[10000], type=[QUERY_SIZE_LIMIT])\n  LogicalProject(earliest_message=[$1], latest_message=[$2], level=[$0])\n    LogicalAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      LogicalProject(level=[$4], message=[$3], created_at=[$0])\n        CalciteLogicalIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n",
-    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], level=[$t0])\n    EnumerableAggregate(group=[{0}], earliest_message=[ARG_MIN($1, $2)], latest_message=[ARG_MAX($1, $2)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]], PushDownContext=[[PROJECT->[level, message, created_at]], OpenSearchRequestBuilder(sourceBuilder={\"from\":0,\"timeout\":\"1m\",\"_source\":{\"includes\":[\"level\",\"message\",\"created_at\"],\"excludes\":[]}}, requestedTotalSize=2147483647, pageSize=null, startFrom=0)])\n"
+    "physical": "EnumerableLimit(fetch=[10000])\n  EnumerableCalc(expr#0..2=[{inputs}], earliest_message=[$t1], latest_message=[$t2], level=[$t0])\n    EnumerableAggregate(group=[{4}], earliest_message=[ARG_MIN($3, $0)], latest_message=[ARG_MAX($3, $0)])\n      CalciteEnumerableIndexScan(table=[[OpenSearch, opensearch-sql_test_index_logs]])\n"
   }
-}
+}
\ No newline at end of file

From fdf0f9df1b07068b07861e554bc052b23e5bc7ed Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 17:45:55 -0700
Subject: [PATCH 23/26] Fix test_docs

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 docs/category.json         |   4 +-
 docs/user/dql/metadata.rst |   3 +-
 docs/user/ppl/cmd/grok.rst |   2 +-
 doctest/test_docs.py       | 714 +++++++++++++++++++------------------
 4 files changed, 373 insertions(+), 350 deletions(-)

diff --git a/docs/category.json b/docs/category.json
index 4645b6c8318..47098ffc032 100644
--- a/docs/category.json
+++ b/docs/category.json
@@ -24,7 +24,6 @@
     "user/ppl/cmd/rename.rst",
     "user/ppl/cmd/search.rst",
     "user/ppl/cmd/sort.rst",
-    "user/ppl/cmd/stats.rst",
     "user/ppl/cmd/syntax.rst",
     "user/ppl/cmd/trendline.rst",
     "user/ppl/cmd/top.rst",
@@ -56,5 +55,8 @@
     "user/dql/aggregations.rst",
     "user/dql/complex.rst",
     "user/dql/metadata.rst"
+  ],
+  "ppl_cli_calcite": [
+    "user/ppl/cmd/stats.rst"
   ]
 }
diff --git a/docs/user/dql/metadata.rst b/docs/user/dql/metadata.rst
index 19696a0a3fc..9e55ab9885b 100644
--- a/docs/user/dql/metadata.rst
+++ b/docs/user/dql/metadata.rst
@@ -35,7 +35,7 @@ Example 1: Show All Indices Information
 SQL query::
 
     os> SHOW TABLES LIKE '%'
-    fetched rows / total rows = 15/15
+    fetched rows / total rows = 16/16
     +----------------+-------------+------------------+------------+---------+----------+------------+-----------+---------------------------+----------------+
     | TABLE_CAT      | TABLE_SCHEM | TABLE_NAME       | TABLE_TYPE | REMARKS | TYPE_CAT | TYPE_SCHEM | TYPE_NAME | SELF_REFERENCING_COL_NAME | REF_GENERATION |
     |----------------+-------------+------------------+------------+---------+----------+------------+-----------+---------------------------+----------------|
@@ -44,6 +44,7 @@ SQL query::
     | docTestCluster | null        | accounts         | BASE TABLE | null    | null     | null       | null      | null                      | null           |
     | docTestCluster | null        | apache           | BASE TABLE | null    | null     | null       | null      | null                      | null           |
     | docTestCluster | null        | books            | BASE TABLE | null    | null     | null       | null      | null                      | null           |
+    | docTestCluster | null        | events           | BASE TABLE | null    | null     | null       | null      | null                      | null           |
     | docTestCluster | null        | json_test        | BASE TABLE | null    | null     | null       | null      | null                      | null           |
     | docTestCluster | null        | nested           | BASE TABLE | null    | null     | null       | null      | null                      | null           |
     | docTestCluster | null        | nyc_taxi         | BASE TABLE | null    | null     | null       | null      | null                      | null           |
diff --git a/docs/user/ppl/cmd/grok.rst b/docs/user/ppl/cmd/grok.rst
index 210fa64b2ea..35f3b0c8461 100644
--- a/docs/user/ppl/cmd/grok.rst
+++ b/docs/user/ppl/cmd/grok.rst
@@ -40,7 +40,7 @@ PPL query::
     |-----------------------+------------|
     | amberduke@pyrami.com  | pyrami.com |
     | hattiebond@netagy.com | netagy.com |
-    | null                  | null       |
+    | null                  |            |
     | daleadams@boink.com   | boink.com  |
     +-----------------------+------------+
 
diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index 1ad584654c5..8d10cf0d118 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -20,69 +20,51 @@
 from opensearch_sql_cli.utils import OutputSettings
 from opensearchpy import OpenSearch, helpers
 
-ENDPOINT = "http://localhost:9200"
-ACCOUNTS = "accounts"
-EMPLOYEES = "employees"
-PEOPLE = "people"
-ACCOUNT2 = "account2"
-NYC_TAXI = "nyc_taxi"
-BOOKS = "books"
-APACHE = "apache"
-WILDCARD = "wildcard"
-NESTED = "nested"
-DATASOURCES = ".ql-datasources"
-WEBLOGS = "weblogs"
-JSON_TEST = "json_test"
-STATE_COUNTRY = "state_country"
-OCCUPATION = "occupation"
-WORKER = "worker"
-WORK_INFORMATION = "work_information"
-EVENTS = "events"
-
-DEBUG_MODE = os.environ.get('DOCTEST_DEBUG', 'false').lower() == 'true'
 
+# =============================================================================
+# CONSTANTS AND CONFIGURATION
+# =============================================================================
 
-class DocTestConnection(OpenSearchConnection):
+ENDPOINT = "http://localhost:9200"
 
-    def __init__(self, query_language="sql"):
-        super(DocTestConnection, self).__init__(endpoint=ENDPOINT, query_language=query_language)
-        self.set_connection()
+TEST_DATA = {
+    'accounts': 'accounts.json',
+    'employees': None,  # Skip loading employee index - comes from test cases in docs
+    'people': 'people.json',
+    'account2': 'account2.json',
+    'nyc_taxi': 'nyc_taxi.json',
+    'books': 'books.json',
+    'apache': 'apache.json',
+    'wildcard': 'wildcard.json',
+    'nested': 'nested_objects.json',
+    '.ql-datasources': 'datasources.json',
+    'weblogs': 'weblogs.json',
+    'json_test': 'json_test.json',
+    'state_country': 'state_country.json',
+    'occupation': 'occupation.json',
+    'worker': 'worker.json',
+    'work_information': 'work_information.json',
+    'events': 'events.json'
+}
 
-        settings = OutputSettings(table_format="psql", is_vertical=False)
-        self.formatter = Formatter(settings)
+DEBUG_MODE = os.environ.get('DOCTEST_DEBUG', 'false').lower() == 'true'
 
-    def process(self, statement):
-        debug(f"Executing {self.query_language.upper()} query: {statement}")
-        
-        data = self.execute_query(statement, use_console=False)
-        
-        debug(f"Query result: {data}")
-        
-        if data is None:
-            debug("Query returned None - this may indicate an error or unsupported function")
-            print("Error: Query returned no data")
-            return
-            
-        output = self.formatter.format_output(data)
-        output = "\n".join(output)
 
-        click.echo(output)
+# =============================================================================
+# UTILITY FUNCTIONS
+# =============================================================================
 
+def debug(message):
+    if DEBUG_MODE:
+        print(f"[DEBUG] {message}", file=sys.stderr)
 
-"""
-For _explain requests, there are several additional request fields that will inconsistently
-appear/change depending on underlying cluster state. This method normalizes these responses in-place
-to make _explain doctests more consistent.
 
-If the passed response is not an _explain response, the input is left unmodified.
-"""
 def normalize_explain_response(data):
     if "root" in data:
         data = data["root"]
 
     if (request := data.get("description", {}).get("request", None)) and request.startswith("OpenSearchQueryRequest("):
         for filter_field in ["needClean", "pitId", "cursorKeepAlive", "searchAfter", "searchResponse"]:
-            # The value of PIT may contain `+=_-`.
             request = re.sub(f", {filter_field}=[A-Za-z0-9+=_-]+", "", request)
         data["description"]["request"] = request
 
@@ -96,47 +78,109 @@ def pretty_print(s):
     try:
         data = json.loads(s)
         normalize_explain_response(data)
-
         print(json.dumps(data, indent=2))
     except json.decoder.JSONDecodeError:
         print(s)
 
 
-sql_cmd = DocTestConnection(query_language="sql")
-ppl_cmd = DocTestConnection(query_language="ppl")
-test_data_client = OpenSearch([ENDPOINT], verify_certs=True)
+def requires_calcite(doc_category):
+    return doc_category.endswith('_calcite')
 
 
-def debug(message):
-    if DEBUG_MODE:
-        print(f"[DEBUG] {message}", file=sys.stderr)
+# =============================================================================
+# CONNECTION AND COMMAND CLASSES
+# =============================================================================
 
+class DocTestConnection(OpenSearchConnection):
 
-def enable_calcite():
-    """Enable Calcite for functions that require it (like EARLIEST/LATEST)"""
-    try:
-        import requests
-        calcite_settings = {
-            "transient": {
-                "plugins.calcite.enabled": True
-            }
-        }
-        response = requests.put(f"{ENDPOINT}/_plugins/_query/settings", 
-                              json=calcite_settings, 
-                              timeout=10)
-        debug(f"Calcite enable response: {response.status_code} - {response.text}")
+    def __init__(self, query_language="sql"):
+        super(DocTestConnection, self).__init__(endpoint=ENDPOINT, query_language=query_language)
+        self.set_connection()
+        settings = OutputSettings(table_format="psql", is_vertical=False)
+        self.formatter = Formatter(settings)
+
+    def process(self, statement):
+        debug(f"Executing {self.query_language.upper()} query: {statement}")
         
-        if response.status_code == 200:
-            debug("Calcite enabled successfully")
-            return True
-        else:
-            debug(f"Failed to enable Calcite: HTTP {response.status_code}")
-            return False
+        data = self.execute_query(statement, use_console=False)
+        debug(f"Query result: {data}")
+        
+        if data is None:
+            debug("Query returned None - this may indicate an error or unsupported function")
+            print("Error: Query returned no data")
+            return
             
-    except Exception as e:
-        debug(f"Failed to enable Calcite: {e}")
-        return False
+        output = self.formatter.format_output(data)
+        output = "\n".join(output)
+        click.echo(output)
+
+
+class CalciteManager:
+    
+    @staticmethod
+    def enable():
+        try:
+            import requests
+            calcite_settings = {
+                "transient": {
+                    "plugins.calcite.enabled": True
+                }
+            }
+            response = requests.put(f"{ENDPOINT}/_plugins/_query/settings", 
+                                  json=calcite_settings, 
+                                  timeout=10)
+            debug(f"Calcite enable response: {response.status_code} - {response.text}")
+            
+            if response.status_code == 200:
+                debug("Calcite enabled successfully")
+                return True
+            else:
+                debug(f"Failed to enable Calcite: HTTP {response.status_code}")
+                return False
+                
+        except Exception as e:
+            debug(f"Failed to enable Calcite: {e}")
+            return False
+
+
+# =============================================================================
+# TEST DATA MANAGEMENT
+# =============================================================================
 
+class TestDataManager:
+    
+    def __init__(self):
+        self.client = OpenSearch([ENDPOINT], verify_certs=True)
+    
+    def load_file(self, filename, index_name):
+        mapping_file_path = './test_mapping/' + filename
+        if os.path.isfile(mapping_file_path):
+            with open(mapping_file_path, 'r') as f:
+                self.client.indices.create(index=index_name, body=f.read())
+
+        data_file_path = './test_data/' + filename
+        def load_json():
+            with open(data_file_path, 'r') as f:
+                for line in f:
+                    yield json.loads(line)
+
+        helpers.bulk(self.client, load_json(), stats_only=True, index=index_name, refresh='wait_for')
+
+    def load_all_test_data(self):
+        for index_name, filename in TEST_DATA.items():
+            if filename is not None:
+                self.load_file(filename, index_name)
+            else:
+                debug(f"Skipping index '{index_name}' - filename is None")
+
+    def cleanup_indices(self):
+        indices_to_delete = list(TEST_DATA.keys())
+        self.client.indices.delete(index=indices_to_delete, ignore_unavailable=True)
+
+
+# =============================================================================
+# DOCTEST PARSERS AND TRANSFORMERS
+# =============================================================================
 
 def sql_cli_transform(s):
     return u'sql_cmd.process({0})'.format(repr(s.strip().rstrip(';')))
@@ -147,7 +191,6 @@ def ppl_cli_transform(s):
 
 
 def bash_transform(s):
-    # TODO: add ppl support, be default cli uses sql
     if s.startswith("opensearchsql"):
         s = re.search(r"opensearchsql\s+-q\s+\"(.*?)\"", s).group(1)
         return u'cmd.process({0})'.format(repr(s.strip().rstrip(';')))
@@ -164,56 +207,40 @@ def bash_transform(s):
     ps1=r'sh\$', comment_prefix='#', transform=bash_transform)
 
 
-def set_up_test_indices(test):
-    set_up(test)
-    enable_calcite()
-    
-    load_file("accounts.json", index_name=ACCOUNTS)
-    load_file("people.json", index_name=PEOPLE)
-    load_file("account2.json", index_name=ACCOUNT2)
-    load_file("nyc_taxi.json", index_name=NYC_TAXI)
-    load_file("books.json", index_name=BOOKS)
-    load_file("apache.json", index_name=APACHE)
-    load_file("wildcard.json", index_name=WILDCARD)
-    load_file("nested_objects.json", index_name=NESTED)
-    load_file("datasources.json", index_name=DATASOURCES)
-    load_file("weblogs.json", index_name=WEBLOGS)
-    load_file("json_test.json", index_name=JSON_TEST)
-    load_file("state_country.json", index_name=STATE_COUNTRY)
-    load_file("occupation.json", index_name=OCCUPATION)
-    load_file("worker.json", index_name=WORKER)
-    load_file("work_information.json", index_name=WORK_INFORMATION)
-    load_file("events.json", index_name=EVENTS)
-
-
-def load_file(filename, index_name):
-    # Create index with the mapping if mapping file exists
-    mapping_file_path = './test_mapping/' + filename
-    if os.path.isfile(mapping_file_path):
-        with open(mapping_file_path, 'r') as f:
-            test_data_client.indices.create(index=index_name, body=f.read())
-
-    # generate iterable data
-    data_file_path = './test_data/' + filename
-    def load_json():
-        with open(data_file_path, 'r') as f:
-            for line in f:
-                yield json.loads(line)
-
-    # Need to enable refresh, because the load won't be visible to search immediately
-    # https://stackoverflow.com/questions/57840161/elasticsearch-python-bulk-helper-api-with-refresh
-    helpers.bulk(test_data_client, load_json(), stats_only=True, index=index_name, refresh='wait_for')
-
-
-def set_up(test):
-    test.globs['sql_cmd'] = sql_cmd
-    test.globs['ppl_cmd'] = ppl_cmd
+# =============================================================================
+# TEST SETUP AND TEARDOWN
+# =============================================================================
+
+test_data_manager = None
+
+
+def get_test_data_manager():
+    global test_data_manager
+    if test_data_manager is None:
+        test_data_manager = TestDataManager()
+    return test_data_manager
+
+
+def set_up_test_indices_with_calcite(test):
+    test.globs['sql_cmd'] = DocTestConnection(query_language="sql")
+    test.globs['ppl_cmd'] = DocTestConnection(query_language="ppl")
+    CalciteManager.enable()
+    get_test_data_manager().load_all_test_data()
+
+
+def set_up_test_indices_without_calcite(test):
+    test.globs['sql_cmd'] = DocTestConnection(query_language="sql")
+    test.globs['ppl_cmd'] = DocTestConnection(query_language="ppl")
+    get_test_data_manager().load_all_test_data()
 
 
 def tear_down(test):
-    # drop leftover tables after each test
-    test_data_client.indices.delete(index=[ACCOUNTS, EMPLOYEES, PEOPLE, ACCOUNT2, NYC_TAXI, BOOKS, APACHE, WILDCARD, NESTED, WEBLOGS, JSON_TEST, STATE_COUNTRY, OCCUPATION, WORKER, WORK_INFORMATION, EVENTS], ignore_unavailable=True)
+    get_test_data_manager().cleanup_indices()
+
 
+# =============================================================================
+# DOCTEST SUITE CREATION
+# =============================================================================
 
 docsuite = partial(doctest.DocFileSuite,
                    tearDown=tear_down,
@@ -221,7 +248,6 @@ def tear_down(test):
                    optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS,
                    encoding='utf-8')
 
-
 doctest_file = partial(os.path.join, '../docs')
 
 
@@ -234,11 +260,11 @@ def run(self, result, debug=False):
         super().run(result, debug)
 
 
-def doc_suite(fn):
+def create_bash_suite(fn, setup_func):
     return docsuite(
         fn,
         parser=bash_parser,
-        setUp=set_up_test_indices,
+        setUp=setup_func,
         globs={
             'sh': partial(
                 subprocess.run,
@@ -253,257 +279,253 @@ def doc_suite(fn):
     )
 
 
+def create_cli_suite(fn, parser, setup_func):
+    return docsuite(
+        fn,
+        parser=parser,
+        setUp=setup_func
+    )
+
+
+# =============================================================================
+# TEST LOADING AND EXECUTION
+# =============================================================================
+
 def load_tests(loader, suite, ignore):
     tests = []
-    # Load doctest docs by category
+    
     with open('../docs/category.json') as json_file:
         category = json.load(json_file)
 
-    bash_docs = category['bash']
-    ppl_cli_docs = category['ppl_cli']
-    sql_cli_docs = category['sql_cli']
-
-    # docs with bash-based examples
-    for fn in doctest_files(bash_docs):
-        tests.append(doc_suite(fn))
-
-    # docs with sql-cli based examples
-    # TODO: add until the migration to new architecture is done, then we have an artifact including ppl and sql both
-    # for fn in doctest_files('sql/basics.rst'):
-    #     tests.append(docsuite(fn, setUp=set_up_accounts))
-    for fn in doctest_files(sql_cli_docs):
-        tests.append(
-            docsuite(
-                fn,
-                parser=sql_cli_parser,
-                setUp=set_up_test_indices
-            )
-        )
-
-    # docs with ppl-cli based examples
-    for fn in doctest_files(ppl_cli_docs):
-        tests.append(
-            docsuite(
-                fn,
-                parser=ppl_cli_parser,
-                setUp=set_up_test_indices
-            )
-        )
+    for category_name, docs in category.items():
+        if not docs:
+            continue
+            
+        if requires_calcite(category_name):
+            setup_func = set_up_test_indices_with_calcite
+            print(f"Loading {len(docs)} files from {category_name} with Calcite enabled")
+        else:
+            setup_func = set_up_test_indices_without_calcite
+            print(f"Loading {len(docs)} files from {category_name} without Calcite")
+
+        if category_name.startswith('bash'):
+            for fn in doctest_files(docs):
+                tests.append(create_bash_suite(fn, setup_func))
+        elif category_name.startswith('sql_cli'):
+            for fn in doctest_files(docs):
+                tests.append(create_cli_suite(fn, sql_cli_parser, setup_func))
+        elif category_name.startswith('ppl_cli'):
+            for fn in doctest_files(docs):
+                tests.append(create_cli_suite(fn, ppl_cli_parser, setup_func))
 
-    # randomize order of tests to make sure they don't depend on each other
     random.shuffle(tests)
-
     return DocTests(tests)
 
 
-# Single file doctest functionality
-def find_doc_file(filename_or_path):
-    """Find documentation file by name or return the path if it's already a full path"""
-    # If it's already a full path that exists, return it
-    if os.path.exists(filename_or_path):
-        return filename_or_path
+# =============================================================================
+# SINGLE FILE TESTING FUNCTIONALITY
+# =============================================================================
+
+class SingleFileTestRunner:
+    
+    def __init__(self):
+        self.test_data_manager = None
     
-    # If it's just a filename, search for it in the docs directory
-    if not os.path.sep in filename_or_path:
+    def get_test_data_manager(self):
+        if self.test_data_manager is None:
+            self.test_data_manager = TestDataManager()
+        return self.test_data_manager
+    
+    def find_doc_file(self, filename_or_path):
+        if os.path.exists(filename_or_path):
+            return filename_or_path
+        
+        if not os.path.sep in filename_or_path:
+            try:
+                with open('../docs/category.json') as json_file:
+                    category = json.load(json_file)
+                
+                all_docs = []
+                for cat_name, docs in category.items():
+                    all_docs.extend(docs)
+                
+                search_filename = filename_or_path
+                if not search_filename.endswith('.rst'):
+                    search_filename += '.rst'
+                
+                matches = [doc for doc in all_docs if doc.endswith(search_filename)]
+                
+                if len(matches) == 1:
+                    found_path = f"../docs/{matches[0]}"
+                    print(f"Found: {found_path}")
+                    return found_path
+                elif len(matches) > 1:
+                    print(f"Multiple files found matching '{search_filename}':")
+                    for match in matches:
+                        print(f"  ../docs/{match}")
+                    print("Please specify the full path or a more specific filename.")
+                    return None
+                else:
+                    print(f"No documentation file found matching '{search_filename}'")
+                    print("Use --list to see all available files")
+                    return None
+                    
+            except Exception as e:
+                print(f"Error searching for file: {e}")
+                return None
+        
+        if not filename_or_path.startswith('../docs/'):
+            potential_path = f"../docs/{filename_or_path}"
+            if os.path.exists(potential_path):
+                return potential_path
+        
+        return filename_or_path
+
+    def determine_doc_type(self, file_path):
         try:
             with open('../docs/category.json') as json_file:
                 category = json.load(json_file)
             
-            # Search in all categories
-            all_docs = category['bash'] + category['ppl_cli'] + category['sql_cli']
+            rel_path = os.path.relpath(file_path, '../docs')
             
-            # Add .rst extension if not present
-            search_filename = filename_or_path
-            if not search_filename.endswith('.rst'):
-                search_filename += '.rst'
+            for category_name, docs in category.items():
+                if rel_path in docs:
+                    return category_name
             
-            # Find files that end with the given filename
-            matches = [doc for doc in all_docs if doc.endswith(search_filename)]
-            
-            if len(matches) == 1:
-                found_path = f"../docs/{matches[0]}"
-                print(f"Found: {found_path}")
-                return found_path
-            elif len(matches) > 1:
-                print(f"Multiple files found matching '{search_filename}':")
-                for match in matches:
-                    print(f"  ../docs/{match}")
-                print("Please specify the full path or a more specific filename.")
-                return None
+            if '/ppl/' in file_path:
+                return 'ppl_cli'
+            elif '/sql/' in file_path or '/dql/' in file_path:
+                return 'sql_cli'
             else:
-                print(f"No documentation file found matching '{search_filename}'")
-                print("Use --list to see all available files")
-                return None
+                return 'bash'
                 
         except Exception as e:
-            print(f"Error searching for file: {e}")
-            return None
-    
-    # If it's a relative path, try to find it
-    if not filename_or_path.startswith('../docs/'):
-        potential_path = f"../docs/{filename_or_path}"
-        if os.path.exists(potential_path):
-            return potential_path
-    
-    return filename_or_path
-
-
-def determine_doc_type(file_path):
-    """Determine the type of documentation file based on category.json"""
-    try:
-        with open('../docs/category.json') as json_file:
-            category = json.load(json_file)
-        
-        # Convert absolute path to relative path from docs directory
-        rel_path = os.path.relpath(file_path, '../docs')
-        
-        if rel_path in category['bash']:
-            return 'bash'
-        elif rel_path in category['ppl_cli']:
-            return 'ppl_cli'
-        elif rel_path in category['sql_cli']:
-            return 'sql_cli'
-        else:
-            # Try to guess based on file path
+            print(f"Warning: Could not determine doc type from category.json: {e}")
             if '/ppl/' in file_path:
                 return 'ppl_cli'
             elif '/sql/' in file_path or '/dql/' in file_path:
                 return 'sql_cli'
             else:
-                return 'bash'  # default fallback
-    except Exception as e:
-        print(f"Warning: Could not determine doc type from category.json: {e}")
-        # Fallback to path-based detection
-        if '/ppl/' in file_path:
-            return 'ppl_cli'
-        elif '/sql/' in file_path or '/dql/' in file_path:
-            return 'sql_cli'
-        else:
-            return 'bash'
-
+                return 'bash'
 
-def run_single_doctest(file_path, verbose=False, endpoint=None):
-    """Run doctest for a single documentation file"""
-    
-    if not os.path.exists(file_path):
-        print(f"Error: File {file_path} does not exist")
-        return False
-    
-    # Update endpoint if provided
-    if endpoint:
-        global ENDPOINT
-        ENDPOINT = endpoint
-        print(f"Using custom endpoint: {endpoint}")
-    
-    doc_type = determine_doc_type(file_path)
-    print(f"Detected doc type: {doc_type}")
-    print(f"Running doctest for: {file_path}")
-    
-    # Configure doctest options
-    optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
-    if verbose:
-        optionflags |= doctest.REPORT_NDIFF
-    
-    # Choose appropriate parser and setup based on doc type
-    if doc_type == 'bash':
-        parser = bash_parser
-        setup_func = set_up_test_indices
-        globs = {
-            'sh': partial(
-                subprocess.run,
-                stdin=subprocess.PIPE,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.STDOUT,
-                timeout=60,
-                shell=True
-            ),
-            'pretty_print': pretty_print
-        }
-    elif doc_type == 'ppl_cli':
-        parser = ppl_cli_parser
-        setup_func = set_up_test_indices
-        globs = {}
-    else:  # sql_cli
-        parser = sql_cli_parser
-        setup_func = set_up_test_indices
-        globs = {}
-    
-    try:
-        print("Setting up test environment...")
+    def run_single_doctest(self, file_path, verbose=False, endpoint=None):
+        if not os.path.exists(file_path):
+            print(f"Error: File {file_path} does not exist")
+            return False
         
-        # Create and run the doctest suite
-        suite = doctest.DocFileSuite(
-            file_path,
-            parser=parser,
-            setUp=setup_func,
-            tearDown=tear_down,
-            optionflags=optionflags,
-            encoding='utf-8',
-            globs=globs
-        )
+        if endpoint:
+            global ENDPOINT
+            ENDPOINT = endpoint
+            print(f"Using custom endpoint: {endpoint}")
+        
+        doc_type = self.determine_doc_type(file_path)
+        print(f"Detected doc type: {doc_type}")
+        print(f"Running doctest for: {file_path}")
         
-        # Run the test
-        runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
-        result = runner.run(suite)
+        optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
+        if verbose:
+            optionflags |= doctest.REPORT_NDIFF
         
-        # Print summary
-        if result.wasSuccessful():
-            print(f"\nSUCCESS: All tests in {os.path.basename(file_path)} passed!")
-            print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
-            return True
+        if requires_calcite(doc_type):
+            setup_func = set_up_test_indices_with_calcite
+            print(f"Using Calcite-enabled setup for category: {doc_type}")
         else:
-            print(f"\nFAILED: {len(result.failures + result.errors)} test(s) failed in {os.path.basename(file_path)}")
-            print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
+            setup_func = set_up_test_indices_without_calcite
+            print(f"Using standard setup (no Calcite) for category: {doc_type}")
+
+        if doc_type.startswith('bash'):
+            parser = bash_parser
+            globs = {
+                'sh': partial(
+                    subprocess.run,
+                    stdin=subprocess.PIPE,
+                    stdout=subprocess.PIPE,
+                    stderr=subprocess.STDOUT,
+                    timeout=60,
+                    shell=True
+                ),
+                'pretty_print': pretty_print
+            }
+        elif doc_type.startswith('ppl_cli'):
+            parser = ppl_cli_parser
+            globs = {}
+        else:
+            parser = sql_cli_parser
+            globs = {}
+        
+        try:
+            print("Setting up test environment...")
             
-            if verbose:
-                print("\nDetailed failure information:")
-                for failure in result.failures:
-                    print(f"\n--- FAILURE in {failure[0]} ---")
-                    print(failure[1])
-                for error in result.errors:
-                    print(f"\n--- ERROR in {error[0]} ---")
-                    print(error[1])
-            else:
-                print("Use --verbose for detailed failure information")
+            suite = doctest.DocFileSuite(
+                file_path,
+                parser=parser,
+                setUp=setup_func,
+                tearDown=tear_down,
+                optionflags=optionflags,
+                encoding='utf-8',
+                globs=globs
+            )
             
-            return False
+            runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
+            result = runner.run(suite)
             
-    except Exception as e:
-        print(f"Error running doctest: {e}")
-        if verbose:
-            import traceback
-            traceback.print_exc()
-        return False
-
+            if result.wasSuccessful():
+                print(f"\nSUCCESS: All tests in {os.path.basename(file_path)} passed!")
+                print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
+                return True
+            else:
+                print(f"\nFAILED: {len(result.failures + result.errors)} test(s) failed in {os.path.basename(file_path)}")
+                print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
+                
+                if verbose:
+                    print("\nDetailed failure information:")
+                    for failure in result.failures:
+                        print(f"\n--- FAILURE in {failure[0]} ---")
+                        print(failure[1])
+                    for error in result.errors:
+                        print(f"\n--- ERROR in {error[0]} ---")
+                        print(error[1])
+                else:
+                    print("Use --verbose for detailed failure information")
+                
+                return False
+                
+        except Exception as e:
+            print(f"Error running doctest: {e}")
+            if verbose:
+                import traceback
+                traceback.print_exc()
+            return False
 
-def list_available_docs():
-    """List all available documentation files that can be tested"""
-    try:
-        with open('../docs/category.json') as json_file:
-            category = json.load(json_file)
-        
-        print("Available documentation files for testing:")
-        print(f"\nBash-based docs ({len(category['bash'])} files):")
-        for doc in sorted(category['bash']):
-            print(f"  ../docs/{doc}")
-        
-        print(f"\nPPL CLI docs ({len(category['ppl_cli'])} files):")
-        for doc in sorted(category['ppl_cli']):
-            print(f"  ../docs/{doc}")
-        
-        print(f"\nSQL CLI docs ({len(category['sql_cli'])} files):")
-        for doc in sorted(category['sql_cli']):
-            print(f"  ../docs/{doc}")
-        
-        total_docs = len(category['bash']) + len(category['ppl_cli']) + len(category['sql_cli'])
-        print(f"\nTotal: {total_docs} documentation files available for testing")
+    def list_available_docs(self):
+        try:
+            with open('../docs/category.json') as json_file:
+                category = json.load(json_file)
+            
+            print("Available documentation files for testing:")
             
-    except Exception as e:
-        print(f"Error reading category.json: {e}")
+            total_docs = 0
+            for category_name, docs in category.items():
+                if not docs:
+                    continue
+                    
+                calcite_status = " (Calcite enabled)" if requires_calcite(category_name) else " (no Calcite)"
+                print(f"\n{category_name.upper()} docs ({len(docs)} files){calcite_status}:")
+                for doc in sorted(docs):
+                    print(f"  ../docs/{doc}")
+                total_docs += len(docs)
+            
+            print(f"\nTotal: {total_docs} documentation files available for testing")
+                
+        except Exception as e:
+            print(f"Error reading category.json: {e}")
 
 
+# =============================================================================
+# MAIN ENTRY POINT
+# =============================================================================
+
 def main():
-    """Main entry point for single file testing"""
     parser = argparse.ArgumentParser(
         description="Run doctest for one or more documentation files, or all files if no arguments provided",
         formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -532,18 +554,17 @@ def main():
     
     args = parser.parse_args()
     
+    runner = SingleFileTestRunner()
+    
     if args.list:
-        list_available_docs()
+        runner.list_available_docs()
         return
     
-    # If no file paths provided, run the default unittest behavior
     if not args.file_paths:
         print("No specific files provided. Running full doctest suite...")
-        # Run the standard unittest discovery
         unittest.main(module=None, argv=['test_docs.py'], exit=False)
         return
     
-    # Single file testing mode
     all_success = True
     total_files = len(args.file_paths)
     
@@ -553,14 +574,13 @@ def main():
             print(f"Testing file {i}/{total_files}: {file_path}")
             print('='*60)
         
-        # Find the actual file path (handles both full paths and just filenames)
-        actual_file_path = find_doc_file(file_path)
+        actual_file_path = runner.find_doc_file(file_path)
         if not actual_file_path:
             print(f"Skipping {file_path} - file not found")
             all_success = False
             continue
         
-        success = run_single_doctest(
+        success = runner.run_single_doctest(
             actual_file_path, 
             verbose=args.verbose,
             endpoint=args.endpoint

From f9b34cf35cc4b024ada526798f56623783a1b0cd Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 18:05:44 -0700
Subject: [PATCH 24/26] Update test_docs.py

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 doctest/test_docs.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index 8d10cf0d118..8c7045c6365 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -29,7 +29,6 @@
 
 TEST_DATA = {
     'accounts': 'accounts.json',
-    'employees': None,  # Skip loading employee index - comes from test cases in docs
     'people': 'people.json',
     'account2': 'account2.json',
     'nyc_taxi': 'nyc_taxi.json',

From 109531da67d2fc50b2709b9424102a8353666aa0 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Wed, 27 Aug 2025 23:18:40 -0700
Subject: [PATCH 25/26] Refactor test_docs.py and fix

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 doctest/build.gradle |  13 +-
 doctest/test_docs.py | 480 +++++++++++++++----------------------------
 2 files changed, 161 insertions(+), 332 deletions(-)

diff --git a/doctest/build.gradle b/doctest/build.gradle
index e4d6babf985..92b1a1c9e2c 100644
--- a/doctest/build.gradle
+++ b/doctest/build.gradle
@@ -89,15 +89,9 @@ task doctest(type: Exec, dependsOn: ['bootstrap']) {
     }
     
     if (docs) {
-        // Single file or multiple files mode
         def args = ['.venv/bin/python', 'test_docs.py']
         
-        // Handle multiple files (comma-separated)
-        if (docs.contains(',')) {
-            args.addAll(docs.split(',').collect { it.trim() })
-        } else {
-            args.add(docs)
-        }
+        args.addAll(docs.split(',').collect { it.trim() })
         
         if (verbose == 'true') {
             args.add('--verbose')
@@ -115,11 +109,6 @@ task doctest(type: Exec, dependsOn: ['bootstrap']) {
     doLast {
         // remove the cloned sql-cli folder
         file("$projectDir/sql-cli").deleteDir()
-        if (docs) {
-            println("Single file doctest done")
-        } else {
-            println("Full doctest suite done")
-        }
     }
 }
 
diff --git a/doctest/test_docs.py b/doctest/test_docs.py
index 8c7045c6365..d79e5b1cf55 100644
--- a/doctest/test_docs.py
+++ b/doctest/test_docs.py
@@ -21,10 +21,6 @@
 from opensearchpy import OpenSearch, helpers
 
 
-# =============================================================================
-# CONSTANTS AND CONFIGURATION
-# =============================================================================
-
 ENDPOINT = "http://localhost:9200"
 
 TEST_DATA = {
@@ -49,15 +45,20 @@
 DEBUG_MODE = os.environ.get('DOCTEST_DEBUG', 'false').lower() == 'true'
 
 
-# =============================================================================
-# UTILITY FUNCTIONS
-# =============================================================================
-
 def debug(message):
     if DEBUG_MODE:
         print(f"[DEBUG] {message}", file=sys.stderr)
 
 
+def detect_doc_type_from_path(file_path):
+    if '/ppl/' in file_path:
+        return 'ppl_cli'
+    elif '/sql/' in file_path or '/dql/' in file_path:
+        return 'sql_cli'
+    else:
+        return 'bash'
+
+
 def normalize_explain_response(data):
     if "root" in data:
         data = data["root"]
@@ -86,14 +87,83 @@ def requires_calcite(doc_category):
     return doc_category.endswith('_calcite')
 
 
-# =============================================================================
-# CONNECTION AND COMMAND CLASSES
-# =============================================================================
+class CategoryManager:
+    
+    def __init__(self, category_file_path='../docs/category.json'):
+        self._categories = self.load_categories(category_file_path)
+        self._all_docs_cache = None
+    
+    def load_categories(self, file_path):
+        try:
+            with open(file_path) as json_file:
+                categories = json.load(json_file)
+            debug(f"Loaded {len(categories)} categories from {file_path}")
+            return categories
+        except Exception as e:
+            raise Exception(f"Failed to load categories from {file_path}: {e}")
+    
+    def get_all_categories(self):
+        return list(self._categories.keys())
+    
+    def get_category_files(self, category_name):
+        return self._categories.get(category_name, [])
+    
+    def get_all_docs(self):
+        if self._all_docs_cache is None:
+            self._all_docs_cache = []
+            for category_name, docs in self._categories.items():
+                self._all_docs_cache.extend(docs)
+        return self._all_docs_cache
+    
+    def find_file_category(self, file_path):
+        # Convert to relative path from docs root
+        if file_path.startswith('../docs/'):
+            rel_path = file_path[8:]  # Remove '../docs/' prefix
+        else:
+            rel_path = file_path
+        
+        for category_name, docs in self._categories.items():
+            if rel_path in docs:
+                debug(f"Found file {rel_path} in category {category_name}")
+                return category_name
+        
+        # Fallback to path-based detection
+        debug(f"File {rel_path} not found in categories, using path-based detection")
+        return detect_doc_type_from_path(file_path)
+    
+    def requires_calcite(self, category_name):
+        return category_name.endswith('_calcite')
+    
+    def get_setup_function(self, category_name):
+        if self.requires_calcite(category_name):
+            return set_up_test_indices_with_calcite
+        else:
+            return set_up_test_indices_without_calcite
+    
+    def get_parser_for_category(self, category_name):
+        if category_name.startswith('bash'):
+            return bash_parser
+        elif category_name.startswith('ppl_cli'):
+            return ppl_cli_parser
+        elif category_name.startswith('sql_cli'):
+            return sql_cli_parser
+        else:
+            # Default fallback
+            return sql_cli_parser
+    
+    def find_matching_files(self, search_filename):
+        if not search_filename.endswith('.rst'):
+            search_filename += '.rst'
+        
+        all_docs = self.get_all_docs()
+        matches = [doc for doc in all_docs if doc.endswith(search_filename)]
+        return matches
+
 
 class DocTestConnection(OpenSearchConnection):
 
-    def __init__(self, query_language="sql"):
-        super(DocTestConnection, self).__init__(endpoint=ENDPOINT, query_language=query_language)
+    def __init__(self, query_language="sql", endpoint=ENDPOINT):
+        super(DocTestConnection, self).__init__(endpoint, query_language=query_language)
         self.set_connection()
         settings = OutputSettings(table_format="psql", is_vertical=False)
         self.formatter = Formatter(settings)
@@ -117,34 +187,19 @@ def process(self, statement):
 class CalciteManager:
     
     @staticmethod
-    def enable():
-        try:
-            import requests
-            calcite_settings = {
-                "transient": {
-                    "plugins.calcite.enabled": True
-                }
+    def set_enabled(enabled):
+        import requests
+        calcite_settings = {
+            "transient": {
+                "plugins.calcite.enabled": enabled
             }
-            response = requests.put(f"{ENDPOINT}/_plugins/_query/settings", 
-                                  json=calcite_settings, 
-                                  timeout=10)
-            debug(f"Calcite enable response: {response.status_code} - {response.text}")
-            
-            if response.status_code == 200:
-                debug("Calcite enabled successfully")
-                return True
-            else:
-                debug(f"Failed to enable Calcite: HTTP {response.status_code}")
-                return False
-                
-        except Exception as e:
-            debug(f"Failed to enable Calcite: {e}")
-            return False
-
-
-# =============================================================================
-# TEST DATA MANAGEMENT
-# =============================================================================
+        }
+        response = requests.put(f"{ENDPOINT}/_plugins/_query/settings", 
+                                json=calcite_settings, 
+                                timeout=10)
+        
+        if response.status_code != 200:
+            raise Exception(f"Failed to set Calcite setting: {response.status_code} {response.text}")
 
 class TestDataManager:
     
@@ -177,10 +232,6 @@ def cleanup_indices(self):
         self.client.indices.delete(index=indices_to_delete, ignore_unavailable=True)
 
 
-# =============================================================================
-# DOCTEST PARSERS AND TRANSFORMERS
-# =============================================================================
-
 def sql_cli_transform(s):
     return u'sql_cmd.process({0})'.format(repr(s.strip().rstrip(';')))
 
@@ -206,10 +257,6 @@ def bash_transform(s):
     ps1=r'sh\$', comment_prefix='#', transform=bash_transform)
 
 
-# =============================================================================
-# TEST SETUP AND TEARDOWN
-# =============================================================================
-
 test_data_manager = None
 
 
@@ -223,13 +270,14 @@ def get_test_data_manager():
 def set_up_test_indices_with_calcite(test):
     test.globs['sql_cmd'] = DocTestConnection(query_language="sql")
     test.globs['ppl_cmd'] = DocTestConnection(query_language="ppl")
-    CalciteManager.enable()
+    CalciteManager.set_enabled(True)
     get_test_data_manager().load_all_test_data()
 
 
 def set_up_test_indices_without_calcite(test):
     test.globs['sql_cmd'] = DocTestConnection(query_language="sql")
     test.globs['ppl_cmd'] = DocTestConnection(query_language="ppl")
+    CalciteManager.set_enabled(False)
     get_test_data_manager().load_all_test_data()
 
 
@@ -237,21 +285,18 @@ def tear_down(test):
     get_test_data_manager().cleanup_indices()
 
 
-# =============================================================================
-# DOCTEST SUITE CREATION
-# =============================================================================
-
 docsuite = partial(doctest.DocFileSuite,
                    tearDown=tear_down,
-                   parser=sql_cli_parser,
                    optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS,
                    encoding='utf-8')
 
-doctest_file = partial(os.path.join, '../docs')
+
+def get_doc_filepath(item):
+    return os.path.join('../docs', item)
 
 
-def doctest_files(items):
-    return (doctest_file(item) for item in items)
+def get_doc_filepaths(items):
+    return (get_doc_filepath(item) for item in items)
 
 
 class DocTests(unittest.TestSuite):
@@ -259,9 +304,9 @@ def run(self, result, debug=False):
         super().run(result, debug)
 
 
-def create_bash_suite(fn, setup_func):
+def create_bash_suite(filepaths, setup_func):
     return docsuite(
-        fn,
+        *filepaths,
         parser=bash_parser,
         setUp=setup_func,
         globs={
@@ -278,251 +323,63 @@ def create_bash_suite(fn, setup_func):
     )
 
 
-def create_cli_suite(fn, parser, setup_func):
+def create_cli_suite(filepaths, parser, setup_func):
     return docsuite(
-        fn,
+        *filepaths,
         parser=parser,
         setUp=setup_func
     )
 
-
-# =============================================================================
-# TEST LOADING AND EXECUTION
-# =============================================================================
-
+# Entry point for unittest discovery
 def load_tests(loader, suite, ignore):
     tests = []
+    category_manager = CategoryManager()
     
-    with open('../docs/category.json') as json_file:
-        category = json.load(json_file)
-
-    for category_name, docs in category.items():
+    for category_name in category_manager.get_all_categories():
+        docs = category_manager.get_category_files(category_name)
         if not docs:
             continue
-            
-        if requires_calcite(category_name):
-            setup_func = set_up_test_indices_with_calcite
-            print(f"Loading {len(docs)} files from {category_name} with Calcite enabled")
-        else:
-            setup_func = set_up_test_indices_without_calcite
-            print(f"Loading {len(docs)} files from {category_name} without Calcite")
 
-        if category_name.startswith('bash'):
-            for fn in doctest_files(docs):
-                tests.append(create_bash_suite(fn, setup_func))
-        elif category_name.startswith('sql_cli'):
-            for fn in doctest_files(docs):
-                tests.append(create_cli_suite(fn, sql_cli_parser, setup_func))
-        elif category_name.startswith('ppl_cli'):
-            for fn in doctest_files(docs):
-                tests.append(create_cli_suite(fn, ppl_cli_parser, setup_func))
+        tests.append(get_test_suite(category_manager, category_name, get_doc_filepaths(docs)))
 
     random.shuffle(tests)
     return DocTests(tests)
 
+def get_test_suite(category_manager: CategoryManager, category_name, filepaths):
+    setup_func = category_manager.get_setup_function(category_name)
 
-# =============================================================================
-# SINGLE FILE TESTING FUNCTIONALITY
-# =============================================================================
+    if category_name.startswith('bash'):
+        return create_bash_suite(filepaths, setup_func)
+    else:
+        parser = category_manager.get_parser_for_category(category_name)
+        return create_cli_suite(filepaths, parser, setup_func)
 
-class SingleFileTestRunner:
+def list_available_docs(category_manager: CategoryManager):
+    categories = category_manager.get_all_categories()
     
-    def __init__(self):
-        self.test_data_manager = None
+    print(f"Available documentation files for testing:\n")
     
-    def get_test_data_manager(self):
-        if self.test_data_manager is None:
-            self.test_data_manager = TestDataManager()
-        return self.test_data_manager
+    total = 0
+    for category_name in categories.items():
+        files = category_manager.get_category_files(category_name)
+        total += len(files)
+        print(f"{category_name} docs ({len(files)} files):\n")
+        for doc in sorted(files):
+            print(f"  ../docs/{doc}\n")
     
-    def find_doc_file(self, filename_or_path):
-        if os.path.exists(filename_or_path):
-            return filename_or_path
-        
-        if not os.path.sep in filename_or_path:
-            try:
-                with open('../docs/category.json') as json_file:
-                    category = json.load(json_file)
-                
-                all_docs = []
-                for cat_name, docs in category.items():
-                    all_docs.extend(docs)
-                
-                search_filename = filename_or_path
-                if not search_filename.endswith('.rst'):
-                    search_filename += '.rst'
-                
-                matches = [doc for doc in all_docs if doc.endswith(search_filename)]
-                
-                if len(matches) == 1:
-                    found_path = f"../docs/{matches[0]}"
-                    print(f"Found: {found_path}")
-                    return found_path
-                elif len(matches) > 1:
-                    print(f"Multiple files found matching '{search_filename}':")
-                    for match in matches:
-                        print(f"  ../docs/{match}")
-                    print("Please specify the full path or a more specific filename.")
-                    return None
-                else:
-                    print(f"No documentation file found matching '{search_filename}'")
-                    print("Use --list to see all available files")
-                    return None
-                    
-            except Exception as e:
-                print(f"Error searching for file: {e}")
-                return None
-        
-        if not filename_or_path.startswith('../docs/'):
-            potential_path = f"../docs/{filename_or_path}"
-            if os.path.exists(potential_path):
-                return potential_path
-        
-        return filename_or_path
-
-    def determine_doc_type(self, file_path):
-        try:
-            with open('../docs/category.json') as json_file:
-                category = json.load(json_file)
-            
-            rel_path = os.path.relpath(file_path, '../docs')
-            
-            for category_name, docs in category.items():
-                if rel_path in docs:
-                    return category_name
-            
-            if '/ppl/' in file_path:
-                return 'ppl_cli'
-            elif '/sql/' in file_path or '/dql/' in file_path:
-                return 'sql_cli'
-            else:
-                return 'bash'
-                
-        except Exception as e:
-            print(f"Warning: Could not determine doc type from category.json: {e}")
-            if '/ppl/' in file_path:
-                return 'ppl_cli'
-            elif '/sql/' in file_path or '/dql/' in file_path:
-                return 'sql_cli'
-            else:
-                return 'bash'
-
-    def run_single_doctest(self, file_path, verbose=False, endpoint=None):
-        if not os.path.exists(file_path):
-            print(f"Error: File {file_path} does not exist")
-            return False
-        
-        if endpoint:
-            global ENDPOINT
-            ENDPOINT = endpoint
-            print(f"Using custom endpoint: {endpoint}")
-        
-        doc_type = self.determine_doc_type(file_path)
-        print(f"Detected doc type: {doc_type}")
-        print(f"Running doctest for: {file_path}")
-        
-        optionflags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
-        if verbose:
-            optionflags |= doctest.REPORT_NDIFF
-        
-        if requires_calcite(doc_type):
-            setup_func = set_up_test_indices_with_calcite
-            print(f"Using Calcite-enabled setup for category: {doc_type}")
-        else:
-            setup_func = set_up_test_indices_without_calcite
-            print(f"Using standard setup (no Calcite) for category: {doc_type}")
-
-        if doc_type.startswith('bash'):
-            parser = bash_parser
-            globs = {
-                'sh': partial(
-                    subprocess.run,
-                    stdin=subprocess.PIPE,
-                    stdout=subprocess.PIPE,
-                    stderr=subprocess.STDOUT,
-                    timeout=60,
-                    shell=True
-                ),
-                'pretty_print': pretty_print
-            }
-        elif doc_type.startswith('ppl_cli'):
-            parser = ppl_cli_parser
-            globs = {}
-        else:
-            parser = sql_cli_parser
-            globs = {}
-        
-        try:
-            print("Setting up test environment...")
-            
-            suite = doctest.DocFileSuite(
-                file_path,
-                parser=parser,
-                setUp=setup_func,
-                tearDown=tear_down,
-                optionflags=optionflags,
-                encoding='utf-8',
-                globs=globs
-            )
-            
-            runner = unittest.TextTestRunner(verbosity=2 if verbose else 1)
-            result = runner.run(suite)
-            
-            if result.wasSuccessful():
-                print(f"\nSUCCESS: All tests in {os.path.basename(file_path)} passed!")
-                print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
-                return True
-            else:
-                print(f"\nFAILED: {len(result.failures + result.errors)} test(s) failed in {os.path.basename(file_path)}")
-                print(f"Tests run: {result.testsRun}, Failures: {len(result.failures)}, Errors: {len(result.errors)}")
-                
-                if verbose:
-                    print("\nDetailed failure information:")
-                    for failure in result.failures:
-                        print(f"\n--- FAILURE in {failure[0]} ---")
-                        print(failure[1])
-                    for error in result.errors:
-                        print(f"\n--- ERROR in {error[0]} ---")
-                        print(error[1])
-                else:
-                    print("Use --verbose for detailed failure information")
-                
-                return False
-                
-        except Exception as e:
-            print(f"Error running doctest: {e}")
-            if verbose:
-                import traceback
-                traceback.print_exc()
-            return False
+    print(f"Total: {total} documentation files available for testing\n")
 
-    def list_available_docs(self):
-        try:
-            with open('../docs/category.json') as json_file:
-                category = json.load(json_file)
-            
-            print("Available documentation files for testing:")
-            
-            total_docs = 0
-            for category_name, docs in category.items():
-                if not docs:
-                    continue
-                    
-                calcite_status = " (Calcite enabled)" if requires_calcite(category_name) else " (no Calcite)"
-                print(f"\n{category_name.upper()} docs ({len(docs)} files){calcite_status}:")
-                for doc in sorted(docs):
-                    print(f"  ../docs/{doc}")
-                total_docs += len(docs)
-            
-            print(f"\nTotal: {total_docs} documentation files available for testing")
-                
-        except Exception as e:
-            print(f"Error reading category.json: {e}")
 
+def resolve_files(category_manager: CategoryManager, file_paths: list[str]):
+    result = []
+    for file_param in file_paths:
+        resolved_files = category_manager.find_matching_files(file_param)
+        # add each file if not already in the list
+        for f in resolved_files:
+            if f not in result:
+                result.append(f)
+    return result
 
-# =============================================================================
-# MAIN ENTRY POINT
-# =============================================================================
 
 def main():
     parser = argparse.ArgumentParser(
@@ -533,13 +390,15 @@ def main():
   python test_docs.py                    # Run all tests (default behavior)
   python test_docs.py stats              # Run single file (extension optional)
   python test_docs.py stats.rst --verbose
-  python test_docs.py stats fields basics
+  python test_docs.py stats fields basics  # Run multiple files
+  python test_docs.py cmd                # Run all files matching 'cmd' (if multiple found)
   python test_docs.py ../docs/user/ppl/cmd/stats.rst --endpoint http://localhost:9201
 
 Performance Tips:
   - Use --verbose for detailed debugging information
   - Ensure OpenSearch is running on the specified endpoint before testing
   - Extension .rst can be omitted for convenience
+  - If a filename matches multiple files, all matches will be executed
         """
     )
     
@@ -552,53 +411,34 @@ def main():
                        help='List all available documentation files')
     
     args = parser.parse_args()
-    
-    runner = SingleFileTestRunner()
-    
+    category_manager = CategoryManager()
+
     if args.list:
-        runner.list_available_docs()
+        list_available_docs(category_manager)
         return
     
     if not args.file_paths:
         print("No specific files provided. Running full doctest suite...")
         unittest.main(module=None, argv=['test_docs.py'], exit=False)
         return
-    
+
+    if args.endpoint:
+        global ENDPOINT
+        ENDPOINT = endpoint
+        print(f"Using custom endpoint: {endpoint}")
+
+    all_files_to_test = resolve_files(category_manager, args.file_paths)
+
+    runner = unittest.TextTestRunner(verbosity=2 if args.verbose else 1)
+
     all_success = True
-    total_files = len(args.file_paths)
-    
-    for i, file_path in enumerate(args.file_paths, 1):
-        if total_files > 1:
-            print(f"\n{'='*60}")
-            print(f"Testing file {i}/{total_files}: {file_path}")
-            print('='*60)
-        
-        actual_file_path = runner.find_doc_file(file_path)
-        if not actual_file_path:
-            print(f"Skipping {file_path} - file not found")
-            all_success = False
-            continue
-        
-        success = runner.run_single_doctest(
-            actual_file_path, 
-            verbose=args.verbose,
-            endpoint=args.endpoint
-        )
-        
-        if not success:
-            all_success = False
-    
-    if total_files > 1:
-        print(f"\n{'='*60}")
-        print(f"SUMMARY: Tested {total_files} files")
-        if all_success:
-            print("All tests passed!")
-        else:
-            print("Some tests failed!")
-        print('='*60)
-    
-    sys.exit(0 if all_success else 1)
+    for file_path in all_files_to_test:
+        doc_type = category_manager.find_file_category(file_path)
+        suite = get_test_suite(category_manager, doc_type, [get_doc_filepath(file_path)])
+        result = runner.run(suite)
+        all_success = all_success and result.wasSuccessful()
 
+    sys.exit(0 if all_success else 1)
 
 if __name__ == '__main__':
     main()

From c930fc42c5695e531eb8c5dbd1569efe70532493 Mon Sep 17 00:00:00 2001
From: Tomoyuki Morita <moritato@amazon.com>
Date: Thu, 28 Aug 2025 09:16:33 -0700
Subject: [PATCH 26/26] Fix issues caused by merge

Signed-off-by: Tomoyuki Morita <moritato@amazon.com>
---
 .../expression/function/PPLFuncImpTable.java  | 19 ++++++++++++-------
 1 file changed, 12 insertions(+), 7 deletions(-)

diff --git a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
index fbdba460ee7..2f3c2759621 100644
--- a/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
+++ b/core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java
@@ -8,7 +8,6 @@
 import static org.apache.calcite.sql.SqlJsonConstructorNullClause.NULL_ON_NULL;
 import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.TYPE_FACTORY;
 import static org.opensearch.sql.calcite.utils.OpenSearchTypeFactory.getLegacyTypeName;
-import static org.opensearch.sql.calcite.utils.UserDefinedFunctionUtils.createAggregateFunction;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.ABS;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.ACOS;
 import static org.opensearch.sql.expression.function.BuiltinFunctionName.ADD;
@@ -828,7 +827,8 @@ void populate() {
           SqlStdOperatorTable.PLUS,
           PPLTypeChecker.family(SqlTypeFamily.NUMERIC, SqlTypeFamily.NUMERIC));
       // Replace with a custom CompositeOperandTypeChecker to check both operands as
-      // SqlStdOperatorTable.ITEM.getOperandTypeChecker() checks only the first operand instead
+      // SqlStdOperatorTable.ITEM.getOperandTypeChecker() checks only the first
+      // operand instead
       // of all operands.
       registerOperator(
           INTERNAL_ITEM,
@@ -842,14 +842,18 @@ void populate() {
           XOR,
           SqlStdOperatorTable.NOT_EQUALS,
           PPLTypeChecker.family(SqlTypeFamily.BOOLEAN, SqlTypeFamily.BOOLEAN));
-      // SqlStdOperatorTable.CASE.getOperandTypeChecker is null. We manually create a type checker
-      // for it. The second and third operands are required to be of the same type. If not,
-      // it will throw an IllegalArgumentException with information Can't find leastRestrictive type
+      // SqlStdOperatorTable.CASE.getOperandTypeChecker is null. We manually create a
+      // type checker
+      // for it. The second and third operands are required to be of the same type. If
+      // not,
+      // it will throw an IllegalArgumentException with information Can't find
+      // leastRestrictive type
       registerOperator(
           IF,
           SqlStdOperatorTable.CASE,
           PPLTypeChecker.family(SqlTypeFamily.BOOLEAN, SqlTypeFamily.ANY, SqlTypeFamily.ANY));
-      // Re-define the type checker for is not null, is present, and is null since their original
+      // Re-define the type checker for is not null, is present, and is null since
+      // their original
       // type checker ANY isn't compatible with struct types.
       registerOperator(
           IS_NOT_NULL,
@@ -902,7 +906,8 @@ void populate() {
           (FunctionImp2)
               (builder, arg1, arg2) -> builder.makeCall(SqlLibraryOperators.STRCMP, arg2, arg1),
           PPLTypeChecker.family(SqlTypeFamily.CHARACTER, SqlTypeFamily.CHARACTER));
-      // SqlStdOperatorTable.SUBSTRING.getOperandTypeChecker is null. We manually create a type
+      // SqlStdOperatorTable.SUBSTRING.getOperandTypeChecker is null. We manually
+      // create a type
       // checker for it.
       register(
           SUBSTRING,
