MonotonicityUtils.java

// 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.trees.expressions.functions;

import org.apache.doris.common.util.TimeUtils;
import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.Expression;
import org.apache.doris.nereids.trees.expressions.literal.BigIntLiteral;
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.NullLiteral;
import org.apache.doris.nereids.trees.expressions.literal.StringLikeLiteral;
import org.apache.doris.nereids.trees.expressions.literal.TimeStampNsLiteral;
import org.apache.doris.nereids.trees.expressions.literal.TimestampTzLiteral;
import org.apache.doris.nereids.types.DataType;
import org.apache.doris.nereids.types.DateTimeV2Type;
import org.apache.doris.nereids.types.TimeStampNsType;
import org.apache.doris.nereids.types.TimeStampTzType;
import org.apache.doris.nereids.types.coercion.DateLikeType;
import org.apache.doris.nereids.util.DateUtils;

import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.zone.ZoneOffsetTransition;
import java.time.zone.ZoneRules;

/** Shared range predicates for monotonic date and time expressions. */
public final class MonotonicityUtils {
    // The last second accepted by FE constant folding for from_{second,millisecond,microsecond}.
    private static final long MAX_EPOCH_SECONDS = 253402271999L;

    private MonotonicityUtils() {
    }

    /** All arguments other than the range argument must be literals. */
    public static boolean hasConstantOtherArguments(ExpressionTrait expression, int rangeArgument) {
        for (int i = 0; i < expression.arity(); i++) {
            if (i != rangeArgument && !(expression.child(i) instanceof Literal)) {
                return false;
            }
        }
        return true;
    }

    public static boolean hasOneVariableArgument(ExpressionTrait expression) {
        return expression.child(0) instanceof Literal ^ expression.child(1) instanceof Literal;
    }

    public static boolean hasConstantRoundingArguments(ExpressionTrait expression) {
        return expression.arity() >= 1 && expression.arity() <= 3
                && (expression.arity() == 1 || !(expression.child(0) instanceof Literal))
                && hasConstantOtherArguments(expression, 0);
    }

    public static boolean hasMonotonicFormat(Expression format) {
        return format instanceof StringLikeLiteral
                && DateUtils.monoFormat.contains(((StringLikeLiteral) format).getValue());
    }

    /** A calendar field cannot wrap while its enclosing date fields are unchanged. */
    public static boolean isWithinSameYear(Literal lower, Literal upper) {
        return hasSameDatePrefix(lower, upper, 1);
    }

    public static boolean isWithinSameMonth(Literal lower, Literal upper) {
        return hasSameDatePrefix(lower, upper, 2);
    }

    public static boolean isWithinSameDay(Literal lower, Literal upper) {
        return hasSameDatePrefix(lower, upper, 3);
    }

    private static boolean hasSameDatePrefix(Literal lower, Literal upper, int fields) {
        if (!(lower instanceof DateLiteral && upper instanceof DateLiteral)) {
            return false;
        }
        DateLiteral start = (DateLiteral) lower;
        DateLiteral end = (DateLiteral) upper;
        return start.getYear() == end.getYear()
                && (fields < 2 || start.getMonth() == end.getMonth())
                && (fields < 3 || start.getDay() == end.getDay());
    }

    /** Preserve the date-only behavior of sub-day extractors. */
    public static boolean isWithinSameHour(Literal lower, Literal upper) {
        return hasSameTimePrefix(lower, upper, 1);
    }

    public static boolean isWithinSameMinute(Literal lower, Literal upper) {
        return hasSameTimePrefix(lower, upper, 2);
    }

    public static boolean isWithinSameSecond(Literal lower, Literal upper) {
        return hasSameTimePrefix(lower, upper, 3);
    }

