Stephen0421 commented on code in PR #10675: URL: https://github.com/apache/paimon/pull/10675#discussion_r4347649943
########## paimon-python/pypaimon/schema/default_value.py: ########## @@ -0,0 +1,263 @@ +# 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. + +"""Validate a scalar column default before it is stored. + +The stored value is still the original string. Scalar checks follow the Java +string casts used by ``DefaultValueUtils`` for booleans, numbers, dates, times +and timestamps. ARRAY, MAP and ROW defaults are accepted by Java's +``StringToArrayCastRule``, ``StringToMapCastRule`` and ``StringToRowCastRule``; +Python does not parse those forms yet and rejects them explicitly. +""" + +import re +from calendar import monthrange +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from pypaimon.schema.data_types import AtomicType + +_TRUE = {"t", "true", "y", "yes", "1"} +_FALSE = {"f", "false", "n", "no", "0"} +_INT_RANGES = { + "TINYINT": (-128, 127), + "SMALLINT": (-32768, 32767), + "INT": (-2147483648, 2147483647), + "INTEGER": (-2147483648, 2147483647), + "BIGINT": (-9223372036854775808, 9223372036854775807), +} +_INTEGRAL = set(_INT_RANGES) +_APPROXIMATE = {"FLOAT", "DOUBLE"} +_CHARACTER = {"STRING", "CHAR", "VARCHAR"} +_BINARY = {"BINARY", "VARBINARY", "BYTES"} +_UNSIGNED_INTEGER_RE = re.compile(r"[0-9]+") +# StringUtils.isNumeric guards the epoch fast path of DATE, TIME and TIMESTAMP. +# It allows a leading '-' but not '+', and only ASCII digits after it. +_JAVA_NUMERIC_RE = re.compile(r"-?[0-9]+") +_INTEGRAL_RE = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]*)") +_DECIMAL_RE = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?") +# Float.parseFloat / Double.parseDouble accept hexadecimal literals and type +# suffixes, but not Python's underscores or case-insensitive special values. +_FLOAT_RE = re.compile( + r"[+-]?(?:NaN|Infinity|" + r"(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|" + r"0[xX](?:[0-9a-fA-F]+(?:\.[0-9a-fA-F]*)?|\.[0-9a-fA-F]+)" + r"[pP][+-]?[0-9]+)[fFdD]?)") +_JAVA_TRIM_CHARS = "".join(chr(i) for i in range(33)) +# DateTimeUtils.DEFAULT_TIMESTAMP_FORMATTER: the time is optional, a space +# requires hour:minute:second, and 'T' requires hour:minute with optional +# seconds. appendFraction has a minimum width of 0, so a bare '.' is allowed. +# Anchor with \Z, not $, so a trailing newline does not slip through. +_TIMESTAMP_SPACE_RE = re.compile( + r"^(\d{4})-(\d{1,2})-(\d{1,2})" + r"(?: (\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.(\d{0,9}))?)?\Z") +_TIMESTAMP_T_RE = re.compile( + r"^(\d{4})-(\d{1,2})-(\d{1,2})" + r"T(\d{1,2}):(\d{1,2})(?::(\d{1,2})(?:\.(\d{0,9}))?)?\Z") + + +def validate_default_value(data_type, default_value: Optional[str]) -> None: + """Raise if ``default_value`` cannot be cast to ``data_type``. + + ``None`` clears the default and is always accepted. + """ + if default_value is None: + return + if not isinstance(data_type, AtomicType): + raise RuntimeError( + "Unsupported default value `{}` for type {}. Python currently " + "validates scalar defaults only; ARRAY, MAP and ROW defaults " + "are not supported.".format(default_value, data_type)) + try: + _convert(data_type, _unquote(default_value)) + except Exception as cause: + raise RuntimeError( + "Unsupported default value `{}` for type {}".format( + default_value, data_type) + ) from cause + + +def _unquote(value: str) -> str: + if len(value) >= 2 and value.startswith("'") and value.endswith("'"): + return value[1:-1] + return value + + +def _convert(data_type, text: str) -> None: + root = _type_root(data_type.type) + if root in _CHARACTER or root in _BINARY: + return + if root == "BOOLEAN" or root == "BOOL": + if text.lower() not in _TRUE and text.lower() not in _FALSE: + raise ValueError("Cannot parse {!r} as BOOLEAN.".format(text)) + return + if root in _INTEGRAL: + _parse_integral(text, root) + return + if root in _APPROXIMATE: + if _FLOAT_RE.fullmatch(text.strip(_JAVA_TRIM_CHARS)) is None: + raise ValueError("Invalid floating-point value {}".format(text)) + return + if root == "DECIMAL" or root == "NUMERIC": + _parse_decimal(text) + return + if root == "DATE": + _parse_date(text) + return + if root == "TIME": + _parse_time(text) + return + if root == "TIMESTAMP": + if _JAVA_NUMERIC_RE.fullmatch(text): + _parse_integral(text, "BIGINT") + return + _parse_timestamp(text) + return + if root == "TIMESTAMP_LTZ": + _parse_timestamp(text) + return + raise RuntimeError( + "Default value do not support the type of {}".format(data_type)) + + +def _type_root(type_name: str) -> str: + match = re.match(r"^([A-Za-z_]+)", type_name.strip()) + if match is None: + raise ValueError("Unrecognized type {}".format(type_name)) + return match.group(1).upper() + + +def _parse_integral(text: str, root: str) -> int: + if _INTEGRAL_RE.fullmatch(text) is None: + raise ValueError("Invalid integer {}".format(text)) + sign = -1 if text[0] == "-" else 1 + body = text[1:] if text[0] in "+-" else text + # BinaryStringUtils truncates fractions, including '.5' and '10.'. It + # checks the original bytes, so whitespace and Unicode digits are invalid. + number = sign * int(body.split(".", 1)[0] or "0") + low, high = _INT_RANGES[root] + if number < low or number > high: + raise ValueError("Value {} is out of range for {}".format(number, root)) + return number + + +def _parse_decimal(text: str) -> None: + match = _DECIMAL_RE.fullmatch(text) + if match is None: + raise ValueError("Invalid decimal {}".format(text)) + Decimal(text) Review Comment: Fixed. `Decimal(text)` is gone, and validation now checks both places where Java fails: 1. `new BigDecimal(String)`: the exponent and the resulting scale (fraction digits minus exponent) must fit in an int, otherwise `NumberFormatException`. This covers `1e2147483648`, `1e-2147483648` and `1.5e-2147483647`. 2. `Decimal.fromBigDecimal` calls `setScale(columnScale)`. For a non-zero value, that needs `BigInteger.TEN.pow(raise)`, which throws `ArithmeticException` once `raise >= 536870920`. That's the point where `bitLength * raise / 32` exceeds `MAX_MAG_LENGTH`. This covers `1e2147483647` and `1e-2147483646`. Zero values never build that power, so `0e-2147483645` stays valid, as in Java. Ordinary precision overflow such as `12345678901` or `1e100000` on DECIMAL(10, 2) is still accepted and becomes a null default, matching Java. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
