| # 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. |
| |
| """Render a row as the string Java produces when casting it to STRING. |
| |
| Mirrors ``RowToStringCastRule`` and the per type ``*ToStringCastRule`` rules of |
| ``paimon-common``, so system tables show the same text in both languages. |
| |
| Only the types whose output provably matches Java are rendered. A row holding |
| one of the types left out is not rendered at all, so the caller keeps the NULL |
| it would have emitted anyway: |
| |
| - FLOAT and DOUBLE go through ``Float/Double.toString``, which up to JDK 18 is |
| the legacy ``FloatingDecimal`` and does not produce the shortest round |
| tripping decimal, while JDK 19+ does: ``2.68873286E11`` against |
| ``2.6887329E11`` for the same float. |
| - TIMESTAMP WITH LOCAL TIME ZONE is formatted in ``TimeZone.getDefault()``, so |
| one manifest reads ``2024-01-02 03:04:05`` on a UTC JVM and |
| ``2024-01-02 06:04:05`` on a Europe/Moscow one. |
| - BINARY, VARBINARY and BYTES are decoded as UTF-8, and malformed input leaves |
| the JDK decoder and Python disagreeing on how many replacement characters to |
| emit: ``ED A0 80`` is one in Java and three here. |
| """ |
| |
| from typing import Any, Optional |
| |
| from pypaimon.table.row.generic_row import _parse_type_precision_scale |
| |
| _UNSUPPORTED_TYPES = ("FLOAT", "REAL", "DOUBLE", "TIMESTAMP_LTZ", |
| "BINARY", "VARBINARY", "BYTES") |
| |
| |
| def cast_row_to_string(row) -> Optional[str]: |
| """Render ``row`` as ``{v1, v2}``, with ``null`` for null fields. |
| |
| An empty row renders as ``{}``, which is what an unpartitioned table has. |
| Returns None when a field has a type this module does not render. |
| """ |
| if row is None: |
| return None |
| fields = getattr(row, "fields", None) or [] |
| # on the declared type, not on the value, so every manifest of a table |
| # answers the same way no matter which stats happen to be null |
| if any(_is_unsupported(field.type) for field in fields): |
| return None |
| parts = [] |
| for i in range(len(fields)): |
| value = row.get_field(i) |
| parts.append("null" if value is None |
| else cast_value_to_string(value, fields[i].type)) |
| return "{" + ", ".join(parts) + "}" |
| |
| |
| def cast_value_to_string(value: Any, data_type) -> str: |
| """Cast a single field value to string the way the Java cast rules do.""" |
| type_name = _type_name(data_type) |
| |
| if _is_unsupported(data_type): |
| raise ValueError( |
| "{} has no portable string form, see the module docstring".format( |
| type_name)) |
| if type_name in ("BOOLEAN", "BOOL"): |
| return "true" if value else "false" |
| if type_name.startswith("DECIMAL") or type_name.startswith("NUMERIC"): |
| # Java Decimal.toString is BigDecimal.toPlainString, which never uses |
| # scientific notation, while str(Decimal) does below 1e-6 |
| return "{:f}".format(value) |
| if type_name == "DATE": |
| return value.isoformat() |
| # TIMESTAMP has to be tested before TIME, it starts with it |
| if type_name.startswith("TIMESTAMP"): |
| precision, _ = _parse_type_precision_scale(data_type) |
| return _format_timestamp(value, precision) |
| if type_name.startswith("TIME"): |
| precision, _ = _parse_type_precision_scale(data_type) |
| return _format_time(value, precision) |
| return str(value) |
| |
| |
| def _type_name(data_type) -> str: |
| name = getattr(data_type, "type", None) |
| return (name if isinstance(name, str) else str(data_type)).upper().strip() |
| |
| |
| def _is_unsupported(data_type) -> bool: |
| type_name = _type_name(data_type) |
| # the other spelling of TIMESTAMP_LTZ, see data_types.py |
| if "WITH LOCAL TIME ZONE" in type_name: |
| return True |
| # first token only, so "FLOAT NOT NULL" is caught as well |
| head = type_name.split("(", 1)[0].split() |
| return bool(head) and head[0] in _UNSUPPORTED_TYPES |
| |
| |
| def _format_timestamp(value, precision: int) -> str: |
| """Format as ``yyyy-MM-dd HH:mm:ss[.fraction]``. |
| |
| The fraction is padded to nine digits and then stripped of trailing zeros |
| down to ``precision`` digits, as ``DateTimeUtils.formatTimestamp`` does. |
| Python datetimes are microsecond resolution, so the last three digits of a |
| TIMESTAMP(7..9) value are always zero. |
| """ |
| text = "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format( |
| value.year, value.month, value.day, |
| value.hour, value.minute, value.second) |
| fraction = "{:09d}".format(value.microsecond * 1000) |
| while len(fraction) > precision and fraction.endswith("0"): |
| fraction = fraction[:-1] |
| return (text + "." + fraction) if fraction else text |
| |
| |
| def _format_time(value, precision: int) -> str: |
| """Format as ``HH:mm:ss[.fraction]``. |
| |
| Digit by digit off the millisecond part, exactly as |
| ``DateTimeUtils.formatTimestampMillis`` does: it stops only once nothing |
| but zeros is left, so a truncated non zero tail keeps the zero before it |
| (``.10`` for 101 ms at precision 2, not ``.1``). |
| """ |
| text = "{:02d}:{:02d}:{:02d}".format(value.hour, value.minute, value.second) |
| if precision <= 0: |
| return text |
| millis = value.microsecond // 1000 |
| digits = [] |
| while precision > 0: |
| digits.append(str(millis // 100)) |
| millis = millis % 100 * 10 |
| if millis == 0: |
| break |
| precision -= 1 |
| return text + "." + "".join(digits) |