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

dianfu pushed a commit to branch release-1.20
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/release-1.20 by this push:
     new 1b9b42b2208 [FLINK-41032][python] Preserve column dtypes when 
converting empty tables to pandas (#29526)
1b9b42b2208 is described below

commit 1b9b42b220885fffdd7644bf5590fe2d65df17e4
Author: Biao Geng <[email protected]>
AuthorDate: Tue Oct 27 15:44:57 2026 +0800

    [FLINK-41032][python] Preserve column dtypes when converting empty tables 
to pandas (#29526)
---
 flink-python/pyflink/table/table.py                | 45 ++++++++++-------
 .../pyflink/table/tests/test_pandas_conversion.py  | 59 +++++++++++++++++++++-
 flink-python/pyflink/table/tests/test_types.py     |  9 ++++
 flink-python/pyflink/table/types.py                | 28 ++++++----
 4 files changed, 110 insertions(+), 31 deletions(-)

diff --git a/flink-python/pyflink/table/table.py 
b/flink-python/pyflink/table/table.py
index 34cf15357f2..5a46aa0c932 100644
--- a/flink-python/pyflink/table/table.py
+++ b/flink-python/pyflink/table/table.py
@@ -16,8 +16,10 @@
 # limitations under the License.
 
################################################################################
 
+from datetime import tzinfo
+
 from py4j.java_gateway import get_method
-from typing import Union
+from typing import Optional, Union
 
 from pyflink.java_gateway import get_gateway
 from pyflink.table import ExplainDetail
@@ -27,7 +29,7 @@ from pyflink.table.serializers import ArrowSerializer
 from pyflink.table.table_descriptor import TableDescriptor
 from pyflink.table.table_result import TableResult
 from pyflink.table.table_schema import TableSchema
-from pyflink.table.types import create_arrow_schema
+from pyflink.table.types import LocalZonedTimestampType, create_arrow_schema
 from pyflink.table.udf import UserDefinedScalarFunctionWrapper, \
     UserDefinedAggregateFunctionWrapper, UserDefinedTableFunctionWrapper
 from pyflink.table.utils import tz_convert_from_internal, to_expression_jarray
@@ -935,27 +937,32 @@ class Table(object):
             
.get(gateway.jvm.org.apache.flink.python.PythonOptions.MAX_ARROW_BATCH_SIZE)
         batches_iterator = 
gateway.jvm.org.apache.flink.table.runtime.arrow.ArrowUtils\
             .collectAsPandasDataFrame(self._j_table, max_arrow_batch_size)
-        if batches_iterator.hasNext():
+        schema = self.get_schema()
+        field_types = schema.get_field_data_types()
+        has_rows = batches_iterator.hasNext()
+        arrow_schema = create_arrow_schema(
+            schema.get_field_names(), field_types, allow_nested=not has_rows)
+        timezone: Optional[tzinfo] = None
+        if any(isinstance(field_type, LocalZonedTimestampType) for field_type 
in field_types):
             import pytz
-            timezone = pytz.timezone(
-                
self._j_table.getTableEnvironment().getConfig().getLocalTimeZone().getId())
-            serializer = ArrowSerializer(
-                create_arrow_schema(self.get_schema().get_field_names(),
-                                    self.get_schema().get_field_data_types()),
-                self.get_schema().to_row_data_type(),
-                timezone)
+            j_timezone = 
self._j_table.getTableEnvironment().getConfig().getLocalTimeZone()
+            timezone_id = j_timezone.getId()
+            if timezone_id.startswith(('GMT+', 'GMT-')):
+                timezone = 
pytz.FixedOffset(j_timezone.normalized().getTotalSeconds() // 60)
+            else:
+                timezone = pytz.timezone(timezone_id)
+        if has_rows:
+            serializer = ArrowSerializer(arrow_schema, 
schema.to_row_data_type(), timezone)
             import pyarrow as pa
             table = 
pa.Table.from_batches(serializer.load_from_iterator(batches_iterator))
-            pdf = table.to_pandas()
-
-            schema = self.get_schema()
-            for field_name in schema.get_field_names():
-                pdf[field_name] = tz_convert_from_internal(
-                    pdf[field_name], schema.get_field_data_type(field_name), 
timezone)
-            return pdf
         else:
-            import pandas as pd
-            return pd.DataFrame.from_records([], 
columns=self.get_schema().get_field_names())
+            table = arrow_schema.empty_table()
+        pdf = table.to_pandas()
+
+        for field_name in schema.get_field_names():
+            pdf[field_name] = tz_convert_from_internal(
+                pdf[field_name], schema.get_field_data_type(field_name), 
timezone)
+        return pdf
 
     def get_schema(self) -> TableSchema:
         """
diff --git a/flink-python/pyflink/table/tests/test_pandas_conversion.py 
b/flink-python/pyflink/table/tests/test_pandas_conversion.py
index 77924dbf8e7..2e436e30622 100644
--- a/flink-python/pyflink/table/tests/test_pandas_conversion.py
+++ b/flink-python/pyflink/table/tests/test_pandas_conversion.py
@@ -164,7 +164,64 @@ class PandasConversionITTests(PandasConversionTestBase):
     def test_empty_to_pandas(self):
         table = self.t_env.from_pandas(self.pdf, self.data_type)
         pdf = table.filter(table.f1 < 0).to_pandas()
-        self.assertTrue(pdf.empty)
+        assert_frame_equal(pdf, table.to_pandas().iloc[:0])
+
+    def test_to_pandas_with_nested_types(self):
+        import pandas as pd
+
+        for expression, error in [('ARRAY[ROW(1)]', ValueError), 
('ROW(ROW(1))', TypeError)]:
+            with self.subTest(expression=expression):
+                query = 'SELECT 1 AS a, %s AS payload FROM (VALUES (1)) AS 
t(x)' % expression
+                result = self.t_env.sql_query(query + ' WHERE 
FALSE').to_pandas()
+                expected = pd.DataFrame({'a': pd.Series(dtype='int32'),
+                                         'payload': pd.Series(dtype='object')})
+                assert_frame_equal(result, expected)
+                with self.assertRaisesRegex(error, 'ROW is not supported'):
+                    self.t_env.sql_query(query).to_pandas()
+
+    def test_empty_to_pandas_with_timestamps(self):
+        original_timezone = self.t_env.get_config().get_local_timezone()
+        self.addCleanup(self.t_env.get_config().set_local_timezone, 
original_timezone)
+        for timezone in ['UTC', 'Asia/Shanghai', 'Etc/GMT-8']:
+            with self.subTest(timezone=timezone):
+                self.t_env.get_config().set_local_timezone(timezone)
+                table = self.t_env.sql_query("""
+                    SELECT
+                        1 AS a,
+                        CAST(TIMESTAMP '2024-01-01 00:00:00.123456789'
+                            AS TIMESTAMP(0)) AS ts_seconds,
+                        CAST(TIMESTAMP '2024-01-01 00:00:00.123456789'
+                            AS TIMESTAMP(3)) AS ts_millis,
+                        CAST(TIMESTAMP '2024-01-01 00:00:00.123456789'
+                            AS TIMESTAMP(6)) AS ts_micros,
+                        TIMESTAMP '2024-01-01 00:00:00.123456789' AS ts_nanos,
+                        TO_TIMESTAMP_LTZ(1704067200123, 3) AS ltz
+                """)
+                expected = table.to_pandas().iloc[:0]
+                result = table.filter(table.a < 0).to_pandas()
+                assert_frame_equal(result, expected)
+                self.assertEqual(timezone, str(result['ltz'].dt.tz))
+
+    def test_to_pandas_with_fixed_offset_timezones(self):
+        original_timezone = self.t_env.get_config().get_local_timezone()
+        self.addCleanup(self.t_env.get_config().set_local_timezone, 
original_timezone)
+        for timezone, named_timezone, offset_seconds in [
+                ('GMT+08:00', 'Asia/Shanghai', 28800),
+                ('GMT+05:30', 'Asia/Kolkata', 19800),
+                ('GMT-03:30', 'America/St_Johns', -12600)]:
+            with self.subTest(timezone=timezone):
+                self.t_env.get_config().set_local_timezone(named_timezone)
+                table = self.t_env.sql_query("""
+                    SELECT 1 AS a, TO_TIMESTAMP_LTZ(1704067200123, 3) AS ltz
+                """)
+                expected_timestamp = table.to_pandas()['ltz'].iloc[0]
+                self.t_env.get_config().set_local_timezone(timezone)
+                result = table.to_pandas()
+                timestamp = result['ltz'].iloc[0]
+                self.assertEqual(expected_timestamp, timestamp)
+                self.assertEqual(datetime.timedelta(seconds=offset_seconds), 
timestamp.utcoffset())
+                empty_result = table.filter(table.a < 0).to_pandas()
+                assert_frame_equal(empty_result, result.iloc[:0])
 
     def test_to_pandas_preserves_local_zoned_timestamp_instants(self):
         import pandas as pd
diff --git a/flink-python/pyflink/table/tests/test_types.py 
b/flink-python/pyflink/table/tests/test_types.py
index 24685e50af1..19c25a69764 100644
--- a/flink-python/pyflink/table/tests/test_types.py
+++ b/flink-python/pyflink/table/tests/test_types.py
@@ -819,6 +819,15 @@ class DataTypeVerificationTests(PyFlinkTestCase):
 
 class DataTypeConvertTests(PyFlinkTestCase):
 
+    def test_timestamp_precision(self):
+        for type_factory in (DataTypes.TIMESTAMP, DataTypes.TIMESTAMP_LTZ):
+            for precision in (0, 3, 6, 9):
+                for nullable in (True, False):
+                    data_type = type_factory(precision, nullable)
+                    with self.subTest(data_type=data_type, 
precision=precision, nullable=nullable):
+                        self.assertEqual(
+                            data_type, 
_from_java_data_type(_to_java_data_type(data_type)))
+
     def test_basic_type(self):
         test_types = [DataTypes.STRING(),
                       DataTypes.BOOLEAN(),
diff --git a/flink-python/pyflink/table/types.py 
b/flink-python/pyflink/table/types.py
index 2e5354ba67b..f39d2fe5f1b 100644
--- a/flink-python/pyflink/table/types.py
+++ b/flink-python/pyflink/table/types.py
@@ -1707,7 +1707,7 @@ def _from_java_data_type(j_data_type):
         elif is_instance_of(logical_type, gateway.jvm.TimeType):
             data_type = DataTypes.TIME(logical_type.getPrecision(), 
logical_type.isNullable())
         elif is_instance_of(logical_type, gateway.jvm.TimestampType):
-            data_type = DataTypes.TIMESTAMP(precision=3, 
nullable=logical_type.isNullable())
+            data_type = DataTypes.TIMESTAMP(logical_type.getPrecision(), 
logical_type.isNullable())
         elif is_instance_of(logical_type, gateway.jvm.BooleanType):
             data_type = DataTypes.BOOLEAN(logical_type.isNullable())
         elif is_instance_of(logical_type, gateway.jvm.TinyIntType):
@@ -1727,7 +1727,8 @@ def _from_java_data_type(j_data_type):
                 TypeError("Unsupported type: %s, ZonedTimestampType is not 
supported yet."
                           % j_data_type)
         elif is_instance_of(logical_type, gateway.jvm.LocalZonedTimestampType):
-            data_type = 
DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(nullable=logical_type.isNullable())
+            data_type = DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(
+                logical_type.getPrecision(), logical_type.isNullable())
         elif is_instance_of(logical_type, gateway.jvm.DayTimeIntervalType) or \
                 is_instance_of(logical_type, 
gateway.jvm.YearMonthIntervalType):
             data_type = _from_java_interval_type(logical_type)
@@ -2186,12 +2187,15 @@ def _create_type_verifier(data_type: DataType, name: 
str = None):
     return verify
 
 
-def create_arrow_schema(field_names: List[str], field_types: List[DataType]):
+def create_arrow_schema(field_names: List[str], field_types: List[DataType], 
*, allow_nested=False):
     """
-    Create an Arrow schema with the specified filed names and types.
+    Create an Arrow schema with the specified field names and types.
+
+    By default, retain the nested-type restrictions of the pandas conversion 
path.
     """
     import pyarrow as pa
-    fields = [pa.field(field_name, to_arrow_type(field_type), 
field_type._nullable)
+    fields = [pa.field(field_name, to_arrow_type(field_type, 
allow_nested=allow_nested),
+                       field_type._nullable)
               for field_name, field_type in zip(field_names, field_types)]
     return pa.schema(fields)
 
@@ -2262,7 +2266,7 @@ def from_arrow_type(arrow_type, nullable: bool = True) -> 
DataType:
         raise TypeError("Unsupported data type to convert to Arrow type: " + 
str(dt))
 
 
-def to_arrow_type(data_type: DataType):
+def to_arrow_type(data_type: DataType, *, allow_nested=False):
     """
     Converts the specified Flink data type to pyarrow data type.
     """
@@ -2310,18 +2314,20 @@ def to_arrow_type(data_type: DataType):
         else:
             return pa.timestamp('ns')
     elif isinstance(data_type, MapType):
-        return pa.map_(to_arrow_type(data_type.key_type), 
to_arrow_type(data_type.value_type))
+        return pa.map_(to_arrow_type(data_type.key_type, 
allow_nested=allow_nested),
+                       to_arrow_type(data_type.value_type, 
allow_nested=allow_nested))
     elif isinstance(data_type, ArrayType):
-        if type(data_type.element_type) in [LocalZonedTimestampType, RowType]:
+        if not allow_nested and type(data_type.element_type) in 
[LocalZonedTimestampType, RowType]:
             raise ValueError("%s is not supported to be used as the element 
type of ArrayType." %
                              data_type.element_type)
-        return pa.list_(to_arrow_type(data_type.element_type))
+        return pa.list_(to_arrow_type(data_type.element_type, 
allow_nested=allow_nested))
     elif isinstance(data_type, RowType):
         for field in data_type:
-            if type(field.data_type) in [LocalZonedTimestampType, RowType]:
+            if not allow_nested and type(field.data_type) in 
[LocalZonedTimestampType, RowType]:
                 raise TypeError("%s is not supported to be used as the field 
type of RowType" %
                                 field.data_type)
-        fields = [pa.field(field.name, to_arrow_type(field.data_type), 
field.data_type._nullable)
+        fields = [pa.field(field.name, to_arrow_type(field.data_type, 
allow_nested=allow_nested),
+                           field.data_type._nullable)
                   for field in data_type]
         return pa.struct(fields)
     elif isinstance(data_type, NullType):

Reply via email to