This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 93089092f8 [python] Render min/max_partition_stats in the $manifests 
system table (#8842)
93089092f8 is described below

commit 93089092f8138628c7ad1a4c138c45b210f8d8f6
Author: Vova Kolmakov <[email protected]>
AuthorDate: Tue Jul 28 20:16:20 2026 +0700

    [python] Render min/max_partition_stats in the $manifests system table 
(#8842)
---
 paimon-python/pypaimon/casting/row_to_string.py    | 145 +++++++++++++++++++
 .../pypaimon/table/system/manifests_table.py       |  11 +-
 paimon-python/pypaimon/tests/row_to_string_test.py | 156 +++++++++++++++++++++
 .../pypaimon/tests/system/manifests_table_test.py  | 101 ++++++++++++-
 4 files changed, 401 insertions(+), 12 deletions(-)

diff --git a/paimon-python/pypaimon/casting/row_to_string.py 
b/paimon-python/pypaimon/casting/row_to_string.py
new file mode 100644
index 0000000000..e775ca457c
--- /dev/null
+++ b/paimon-python/pypaimon/casting/row_to_string.py
@@ -0,0 +1,145 @@
+# 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)
diff --git a/paimon-python/pypaimon/table/system/manifests_table.py 
b/paimon-python/pypaimon/table/system/manifests_table.py
index 1975a4208a..a2bcb90877 100644
--- a/paimon-python/pypaimon/table/system/manifests_table.py
+++ b/paimon-python/pypaimon/table/system/manifests_table.py
@@ -21,6 +21,7 @@ from typing import List, Optional
 
 import pyarrow
 
+from pypaimon.casting.row_to_string import cast_row_to_string
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
 from pypaimon.schema.data_types import AtomicType, DataField, RowType
 from pypaimon.table.system.system_table import SystemTable
@@ -75,12 +76,10 @@ class ManifestsTable(SystemTable):
             num_added.append(int(meta.num_added_files))
             num_deleted.append(int(meta.num_deleted_files))
             schema_ids.append(int(meta.schema_id))
-            # TODO: render min/max_partition_stats by casting partition
-            # rows to their string form. pypaimon
-            # has SimpleStats but no shared partition-row-to-string
-            # helper yet; emit NULL to preserve the column shape.
-            min_partition_stats.append(None)
-            max_partition_stats.append(None)
+            min_partition_stats.append(
+                cast_row_to_string(meta.partition_stats.min_values))
+            max_partition_stats.append(
+                cast_row_to_string(meta.partition_stats.max_values))
             min_row_ids.append(
                 None if meta.min_row_id is None else int(meta.min_row_id))
             max_row_ids.append(
diff --git a/paimon-python/pypaimon/tests/row_to_string_test.py 
b/paimon-python/pypaimon/tests/row_to_string_test.py
new file mode 100644
index 0000000000..654aaae9bd
--- /dev/null
+++ b/paimon-python/pypaimon/tests/row_to_string_test.py
@@ -0,0 +1,156 @@
+# 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.
+
+"""Tests that cast_row_to_string reproduces the Java cast-to-string rules."""
+
+import unittest
+from datetime import date, datetime, time
+from decimal import Decimal
+
+from pypaimon.casting.row_to_string import (cast_row_to_string,
+                                            cast_value_to_string)
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.generic_row import GenericRow
+
+
+def _row(*pairs):
+    fields = [DataField(i, "f" + str(i), AtomicType(t))
+              for i, (t, _) in enumerate(pairs)]
+    return GenericRow([v for _, v in pairs], fields)
+
+
+def _cast(type_name, value):
+    return cast_value_to_string(value, AtomicType(type_name))
+
+
+class RowToStringTest(unittest.TestCase):
+
+    def test_none_row(self):
+        self.assertIsNone(cast_row_to_string(None))
+
+    def test_empty_row_of_unpartitioned_table(self):
+        self.assertEqual("{}", cast_row_to_string(_row()))
+
+    def test_fields_are_comma_separated(self):
+        self.assertEqual("{1, a}", cast_row_to_string(_row(("INT", 1),
+                                                           ("STRING", "a"))))
+
+    def test_null_field_is_a_literal(self):
+        self.assertEqual("{null, a}", cast_row_to_string(_row(("INT", None),
+                                                              ("STRING", 
"a"))))
+
+    def test_boolean_is_lower_case(self):
+        self.assertEqual("true", _cast("BOOLEAN", True))
+        self.assertEqual("false", _cast("BOOLEAN", False))
+
+    def test_integers(self):
+        self.assertEqual("-7", _cast("TINYINT", -7))
+        self.assertEqual("42", _cast("INT", 42))
+        self.assertEqual("9223372036854775807", _cast("BIGINT",
+                                                      9223372036854775807))
+
+    def test_decimal_keeps_its_scale(self):
+        self.assertEqual("1.50", _cast("DECIMAL(10, 2)", Decimal("1.50")))
+
+    def test_decimal_never_uses_scientific_notation(self):
+        # Java Decimal.toString is BigDecimal.toPlainString; str(Decimal)
+        # would give "0E-9" and "-1E-9" here
+        self.assertEqual("0.000000000",
+                         _cast("DECIMAL(20, 9)", Decimal("0E-9")))
+        self.assertEqual("-0.000000001",
+                         _cast("DECIMAL(20, 9)", Decimal("-1E-9")))
+        self.assertEqual("0.000000000000000000",
+                         _cast("DECIMAL(38, 18)", Decimal("0E-18")))
+
+    def test_row_with_a_float_field_is_not_rendered(self):
+        self.assertIsNone(cast_row_to_string(_row(("INT", 1),
+                                                  ("FLOAT", 1.5))))
+        self.assertIsNone(cast_row_to_string(_row(("DOUBLE", 1.5))))
+        self.assertIsNone(cast_row_to_string(_row(("REAL", 1.5))))
+        self.assertIsNone(cast_row_to_string(_row(("FLOAT NOT NULL", 1.5))))
+
+    def test_float_field_stops_the_row_even_when_its_value_is_null(self):
+        # the check is on the declared type, so all rows of a table agree
+        self.assertIsNone(cast_row_to_string(_row(("INT", 1),
+                                                  ("DOUBLE", None))))
+
+    def test_row_with_a_local_zoned_timestamp_is_not_rendered(self):
+        # Java formats these in TimeZone.getDefault(), so the same manifest
+        # reads differently depending on where the query runs
+        value = datetime(2024, 1, 2, 3, 4, 5)
+        self.assertIsNone(cast_row_to_string(_row(("TIMESTAMP_LTZ(3)", 
value))))
+        self.assertIsNone(cast_row_to_string(
+            _row(("TIMESTAMP(3) WITH LOCAL TIME ZONE", value))))
+
+    def test_unrenderable_values_are_rejected(self):
+        self.assertRaises(ValueError, _cast, "FLOAT", 1.5)
+        self.assertRaises(ValueError, _cast, "DOUBLE", 1.5)
+        self.assertRaises(ValueError, _cast, "TIMESTAMP_LTZ(3)",
+                          datetime(2024, 1, 2, 3, 4, 5))
+        self.assertRaises(ValueError, _cast, "BYTES", b"ab")
+
+    def test_row_with_a_binary_field_is_not_rendered(self):
+        # malformed UTF-8 yields a different replacement char count than the
+        # JDK decoder gives, so binary is not rendered at all
+        self.assertIsNone(cast_row_to_string(_row(("BYTES", b"ab"))))
+        self.assertIsNone(cast_row_to_string(_row(("BINARY(2)", b"ab"))))
+        self.assertIsNone(cast_row_to_string(_row(("VARBINARY(10)", b"ab"))))
+
+    def test_date(self):
+        self.assertEqual("2024-01-02", _cast("DATE", date(2024, 1, 2)))
+
+    def test_timestamp_separator_is_a_space(self):
+        value = datetime(2024, 1, 2, 3, 4, 5, 123456)
+        self.assertEqual("2024-01-02 03:04:05.123456",
+                         _cast("TIMESTAMP(6)", value))
+
+    def test_timestamp_fraction_is_kept_up_to_precision(self):
+        value = datetime(2024, 1, 2, 3, 4, 5, 0)
+        self.assertEqual("2024-01-02 03:04:05", _cast("TIMESTAMP(0)", value))
+        self.assertEqual("2024-01-02 03:04:05.000", _cast("TIMESTAMP(3)", 
value))
+        self.assertEqual("2024-01-02 03:04:05.000000",
+                         _cast("TIMESTAMP(6)", value))
+
+    def test_timestamp_trailing_zeros_are_stripped_down_to_precision(self):
+        value = datetime(2024, 1, 2, 3, 4, 5, 120000)
+        self.assertEqual("2024-01-02 03:04:05.12", _cast("TIMESTAMP(0)", 
value))
+        self.assertEqual("2024-01-02 03:04:05.120", _cast("TIMESTAMP(3)", 
value))
+
+    def test_time(self):
+        self.assertEqual("03:04:05", _cast("TIME", time(3, 4, 5)))
+        self.assertEqual("03:04:05", _cast("TIME(0)", time(3, 4, 5)))
+        self.assertEqual("03:04:05.123",
+                         _cast("TIME(3)", time(3, 4, 5, 123000)))
+
+    def test_time_keeps_one_fraction_digit_at_least(self):
+        self.assertEqual("03:04:05.0", _cast("TIME(3)", time(3, 4, 5)))
+        self.assertEqual("03:04:05.5", _cast("TIME(3)", time(3, 4, 5, 500000)))
+
+    def test_time_keeps_a_zero_that_a_truncated_tail_sits_behind(self):
+        # 101 ms at precision 2 is ".10" in Java, not ".1": it stops only
+        # once the remaining fraction is exactly zero
+        self.assertEqual("03:04:05.10", _cast("TIME(2)", time(3, 4, 5, 
101000)))
+        self.assertEqual("03:04:05.00", _cast("TIME(2)", time(3, 4, 5, 1000)))
+        self.assertEqual("03:04:05.0", _cast("TIME(2)", time(3, 4, 5)))
+        self.assertEqual("03:04:05.01", _cast("TIME(2)", time(3, 4, 5, 10000)))
+
+    def test_unknown_type_falls_back_to_str(self):
+        self.assertEqual("x", _cast("SOMETHING_NEW", "x"))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/system/manifests_table_test.py 
b/paimon-python/pypaimon/tests/system/manifests_table_test.py
index 825ae23934..d88f4fda4c 100644
--- a/paimon-python/pypaimon/tests/system/manifests_table_test.py
+++ b/paimon-python/pypaimon/tests/system/manifests_table_test.py
@@ -63,6 +63,46 @@ class ManifestsTableTest(unittest.TestCase):
         writer.close()
         commit.close()
 
+    def _write_two_partitions(self):
+        fields = [
+            DataField.from_dict({"id": 0, "name": "id", "type": "INT"}),
+            DataField.from_dict({"id": 1, "name": "pt", "type": "INT"}),
+            DataField.from_dict({"id": 2, "name": "dt", "type": "STRING"}),
+        ]
+        self.catalog.create_table(
+            "db.p", Schema(fields=fields, partition_keys=["pt", "dt"]), False)
+        table = self.catalog.get_table("db.p")
+        write_builder = table.new_batch_write_builder()
+        writer = write_builder.new_write()
+        commit = write_builder.new_commit()
+        writer.write_arrow(pa.table({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "pt": pa.array([1, 2], type=pa.int32()),
+            "dt": ["2024-01-01", "2024-01-02"],
+        }))
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+
+    def _write_partition(self, name, partition_type, partition_column):
+        fields = [
+            DataField.from_dict({"id": 0, "name": "id", "type": "INT"}),
+            DataField.from_dict({"id": 1, "name": "pt", "type": 
partition_type}),
+        ]
+        self.catalog.create_table(
+            "db." + name, Schema(fields=fields, partition_keys=["pt"]), False)
+        table = self.catalog.get_table("db." + name)
+        write_builder = table.new_batch_write_builder()
+        writer = write_builder.new_write()
+        commit = write_builder.new_commit()
+        writer.write_arrow(pa.table({
+            "id": pa.array([1, 2], type=pa.int32()),
+            "pt": partition_column,
+        }))
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+
     def test_manifests_table_loaded_via_catalog(self):
         table = self.catalog.get_table("db.t$manifests")
         self.assertIsInstance(table, ManifestsTable)
@@ -100,12 +140,61 @@ class ManifestsTableTest(unittest.TestCase):
         for n_added in arrow_table.column("num_added_files").to_pylist():
             self.assertGreaterEqual(n_added, 0)
 
-        # min/max_partition_stats are placeholders until the partition
-        # cast-to-string helper lands; pin the placeholder contract.
-        for value in arrow_table.column("min_partition_stats").to_pylist():
-            self.assertIsNone(value)
-        for value in arrow_table.column("max_partition_stats").to_pylist():
-            self.assertIsNone(value)
+    def test_partition_stats_of_unpartitioned_table(self):
+        self._write_one_commit()
+        arrow_table = _read(self.catalog.get_table("db.t$manifests"))
+
+        # an empty partition row renders as "{}", the same as in Java
+        self.assertEqual(["{}"] * arrow_table.num_rows,
+                         arrow_table.column("min_partition_stats").to_pylist())
+        self.assertEqual(["{}"] * arrow_table.num_rows,
+                         arrow_table.column("max_partition_stats").to_pylist())
+
+    def test_partition_stats_span_the_written_partitions(self):
+        self._write_two_partitions()
+        arrow_table = _read(self.catalog.get_table("db.p$manifests"))
+        self.assertEqual(1, arrow_table.num_rows)
+
+        self.assertEqual(["{1, 2024-01-01}"],
+                         arrow_table.column("min_partition_stats").to_pylist())
+        self.assertEqual(["{2, 2024-01-02}"],
+                         arrow_table.column("max_partition_stats").to_pylist())
+
+    def test_partition_stats_stay_null_for_a_float_partition(self):
+        # Java prints these with Double.toString, whose output depends on the
+        # JVM version, so the columns keep the NULL they had before
+        self._write_partition("d", "DOUBLE",
+                              pa.array([1.5, 2.5], type=pa.float64()))
+        arrow_table = _read(self.catalog.get_table("db.d$manifests"))
+        self.assertEqual(1, arrow_table.num_rows)
+
+        self.assertEqual([None],
+                         arrow_table.column("min_partition_stats").to_pylist())
+        self.assertEqual([None],
+                         arrow_table.column("max_partition_stats").to_pylist())
+
+    def test_partition_stats_stay_null_for_a_binary_partition(self):
+        self._write_partition("b", "BYTES",
+                              pa.array([b"a", b"b"], type=pa.binary()))
+        arrow_table = _read(self.catalog.get_table("db.b$manifests"))
+        self.assertEqual(1, arrow_table.num_rows)
+
+        self.assertEqual([None],
+                         arrow_table.column("min_partition_stats").to_pylist())
+        self.assertEqual([None],
+                         arrow_table.column("max_partition_stats").to_pylist())
+
+    def test_decimal_partition_stats_carry_the_full_scale(self):
+        # str(Decimal) would render the zero as "0E-9" here, Java never does
+        self._write_partition("n", "DECIMAL(20, 9)",
+                              pa.array([0, 1], type=pa.decimal128(20, 9)))
+        arrow_table = _read(self.catalog.get_table("db.n$manifests"))
+        self.assertEqual(1, arrow_table.num_rows)
+
+        self.assertEqual(["{0.000000000}"],
+                         arrow_table.column("min_partition_stats").to_pylist())
+        self.assertEqual(["{1.000000000}"],
+                         arrow_table.column("max_partition_stats").to_pylist())
 
 
 if __name__ == "__main__":

Reply via email to