diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java index 64361ba9a979ad..4c4167644abd8e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/AbstractMaterializedViewRule.java @@ -613,6 +613,37 @@ protected List rewriteExpression(List sourceEx // if contains any slot to rewrite, which means could not be rewritten by target, // expressionShuttledToRewrite is slot#0 > '2024-01-01' but mv plan output is date_trunc(slot#0, 'day') // which would try to rewrite + Expression queryOriginalExpr = sourceExpressionsToWrite.get(exprIndex); + + // Check if this is a synthetic date_trunc equality predicate + if (queryExprToInfoMap.containsKey(queryOriginalExpr) + && queryExprToInfoMap.get(queryOriginalExpr).isSyntheticDateTruncEquality) { + // Synthetic predicate: date_trunc(slot, 'unit') = literal + // The expression is already in date_trunc form, so we need to use the + // viewExprParamToDateTruncMap to properly rewrite it to MV output slots + if (!viewExprParamToDateTruncMap.isEmpty() + && expressionShuttledToRewrite instanceof ComparisonPredicate + && !expressionShuttledToRewrite.children().isEmpty()) { + Expression queryShuttledExprParam = expressionShuttledToRewrite.child(0); + if (queryShuttledExprParam instanceof DateTrunc) { + // The left side is date_trunc(slot, unit), extract the slot parameter + Expression dateTruncParam = queryShuttledExprParam.child(0).getDataType().isDateLikeType() + ? queryShuttledExprParam.child(0) : queryShuttledExprParam.child(1); + if (viewExprParamToDateTruncMap.containsKey(dateTruncParam)) { + // Replace the date_trunc parameter with the MV's date_trunc expression + replacedExpression = ExpressionUtils.replace(expressionShuttledToRewrite, + targetToTargetReplacementMappingQueryBased, + viewExprParamToDateTruncMap); + if (!replacedExpression.anyMatch(slotsToRewrite::contains)) { + rewrittenExpressions.add(replacedExpression); + continue; + } + } + } + } + return ImmutableList.of(); + } + if (viewExprParamToDateTruncMap.isEmpty() || expressionShuttledToRewrite.children().isEmpty() || !(expressionShuttledToRewrite instanceof ComparisonPredicate)) { @@ -621,7 +652,6 @@ protected List rewriteExpression(List sourceEx return ImmutableList.of(); } Expression queryShuttledExprParam = expressionShuttledToRewrite.child(0); - Expression queryOriginalExpr = sourceExpressionsToWrite.get(exprIndex); if (!queryExprToInfoMap.containsKey(queryOriginalExpr) || !viewExprParamToDateTruncMap.containsKey(queryShuttledExprParam)) { // query expr contains expression info or mv out contains date_trunc expression, diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetector.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetector.java new file mode 100644 index 00000000000000..90739fd301634a --- /dev/null +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetector.java @@ -0,0 +1,129 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.rules.exploration.mv; + +import org.apache.doris.nereids.trees.expressions.literal.DateLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateV2Literal; +import org.apache.doris.nereids.types.DateV2Type; + +import java.time.YearMonth; +import java.util.Optional; + +/** + * Utility class to detect if a date range covers exactly one complete date_trunc bucket. + * Supports 'day', 'month', and 'year' granularities. + */ +public class DateTruncRangeDetector { + + /** + * Detect if [lower, upper] covers exactly one whole date_trunc bucket. + * Returns the granularity and bucket start if match found. + */ + public static Optional detectWholeBucket(DateLiteral lower, DateLiteral upper) { + if (lower == null || upper == null) { + return Optional.empty(); + } + + // Check year first (coarsest granularity) + if (coversWholeYear(lower, upper)) { + return Optional.of(new BucketInfo("year", getBucketStart(lower, "year"))); + } + + // Check month + if (coversWholeMonth(lower, upper)) { + return Optional.of(new BucketInfo("month", getBucketStart(lower, "month"))); + } + + // Check day + if (coversWholeDay(lower, upper)) { + return Optional.of(new BucketInfo("day", getBucketStart(lower, "day"))); + } + + return Optional.empty(); + } + + /** + * Check if [lower, upper] covers exactly one whole year. + */ + public static boolean coversWholeYear(DateLiteral lower, DateLiteral upper) { + if (lower.getYear() != upper.getYear()) { + return false; + } + return lower.getMonth() == 1 && lower.getDay() == 1 + && upper.getMonth() == 12 && upper.getDay() == 31; + } + + /** + * Check if [lower, upper] covers exactly one whole month. + */ + public static boolean coversWholeMonth(DateLiteral lower, DateLiteral upper) { + if (lower.getYear() != upper.getYear() || lower.getMonth() != upper.getMonth()) { + return false; + } + if (lower.getDay() != 1) { + return false; + } + int lastDayOfMonth = YearMonth.of((int) lower.getYear(), (int) lower.getMonth()).lengthOfMonth(); + return upper.getDay() == lastDayOfMonth; + } + + /** + * Check if [lower, upper] covers exactly one whole day. + */ + public static boolean coversWholeDay(DateLiteral lower, DateLiteral upper) { + return lower.getYear() == upper.getYear() + && lower.getMonth() == upper.getMonth() + && lower.getDay() == upper.getDay(); + } + + /** + * Get the bucket start for a given granularity. + */ + public static DateLiteral getBucketStart(DateLiteral anyDateInBucket, String unit) { + boolean isV2 = anyDateInBucket instanceof DateV2Literal + || anyDateInBucket.getDataType() instanceof DateV2Type; + + long year = anyDateInBucket.getYear(); + long month = anyDateInBucket.getMonth(); + long day = anyDateInBucket.getDay(); + + switch (unit.toLowerCase()) { + case "year": + return isV2 ? new DateV2Literal(year, 1, 1) : new DateLiteral(year, 1, 1); + case "month": + return isV2 ? new DateV2Literal(year, month, 1) : new DateLiteral(year, month, 1); + case "day": + return isV2 ? new DateV2Literal(year, month, day) : new DateLiteral(year, month, day); + default: + throw new IllegalArgumentException("Unsupported unit: " + unit); + } + } + + /** + * Container for bucket detection result. + */ + public static class BucketInfo { + public final String unit; + public final DateLiteral bucketStart; + + public BucketInfo(String unit, DateLiteral bucketStart) { + this.unit = unit; + this.bucketStart = bucketStart; + } + } +} diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java index bbd9f6181dab9e..6d53ce3068086e 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java @@ -23,15 +23,22 @@ import org.apache.doris.nereids.rules.expression.ExpressionNormalization; import org.apache.doris.nereids.rules.expression.ExpressionOptimization; import org.apache.doris.nereids.rules.expression.ExpressionRewriteContext; +import org.apache.doris.nereids.trees.expressions.Alias; import org.apache.doris.nereids.trees.expressions.ComparisonPredicate; import org.apache.doris.nereids.trees.expressions.EqualTo; import org.apache.doris.nereids.trees.expressions.Expression; import org.apache.doris.nereids.trees.expressions.GreaterThan; +import org.apache.doris.nereids.trees.expressions.GreaterThanEqual; +import org.apache.doris.nereids.trees.expressions.LessThan; import org.apache.doris.nereids.trees.expressions.LessThanEqual; import org.apache.doris.nereids.trees.expressions.SlotReference; import org.apache.doris.nereids.trees.expressions.functions.agg.AggregateFunction; +import org.apache.doris.nereids.trees.expressions.functions.scalar.DateTrunc; import org.apache.doris.nereids.trees.expressions.literal.BooleanLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral; import org.apache.doris.nereids.trees.expressions.literal.Literal; +import org.apache.doris.nereids.trees.expressions.literal.StringLikeLiteral; import org.apache.doris.nereids.util.ExpressionUtils; import org.apache.doris.nereids.util.Utils; @@ -225,6 +232,14 @@ public static Map compensateRangePredicate(StructInf // normalized expressions is not in query, can not compensate return null; } + + // Try to detect whole-bucket ranges and synthesize date_trunc equality predicates + Map syntheticPredicates = detectAndSynthesizeWholeBucketPredicates( + normalizedExpressions, viewStructInfo, viewToQuerySlotMapping); + if (syntheticPredicates != null) { + return syntheticPredicates; + } + Map normalizedExpressionsWithLiteral = new HashMap<>(); for (Expression expression : normalizedExpressions) { Set literalSet = expression.collect(expressionTreeNode -> expressionTreeNode instanceof Literal); @@ -254,6 +269,184 @@ private static Set normalizeExpression(Expression expression, Cascad return ExpressionUtils.extractConjunctionToSet(expression); } + /** + * Detect if normalized expressions form a whole-bucket range and synthesize date_trunc equality predicates. + * Returns null if no whole-bucket pattern detected. + */ + private static Map detectAndSynthesizeWholeBucketPredicates( + Set normalizedExpressions, + StructInfo viewStructInfo, + SlotMapping viewToQuerySlotMapping) { + // Group predicates by slot + Map> slotToPredicates = new HashMap<>(); + for (Expression expr : normalizedExpressions) { + if (!(expr instanceof ComparisonPredicate)) { + continue; + } + ComparisonPredicate pred = (ComparisonPredicate) expr; + Expression left = pred.left(); + if (left instanceof SlotReference) { + slotToPredicates.computeIfAbsent(left, k -> new ArrayList<>()).add(expr); + } + } + + // Check if view has date_trunc on any of these slots + Map viewDateTruncMap = extractViewDateTruncExpressions(viewStructInfo); + if (viewDateTruncMap.isEmpty()) { + return null; + } + + // Map view slots to query slots + Map viewToQuerySlotMap = viewToQuerySlotMapping.toSlotReferenceMap(); + Map querySlotToViewDateTrunc = new HashMap<>(); + for (Map.Entry entry : viewDateTruncMap.entrySet()) { + SlotReference viewSlot = entry.getKey(); + SlotReference querySlot = viewToQuerySlotMap.get(viewSlot); + if (querySlot != null) { + querySlotToViewDateTrunc.put(querySlot, entry.getValue()); + } + } + + // Try to detect whole-bucket ranges + for (Map.Entry> entry : slotToPredicates.entrySet()) { + if (!(entry.getKey() instanceof SlotReference)) { + continue; + } + SlotReference slot = (SlotReference) entry.getKey(); + DateTrunc viewDateTrunc = querySlotToViewDateTrunc.get(slot); + if (viewDateTrunc == null) { + continue; + } + + List predicates = entry.getValue(); + if (predicates.size() != 2) { + continue; + } + + // Extract lower and upper bounds + DateLiteral lowerBound = null; + DateLiteral upperBound = null; + for (Expression pred : predicates) { + if (!(pred instanceof ComparisonPredicate)) { + continue; + } + ComparisonPredicate cp = (ComparisonPredicate) pred; + if (!(cp.right() instanceof DateLiteral) || cp.right() instanceof DateTimeLiteral) { + continue; + } + DateLiteral literal = (DateLiteral) cp.right(); + if (cp instanceof GreaterThanEqual) { + lowerBound = literal; + } else if (cp instanceof GreaterThan) { + // dt > '2024-12-31' is equivalent to dt >= '2025-01-01' for DATE type + lowerBound = (DateLiteral) literal.plusDays(1); + } else if (cp instanceof LessThanEqual) { + upperBound = literal; + } else if (cp instanceof LessThan) { + // dt < '2025-02-01' is equivalent to dt <= '2025-01-31' for DATE type + upperBound = (DateLiteral) literal.plusDays(-1); + } + } + + if (lowerBound == null || upperBound == null) { + continue; + } + if (lowerBound.getDouble() > upperBound.getDouble()) { + continue; + } + + // Detect whole bucket + java.util.Optional bucketInfo = + DateTruncRangeDetector.detectWholeBucket(lowerBound, upperBound); + if (!bucketInfo.isPresent()) { + continue; + } + + // Check if bucket unit matches view date_trunc unit + String bucketUnit = bucketInfo.get().unit; + String viewUnit = extractDateTruncUnit(viewDateTrunc); + if (viewUnit == null || !viewUnit.equalsIgnoreCase(bucketUnit)) { + continue; + } + + // Synthesize date_trunc(slot, unit) = bucket_start + DateLiteral bucketStart = bucketInfo.get().bucketStart; + Expression syntheticPredicate = new EqualTo( + rebuildDateTruncOnQuerySlot(viewDateTrunc, slot), + bucketStart + ); + + // Build result map with synthetic predicate and remaining non-date predicates + Map result = new HashMap<>(); + result.put(syntheticPredicate, new ExpressionInfo(bucketStart, true)); + + // Add remaining predicates that are not part of the detected date range + Set consumedPredicates = new HashSet<>(predicates); + for (Expression expr : normalizedExpressions) { + if (!consumedPredicates.contains(expr)) { + Set literalSet = expr.collect(e -> e instanceof Literal); + if (expr.anyMatch(AggregateFunction.class::isInstance)) { + return null; + } + if (literalSet.size() == 1 && expr instanceof ComparisonPredicate + && !(expr instanceof GreaterThan || expr instanceof LessThanEqual)) { + result.put(expr, new ExpressionInfo(literalSet.iterator().next())); + } else { + result.put(expr, ExpressionInfo.EMPTY); + } + } + } + return result; + } + + return null; + } + + /** + * Extract date_trunc expressions from view output shuttled expressions. + */ + private static Map extractViewDateTruncExpressions(StructInfo viewStructInfo) { + Map result = new HashMap<>(); + for (Expression expr : viewStructInfo.getPlanOutputShuttledExpressions()) { + Expression unwrapped = expr; + if (unwrapped instanceof Alias) { + unwrapped = ((Alias) unwrapped).child(); + } + if (unwrapped instanceof DateTrunc) { + DateTrunc dateTrunc = (DateTrunc) unwrapped; + Expression dateArg = dateTrunc.child(0).getDataType().isDateLikeType() + ? dateTrunc.child(0) : dateTrunc.child(1); + if (dateArg instanceof SlotReference) { + result.put((SlotReference) dateArg, dateTrunc); + } + } + } + return result; + } + + /** + * Extract the unit string from a DateTrunc expression. + */ + private static String extractDateTruncUnit(DateTrunc dateTrunc) { + Expression arg0 = dateTrunc.child(0); + Expression arg1 = dateTrunc.child(1); + if (arg1 instanceof StringLikeLiteral) { + return ((StringLikeLiteral) arg1).getStringValue(); + } else if (arg0 instanceof StringLikeLiteral) { + return ((StringLikeLiteral) arg0).getStringValue(); + } + return null; + } + + private static DateTrunc rebuildDateTruncOnQuerySlot(DateTrunc viewDateTrunc, SlotReference querySlot) { + Expression arg0 = viewDateTrunc.child(0); + Expression arg1 = viewDateTrunc.child(1); + if (arg0.getDataType().isDateLikeType()) { + return new DateTrunc(querySlot, arg1); + } + return new DateTrunc(arg0, querySlot); + } + /** * compensate residual predicates */ @@ -302,12 +495,18 @@ public String toString() { */ public static final class ExpressionInfo { - public static final ExpressionInfo EMPTY = new ExpressionInfo(null); + public static final ExpressionInfo EMPTY = new ExpressionInfo(null, false); public final Literal literal; + public final boolean isSyntheticDateTruncEquality; public ExpressionInfo(Literal literal) { + this(literal, false); + } + + public ExpressionInfo(Literal literal, boolean isSyntheticDateTruncEquality) { this.literal = literal; + this.isSyntheticDateTruncEquality = isSyntheticDateTruncEquality; } } diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetectorTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetectorTest.java new file mode 100644 index 00000000000000..7a69cb581a7f89 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/exploration/mv/DateTruncRangeDetectorTest.java @@ -0,0 +1,206 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.nereids.rules.exploration.mv; + +import org.apache.doris.nereids.trees.expressions.literal.DateLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateTimeLiteral; +import org.apache.doris.nereids.trees.expressions.literal.DateV2Literal; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +class DateTruncRangeDetectorTest { + + @Test + void testCoversWholeMonth_January() { + DateLiteral lower = new DateLiteral(2025, 1, 1); + DateLiteral upper = new DateLiteral(2025, 1, 31); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeMonth_February_LeapYear() { + DateLiteral lower = new DateLiteral(2024, 2, 1); + DateLiteral upper = new DateLiteral(2024, 2, 29); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeMonth_February_NonLeapYear() { + DateLiteral lower = new DateLiteral(2025, 2, 1); + DateLiteral upper = new DateLiteral(2025, 2, 28); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeMonth_Partial() { + DateLiteral lower = new DateLiteral(2025, 1, 5); + DateLiteral upper = new DateLiteral(2025, 1, 31); + Assertions.assertFalse(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeMonth_WrongEndDay() { + DateLiteral lower = new DateLiteral(2025, 1, 1); + DateLiteral upper = new DateLiteral(2025, 1, 30); + Assertions.assertFalse(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeMonth_CrossMonth() { + DateLiteral lower = new DateLiteral(2025, 1, 15); + DateLiteral upper = new DateLiteral(2025, 2, 15); + Assertions.assertFalse(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testCoversWholeYear() { + DateLiteral lower = new DateLiteral(2025, 1, 1); + DateLiteral upper = new DateLiteral(2025, 12, 31); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeYear(lower, upper)); + } + + @Test + void testCoversWholeYear_Partial() { + DateLiteral lower = new DateLiteral(2025, 2, 1); + DateLiteral upper = new DateLiteral(2025, 12, 31); + Assertions.assertFalse(DateTruncRangeDetector.coversWholeYear(lower, upper)); + } + + @Test + void testCoversWholeDay() { + DateLiteral lower = new DateLiteral(2025, 1, 15); + DateLiteral upper = new DateLiteral(2025, 1, 15); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeDay(lower, upper)); + } + + @Test + void testCoversWholeDay_DifferentDays() { + DateLiteral lower = new DateLiteral(2025, 1, 15); + DateLiteral upper = new DateLiteral(2025, 1, 16); + Assertions.assertFalse(DateTruncRangeDetector.coversWholeDay(lower, upper)); + } + + @Test + void testDetectWholeBucket_Month() { + DateLiteral lower = new DateLiteral(2025, 1, 1); + DateLiteral upper = new DateLiteral(2025, 1, 31); + Optional result = + DateTruncRangeDetector.detectWholeBucket(lower, upper); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("month", result.get().unit); + Assertions.assertEquals(new DateLiteral(2025, 1, 1).getDouble(), + result.get().bucketStart.getDouble()); + } + + @Test + void testDetectWholeBucket_Year() { + DateLiteral lower = new DateLiteral(2025, 1, 1); + DateLiteral upper = new DateLiteral(2025, 12, 31); + Optional result = + DateTruncRangeDetector.detectWholeBucket(lower, upper); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("year", result.get().unit); + } + + @Test + void testDetectWholeBucket_Day() { + DateLiteral lower = new DateLiteral(2025, 1, 15); + DateLiteral upper = new DateLiteral(2025, 1, 15); + Optional result = + DateTruncRangeDetector.detectWholeBucket(lower, upper); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("day", result.get().unit); + } + + @Test + void testDetectWholeBucket_NoMatch() { + DateLiteral lower = new DateLiteral(2025, 1, 5); + DateLiteral upper = new DateLiteral(2025, 1, 31); + Optional result = + DateTruncRangeDetector.detectWholeBucket(lower, upper); + Assertions.assertFalse(result.isPresent()); + } + + @Test + void testGetBucketStart_Month() { + DateLiteral date = new DateLiteral(2025, 1, 15); + DateLiteral bucketStart = DateTruncRangeDetector.getBucketStart(date, "month"); + Assertions.assertEquals(2025, bucketStart.getYear()); + Assertions.assertEquals(1, bucketStart.getMonth()); + Assertions.assertEquals(1, bucketStart.getDay()); + } + + @Test + void testGetBucketStart_Year() { + DateLiteral date = new DateLiteral(2025, 6, 15); + DateLiteral bucketStart = DateTruncRangeDetector.getBucketStart(date, "year"); + Assertions.assertEquals(2025, bucketStart.getYear()); + Assertions.assertEquals(1, bucketStart.getMonth()); + Assertions.assertEquals(1, bucketStart.getDay()); + } + + @Test + void testGetBucketStart_Day() { + DateLiteral date = new DateLiteral(2025, 1, 15); + DateLiteral bucketStart = DateTruncRangeDetector.getBucketStart(date, "day"); + Assertions.assertEquals(2025, bucketStart.getYear()); + Assertions.assertEquals(1, bucketStart.getMonth()); + Assertions.assertEquals(15, bucketStart.getDay()); + } + + @Test + void testCoversWholeMonth_DateV2Literal() { + DateV2Literal lower = new DateV2Literal(2025, 1, 1); + DateV2Literal upper = new DateV2Literal(2025, 1, 31); + Assertions.assertTrue(DateTruncRangeDetector.coversWholeMonth(lower, upper)); + } + + @Test + void testDetectWholeBucket_DateV2Literal() { + DateV2Literal lower = new DateV2Literal(2025, 1, 1); + DateV2Literal upper = new DateV2Literal(2025, 1, 31); + Optional result = + DateTruncRangeDetector.detectWholeBucket(lower, upper); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("month", result.get().unit); + Assertions.assertTrue(result.get().bucketStart instanceof DateV2Literal); + } + + @Test + void testGetBucketStart_DateV2Literal() { + DateV2Literal date = new DateV2Literal(2025, 1, 15); + DateLiteral bucketStart = DateTruncRangeDetector.getBucketStart(date, "month"); + Assertions.assertTrue(bucketStart instanceof DateV2Literal); + Assertions.assertEquals(2025, bucketStart.getYear()); + Assertions.assertEquals(1, bucketStart.getMonth()); + Assertions.assertEquals(1, bucketStart.getDay()); + } + + @Test + void testCoversWholeMonth_DateTimeLiteral_ShouldNotMatch() { + // DateTimeLiteral with midnight times looks like a whole month by year/month/day, + // but dt <= '2025-01-31 00:00:00' does not cover the full day of Jan 31. + // The detection should exclude DateTimeLiteral at the caller level. + // Here we verify that DateTimeLiteral is a subclass of DateLiteral (the reason filtering is needed). + DateTimeLiteral dtLiteral = new DateTimeLiteral(2025, 1, 1, 0, 0, 0); + Assertions.assertTrue(dtLiteral instanceof DateLiteral); + } +} diff --git a/regression-test/suites/nereids_rules_p0/mv/date_trunc/test_date_trunc_whole_month_rewrite.groovy b/regression-test/suites/nereids_rules_p0/mv/date_trunc/test_date_trunc_whole_month_rewrite.groovy new file mode 100644 index 00000000000000..d1e0ee72afaedd --- /dev/null +++ b/regression-test/suites/nereids_rules_p0/mv/date_trunc/test_date_trunc_whole_month_rewrite.groovy @@ -0,0 +1,229 @@ +package mv.date_trunc +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_date_trunc_whole_month_rewrite") { + String db = context.config.getDbNameByFile(context.file) + sql "use ${db}" + sql "set runtime_filter_mode=OFF" + sql "SET ignore_shape_nodes='PhysicalDistribute,PhysicalProject'" + + sql """ + drop table if exists tb_detail + """ + + sql """ + CREATE TABLE tb_detail ( + dt DATE NOT NULL, + uuid VARCHAR(50) NOT NULL, + amt DECIMAL(10, 2) NOT NULL + ) DUPLICATE KEY(dt, uuid) + AUTO PARTITION BY RANGE (date_trunc(dt, 'day')) () + DISTRIBUTED BY HASH(uuid) BUCKETS 3 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + insert into tb_detail values + ('2025-01-01', 'uuid1', 100.00), + ('2025-01-15', 'uuid2', 200.00), + ('2025-01-31', 'uuid3', 300.00), + ('2025-02-01', 'uuid4', 400.00), + ('2025-02-28', 'uuid5', 500.00), + ('2024-02-01', 'uuid6', 600.00), + ('2024-02-29', 'uuid7', 700.00) + """ + + sql """analyze table tb_detail with sync""" + + // Create MV with date_trunc by month + def mv_name = "mv_month" + sql """ + drop materialized view if exists ${mv_name} + """ + + sql """ + CREATE MATERIALIZED VIEW ${mv_name} + BUILD IMMEDIATE + REFRESH ON MANUAL + PARTITION BY (month_dt) + DISTRIBUTED BY RANDOM BUCKETS AUTO + PROPERTIES ('replication_num' = '1') + AS SELECT + date_trunc(dt, 'month') AS month_dt, + SUM(amt) AS gmv, + COUNT(DISTINCT uuid) AS uv + FROM tb_detail + GROUP BY month_dt + """ + + def job_name = getJobName(db, mv_name) + waitingMTMVTaskFinished(job_name) + + sql """analyze table ${mv_name} with sync""" + + // Test 1: Whole month range with <= (January 2025) + def query1 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail + WHERE dt >= '2025-01-01' AND dt <= '2025-01-31' + """ + + explain { + sql("${query1}") + contains("${mv_name} chose") + } + + def result1 = sql "${query1}" + def expected1 = sql """ + SELECT SUM(amt) FROM tb_detail WHERE dt >= '2025-01-01' AND dt <= '2025-01-31' + """ + assertEquals(expected1, result1) + + // Test 2: Whole month range with < (February 2025) + def query2 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail + WHERE dt >= '2025-02-01' AND dt < '2025-03-01' + """ + + explain { + sql("${query2}") + contains("${mv_name} chose") + } + + // Test 3: Leap year February (2024) + def query3 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail + WHERE dt >= '2024-02-01' AND dt <= '2024-02-29' + """ + + explain { + sql("${query3}") + contains("${mv_name} chose") + } + + def result3 = sql "${query3}" + def expected3 = sql """ + SELECT SUM(amt) FROM tb_detail WHERE dt >= '2024-02-01' AND dt <= '2024-02-29' + """ + assertEquals(expected3, result3) + + // Test 4: Partial month should NOT rewrite + def query4 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail + WHERE dt >= '2025-01-05' AND dt <= '2025-01-31' + """ + + // This should not use MV (partial month) + explain { + sql("${query4}") + contains("${mv_name} fail") + } + + sql """ + drop table if exists tb_detail_v2 + """ + + sql """ + CREATE TABLE tb_detail_v2 ( + dt DATEV2 NOT NULL, + uuid VARCHAR(50) NOT NULL, + amt DECIMAL(10, 2) NOT NULL + ) DUPLICATE KEY(dt, uuid) + AUTO PARTITION BY RANGE (date_trunc(dt, 'day')) () + DISTRIBUTED BY HASH(uuid) BUCKETS 3 + PROPERTIES ("replication_num" = "1") + """ + + sql """ + insert into tb_detail_v2 values + ('2025-01-01', 'uuid1', 100.00), + ('2025-01-15', 'uuid2', 200.00), + ('2025-01-31', 'uuid3', 300.00), + ('2025-02-01', 'uuid4', 400.00), + ('2025-02-28', 'uuid5', 500.00), + ('2024-02-01', 'uuid6', 600.00), + ('2024-02-29', 'uuid7', 700.00) + """ + + sql """analyze table tb_detail_v2 with sync""" + + sql """ + drop materialized view if exists mv_month_v2 + """ + + sql """ + CREATE MATERIALIZED VIEW mv_month_v2 + BUILD IMMEDIATE + REFRESH ON MANUAL + PARTITION BY (month_dt) + DISTRIBUTED BY RANDOM BUCKETS AUTO + PROPERTIES ('replication_num' = '1') + AS SELECT + date_trunc(dt, 'month') AS month_dt, + SUM(amt) AS gmv, + COUNT(DISTINCT uuid) AS uv + FROM tb_detail_v2 + GROUP BY month_dt + """ + + waitingMTMVTaskFinished(getJobName(db, "mv_month_v2")) + + sql """analyze table mv_month_v2 with sync""" + + def query5 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail_v2 + WHERE dt >= '2025-01-01' AND dt <= '2025-01-31' + """ + + explain { + sql("${query5}") + contains("mv_month_v2 chose") + } + + def result5 = sql "${query5}" + def expected5 = sql """ + SELECT SUM(amt) FROM tb_detail_v2 WHERE dt >= '2025-01-01' AND dt <= '2025-01-31' + """ + assertEquals(expected5, result5) + + def query6 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail_v2 + WHERE dt >= '2025-02-01' AND dt < '2025-03-01' + """ + + explain { + sql("${query6}") + contains("mv_month_v2 chose") + } + + def query7 = """ + SELECT SUM(amt) AS gmv + FROM tb_detail_v2 + WHERE dt >= '2025-01-05' AND dt <= '2025-01-31' + """ + + explain { + sql("${query7}") + contains("mv_month_v2 fail") + } +}