Stephen0421 commented on code in PR #10675:
URL: https://github.com/apache/paimon/pull/10675#discussion_r4347647347


##########
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)
+
+
+def _parse_date(text: str) -> None:
+    if _JAVA_NUMERIC_RE.fullmatch(text):
+        _parse_integral(text, "INT")
+        return
+    # DateTimeUtils.parseDate also accepts year-month and ignores a timestamp's
+    # time suffix. Missing month/day components default to January/the first.
+    space = text.find(" ")
+    if space > 0:
+        text = text[:space]
+    parts = [part.strip(_JAVA_TRIM_CHARS) for part in text.split("-")]
+    if len(parts) > 3 or any(
+            _UNSIGNED_INTEGER_RE.fullmatch(part) is None for part in parts):
+        raise ValueError("Cannot parse {!r} as DATE.".format(text))
+    values = [_parse_integral(part, "INT") for part in parts]
+    year, month, day = values + [1] * (3 - len(values))
+    # Java DATE permits year zero, unlike datetime.
+    if not (0 <= year <= 9999 and 1 <= month <= 12
+            and 1 <= day <= monthrange(year, month)[1]):
+        raise ValueError("Cannot parse {!r} as DATE.".format(text))
+
+
+def _parse_time(text: str) -> None:
+    if _JAVA_NUMERIC_RE.fullmatch(text):
+        _parse_integral(text, "INT")
+        return
+    # DateTimeUtils.parseTime splits on ':' and '.' and only requires each
+    # component to be a non-negative int. It does not bound hour/minute/second,
+    # so '25:00:00' and '12:00:99' are accepted, and an optional '[+-]hh[:mm]'
+    # timezone suffix may follow. Mirror that instead of building a real time.
+    end = len(text)
+    timezone = text.find("-")
+    if timezone < 0:
+        timezone = text.find("+")
+    if timezone >= 0:
+        end = timezone
+        suffix = text[timezone + 1:]
+        colon = suffix.find(":")
+        parts = [suffix] if colon < 0 else [suffix[:colon], suffix[colon + 1:]]
+        for part in parts:
+            _check_java_int(part)
+    body = text[:end]
+    colon1 = body.find(":")
+    if colon1 < 0:
+        _check_java_int(body)
+        return
+    _check_java_int(body[:colon1])
+    colon2 = body.find(":", colon1 + 1)
+    if colon2 < 0:
+        _check_java_int(body[colon1 + 1:])

Review Comment:
   Fixed. `_parse_time` now follows `DateTimeUtils.parseTime` index by index: 
`colon1`, `colon2` and `dot` are searched in the original string, offset 
included, and substrings are bounded the way `String.substring` is. 
`12:30+01:00` and `12+01:00` are now rejected, while `12:30:00+01:00`, 
`12:30+01` and `12+01` are still accepted. These cases are in the unit tests, 
and `12:30+01:00` is also in the interop rejection list.



##########
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})"

Review Comment:
   Fixed. The timestamp regexes use `[0-9]` instead of `\d`, so full-width and 
other Unicode digits in any component (date, time or fraction) are rejected for 
both TIMESTAMP and TIMESTAMP_LTZ. The year now follows `yyyy` exactly: four 
digits, or `+` followed by 5 to 19 digits within the era-year range. The 
persisted-schema interop test covers your example for both TIMESTAMP(3) and 
TIMESTAMP_LTZ(3).



-- 
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]

Reply via email to