| # 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.utils.temporal_string import format_time, format_timestamp | |
| 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: | |
| return format_timestamp(value, precision) | |
| def _format_time(value, precision: int) -> str: | |
| millis = ((value.hour * 60 + value.minute) * 60 + value.second) * 1000 + value.microsecond // 1000 | |
| return format_time(millis, precision) |