diff --git a/docs/changelog/131341.yaml b/docs/changelog/131341.yaml
new file mode 100644
index 0000000000000..d89efddf9e014
--- /dev/null
+++ b/docs/changelog/131341.yaml
@@ -0,0 +1,5 @@
+pr: 131341
+summary: Consider min/max from predicates when transform date_trunc/bucket to `round_to`
+area: ES|QL
+type: enhancement
+issues: []
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec
index 49b16baf30f58..06ac461cb6c62 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/bucket.csv-spec
@@ -884,3 +884,108 @@ c:long | b:datetime | yr:datetime
9 | 1989-01-01T00:00:00.000Z | 1988-01-01T00:00:00.000Z
13 | 1990-01-01T00:00:00.000Z | 1989-01-01T00:00:00.000Z
;
+
+bucketYearInAggWithGTOutOfRange#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| WHERE hire_date >= "2000-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(hire_date, 1 month)
+| SORT bucket;
+
+COUNT(*):long | bucket:date
+;
+
+bucketYearInAggWithLTOutOfRange#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| WHERE hire_date <= "1980-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(hire_date, 1 year)
+| SORT bucket;
+
+COUNT(*):long | bucket:date
+;
+
+bucketYearInAggWithGTLTOutOfRange#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| WHERE hire_date <= "1980-01-01T00:00:00Z" and hire_date >= "1970-01-01"
+| STATS COUNT(*) by bucket = BUCKET(hire_date, 1 week)
+| SORT bucket;
+
+COUNT(*):long | bucket:date
+;
+
+bucketYearInAggWithEQOutOfRange#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| WHERE hire_date == "1980-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(hire_date, 1 hour)
+| SORT bucket;
+
+COUNT(*):long | bucket:date
+;
+
+bucketWithRename#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| RENAME hire_date as x, x as y
+| WHERE y >= "1980-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(y, 1 hour)
+| SORT bucket
+| LIMIT 5
+;
+
+COUNT(*):long | bucket:datetime
+1 | 1985-02-18T00:00:00.000Z
+1 | 1985-02-24T00:00:00.000Z
+1 | 1985-05-13T00:00:00.000Z
+1 | 1985-07-09T00:00:00.000Z
+1 | 1985-09-17T00:00:00.000Z
+;
+
+bucketWithEval#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| EVAL x = hire_date
+| WHERE x >= "1980-01-01T00:00:00Z" and hire_date <= "1990-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(x, 1 hour)
+| SORT bucket
+| LIMIT 5
+;
+
+COUNT(*):long | bucket:datetime
+1 | 1985-02-18T00:00:00.000Z
+1 | 1985-02-24T00:00:00.000Z
+1 | 1985-05-13T00:00:00.000Z
+1 | 1985-07-09T00:00:00.000Z
+1 | 1985-09-17T00:00:00.000Z
+;
+
+bucketWithEvalExpression#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| EVAL x = hire_date + 1 year
+| WHERE x >= "1980-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(x, 1 hour)
+| SORT bucket
+| LIMIT 5
+;
+
+COUNT(*):long | bucket:datetime
+1 | 1986-02-18T00:00:00.000Z
+1 | 1986-02-24T00:00:00.000Z
+1 | 1986-05-13T00:00:00.000Z
+1 | 1986-07-09T00:00:00.000Z
+1 | 1986-09-17T00:00:00.000Z
+;
+
+bucketWithRenameEvalExpression#[skip:-8.13.99, reason:BUCKET renamed in 8.14]
+FROM employees
+| EVAL x = hire_date + 1 year
+| RENAME x as y
+| WHERE y >= "1980-01-01T00:00:00Z"
+| STATS COUNT(*) by bucket = BUCKET(y, 1 hour)
+| SORT bucket
+| LIMIT 5
+;
+
+COUNT(*):long | bucket:datetime
+1 | 1986-02-18T00:00:00.000Z
+1 | 1986-02-24T00:00:00.000Z
+1 | 1986-05-13T00:00:00.000Z
+1 | 1986-07-09T00:00:00.000Z
+1 | 1986-09-17T00:00:00.000Z
+;
diff --git a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec
index 4b9e1512844b4..788a5f9877dea 100644
--- a/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec
+++ b/x-pack/plugin/esql/qa/testFixtures/src/main/resources/date.csv-spec
@@ -1581,4 +1581,258 @@ x:date | y:date
;
+evalDateTruncMonthIntervalWithGTEInRange
+FROM employees
+| SORT hire_date
+| WHERE hire_date >= "1990-01-01"
+| EVAL x = date_trunc(1 month, hire_date)
+| KEEP emp_no, hire_date, x
+| LIMIT 5;
+
+emp_no:integer | hire_date:date | x:date
+10082 | 1990-01-03T00:00:00.000Z | 1990-01-01T00:00:00.000Z
+10096 | 1990-01-14T00:00:00.000Z | 1990-01-01T00:00:00.000Z
+10011 | 1990-01-22T00:00:00.000Z | 1990-01-01T00:00:00.000Z
+10056 | 1990-02-01T00:00:00.000Z | 1990-02-01T00:00:00.000Z
+10086 | 1990-02-16T00:00:00.000Z | 1990-02-01T00:00:00.000Z
+;
+
+evalDateTruncHoursIntervalWithLTEInRange
+FROM employees
+| SORT hire_date desc
+| WHERE hire_date <= "1990-01-01"
+| EVAL x = date_trunc(240 hours, hire_date)
+| KEEP emp_no, hire_date, x
+| LIMIT 5;
+
+emp_no:integer | hire_date:date | x:date
+10023 | 1989-12-17T00:00:00.000Z | 1989-12-17T00:00:00.000Z
+10041 | 1989-11-12T00:00:00.000Z | 1989-11-07T00:00:00.000Z
+10069 | 1989-11-05T00:00:00.000Z | 1989-10-28T00:00:00.000Z
+10092 | 1989-09-22T00:00:00.000Z | 1989-09-18T00:00:00.000Z
+10038 | 1989-09-20T00:00:00.000Z | 1989-09-18T00:00:00.000Z
+;
+
+evalDateTruncWeeklyIntervalWithLTGTInRange
+from employees
+| SORT hire_date
+| WHERE hire_date > "1986-01-01" and hire_date < "1988-01-01"
+| EVAL x = date_trunc(1 week, hire_date)
+| KEEP emp_no, hire_date, x
+| LIMIT 5;
+
+emp_no:integer | hire_date:date | x:date
+10053 | 1986-02-04T00:00:00.000Z | 1986-02-03T00:00:00.000Z
+10066 | 1986-02-26T00:00:00.000Z | 1986-02-24T00:00:00.000Z
+10090 | 1986-03-14T00:00:00.000Z | 1986-03-10T00:00:00.000Z
+10079 | 1986-03-27T00:00:00.000Z | 1986-03-24T00:00:00.000Z
+10001 | 1986-06-26T00:00:00.000Z | 1986-06-23T00:00:00.000Z
+;
+
+evalDateTruncQuarterlyIntervalWithGTInRange
+from employees
+| SORT hire_date
+| WHERE hire_date > "1980-01-01"
+| EVAL x = date_trunc(3 month, hire_date)
+| KEEP emp_no, hire_date, x
+| LIMIT 5;
+
+emp_no:integer | hire_date:date | x:date
+10009 | 1985-02-18T00:00:00.000Z | 1985-01-01T00:00:00.000Z
+10048 | 1985-02-24T00:00:00.000Z | 1985-01-01T00:00:00.000Z
+10098 | 1985-05-13T00:00:00.000Z | 1985-04-01T00:00:00.000Z
+10076 | 1985-07-09T00:00:00.000Z | 1985-07-01T00:00:00.000Z
+10061 | 1985-09-17T00:00:00.000Z | 1985-07-01T00:00:00.000Z
+;
+
+dateTruncGroupingYearIntervalWithLTInRange
+from employees
+| WHERE hire_date < "2025-01-01"
+| EVAL y = date_trunc(1 year, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1985-01-01T00:00:00.000Z | 11
+1986-01-01T00:00:00.000Z | 11
+1987-01-01T00:00:00.000Z | 15
+1988-01-01T00:00:00.000Z | 9
+1989-01-01T00:00:00.000Z | 13
+;
+
+dateTruncGroupingYearIntervalWithLTOutOfRange
+from employees
+| WHERE hire_date < "1980-01-01"
+| EVAL y = date_trunc(1 year, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+;
+
+dateTruncGroupingYearIntervalWithGTOutOfRange
+from employees
+| WHERE hire_date > "2000-01-01"
+| EVAL y = date_trunc(1 year, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+;
+dateTruncGroupingMonthIntervalWithLTGTInRange
+from employees
+| WHERE hire_date > "1987-01-01" and hire_date < "1988-01-01"
+| EVAL y = date_trunc(1 month, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncGroupingDayIntervalWithEQInRange
+from employees
+| WHERE hire_date == "1988-02-10"
+| EVAL y = date_trunc(1 day, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1988-02-10T00:00:00.000Z | 1
+;
+
+dateTruncGroupingDayIntervalWithEQOutOfRange
+from employees
+| WHERE hire_date == "2025-01-01"
+| EVAL y = date_trunc(1 day, hire_date)
+| stats c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+;
+
+dateTruncWithEval
+from employees
+| EVAL x = hire_date
+| WHERE x > "1987-01-01" and hire_date < "1988-01-01"
+| EVAL y = date_trunc(1 month, x)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncWithEvalExpression
+from employees
+| EVAL x = hire_date + 1 year
+| WHERE x > "1987-01-01" and x < "1988-01-01"
+| EVAL y = date_trunc(1 month, x)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-02-01T00:00:00.000Z | 2
+1987-03-01T00:00:00.000Z | 2
+1987-06-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncWithRename
+FROM employees
+| RENAME hire_date as x
+| WHERE x > "1987-01-01" and x < "1988-01-01"
+| EVAL y = date_trunc(1 month, x)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncWithRenameChain
+FROM employees
+| RENAME hire_date as a, a as x
+| WHERE x > "1987-01-01" and x < "1988-01-01"
+| EVAL y = date_trunc(1 month, x)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncWithRenameBack
+FROM employees
+| RENAME hire_date as x, x as hire_date
+| WHERE hire_date > "1987-01-01" and hire_date < "1988-01-01"
+| EVAL y = date_trunc(1 month, hire_date)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
+
+dateTruncWithEvalRename
+FROM employees
+| EVAL a = hire_date
+| RENAME hire_date as b
+| WHERE a > "1987-01-01" and a < "1988-01-01"
+| EVAL y = date_trunc(1 month, b)
+| STATS c = count(emp_no) by y
+| SORT y
+| KEEP y, c
+| LIMIT 5;
+
+y:date | c:long
+1987-03-01T00:00:00.000Z | 5
+1987-04-01T00:00:00.000Z | 3
+1987-05-01T00:00:00.000Z | 1
+1987-07-01T00:00:00.000Z | 1
+1987-08-01T00:00:00.000Z | 2
+;
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/LocalSurrogateExpression.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/LocalSurrogateExpression.java
index f0401ae1d4f05..99a6b0397f88e 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/LocalSurrogateExpression.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/LocalSurrogateExpression.java
@@ -8,15 +8,19 @@
package org.elasticsearch.xpack.esql.expression;
import org.elasticsearch.xpack.esql.core.expression.Expression;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison;
import org.elasticsearch.xpack.esql.stats.SearchStats;
+import java.util.List;
+
/**
* Interface signaling to the local logical plan optimizer that the declaring expression
* has to be replaced by a different form.
* Implement this on {@code Function}s when:
*
- *
The expression can be rewritten to another expression on data node, with the statistics available in SearchStats.
- * Like {@code DateTrunc} and {@code Bucket} could be rewritten to {@code RoundTo} with the min/max values on the date field.
+ *
The expression can be rewritten to another expression on data node, with the statistics available in SearchStats and predicates
+ * in the query. Like {@code DateTrunc} and {@code Bucket} could be rewritten to {@code RoundTo} with the min/max values on the date
+ * field.
*
*
*/
@@ -24,5 +28,10 @@ public interface LocalSurrogateExpression {
/**
* Returns the expression to be replaced by or {@code null} if this cannot be replaced.
*/
- Expression surrogate(SearchStats searchStats);
+ Expression surrogate(SearchStats searchStats, List binaryComparisons);
+
+ /**
+ * Returns the field that can be used by {@code LocalSubstituteSurrogateExpressions} to check predicates against.
+ */
+ Expression field();
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Bucket.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Bucket.java
index 01bc4dd2b4eec..c97e671de9c75 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Bucket.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/grouping/Bucket.java
@@ -35,6 +35,7 @@
import org.elasticsearch.xpack.esql.expression.function.scalar.math.Floor;
import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.Div;
import org.elasticsearch.xpack.esql.expression.predicate.operator.arithmetic.Mul;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison;
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
import org.elasticsearch.xpack.esql.stats.SearchStats;
@@ -498,7 +499,7 @@ public String toString() {
}
@Override
- public Expression surrogate(SearchStats searchStats) {
+ public Expression surrogate(SearchStats searchStats, List binaryComparisons) {
// LocalSubstituteSurrogateExpressions should make sure this doesn't happen
assert searchStats != null : "SearchStats cannot be null";
return maybeSubstituteWithRoundTo(
@@ -506,6 +507,7 @@ public Expression surrogate(SearchStats searchStats) {
field(),
buckets(),
searchStats,
+ binaryComparisons,
(interval, minValue, maxValue) -> getDateRounding(FoldContext.small(), minValue, maxValue)
);
}
diff --git a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java
index 9b4d312e9df42..d894ab9220550 100644
--- a/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java
+++ b/x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/scalar/date/DateTrunc.java
@@ -17,6 +17,7 @@
import org.elasticsearch.compute.ann.Fixed;
import org.elasticsearch.compute.operator.EvalOperator.ExpressionEvaluator;
import org.elasticsearch.core.TimeValue;
+import org.elasticsearch.core.Tuple;
import org.elasticsearch.logging.LogManager;
import org.elasticsearch.logging.Logger;
import org.elasticsearch.xpack.esql.core.expression.Expression;
@@ -27,12 +28,19 @@
import org.elasticsearch.xpack.esql.core.tree.Source;
import org.elasticsearch.xpack.esql.core.type.DataType;
import org.elasticsearch.xpack.esql.core.type.MultiTypeEsField;
+import org.elasticsearch.xpack.esql.core.util.Holder;
import org.elasticsearch.xpack.esql.expression.LocalSurrogateExpression;
import org.elasticsearch.xpack.esql.expression.function.Example;
import org.elasticsearch.xpack.esql.expression.function.FunctionInfo;
import org.elasticsearch.xpack.esql.expression.function.Param;
import org.elasticsearch.xpack.esql.expression.function.scalar.EsqlScalarFunction;
import org.elasticsearch.xpack.esql.expression.function.scalar.math.RoundTo;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.Equals;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.EsqlBinaryComparison;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThan;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.GreaterThanOrEqual;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThan;
+import org.elasticsearch.xpack.esql.expression.predicate.operator.comparison.LessThanOrEqual;
import org.elasticsearch.xpack.esql.io.stream.PlanStreamInput;
import org.elasticsearch.xpack.esql.stats.SearchStats;
@@ -125,7 +133,7 @@ Expression interval() {
return interval;
}
- Expression field() {
+ public Expression field() {
return timestampField;
}
@@ -287,7 +295,7 @@ public static ExpressionEvaluator.Factory evaluator(
}
@Override
- public Expression surrogate(SearchStats searchStats) {
+ public Expression surrogate(SearchStats searchStats, List binaryComparisons) {
// LocalSubstituteSurrogateExpressions should make sure this doesn't happen
assert searchStats != null : "SearchStats cannot be null";
return maybeSubstituteWithRoundTo(
@@ -295,6 +303,7 @@ public Expression surrogate(SearchStats searchStats) {
field(),
interval(),
searchStats,
+ binaryComparisons,
(interval, minValue, maxValue) -> createRounding(interval, DEFAULT_TZ, minValue, maxValue)
);
}
@@ -304,27 +313,42 @@ public static RoundTo maybeSubstituteWithRoundTo(
Expression field,
Expression foldableTimeExpression,
SearchStats searchStats,
+ List binaryComparisons,
TriFunction