TimeZoneParser.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.common.util;

import java.time.DateTimeException;
import java.time.ZoneId;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Doris timezone syntax, independent of the Java timezone database's input grammar. */
public final class TimeZoneParser {
    public enum Usage {
        SESSION, DATETIME, TIMESTAMPTZ
    }

    private static final Pattern OFFSET = Pattern.compile("^([+-])([0-9]{1,2})(?::([0-9]{2}))?$");
    private static final Pattern TEXT_OFFSET = Pattern.compile(
            "^([+-])([0-9]{1,2})(?::?([0-9]{2}))?(?::([0-9]{2}))?$");
    private static final Map<String, String> REGION_NAMES;

    static {
        Map<String, String> names = new HashMap<>();
        for (String region : ZoneId.getAvailableZoneIds()) {
            names.put(region, region);
        }
        REGION_NAMES = Collections.unmodifiableMap(names);
    }

    private TimeZoneParser() {
    }

    public static String normalize(String value, Usage usage) {
        if (value == null || value.isEmpty()) {
            throw new DateTimeException("Empty timezone");
        }
        String name = findAlias(value);
        if (name != null) {
            return name;
        }
        String region = findRegion(value);
        if (region != null) {
            return region;
        }
        String upper = value.toUpperCase(Locale.ROOT);
        // Java's standard UTC names are not present in SHORT_IDS.
        if (upper.equals("UT") || upper.equals("Z")) {
            return "UTC";
        }
        boolean prefixed = upper.startsWith("UTC") || upper.startsWith("GMT");
        String offset = prefixed ? value.substring(3) : value;
        Matcher matcher = (usage == Usage.SESSION || prefixed ? OFFSET : TEXT_OFFSET).matcher(offset);
        if (!matcher.matches()
                || (usage == Usage.SESSION && !prefixed && matcher.group(3) == null)) {
            throw new DateTimeException("Invalid timezone: " + value);
        }
        boolean negative = "-".equals(matcher.group(1));
        int hour = Integer.parseInt(matcher.group(2));
        int minute = matcher.group(3) == null ? 0 : Integer.parseInt(matcher.group(3));
        int second = !prefixed && usage != Usage.SESSION && matcher.group(4) != null
                ? Integer.parseInt(matcher.group(4)) : 0;
        // Prefixed offsets have the same contract as session offsets in BE.
        Usage policy = prefixed ? Usage.SESSION : usage;
        int limit = policy == Usage.SESSION ? (negative ? 12 : 14)
                : policy == Usage.DATETIME ? 14 : 18;
        if (hour > limit || minute >= 60 || second >= 60
                || (policy != Usage.TIMESTAMPTZ && hour == limit && minute != 0)
                || (policy == Usage.TIMESTAMPTZ && hour == limit && (minute != 0 || second != 0))
                || (policy == Usage.DATETIME
                        && (matcher.group(4) != null || (minute != 0 && minute != 30 && minute != 45)))) {
            throw new DateTimeException("Invalid timezone offset: " + value);
        }
        String normalized = String.format(Locale.ROOT, "%s%02d:%02d", negative ? "-" : "+", hour, minute);
        if (second != 0) {
            normalized += String.format(Locale.ROOT, ":%02d", second);
        }
        return prefixed ? upper.substring(0, 3) + normalized : normalized;
    }

    private static String findAlias(String value) {
        for (String alias : TimeUtils.timeZoneAliasMap.keySet()) {
            if (alias.equalsIgnoreCase(value)) {
                return TimeUtils.getCanonicalTimeZone(alias);
            }
        }
        return null;
    }

    private static String findRegion(String value) {
        return REGION_NAMES.get(value);
    }

    public static ZoneId parse(String value, Usage usage) {
        return ZoneId.of(normalize(value, usage));
    }
}