    private static boolean hasSameTimePrefix(Literal lower, Literal upper, int fields) {
        if (lower instanceof TimeStampNsLiteral && upper instanceof TimeStampNsLiteral) {
            TimeStampNsLiteral start = (TimeStampNsLiteral) lower;
            TimeStampNsLiteral end = (TimeStampNsLiteral) upper;
            return hasSameDatePrefix(lower, upper, 3)
                    && start.getHour() == end.getHour()
                    && (fields < 2 || start.getMinute() == end.getMinute())
                    && (fields < 3 || start.getSecond() == end.getSecond());
        }
        if (lower instanceof DateTimeLiteral && upper instanceof DateTimeLiteral) {
            DateTimeLiteral start = (DateTimeLiteral) lower;
            DateTimeLiteral end = (DateTimeLiteral) upper;
            return hasSameDatePrefix(lower, upper, 3)
                    && start.getHour() == end.getHour()
                    && (fields < 2 || start.getMinute() == end.getMinute())
                    && (fields < 3 || start.getSecond() == end.getSecond());
        }
        return lower instanceof DateLiteral && upper instanceof DateLiteral;
    }

    /** Check the range in the unit used by from_second, from_millisecond, or from_microsecond. */
    public static boolean isEpochToLocalMonotonic(Literal lower, Literal upper, long unitsPerSecond) {
        if (!(lower instanceof BigIntLiteral) || ((BigIntLiteral) lower).getValue() < 0) {
            return false;
        }
        ZoneId zoneId;
        try {
            zoneId = TimeUtils.getDorisZoneId();
        } catch (DateTimeException e) {
            return false;
        }
        if (zoneId.getRules().isFixedOffset()) {
            return true;
        }
        if (!(upper instanceof BigIntLiteral)) {
            return false;
        }
        long lowerValue = ((BigIntLiteral) lower).getValue();
        long upperValue = ((BigIntLiteral) upper).getValue();
        if (upperValue < lowerValue || upperValue / unitsPerSecond > MAX_EPOCH_SECONDS) {
            return false;
        }
        Instant lowerInstant = epochToInstant(lowerValue, unitsPerSecond);
        Instant upperInstant = epochToInstant(upperValue, unitsPerSecond);
        if (LocalDateTime.ofInstant(upperInstant, zoneId).getYear() > 9999) {
            return false;
        }
        return isInstantToLocalMonotonic(zoneId, lowerInstant, upperInstant);
    }

    private static Instant epochToInstant(long value, long unitsPerSecond) {
        return Instant.ofEpochSecond(value / unitsPerSecond,
                value % unitsPerSecond * (1000000000L / unitsPerSecond));
    }

    /** Instant-to-local conversion can move backward only at a fall-back transition. */
    public static boolean isInstantToLocalMonotonic(ZoneId zoneId, Instant lower, Instant upper) {
        return zoneId.getRules().isFixedOffset()
                || lower != null && upper != null && !upper.isBefore(lower)
                && !hasFallbackTransitionInInstantRange(zoneId, lower, upper);
    }

    /** Local-to-instant conversion may lose its order across a spring-forward gap. */
    public static boolean isLocalToInstantMonotonic(ZoneId zoneId, LocalDateTime lower, LocalDateTime upper) {
        return zoneId.getRules().isFixedOffset()
                || lower != null && upper != null && !upper.isBefore(lower)
                && !hasGapTransitionInLocalDateTimeRange(zoneId, lower, upper);
    }

    /** Whether the instant interval (lower, upper] crosses a fall-back transition. */
    private static boolean hasFallbackTransitionInInstantRange(ZoneId zoneId, Instant lower, Instant upper) {
        ZoneRules rules = zoneId.getRules();
        ZoneOffsetTransition transition = rules.nextTransition(lower);
        while (transition != null && !transition.getInstant().isAfter(upper)) {
            if (transition.isOverlap()) {
                return true;
            }
            transition = rules.nextTransition(transition.getInstant());
        }
        return false;
    }

    /** Whether the local interval intersects a spring-forward gap. */
    private static boolean hasGapTransitionInLocalDateTimeRange(
            ZoneId zoneId, LocalDateTime lower, LocalDateTime upper) {
        ZoneRules rules = zoneId.getRules();
        Instant searchStart = lower.minusDays(2).atZone(zoneId).toInstant();
        ZoneOffsetTransition transition = rules.nextTransition(searchStart);
        while (transition != null && !transition.getDateTimeBefore().isAfter(upper)) {
            if (transition.isGap()
                    && upper.isAfter(transition.getDateTimeBefore())
                    && lower.isBefore(transition.getDateTimeAfter())) {
                return true;
            }
            transition = rules.nextTransition(transition.getInstant());
        }
        return false;
    }

