From 94698b9d5f8df3561f53e5751a6aeaa734b5b6d1 Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Wed, 29 Jul 2026 19:45:58 +0800 Subject: [PATCH 1/6] [CALCITE-7680] PruneEmptyRules should prune Sort when OFFSET is greater than or equal to max input rows --- .../org/apache/calcite/plan/RelOptRules.java | 1 + .../calcite/rel/rules/PruneEmptyRules.java | 42 +++++++++++++++++++ .../apache/calcite/test/RelOptRulesTest.java | 11 +++++ .../apache/calcite/test/RelOptRulesTest.xml | 19 +++++++++ 4 files changed, 73 insertions(+) diff --git a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java index 434e278dcb8..e4c8f3b28ca 100644 --- a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java +++ b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java @@ -108,6 +108,7 @@ private RelOptRules() { PruneEmptyRules.JOIN_LEFT_INSTANCE, PruneEmptyRules.JOIN_RIGHT_INSTANCE, PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE, + PruneEmptyRules.SORT_OFFSET_INSTANCE, PruneEmptyRules.EMPTY_TABLE_INSTANCE, SingleValuesOptimizationRules.JOIN_LEFT_INSTANCE, SingleValuesOptimizationRules.JOIN_RIGHT_INSTANCE, diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java index 02b0bd8af1b..ce028b0b41d 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java @@ -232,6 +232,24 @@ private static boolean isEmpty(RelNode node) { public static final RelOptRule SORT_FETCH_ZERO_INSTANCE = SortFetchZeroRuleConfig.DEFAULT.toRule(); + /** + * Rule that converts a {@link org.apache.calcite.rel.core.Sort} + * to empty if its {@code OFFSET} is greater than or equal to the maximum + * number of rows its input can produce, so that all rows are skipped. + * + *

Examples: + * + *

+ * + *

It relies on {@link org.apache.calcite.rel.metadata.RelMdMaxRowCount} + * to derive the input row count. If the stats are not available then the rule + * is a noop. + */ + public static final RelOptRule SORT_OFFSET_INSTANCE = + SortOffsetGreaterThanMaxRowsRuleConfig.DEFAULT.toRule(); + /** * Rule that converts an {@link org.apache.calcite.rel.core.Aggregate} * to empty if its child is empty. @@ -544,6 +562,30 @@ public interface SortFetchZeroRuleConfig extends PruneEmptyRule.Config { } } + /** Configuration for a rule that prunes a Sort if its {@code OFFSET} skips at + * least as many rows as its input can ever produce. */ + @Value.Immutable + public interface SortOffsetGreaterThanMaxRowsRuleConfig extends PruneEmptyRule.Config { + SortOffsetGreaterThanMaxRowsRuleConfig DEFAULT = + ImmutableSortOffsetGreaterThanMaxRowsRuleConfig.of() + .withOperandSupplier(b -> b.operand(Sort.class).anyInputs()) + .withDescription("PruneSortOffsetGreaterThanMaxRows"); + + @Override default PruneEmptyRule toRule() { + return new RemoveEmptySingleRule(this) { + @Override public boolean matches(final RelOptRuleCall call) { + final Sort sort = call.rel(0); + // Only consider a static (non-dynamic) OFFSET. If the offset skips at + // least as many rows as the input can ever produce, the Sort returns + // no rows. RelMdMaxRowCount#getMaxRowCount(Sort) already subtracts the + // offset from the input row count, so the Sort is definitely empty. + return sort.offset instanceof RexLiteral + && RelMdUtil.isRelDefinitelyEmpty(call.getMetadataQuery(), sort); + } + }; + } + } + /** Configuration for rule that prunes a join it its left input is * empty. */ @Value.Immutable diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java index 4ba2f4aa962..f5500802a04 100644 --- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java +++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java @@ -5841,6 +5841,17 @@ private void checkEmptyJoin(RelOptFixture f) { sql(sql).withRule(PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE).check(); } + /** Tests that a Sort whose OFFSET skips at least as many rows as its input + * can produce is pruned to empty by + * {@link PruneEmptyRules#SORT_OFFSET_INSTANCE}. */ + @Test void testEmptySortOffsetGreaterThanMaxRows() { + // The input VALUES has at most 2 rows, so 'OFFSET 5' skips them all. + final String sql = "select * from (values (1, 2), (3, 4)) as t (a, b)\n" + + "order by a\n" + + "offset 5 rows"; + sql(sql).withRule(PruneEmptyRules.SORT_OFFSET_INSTANCE).check(); + } + @Test void testEmptyAggregate() { final String sql = "select sum(empno) from emp where false group by deptno"; sql(sql) diff --git a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml index ab26ca8524b..45dfc00ba42 100644 --- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml +++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml @@ -4451,6 +4451,25 @@ LogicalSort(sort0=[$7], dir0=[ASC], fetch=[0]) + + + + + + + + + + + From 659ee7a1d7f84c10989917814913a85189e14724 Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Thu, 30 Jul 2026 10:14:46 +0800 Subject: [PATCH 2/6] Addressed --- .../java/org/apache/calcite/rel/rules/PruneEmptyRules.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java index ce028b0b41d..4fd9a5dc604 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java @@ -242,10 +242,6 @@ private static boolean isEmpty(RelNode node) { *

- * - *

It relies on {@link org.apache.calcite.rel.metadata.RelMdMaxRowCount} - * to derive the input row count. If the stats are not available then the rule - * is a noop. */ public static final RelOptRule SORT_OFFSET_INSTANCE = SortOffsetGreaterThanMaxRowsRuleConfig.DEFAULT.toRule(); From a4276c88d272979ad3b9577a3687fd017c38a4fd Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Thu, 30 Jul 2026 14:52:41 +0800 Subject: [PATCH 3/6] Addressed --- .../org/apache/calcite/plan/RelOptRules.java | 3 +- .../calcite/rel/rules/PruneEmptyRules.java | 69 +++++++------------ .../rel/rules/SortRemoveRedundantRule.java | 2 +- .../rel/rel2sql/RelToSqlConverterTest.java | 4 +- .../apache/calcite/test/RelOptRulesTest.java | 6 +- 5 files changed, 31 insertions(+), 53 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java index e4c8f3b28ca..abd195cdbc2 100644 --- a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java +++ b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java @@ -107,8 +107,7 @@ private RelOptRules() { PruneEmptyRules.WINDOW_INSTANCE, PruneEmptyRules.JOIN_LEFT_INSTANCE, PruneEmptyRules.JOIN_RIGHT_INSTANCE, - PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE, - PruneEmptyRules.SORT_OFFSET_INSTANCE, + PruneEmptyRules.SORT_EMPTY_INSTANCE, PruneEmptyRules.EMPTY_TABLE_INSTANCE, SingleValuesOptimizationRules.JOIN_LEFT_INSTANCE, SingleValuesOptimizationRules.JOIN_RIGHT_INSTANCE, diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java index 4fd9a5dc604..a84a638e461 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java @@ -42,7 +42,6 @@ import org.apache.calcite.rel.logical.LogicalValues; import org.apache.calcite.rel.metadata.RelMdUtil; import org.apache.calcite.rel.type.RelDataType; -import org.apache.calcite.rex.RexDynamicParam; import org.apache.calcite.rex.RexLiteral; import org.apache.calcite.rex.RexNode; import org.apache.calcite.tools.RelBuilder; @@ -221,30 +220,34 @@ private static boolean isEmpty(RelNode node) { /** * Rule that converts a {@link org.apache.calcite.rel.core.Sort} - * to empty if it has {@code LIMIT 0}. + * to empty if it is definitely empty, for example because its child is empty, + * it has {@code LIMIT 0}, or its {@code OFFSET} is greater than or equal to + * the maximum number of rows its input can produce. * *

Examples: * *

+ * + *

It relies on {@link org.apache.calcite.rel.metadata.RelMdMaxRowCount} + * to derive whether the Sort is definitely empty. */ - public static final RelOptRule SORT_FETCH_ZERO_INSTANCE = - SortFetchZeroRuleConfig.DEFAULT.toRule(); + public static final RelOptRule SORT_EMPTY_INSTANCE = + SortEmptyRuleConfig.DEFAULT.toRule(); /** * Rule that converts a {@link org.apache.calcite.rel.core.Sort} - * to empty if its {@code OFFSET} is greater than or equal to the maximum - * number of rows its input can produce, so that all rows are skipped. - * - *

Examples: + * to empty if it has {@code LIMIT 0}. * - *

+ * @deprecated Use {@link #SORT_EMPTY_INSTANCE}, which covers this case and + * also Sort nodes that are empty for other reasons (e.g. a large OFFSET). */ - public static final RelOptRule SORT_OFFSET_INSTANCE = - SortOffsetGreaterThanMaxRowsRuleConfig.DEFAULT.toRule(); + @Deprecated // to be removed before 2.0 + public static final RelOptRule SORT_FETCH_ZERO_INSTANCE = + SortEmptyRuleConfig.DEFAULT.toRule(); /** * Rule that converts an {@link org.apache.calcite.rel.core.Aggregate} @@ -539,44 +542,20 @@ public interface IntersectEmptyPruneRuleConfig extends PruneEmptyRule.Config { } } - /** Configuration for a rule that prunes a Sort if it has limit 0. */ + /** Configuration for a rule that prunes a Sort if it is definitely empty, + * for example because its input is empty, it has {@code LIMIT 0}, or its + * {@code OFFSET} skips more rows than the input can produce. */ @Value.Immutable - public interface SortFetchZeroRuleConfig extends PruneEmptyRule.Config { - SortFetchZeroRuleConfig DEFAULT = ImmutableSortFetchZeroRuleConfig.of() + public interface SortEmptyRuleConfig extends PruneEmptyRule.Config { + SortEmptyRuleConfig DEFAULT = ImmutableSortEmptyRuleConfig.of() .withOperandSupplier(b -> b.operand(Sort.class).anyInputs()) - .withDescription("PruneSortLimit0"); - - @Override default PruneEmptyRule toRule() { - return new RemoveEmptySingleRule(this) { - @Override public boolean matches(final RelOptRuleCall call) { - Sort sort = call.rel(0); - return sort.fetch != null - && !(sort.fetch instanceof RexDynamicParam) - && RexLiteral.bigDecimalValue(sort.fetch).equals(BigDecimal.ZERO); - } - }; - } - } - - /** Configuration for a rule that prunes a Sort if its {@code OFFSET} skips at - * least as many rows as its input can ever produce. */ - @Value.Immutable - public interface SortOffsetGreaterThanMaxRowsRuleConfig extends PruneEmptyRule.Config { - SortOffsetGreaterThanMaxRowsRuleConfig DEFAULT = - ImmutableSortOffsetGreaterThanMaxRowsRuleConfig.of() - .withOperandSupplier(b -> b.operand(Sort.class).anyInputs()) - .withDescription("PruneSortOffsetGreaterThanMaxRows"); + .withDescription("PruneSortIfEmpty"); @Override default PruneEmptyRule toRule() { return new RemoveEmptySingleRule(this) { @Override public boolean matches(final RelOptRuleCall call) { final Sort sort = call.rel(0); - // Only consider a static (non-dynamic) OFFSET. If the offset skips at - // least as many rows as the input can ever produce, the Sort returns - // no rows. RelMdMaxRowCount#getMaxRowCount(Sort) already subtracts the - // offset from the input row count, so the Sort is definitely empty. - return sort.offset instanceof RexLiteral - && RelMdUtil.isRelDefinitelyEmpty(call.getMetadataQuery(), sort); + return RelMdUtil.isRelDefinitelyEmpty(call.getMetadataQuery(), sort); } }; } diff --git a/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java b/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java index 9bcf026fc65..b88773774f2 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java @@ -113,7 +113,7 @@ protected SortRemoveRedundantRule(final SortRemoveRedundantRule.Config config) { // If sort is 'order by x' or 'order by x limit n', target threshold is 1. // If sort is pure limit, the target threshold is the limit's fetch. // If the limit's fetch is 0, we could use - // CoreRules.SORT_FETCH_ZERO_INSTANCE to deal with it, so we don't need to + // PruneEmptyRules.SORT_EMPTY_INSTANCE to deal with it, so we don't need to // deal with it in this rule. final Optional rowCountThreshold = getRowCountThreshold(sort); diff --git a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java index 772c409692b..0d5aee3c1d7 100644 --- a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java +++ b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java @@ -8068,7 +8068,7 @@ private void checkLiteral2(String expression, String expected) { + "from (values (1, 'a'), (2, 'bb')) as t(x, y)\n" + "limit 0"; final RuleSet rules = - RuleSets.ofList(PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE); + RuleSets.ofList(PruneEmptyRules.SORT_EMPTY_INSTANCE); final String expectedMysql = "SELECT *\n" + "FROM (SELECT NULL AS `X`, NULL AS `Y`) AS `t`\n" + "WHERE 1 = 0"; @@ -8136,7 +8136,7 @@ private void checkLiteral2(String expression, String expected) { + "limit 0"; final String sql = "SELECT SUBSTRING(y, 1, 1) FROM (" + sql0 + ") t"; final RuleSet rules = - RuleSets.ofList(PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE); + RuleSets.ofList(PruneEmptyRules.SORT_EMPTY_INSTANCE); final String expected = "SELECT SUBSTRING(`Y`, 1, 1)\n" + "FROM (SELECT NULL AS `X`, NULL AS `Y`) AS `t`\n" + "WHERE 1 = 0"; diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java index f5500802a04..16bf4baecde 100644 --- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java +++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java @@ -5838,18 +5838,18 @@ private void checkEmptyJoin(RelOptFixture f) { @Test void testEmptySortLimitZero() { final String sql = "select * from emp order by deptno limit 0"; - sql(sql).withRule(PruneEmptyRules.SORT_FETCH_ZERO_INSTANCE).check(); + sql(sql).withRule(PruneEmptyRules.SORT_EMPTY_INSTANCE).check(); } /** Tests that a Sort whose OFFSET skips at least as many rows as its input * can produce is pruned to empty by - * {@link PruneEmptyRules#SORT_OFFSET_INSTANCE}. */ + * {@link PruneEmptyRules#SORT_EMPTY_INSTANCE}. */ @Test void testEmptySortOffsetGreaterThanMaxRows() { // The input VALUES has at most 2 rows, so 'OFFSET 5' skips them all. final String sql = "select * from (values (1, 2), (3, 4)) as t (a, b)\n" + "order by a\n" + "offset 5 rows"; - sql(sql).withRule(PruneEmptyRules.SORT_OFFSET_INSTANCE).check(); + sql(sql).withRule(PruneEmptyRules.SORT_EMPTY_INSTANCE).check(); } @Test void testEmptyAggregate() { From 4dd4d6c6023b88d921580a656c24ea890eadec9e Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Thu, 30 Jul 2026 15:17:30 +0800 Subject: [PATCH 4/6] Addressed --- .../main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java index a84a638e461..6972e0f4bd1 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java @@ -49,7 +49,6 @@ import org.immutables.value.Value; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; From 16d480b42de2b2ca6f420ef951422f17954b935b Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Sat, 1 Aug 2026 15:25:59 +0800 Subject: [PATCH 5/6] Addressed --- .../org/apache/calcite/plan/RelOptRules.java | 1 - .../calcite/rel/rules/PruneEmptyRules.java | 76 +++++++------------ .../rel/rules/SortRemoveRedundantRule.java | 2 +- .../rel/rel2sql/RelToSqlConverterTest.java | 10 ++- .../apache/calcite/test/RelOptRulesTest.java | 6 +- 5 files changed, 36 insertions(+), 59 deletions(-) diff --git a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java index abd195cdbc2..16152bc800d 100644 --- a/core/src/main/java/org/apache/calcite/plan/RelOptRules.java +++ b/core/src/main/java/org/apache/calcite/plan/RelOptRules.java @@ -107,7 +107,6 @@ private RelOptRules() { PruneEmptyRules.WINDOW_INSTANCE, PruneEmptyRules.JOIN_LEFT_INSTANCE, PruneEmptyRules.JOIN_RIGHT_INSTANCE, - PruneEmptyRules.SORT_EMPTY_INSTANCE, PruneEmptyRules.EMPTY_TABLE_INSTANCE, SingleValuesOptimizationRules.JOIN_LEFT_INSTANCE, SingleValuesOptimizationRules.JOIN_RIGHT_INSTANCE, diff --git a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java index 6972e0f4bd1..a9b8b0a5b5b 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/PruneEmptyRules.java @@ -35,7 +35,7 @@ import org.apache.calcite.rel.core.Minus; import org.apache.calcite.rel.core.Project; import org.apache.calcite.rel.core.Sort; -import org.apache.calcite.rel.core.TableScan; +import org.apache.calcite.rel.core.TableModify; import org.apache.calcite.rel.core.Union; import org.apache.calcite.rel.core.Values; import org.apache.calcite.rel.core.Window; @@ -217,36 +217,17 @@ private static boolean isEmpty(RelNode node) { public static final RelOptRule SORT_INSTANCE = RemoveEmptySingleRule.RemoveEmptySingleRuleConfig.SORT.toRule(); - /** - * Rule that converts a {@link org.apache.calcite.rel.core.Sort} - * to empty if it is definitely empty, for example because its child is empty, - * it has {@code LIMIT 0}, or its {@code OFFSET} is greater than or equal to - * the maximum number of rows its input can produce. - * - *

Examples: - * - *

- * - *

It relies on {@link org.apache.calcite.rel.metadata.RelMdMaxRowCount} - * to derive whether the Sort is definitely empty. - */ - public static final RelOptRule SORT_EMPTY_INSTANCE = - SortEmptyRuleConfig.DEFAULT.toRule(); - /** * Rule that converts a {@link org.apache.calcite.rel.core.Sort} * to empty if it has {@code LIMIT 0}. * - * @deprecated Use {@link #SORT_EMPTY_INSTANCE}, which covers this case and - * also Sort nodes that are empty for other reasons (e.g. a large OFFSET). + * @deprecated Use {@link #EMPTY_TABLE_INSTANCE}, which uses + * {@link RelMdUtil#isRelDefinitelyEmpty} to prune any relational expression + * that is definitely empty. */ @Deprecated // to be removed before 2.0 public static final RelOptRule SORT_FETCH_ZERO_INSTANCE = - SortEmptyRuleConfig.DEFAULT.toRule(); + ZeroMaxRowsRuleConfig.DEFAULT.toRule(); /** * Rule that converts an {@link org.apache.calcite.rel.core.Aggregate} @@ -541,25 +522,6 @@ public interface IntersectEmptyPruneRuleConfig extends PruneEmptyRule.Config { } } - /** Configuration for a rule that prunes a Sort if it is definitely empty, - * for example because its input is empty, it has {@code LIMIT 0}, or its - * {@code OFFSET} skips more rows than the input can produce. */ - @Value.Immutable - public interface SortEmptyRuleConfig extends PruneEmptyRule.Config { - SortEmptyRuleConfig DEFAULT = ImmutableSortEmptyRuleConfig.of() - .withOperandSupplier(b -> b.operand(Sort.class).anyInputs()) - .withDescription("PruneSortIfEmpty"); - - @Override default PruneEmptyRule toRule() { - return new RemoveEmptySingleRule(this) { - @Override public boolean matches(final RelOptRuleCall call) { - final Sort sort = call.rel(0); - return RelMdUtil.isRelDefinitelyEmpty(call.getMetadataQuery(), sort); - } - }; - } - } - /** Configuration for rule that prunes a join it its left input is * empty. */ @Value.Immutable @@ -700,24 +662,38 @@ public interface CorrelateRightEmptyRuleConfig extends PruneEmptyRule.Config { } } - /** Configuration for rule that transforms an empty relational expression into - * an empty values. + /** Configuration for rule that transforms a relational expression into an + * empty values if it is definitely empty. * *

It relies on {@link org.apache.calcite.rel.metadata.RelMdMaxRowCount} to * derive if the relation is empty or not. If the stats are not available then - * the rule is a noop. */ + * the rule is a noop. + * + *

{@link Values} is excluded because it is already a values and does not + * need to be replaced. {@link TableModify} is excluded because it may have + * side effects even when no rows are modified. */ @Value.Immutable public interface ZeroMaxRowsRuleConfig extends PruneEmptyRule.Config { + Predicate CAN_BE_REPLACED_BY_EMPTY_VALUES = rel -> + !(rel instanceof Values) && !(rel instanceof TableModify); + ZeroMaxRowsRuleConfig DEFAULT = ImmutableZeroMaxRowsRuleConfig.of() - .withOperandSupplier(b0 -> b0.operand(TableScan.class).noInputs()) - .withDescription("PruneZeroRowsTable"); + .withOperandSupplier(b0 -> b0.operand(RelNode.class) + .predicate(CAN_BE_REPLACED_BY_EMPTY_VALUES) + .anyInputs()) + .withDescription("PruneZeroRows"); @Override default PruneEmptyRule toRule() { - return new RemoveEmptySingleRule(this) { - @Override public boolean matches(RelOptRuleCall call) { + return new PruneEmptyRule(this) { + @Override public boolean matches(final RelOptRuleCall call) { RelNode node = call.rel(0); return RelMdUtil.isRelDefinitelyEmpty(call.getMetadataQuery(), node); } + + @Override public void onMatch(final RelOptRuleCall call) { + RelNode node = call.rel(0); + call.transformTo(call.builder().push(node).empty().build()); + } }; } } diff --git a/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java b/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java index b88773774f2..b7450b2c42e 100644 --- a/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java +++ b/core/src/main/java/org/apache/calcite/rel/rules/SortRemoveRedundantRule.java @@ -113,7 +113,7 @@ protected SortRemoveRedundantRule(final SortRemoveRedundantRule.Config config) { // If sort is 'order by x' or 'order by x limit n', target threshold is 1. // If sort is pure limit, the target threshold is the limit's fetch. // If the limit's fetch is 0, we could use - // PruneEmptyRules.SORT_EMPTY_INSTANCE to deal with it, so we don't need to + // PruneEmptyRules.EMPTY_TABLE_INSTANCE to deal with it, so we don't need to // deal with it in this rule. final Optional rowCountThreshold = getRowCountThreshold(sort); diff --git a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java index 0d5aee3c1d7..1daf5168ef7 100644 --- a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java +++ b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java @@ -8068,7 +8068,7 @@ private void checkLiteral2(String expression, String expected) { + "from (values (1, 'a'), (2, 'bb')) as t(x, y)\n" + "limit 0"; final RuleSet rules = - RuleSets.ofList(PruneEmptyRules.SORT_EMPTY_INSTANCE); + RuleSets.ofList(PruneEmptyRules.EMPTY_TABLE_INSTANCE); final String expectedMysql = "SELECT *\n" + "FROM (SELECT NULL AS `X`, NULL AS `Y`) AS `t`\n" + "WHERE 1 = 0"; @@ -8136,9 +8136,11 @@ private void checkLiteral2(String expression, String expected) { + "limit 0"; final String sql = "SELECT SUBSTRING(y, 1, 1) FROM (" + sql0 + ") t"; final RuleSet rules = - RuleSets.ofList(PruneEmptyRules.SORT_EMPTY_INSTANCE); - final String expected = "SELECT SUBSTRING(`Y`, 1, 1)\n" - + "FROM (SELECT NULL AS `X`, NULL AS `Y`) AS `t`\n" + RuleSets.ofList(PruneEmptyRules.EMPTY_TABLE_INSTANCE); + // EMPTY_TABLE_INSTANCE is now a general rule that prunes any definitely + // empty expression to an empty Values, so the outer Project is also pruned. + final String expected = "SELECT *\n" + + "FROM (SELECT NULL AS `EXPR$0`) AS `t`\n" + "WHERE 1 = 0"; sql(sql).optimize(rules, null).withMysql().ok(expected); } diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java index 16bf4baecde..5e284ab7280 100644 --- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java +++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java @@ -5838,18 +5838,18 @@ private void checkEmptyJoin(RelOptFixture f) { @Test void testEmptySortLimitZero() { final String sql = "select * from emp order by deptno limit 0"; - sql(sql).withRule(PruneEmptyRules.SORT_EMPTY_INSTANCE).check(); + sql(sql).withRule(PruneEmptyRules.EMPTY_TABLE_INSTANCE).check(); } /** Tests that a Sort whose OFFSET skips at least as many rows as its input * can produce is pruned to empty by - * {@link PruneEmptyRules#SORT_EMPTY_INSTANCE}. */ + * {@link PruneEmptyRules#EMPTY_TABLE_INSTANCE}. */ @Test void testEmptySortOffsetGreaterThanMaxRows() { // The input VALUES has at most 2 rows, so 'OFFSET 5' skips them all. final String sql = "select * from (values (1, 2), (3, 4)) as t (a, b)\n" + "order by a\n" + "offset 5 rows"; - sql(sql).withRule(PruneEmptyRules.SORT_EMPTY_INSTANCE).check(); + sql(sql).withRule(PruneEmptyRules.EMPTY_TABLE_INSTANCE).check(); } @Test void testEmptyAggregate() { From 6d0064e8e7f33cf02547bd069b9170dc43a62423 Mon Sep 17 00:00:00 2001 From: xuzifu666 <1206332514@qq.com> Date: Sat, 1 Aug 2026 15:57:08 +0800 Subject: [PATCH 6/6] Addressed --- .../apache/calcite/test/RelOptRulesTest.java | 17 +++++++++ .../apache/calcite/test/RelOptRulesTest.xml | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java index 5e284ab7280..1c766bf2619 100644 --- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java +++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java @@ -5852,6 +5852,23 @@ private void checkEmptyJoin(RelOptFixture f) { sql(sql).withRule(PruneEmptyRules.EMPTY_TABLE_INSTANCE).check(); } + /** Tests that {@link PruneEmptyRules#EMPTY_TABLE_INSTANCE} prunes a Filter + * whose condition is always false, even when its input is not an empty + * Values. */ + @Test void testEmptyFilterAlwaysFalse() { + final String sql = "select * from emp where false"; + sql(sql).withRule(PruneEmptyRules.EMPTY_TABLE_INSTANCE).check(); + } + + /** Tests that {@link PruneEmptyRules#EMPTY_TABLE_INSTANCE} does not prune a + * {@code TableModify}, because it may have side effects even when no rows are + * modified. The input Filter is still pruned to empty Values. */ + @Test void testEmptyTableModifyNotPruned() { + final String sql = "insert into sales.dept(deptno, name)\n" + + "select empno, ename from emp where false"; + sql(sql).withRule(PruneEmptyRules.EMPTY_TABLE_INSTANCE).check(); + } + @Test void testEmptyAggregate() { final String sql = "select sum(empno) from emp where false group by deptno"; sql(sql) diff --git a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml index 45dfc00ba42..9532c029782 100644 --- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml +++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml @@ -4192,6 +4192,23 @@ LogicalProject(EXPR$0=[+(+($0, $1), $0)]) + + + + + + + + + + + @@ -4487,6 +4504,26 @@ LogicalProject(PRODUCTID=[$0], NAME=[$1], SUPPLIERID=[$2]) + + + + + + + + + + +