    /** Dispatch date-like casts to their range checks. */
    public static boolean isDateLikeCastMonotonic(
            DataType sourceType, DataType targetType, Literal lower, Literal upper) {
        if (!(sourceType instanceof DateLikeType && targetType instanceof DateLikeType)) {
            return false;
        }
        if (targetType instanceof TimeStampNsType && !isRangeWithinTimeStampNs(targetType, lower, upper)) {
            return false;
        }
        if (sourceType instanceof TimeStampTzType
                && (targetType instanceof DateTimeV2Type || targetType instanceof TimeStampNsType)) {
            int destinationScale = targetType instanceof DateTimeV2Type
                    ? ((DateTimeV2Type) targetType).getScale() : TimeStampNsType.SCALE;
            return isTimeStampTzToLocalMonotonic(
                    (TimeStampTzType) sourceType, destinationScale, lower, upper);
        }
        if (sourceType instanceof TimeStampNsType && targetType instanceof TimeStampTzType) {
            return isTimeStampNsToTimeStampTzMonotonic((TimeStampTzType) targetType, lower, upper);
        }
        return true;
    }

    private static boolean isRangeWithinTimeStampNs(DataType targetType, Literal lower, Literal upper) {
        if (lower == null || upper == null) {
            return false;
        }
        try {
            return !(lower.checkedCastTo(targetType) instanceof NullLiteral)
                    && !(upper.checkedCastTo(targetType) instanceof NullLiteral);
        } catch (AnalysisException e) {
            return false;
        }
    }

    /** Check a TIMESTAMPTZ cast to a local datetime after accounting for its output scale. */
    public static boolean isTimeStampTzToLocalMonotonic(
            TimeStampTzType sourceType, int destinationScale, Literal lower, Literal upper) {
        ZoneId zoneId;
        try {
            zoneId = TimeUtils.getDorisZoneId();
        } catch (DateTimeException e) {
            return false;
        }
        if (zoneId.getRules().isFixedOffset()) {
            return true;
        }
        // Rounding UTC before zone conversion may cross a fall-back just outside the input range.
        if (destinationScale < sourceType.getScale()
                || !(lower instanceof TimestampTzLiteral) || !(upper instanceof TimestampTzLiteral)) {
            return false;
        }
        Instant lowerInstant = ((TimestampTzLiteral) lower).toJavaDateType().toInstant(ZoneOffset.UTC);
        Instant upperInstant = ((TimestampTzLiteral) upper).toJavaDateType().toInstant(ZoneOffset.UTC);
        return isInstantToLocalMonotonic(zoneId, lowerInstant, upperInstant);
    }

    /** Check a local TIMESTAMP_NS cast after rounding to the TIMESTAMPTZ output scale. */
    public static boolean isTimeStampNsToTimeStampTzMonotonic(
            TimeStampTzType destinationType, Literal lower, Literal upper) {
        ZoneId zoneId;
        try {
            zoneId = TimeUtils.getDorisZoneId();
        } catch (DateTimeException e) {
            return false;
        }
        if (zoneId.getRules().isFixedOffset()) {
            return true;
        }
        if (!(lower instanceof TimeStampNsLiteral) || !(upper instanceof TimeStampNsLiteral)) {
            return false;
        }
        LocalDateTime lowerDateTime = roundTimeStampNs((TimeStampNsLiteral) lower, destinationType.getScale());
        LocalDateTime upperDateTime = roundTimeStampNs((TimeStampNsLiteral) upper, destinationType.getScale());
        return isLocalToInstantMonotonic(zoneId, lowerDateTime, upperDateTime);
    }

    private static LocalDateTime roundTimeStampNs(TimeStampNsLiteral literal, int scale) {
        long factor = (long) Math.pow(10, DateUtils.NANOSECOND_SCALE - scale);
        LocalDateTime dateTime = literal.toJavaDateType().plusNanos(factor / 2);
        return dateTime.withNano((int) (dateTime.getNano() / factor * factor));
    }
}