This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new ffd8d8836df [FLINK-40190][python] Introduce PyFlink DataFrame creation
and conversion APIs (#28934)
ffd8d8836df is described below
commit ffd8d8836df6da3c6ddd92a4ba2d291017588a11
Author: Liu Liu <[email protected]>
AuthorDate: Tue Aug 18 14:31:18 2026 +0800
[FLINK-40190][python] Introduce PyFlink DataFrame creation and conversion
APIs (#28934)
---
.../docs/reference/pyflink.dataframe/creation.rst | 14 +
.../docs/reference/pyflink.dataframe/dataframe.rst | 2 +
flink-python/pyflink/dataframe/__init__.py | 13 +-
flink-python/pyflink/dataframe/convert.py | 384 ++++++++++++++++++++-
flink-python/pyflink/dataframe/dataframe.py | 45 ++-
.../pyflink/dataframe/tests/test_convert.py | 159 +++++++++
.../pyflink/dataframe/tests/test_dataframe.py | 267 ++++++++++++++
flink-python/pyflink/table/table_environment.py | 161 ++++++++-
.../pyflink/table/tests/test_pandas_conversion.py | 21 ++
.../table/tests/test_table_environment_api.py | 117 +++++++
flink-python/pyflink/table/tests/test_types.py | 34 +-
flink-python/pyflink/table/types.py | 19 +-
.../flink/table/runtime/arrow/ArrowUtils.java | 118 ++++++-
.../arrow/vectors/ArrowTimestampColumnVector.java | 9 +-
.../flink/table/utils/python/PythonTableUtils.java | 18 +-
.../flink/table/runtime/arrow/ArrowUtilsTest.java | 159 +++++++++
16 files changed, 1507 insertions(+), 33 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/creation.rst
b/flink-python/docs/reference/pyflink.dataframe/creation.rst
index 51692dab6bc..9a4b577a12e 100644
--- a/flink-python/docs/reference/pyflink.dataframe/creation.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/creation.rst
@@ -30,6 +30,16 @@ Example::
... {"id": 2, "name": "Bob"},
... ])
>>> users = pf.from_dict({"id": [1, 2], "name": ["Alice", "Bob"]})
+ >>> import pandas as pd
+ >>> import pyarrow as pa
+ >>> pandas_users = pf.from_pandas(
+ ... pd.DataFrame({"id": [1, 2], "name": ["Alice", "Bob"]})
+ ... )
+ >>> arrow_users = pf.from_arrow(
+ ... pa.table({"id": [1, 2], "name": ["Alice", "Bob"]})
+ ... )
+ >>> table_users = pf.from_table(users.to_table())
+ >>> identifiers = pf.range(5)
.. currentmodule:: pyflink.dataframe
@@ -38,3 +48,7 @@ Example::
from_records
from_dict
+ from_pandas
+ from_arrow
+ from_table
+ range
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 19ebb0ad9c0..29c09bee435 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -76,6 +76,8 @@ Results
:toctree: api/
DataFrame.collect
+ DataFrame.to_table
+ DataFrame.to_pandas
Expressions
-----------
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 4efe9fa1c8c..88e7ca2aad5 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -38,7 +38,14 @@ Example::
<Row(1, 'Alice', 31)>
"""
-from pyflink.dataframe.convert import from_dict, from_records
+from pyflink.dataframe.convert import (
+ from_arrow,
+ from_dict,
+ from_pandas,
+ from_records,
+ from_table,
+ range,
+)
from pyflink.dataframe.context import (
get_or_create_table_environment,
get_table_environment,
@@ -54,8 +61,12 @@ __all__ = [
"DataType",
"col",
"lit",
+ "from_arrow",
"from_dict",
+ "from_pandas",
"from_records",
+ "from_table",
+ "range",
"read_generic",
"set_table_environment",
"get_table_environment",
diff --git a/flink-python/pyflink/dataframe/convert.py
b/flink-python/pyflink/dataframe/convert.py
index 9a78fa65ee3..a7265466dd1 100644
--- a/flink-python/pyflink/dataframe/convert.py
+++ b/flink-python/pyflink/dataframe/convert.py
@@ -16,26 +16,95 @@
# limitations under the License.
################################################################################
+import builtins
from enum import Enum
from typing import (
Any,
Collection,
List,
Mapping,
+ NamedTuple,
Optional,
Sequence,
+ TYPE_CHECKING,
Tuple,
Union,
cast,
)
+if TYPE_CHECKING:
+ import pandas
+ import pyarrow
+
from pyflink.dataframe.context import get_or_create_table_environment
from pyflink.dataframe.dataframe import DataFrame
+from pyflink.table import Schema, Table
+from pyflink.table.types import (
+ _create_converter,
+ _create_type_verifier,
+ _has_nulltype,
+ _infer_schema_from_data,
+ DataTypes,
+ LocalZonedTimestampType,
+ RowField,
+ RowType,
+ TimestampType,
+ from_arrow_type,
+)
from pyflink.util.api_stability_decorators import PublicEvolving
-__all__ = ["from_dict", "from_records"]
+__all__ = [
+ "from_arrow",
+ "from_dict",
+ "from_pandas",
+ "from_records",
+ "from_table",
+ "range",
+]
_SCALAR_SEQUENCE_TYPES = (str, bytes, bytearray, memoryview)
+_BIGINT_MIN = -(1 << 63)
+_BIGINT_MAX = (1 << 63) - 1
+
+
+class _WatermarkSpec(NamedTuple):
+ column: str
+ expression: str
+
+ @classmethod
+ def parse(cls, watermark: Optional[Tuple[str, str]]) ->
Optional["_WatermarkSpec"]:
+ if watermark is None:
+ return None
+ if not isinstance(watermark, tuple) or len(watermark) != 2:
+ raise TypeError("watermark must be a tuple of (column,
expression)")
+ if any(not isinstance(value, str) or not value.strip() for value in
watermark):
+ raise TypeError(
+ "watermark column and expression must be non-empty strings"
+ )
+ return cls(*watermark)
+
+ def normalize_row_type(self, row_type: RowType) -> RowType:
+ matching_fields = [
+ field for field in row_type.fields if field.name == self.column
+ ]
+ if not matching_fields:
+ raise ValueError(
+ f"watermark column {self.column!r} is not present in data"
+ )
+
+ watermark_type = matching_fields[0].data_type
+ if not isinstance(watermark_type, (TimestampType,
LocalZonedTimestampType)):
+ raise ValueError(
+ f"watermark column {self.column!r} must have a timestamp type"
+ )
+
+ fields = []
+ for field in row_type.fields:
+ data_type = field.data_type
+ if field.name == self.column and data_type.precision != 3:
+ data_type = type(data_type)(3, data_type._nullable)
+ fields.append(RowField(field.name, data_type, field.description))
+ return RowType(fields, row_type._nullable)
class _RecordType(Enum):
@@ -120,10 +189,233 @@ def _validate_schema(schema: List[str]) -> None:
raise ValueError("schema field names must be unique")
+def _resolve_column_names(
+ input_names: Sequence[str], schema: Optional[List[str]]
+) -> List[str]:
+ column_names = list(input_names) if schema is None else schema
+ if (
+ schema is not None
+ and isinstance(schema, list)
+ and len(schema) != len(input_names)
+ ):
+ raise ValueError(
+ f"schema has {len(schema)} fields but data has "
+ f"{len(input_names)} columns"
+ )
+ _validate_schema(column_names)
+ return column_names
+
+
+def _normalize_pandas_column_name(name: Any) -> str:
+ """Normalize a pandas column label to the corresponding Arrow field
name."""
+ if isinstance(name, str):
+ return name
+ if isinstance(name, bytes):
+ return name.decode("utf-8")
+ if isinstance(name, tuple):
+ return str(tuple(_normalize_pandas_column_name(value) for value in
name))
+ return str(name)
+
+
+def _infer_schema_and_create_dataframe(
+ rows: Sequence[Sequence[Any]],
+ column_names: List[str],
+ watermark: Optional[_WatermarkSpec] = None,
+) -> DataFrame:
+ row_type = _infer_schema_from_data(rows, names=column_names)
+ if watermark is None:
+ table_schema = None
+ else:
+ row_type = watermark.normalize_row_type(row_type)
+ table_schema = (
+ Schema.new_builder()
+ .from_row_data_type(row_type)
+ .watermark(*watermark)
+ .build()
+ )
+ converter = _create_converter(row_type)
+ verify_row = _create_type_verifier(row_type)
+ sql_rows = []
+ for row in rows:
+ row = converter(row)
+ verify_row(row)
+ sql_rows.append(row_type.to_sql_type(row))
+
+ table = get_or_create_table_environment()._from_elements(
+ sql_rows, row_type, table_schema
+ )
+ return DataFrame(table)
+
+
+@PublicEvolving()
+def from_table(table: Table) -> DataFrame:
+ """
+ Create a DataFrame that wraps a PyFlink Table.
+
+ :param table: Table to wrap without copying or converting it.
+ :return: A DataFrame backed by the exact supplied Table.
+ :raises TypeError: If ``table`` is not a :class:`~pyflink.table.Table`.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> table = table_env.from_elements([(1, "Alice")], ["id", "name"])
+ >>> dataframe = pf.from_table(table)
+ >>> dataframe.to_table() is table
+ True
+
+ .. versionadded:: 2.4.0
+ """
+ if not isinstance(table, Table):
+ raise TypeError("table must be a pyflink.table.Table")
+ return DataFrame(table)
+
+
+@PublicEvolving()
+def from_pandas(
+ pdf: "pandas.DataFrame",
+ schema: Optional[List[str]] = None,
+ watermark: Optional[Tuple[str, str]] = None,
+) -> DataFrame:
+ """
+ Create a DataFrame from a pandas DataFrame.
+
+ Types are inferred from the Arrow representation of the pandas columns. An
explicit ``schema``
+ renames columns positionally and must contain exactly one unique,
non-empty name per input
+ column. Empty inputs are supported when their pandas dtypes can be
converted to Flink types.
+ Timezone-aware timestamps are represented as ``TIMESTAMP`` in the
TableEnvironment's
+ configured local timezone; timezone-naive timestamps are unchanged.
+
+ ``watermark`` declares an event-time column and its SQL watermark
expression. The selected
+ column must have a timestamp-compatible type. Its precision is normalized
to milliseconds;
+ values with finer precision are truncated to ``TIMESTAMP(3)``.
+
+ :param pdf: pandas DataFrame to convert.
+ :param schema: Optional list of positional result column names.
+ :param watermark: Optional ``(column, expression)`` watermark declaration.
+ :return: A DataFrame containing the pandas rows.
+ :raises TypeError: If the input, schema, watermark, or inferred types are
invalid.
+ :raises ValueError: If schema width or watermark column requirements are
not met.
+
+ Example::
+
+ >>> import pandas as pd
+ >>> import pyflink.dataframe as pf
+ >>> pdf = pd.DataFrame({"identifier": [1, 2], "name": ["Alice",
"Bob"]})
+ >>> dataframe = pf.from_pandas(pdf, schema=["id", "name"])
+ >>> events = pf.from_pandas(
+ ... pd.DataFrame({"ts": pd.to_datetime(["2026-01-01T00:00:00Z"])}),
+ ... watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ import pandas as pd
+
+ if not isinstance(pdf, pd.DataFrame):
+ raise TypeError(
+ f"data must be a pandas.DataFrame, but was {type(pdf).__name__}"
+ )
+
+ import pyarrow as pa
+
+ input_names = [_normalize_pandas_column_name(name) for name in pdf.columns]
+ arrow_pdf = pdf.copy(deep=False)
+ arrow_pdf.columns = [
+ f"__pyflink_dataframe_column_{index}"
+ for index in builtins.range(len(input_names))
+ ]
+ return from_arrow(
+ pa.Table.from_pandas(arrow_pdf, preserve_index=False),
+ schema=input_names if schema is None else schema,
+ watermark=watermark,
+ )
+
+
+@PublicEvolving()
+def from_arrow(
+ table: "pyarrow.Table",
+ schema: Optional[List[str]] = None,
+ watermark: Optional[Tuple[str, str]] = None,
+) -> DataFrame:
+ """
+ Create a DataFrame from a PyArrow Table without converting through pandas.
+
+ An explicit ``schema`` renames columns positionally and must contain
exactly one unique,
+ non-empty name per input column. Empty tables are supported when their
Arrow field types can be
+ converted to Flink types.
+
+ ``watermark`` declares an event-time column and its SQL watermark
expression. The selected
+ column must have a timestamp-compatible type. Its precision is normalized
to milliseconds;
+ values with finer precision are truncated to ``TIMESTAMP(3)``.
+
+ :param table: PyArrow Table to convert.
+ :param schema: Optional list of positional result column names.
+ :param watermark: Optional ``(column, expression)`` watermark declaration.
+ :return: A DataFrame containing the Arrow rows.
+ :raises TypeError: If the input, schema, watermark, or inferred types are
invalid.
+ :raises ValueError: If schema width or watermark column requirements are
not met.
+
+ Example::
+
+ >>> import pyarrow as pa
+ >>> import pyflink.dataframe as pf
+ >>> table = pa.table({"id": [1, 2], "name": ["Alice", "Bob"]})
+ >>> dataframe = pf.from_arrow(table)
+ >>> events = pf.from_arrow(
+ ... pa.table({"ts": pa.array([0], type=pa.timestamp("ms"))}),
+ ... watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ import pyarrow as pa
+
+ if not isinstance(table, pa.Table):
+ raise TypeError(
+ f"data must be a pyarrow.Table, but was {type(table).__name__}"
+ )
+ watermark_spec = _WatermarkSpec.parse(watermark)
+ names = _resolve_column_names(table.column_names, schema)
+ row_type = RowType(
+ [
+ RowField(
+ name,
+ from_arrow_type(field.type, field.nullable),
+ )
+ for name, field in zip(names, table.schema)
+ ]
+ )
+ null_field_names = [
+ field.name for field in row_type.fields if
_has_nulltype(field.data_type)
+ ]
+ if null_field_names:
+ columns = ", ".join(repr(name) for name in null_field_names)
+ raise TypeError(
+ f"Cannot infer Flink data types for columns with Arrow null types:
{columns}. "
+ "Use explicit pandas or Arrow dtypes for these columns."
+ )
+ if watermark_spec is None:
+ table_schema = None
+ else:
+ row_type = watermark_spec.normalize_row_type(row_type)
+ table_schema = (
+ Schema.new_builder()
+ .from_row_data_type(row_type)
+ .watermark(*watermark_spec)
+ .build()
+ )
+ result = get_or_create_table_environment()._from_arrow(
+ table, row_type, table_schema
+ )
+ return DataFrame(result)
+
+
@PublicEvolving()
def from_records(
data: Sequence[Union[Sequence[Any], Mapping[str, Any]]],
schema: Optional[List[str]] = None,
+ watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from row-oriented records.
@@ -137,8 +429,13 @@ def from_records(
Field types are inferred from the record values.
+ ``watermark`` declares an event-time column and its SQL watermark
expression. The selected
+ column must have a timestamp-compatible type. Its precision is normalized
to milliseconds;
+ values with finer precision are truncated to ``TIMESTAMP(3)`` or
``TIMESTAMP_LTZ(3)``.
+
:param data: Non-empty sequence of mapping or sequence records.
:param schema: Optional non-empty list of field names.
+ :param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the records.
:raises TypeError: If a record or schema has an invalid type.
:raises ValueError: If data or schema is empty, schema field names are
invalid, a required
@@ -163,6 +460,11 @@ def from_records(
>>> selected_users = pf.from_records(
... [User(1, "Alice")], schema=["name", "id"]
... )
+ >>> from datetime import datetime
+ >>> events = pf.from_records(
+ ... [{"id": 1, "ts": datetime(2026, 1, 1)}],
+ ... watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ ... )
.. versionadded:: 2.4.0
"""
@@ -172,6 +474,7 @@ def from_records(
)
if not data:
raise ValueError("data must not be empty")
+ watermark_spec = _WatermarkSpec.parse(watermark)
first_record = data[0]
try:
@@ -204,14 +507,14 @@ def from_records(
raise ValueError(f"invalid record at index {index}") from error
rows.append(row)
- return DataFrame(
- get_or_create_table_environment().from_elements(rows, schema)
- )
+ return _infer_schema_and_create_dataframe(rows, schema, watermark_spec)
@PublicEvolving()
def from_dict(
- data: Mapping[str, Sequence[Any]], schema: Optional[List[str]] = None
+ data: Mapping[str, Sequence[Any]],
+ schema: Optional[List[str]] = None,
+ watermark: Optional[Tuple[str, str]] = None,
) -> DataFrame:
"""
Create a DataFrame from a column-oriented dictionary.
@@ -219,8 +522,13 @@ def from_dict(
All selected columns must contain the same non-zero number of values.
``schema`` can select a
subset of columns and controls their order. If omitted, dictionary
insertion order is used.
+ ``watermark`` declares an event-time column and its SQL watermark
expression. The selected
+ column must have a timestamp-compatible type. Its precision is normalized
to milliseconds;
+ values with finer precision are truncated to ``TIMESTAMP(3)`` or
``TIMESTAMP_LTZ(3)``.
+
:param data: Non-empty mapping of column names to value sequences.
:param schema: Optional non-empty list of selected column names.
+ :param watermark: Optional ``(column, expression)`` watermark declaration.
:return: A DataFrame containing the selected columns.
:raises TypeError: If ``data`` is not a mapping, or the selected schema or
a selected column
value has an invalid type.
@@ -229,11 +537,16 @@ def from_dict(
Example::
+ >>> from datetime import datetime
>>> import pyflink.dataframe as pf
>>> users = pf.from_dict(
... {"name": ["Alice", "Bob"], "id": [1, 2]},
... schema=["id", "name"],
... )
+ >>> events = pf.from_dict(
+ ... {"id": [1], "ts": [datetime(2026, 1, 1)]},
+ ... watermark=("ts", "ts - INTERVAL '5' SECOND"),
+ ... )
.. versionadded:: 2.4.0
"""
@@ -241,6 +554,7 @@ def from_dict(
raise TypeError("data must be a mapping")
if not data:
raise ValueError("data must not be empty")
+ watermark_spec = _WatermarkSpec.parse(watermark)
if schema is None:
schema = list(data.keys())
_validate_schema(schema)
@@ -263,8 +577,60 @@ def from_dict(
raise ValueError("data must contain at least one row")
rows = [
tuple(data[name][row_index] for name in schema)
- for row_index in range(row_count)
+ for row_index in builtins.range(row_count)
]
- return DataFrame(
- get_or_create_table_environment().from_elements(rows, schema)
- )
+ return _infer_schema_and_create_dataframe(rows, schema, watermark_spec)
+
+
+@PublicEvolving()
+def range(start_or_end: int, end: Optional[int] = None, step: int = 1) ->
DataFrame:
+ """
+ Create a DataFrame containing an integer range in one ``id`` column.
+
+ The arguments follow Python's built-in :func:`range` semantics. The result
always has an
+ ``id BIGINT`` column, including when the requested range is empty.
+
+ :param start_or_end: End value when ``end`` is omitted, otherwise the
start value.
+ :param end: Optional exclusive end value.
+ :param step: Distance between adjacent values; must not be zero.
+ :return: A DataFrame with one ``id`` column.
+ :raises TypeError: If an argument is not an integer.
+ :raises ValueError: If ``step`` is zero or the range contains values
outside the signed
+ ``BIGINT`` bounds.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> identifiers = pf.range(1, 6, 2)
+ >>> identifiers.collect()
+ [<Row(1)>, <Row(3)>, <Row(5)>]
+
+ .. versionadded:: 2.4.0
+ """
+ if not isinstance(start_or_end, int):
+ raise TypeError("start_or_end must be an integer")
+ if end is not None and not isinstance(end, int):
+ raise TypeError("end must be an integer")
+ if not isinstance(step, int):
+ raise TypeError("step must be an integer")
+ if step == 0:
+ raise ValueError("step must not be zero")
+
+ if end is None:
+ start = 0
+ stop = start_or_end
+ else:
+ start = start_or_end
+ stop = end
+ values = builtins.range(start, stop, step)
+ has_values = start < stop if step > 0 else start > stop
+ if has_values and not (
+ _BIGINT_MIN <= values[0] <= _BIGINT_MAX
+ and _BIGINT_MIN <= values[-1] <= _BIGINT_MAX
+ ):
+ raise ValueError("range values must fit in signed BIGINT")
+
+ row_type = DataTypes.ROW([DataTypes.FIELD("id", DataTypes.BIGINT())])
+ sql_rows = [row_type.to_sql_type((value,)) for value in values]
+ table = get_or_create_table_environment()._from_elements(sql_rows,
row_type)
+ return DataFrame(table)
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index ba12d06399f..35f03af35f8 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -16,7 +16,10 @@
# limitations under the License.
################################################################################
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union, overload
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple,
Union, overload
+
+if TYPE_CHECKING:
+ import pandas
from pyflink.common import Row
from pyflink.dataframe.datatype import DataType
@@ -434,6 +437,46 @@ class DataFrame:
with self._table.execute().collect() as rows:
return list(rows)
+ @PublicEvolving()
+ def to_table(self) -> Table:
+ """
+ Return the underlying PyFlink Table without copying or converting it.
+
+ This method does not trigger job execution.
+
+ :return: The exact Table wrapped by this DataFrame.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> table = table_env.from_elements([(1,)], ["id"])
+ >>> dataframe = pf.from_table(table)
+ >>> dataframe.to_table() is table
+ True
+
+ .. versionadded:: 2.4.0
+ """
+ return self._table
+
+ @PublicEvolving()
+ def to_pandas(self) -> "pandas.DataFrame":
+ """
+ Execute this DataFrame and collect its rows into a pandas DataFrame.
+
+ All results are transferred to the client and must fit in client
memory.
+
+ :return: A pandas DataFrame containing all result rows.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> dataframe = pf.from_records([{"id": 1}, {"id": 2}])
+ >>> pdf = dataframe.to_pandas()
+
+ .. versionadded:: 2.4.0
+ """
+ return self._table.to_pandas()
+
# ======================== I/O ========================
@PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/tests/test_convert.py
b/flink-python/pyflink/dataframe/tests/test_convert.py
index f08ede67ab7..a204f7a6611 100644
--- a/flink-python/pyflink/dataframe/tests/test_convert.py
+++ b/flink-python/pyflink/dataframe/tests/test_convert.py
@@ -17,9 +17,15 @@
################################################################################
import unittest
+from datetime import datetime
+from unittest.mock import Mock, patch
from typing import NamedTuple
+import pandas as pd
+import pyarrow as pa
import pyflink.dataframe as pf
+import pyflink.dataframe.convert as dataframe_convert
+from pyflink.table.types import BigIntType, RowType
class _Point(NamedTuple):
@@ -224,5 +230,158 @@ class FromDictTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "schema field names must be
unique"):
pf.from_dict({"id": [1]}, schema=["id", "id"])
+
+class CreationValidationTests(unittest.TestCase):
+ def test_parses_watermark_into_semantic_specification(self):
+ watermark = dataframe_convert._WatermarkSpec.parse(
+ ("ts", "ts - INTERVAL '5' SECOND")
+ )
+
+ self.assertEqual(watermark.column, "ts")
+ self.assertEqual(watermark.expression, "ts - INTERVAL '5' SECOND")
+
+ def test_watermark_spec_unpacks_column_before_expression(self):
+ watermark = dataframe_convert._WatermarkSpec(
+ "ts", "ts - INTERVAL '5' SECOND"
+ )
+
+ self.assertEqual(tuple(watermark), ("ts", "ts - INTERVAL '5' SECOND"))
+
+ def test_rejects_invalid_watermarks(self):
+ invalid_watermarks = [
+ ("ts", "watermark must be a tuple"),
+ (("ts",), "watermark must be a tuple"),
+ (("ts", "ts", "extra"), "watermark must be a tuple"),
+ (("", "ts"), "must be non-empty strings"),
+ (("ts", ""), "must be non-empty strings"),
+ ((1, "ts"), "must be non-empty strings"),
+ ]
+ for watermark, message in invalid_watermarks:
+ with self.subTest(watermark=watermark):
+ with self.assertRaisesRegex(TypeError, message):
+ pf.from_dict(
+ {"ts": [datetime(2026, 1, 1)]}, watermark=watermark
+ )
+
+ def test_pandas_and_arrow_reject_invalid_positional_schemas(self):
+ inputs = [
+ (pf.from_pandas, pd.DataFrame({"left": [1], "right": [2]})),
+ (pf.from_arrow, pa.table({"left": [1], "right": [2]})),
+ ]
+ invalid_schemas = [
+ ("names", TypeError, "schema must be a list of strings"),
+ (["left", 2], TypeError, "schema must be a list of strings"),
+ (["left"], ValueError, "schema has 1 fields but data has 2
columns"),
+ (["left", "left"], ValueError, "schema field names must be
unique"),
+ ]
+ for creator, data in inputs:
+ for schema, error_type, message in invalid_schemas:
+ with self.subTest(creator=creator.__name__, schema=schema):
+ with self.assertRaisesRegex(error_type, message):
+ creator(data, schema=schema)
+
+ def test_pandas_rejects_duplicate_columns_without_schema(self):
+ pdf = pd.DataFrame([[1, 2]], columns=["value", "value"])
+
+ with self.assertRaisesRegex(ValueError, "schema field names must be
unique"):
+ pf.from_pandas(pdf)
+
+ def test_rejects_columnar_fields_containing_null_type(self):
+ inputs = [
+ (
+ lambda: pf.from_pandas(pd.DataFrame({"value": [None]})),
+ "columns with Arrow null types: 'value'",
+ ),
+ (
+ lambda: pf.from_arrow(
+ pa.table({"left": pa.nulls(1), "right": pa.nulls(1)})
+ ),
+ "columns with Arrow null types: 'left', 'right'",
+ ),
+ (
+ lambda: pf.from_arrow(
+ pa.table(
+ {
+ "value": pa.array(
+ [[None]], type=pa.list_(pa.null())
+ )
+ }
+ )
+ ),
+ "columns with Arrow null types: 'value'",
+ ),
+ ]
+ for creator, message in inputs:
+ with self.subTest(creator=creator):
+ with self.assertRaisesRegex(TypeError, message):
+ creator()
+
+ def test_rejects_invalid_table_and_columnar_inputs(self):
+ invalid_inputs = [
+ (pf.from_table, object(), "pyflink.table.Table"),
+ (pf.from_pandas, object(), "pandas.DataFrame"),
+ (pf.from_arrow, object(), "pyarrow.Table"),
+ ]
+ for creator, data, message in invalid_inputs:
+ with self.subTest(creator=creator.__name__):
+ with self.assertRaisesRegex(TypeError, message):
+ creator(data)
+
+
+class RangeTests(unittest.TestCase):
+ def test_matches_python_range_and_preserves_bigint_schema_when_empty(self):
+ cases = [
+ ((4,), [(0,), (1,), (2,), (3,)]),
+ ((4, -1, -2), [(4,), (2,), (0,)]),
+ ((2, 2), []),
+ ((2**63 - 1, 2**63), [(2**63 - 1,)]),
+ ((-(2**63), -(2**63) + 1), [(-(2**63),)]),
+ ]
+ for arguments, expected_rows in cases:
+ table_environment = Mock()
+ table_environment._from_elements.return_value = object()
+ with self.subTest(arguments=arguments), patch(
+ "pyflink.dataframe.convert.get_or_create_table_environment",
+ return_value=table_environment,
+ ):
+ pf.range(*arguments)
+
+ rows, row_type =
table_environment._from_elements.call_args.args[:2]
+ self.assertEqual([row[1:] for row in rows], expected_rows)
+ self.assertIsInstance(row_type, RowType)
+ self.assertEqual(row_type.field_names(), ["id"])
+ self.assertIsInstance(row_type.field_types()[0], BigIntType)
+
+ def test_rejects_values_outside_bigint_bounds(self):
+ invalid_ranges = [
+ (2**63, 2**63 + 1),
+ (2**63 - 1, 2**63 + 2),
+ (-(2**63) - 1, -(2**63) - 2, -1),
+ (-(2**63), -(2**63) - 3, -1),
+ ]
+ table_environment = Mock()
+ for arguments in invalid_ranges:
+ with self.subTest(arguments=arguments), patch(
+ "pyflink.dataframe.convert.get_or_create_table_environment",
+ return_value=table_environment,
+ ) as get_table_environment:
+ with self.assertRaisesRegex(
+ ValueError, "range values must fit in signed BIGINT"
+ ):
+ pf.range(*arguments)
+ get_table_environment.assert_not_called()
+
+ def test_rejects_invalid_arguments(self):
+ invalid_arguments = [
+ ((1.5,), TypeError, "start_or_end must be an integer"),
+ ((0, 1.5), TypeError, "end must be an integer"),
+ ((0, 1, 1.5), TypeError, "step must be an integer"),
+ ((0, 1, 0), ValueError, "step must not be zero"),
+ ]
+ for arguments, error_type, message in invalid_arguments:
+ with self.subTest(arguments=arguments):
+ with self.assertRaisesRegex(error_type, message):
+ pf.range(*arguments)
+
if __name__ == "__main__":
unittest.main()
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index 8f5b7620cab..a3d095f2ab8 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -17,8 +17,11 @@
################################################################################
import unittest
+from datetime import datetime, timezone
from typing import NamedTuple
+import pandas as pd
+import pyarrow as pa
import pyflink.dataframe as pf
from py4j.protocol import Py4JJavaError
from pyflink.common import Row
@@ -28,6 +31,7 @@ from pyflink.table import (
TableEnvironment,
)
from pyflink.table.expression import Expression
+from pyflink.table.types import LocalZonedTimestampType, TimestampType
from pyflink.testing.test_case_utils import (
PyFlinkDataFrameUTTestCase,
PyFlinkITTestCase,
@@ -77,6 +81,17 @@ class _Table:
return _TableResult(self._iterator)
+class _PandasTable:
+ def __init__(self, result=None, error=None):
+ self._result = result
+ self._error = error
+
+ def to_pandas(self):
+ if self._error is not None:
+ raise self._error
+ return self._result
+
+
class DataFrameCollectTests(unittest.TestCase):
def test_collect_returns_all_rows_and_closes_iterator(self):
iterator = _CloseableIterator([Row(1, "Alice")])
@@ -95,6 +110,24 @@ class DataFrameCollectTests(unittest.TestCase):
self.assertTrue(iterator.closed)
+class DataFrameConversionTests(unittest.TestCase):
+ def test_to_table_returns_underlying_table(self):
+ table = _PandasTable()
+
+ self.assertIs(pf.DataFrame(table).to_table(), table)
+
+ def test_to_pandas_delegates_to_underlying_table(self):
+ expected = pd.DataFrame({"id": [1]})
+
+ self.assertIs(pf.DataFrame(_PandasTable(expected)).to_pandas(),
expected)
+
+ def test_to_pandas_propagates_errors(self):
+ with self.assertRaisesRegex(RuntimeError, "conversion failed"):
+ pf.DataFrame(
+ _PandasTable(error=RuntimeError("conversion failed"))
+ ).to_pandas()
+
+
class DataFrameCreationTests(PyFlinkDataFrameUTTestCase):
def test_from_dict_uses_insertion_order_without_schema(self):
dataframe = pf.from_dict({"name": ["Alice"], "id": [1]})
@@ -192,6 +225,202 @@ class DataFrameCreationTests(PyFlinkDataFrameUTTestCase):
[TableDataTypes.STRING(), TableDataTypes.BIGINT()],
)
+ def test_from_pandas_and_arrow_rename_columns_positionally(self):
+ inputs = [
+ pd.DataFrame(
+ {"original_id": [1], "original_ts": [datetime(2026, 1, 1)]}
+ ),
+ pa.table(
+ {
+ "original_id": pa.array([1], type=pa.int64()),
+ "original_ts": pa.array(
+ [datetime(2026, 1, 1)], type=pa.timestamp("us")
+ ),
+ }
+ ),
+ ]
+ for creator, data in zip((pf.from_pandas, pf.from_arrow), inputs):
+ with self.subTest(creator=creator.__name__):
+ dataframe = creator(data, schema=["id", "ts"])
+ self.assert_dataframe_schema(dataframe, ["id", "ts"])
+
+ duplicate_pdf = pd.DataFrame(
+ [[1, "Alice"], [2, "Bob"]], columns=["value", "value"]
+ )
+ dataframe = pf.from_pandas(duplicate_pdf, schema=["id", "name"])
+ self.assert_dataframe_schema(
+ dataframe,
+ ["id", "name"],
+ [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+ )
+
+ def test_from_pandas_normalizes_inferred_column_names(self):
+ dataframe = pf.from_pandas(pd.DataFrame([[1, 2]]))
+
+ self.assert_dataframe_schema(
+ dataframe,
+ ["0", "1"],
+ [TableDataTypes.BIGINT(), TableDataTypes.BIGINT()],
+ )
+
+ def test_empty_pandas_and_arrow_inputs_preserve_inferred_types(self):
+ inputs = [
+ (
+ pf.from_pandas,
+ pd.DataFrame({"id": pd.Series([], dtype="int64")}),
+ ),
+ (
+ pf.from_arrow,
+ pa.table({"id": pa.array([], type=pa.int64())}),
+ ),
+ ]
+ for creator, data in inputs:
+ with self.subTest(creator=creator.__name__):
+ dataframe = creator(data)
+ self.assert_dataframe_schema(
+ dataframe,
+ ["id"],
+ [TableDataTypes.BIGINT()],
+ )
+
+ def test_from_pandas_schema_inference(self):
+ pdf = pd.DataFrame(
+ {
+ "original_id": [1.0, None],
+ "original_name": ["Alice", None],
+ "original_ts": pd.Series(
+ pd.to_datetime(
+ ["2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z"]
+ )
+ ),
+ }
+ )
+ names = ["id", "name", "ts"]
+
+ dataframe_schema = (
+ pf.from_pandas(pdf, schema=names).to_table().get_resolved_schema()
+ )
+ table_schema = self.t_env.from_pandas(
+ pdf, schema=names
+ ).get_resolved_schema()
+
+ self.assertEqual(
+ table_schema.get_column_names(),
dataframe_schema.get_column_names()
+ )
+ self.assertEqual(
+ table_schema.get_column_data_types(),
+ dataframe_schema.get_column_data_types(),
+ )
+
+ empty_pdf = pd.DataFrame(
+ {
+ "original_id": pd.Series([], dtype="float64"),
+ "original_name": pd.Series([], dtype="string"),
+ "original_ts": pd.Series([], dtype="datetime64[ns, UTC]"),
+ }
+ )
+ empty_schema = pf.from_pandas(
+ empty_pdf, schema=names
+ ).to_table().get_resolved_schema()
+ self.assertEqual(
+ dataframe_schema.get_column_names(),
empty_schema.get_column_names()
+ )
+ self.assertEqual(
+ dataframe_schema.get_column_data_types(),
+ empty_schema.get_column_data_types(),
+ )
+
+ def test_timezone_aware_creation_supports_java_timezone_ids(self):
+ original_timezone = self.t_env.get_config().get_local_timezone()
+ self.t_env.get_config().set_local_timezone("SystemV/PST8PDT")
+ try:
+ dataframe = pf.from_arrow(
+ pa.table({
+ "ts": pa.array([0], type=pa.timestamp("ms", tz="UTC")),
+ })
+ )
+ self.assert_dataframe_schema(
+ dataframe,
+ ["ts"],
+ [TableDataTypes.TIMESTAMP(3)],
+ )
+ finally:
+ self.t_env.get_config().set_local_timezone(original_timezone)
+
+ def test_creators_attach_and_normalize_watermarks(self):
+ timestamp = datetime(2026, 1, 1, 0, 0, 0, 123456)
+ creators = [
+ (
+ lambda: pf.from_dict(
+ {"ts": [timestamp]},
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ LocalZonedTimestampType,
+ ),
+ (
+ lambda: pf.from_records(
+ [{"ts": timestamp}],
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ LocalZonedTimestampType,
+ ),
+ (
+ lambda: pf.from_pandas(
+ pd.DataFrame(
+ {
+ "ts": pd.Series(
+ [timestamp.replace(tzinfo=timezone.utc)],
+ dtype="datetime64[us, UTC]",
+ )
+ }
+ ),
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ TimestampType,
+ ),
+ (
+ lambda: pf.from_arrow(
+ pa.table(
+ {
+ "ts": pa.array(
+ [timestamp.replace(tzinfo=timezone.utc)],
+ type=pa.timestamp("us", tz="UTC"),
+ )
+ }
+ ),
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ TimestampType,
+ ),
+ ]
+ for creator, expected_type in creators:
+ with self.subTest(creator=creator):
+ resolved_schema = creator().to_table().get_resolved_schema()
+ timestamp_type = resolved_schema.get_column_data_types()[0]
+ self.assertIsInstance(timestamp_type, expected_type)
+ self.assertEqual(timestamp_type.precision, 3)
+ watermark_specs = resolved_schema.get_watermark_specs()
+ self.assertEqual(len(watermark_specs), 1)
+ self.assertEqual(watermark_specs[0].get_rowtime_attribute(),
"ts")
+
+ def test_watermark_requires_existing_timestamp_column(self):
+ invalid_watermarks = [
+ (("missing", "ts"), "watermark column 'missing' is not present"),
+ (("id", "id"), "watermark column 'id' must have a timestamp type"),
+ ]
+ for watermark, message in invalid_watermarks:
+ with self.subTest(watermark=watermark):
+ with self.assertRaisesRegex(ValueError, message):
+ pf.from_records(
+ [{"id": 1, "ts": datetime(2026, 1, 1)}],
+ watermark=watermark,
+ )
+
+ def test_from_table_and_to_table_preserve_identity(self):
+ table = self.t_env.from_elements([(1,)], ["id"])
+
+ self.assertIs(pf.from_table(table).to_table(), table)
+
class DataFrameSelectTests(PyFlinkDataFrameUTTestCase):
def setUp(self):
@@ -572,6 +801,44 @@ class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
[Row(1, "Alice"), Row(2, "Bob")],
)
+ def test_pandas_to_pandas_round_trip(self):
+ original_timezone = self.t_env.get_config().get_local_timezone()
+ self.t_env.get_config().set_local_timezone("America/New_York")
+ try:
+ first_fold = pd.Timestamp("2026-11-01T05:30:00.123Z")
+ second_fold = pd.Timestamp("2026-11-01T06:30:00.123Z")
+ pdf = pd.DataFrame(
+ {
+ "id": [0, 1, 2, 3],
+ "ts": pd.Series(
+ [None, first_fold, second_fold, None],
+ dtype="datetime64[ms, UTC]",
+ ),
+ }
+ )
+
+ result = (
+ pf.from_pandas(pdf)
+ .filter(pf.col("id") > 0)
+ .with_column("id_plus_one", pf.col("id") + 1)
+ .select("id", "id_plus_one", "ts")
+ .to_pandas()
+ .sort_values("id")
+ .reset_index(drop=True)
+ )
+
+ self.assertEqual(list(result.columns), ["id", "id_plus_one", "ts"])
+ self.assertEqual(result["id"].tolist(), [1, 2, 3])
+ self.assertEqual(result["id_plus_one"].tolist(), [2, 3, 4])
+ self.assertEqual(result["ts"].isna().tolist(), [False, False,
True])
+ local_fold = pd.Timestamp("2026-11-01T01:30:00.123")
+ self.assertEqual(
+ result["ts"].tolist()[:2],
+ [local_fold, local_fold],
+ )
+ finally:
+ self.t_env.get_config().set_local_timezone(original_timezone)
+
def test_basic_functionality(self):
df = pf.from_dict(
{
diff --git a/flink-python/pyflink/table/table_environment.py
b/flink-python/pyflink/table/table_environment.py
index f0e4bedba17..a1d90c41db1 100644
--- a/flink-python/pyflink/table/table_environment.py
+++ b/flink-python/pyflink/table/table_environment.py
@@ -45,9 +45,9 @@ from pyflink.table.statement_set import StatementSet
from pyflink.table.table_config import TableConfig
from pyflink.table.table_descriptor import TableDescriptor
from pyflink.table.table_result import TableResult
-from pyflink.table.types import _create_type_verifier, RowType, DataType, \
+from pyflink.table.types import _create_type_verifier, RowType, DataType,
TimestampType, \
_infer_schema_from_data, _create_converter, from_arrow_type, RowField,
create_arrow_schema, \
- _to_java_data_type
+ to_arrow_type, _to_java_data_type
from pyflink.table.udf import UserDefinedFunctionWrapper, AggregateFunction,
udaf, \
udtaf, TableAggregateFunction
from pyflink.table.utils import to_expression_jarray
@@ -61,6 +61,64 @@ __all__ = [
]
+_TIMESTAMP_UNITS_PER_SECOND = {
+ 's': 1,
+ 'ms': 1_000,
+ 'us': 1_000_000,
+ 'ns': 1_000_000_000,
+}
+
+
+def _cast_arrow_timestamp(array, target_type):
+ import pyarrow as pa
+ import pyarrow.compute as pc
+
+ if isinstance(array, pa.ChunkedArray):
+ chunks = [
+ _cast_arrow_timestamp(chunk, target_type) for chunk in array.chunks
+ ]
+ return pa.chunked_array(chunks, type=target_type)
+ if array.type == target_type:
+ return array
+
+ source_units = _TIMESTAMP_UNITS_PER_SECOND[array.type.unit]
+ target_units = _TIMESTAMP_UNITS_PER_SECOND[target_type.unit]
+ values = array.view(pa.int64())
+ if source_units > target_units:
+ divisor = source_units // target_units
+ quotient = pc.divide_checked(values, divisor)
+ remainder = pc.subtract_checked(
+ values, pc.multiply_checked(quotient, divisor))
+ # Arrow integer division truncates toward zero. Timestamp precision
reduction
+ # needs floor division to preserve the local date and time before the
epoch.
+ values = pc.subtract_checked(
+ quotient,
+ pc.cast(pc.less(remainder, 0), pa.int64()),
+ )
+ elif source_units < target_units:
+ values = pc.multiply_checked(values, target_units // source_units)
+ return values.view(target_type)
+
+
+def _contains_timezone_aware_timestamp(arrow_type):
+ import pyarrow as pa
+
+ if pa.types.is_timestamp(arrow_type):
+ return arrow_type.tz is not None
+ if pa.types.is_struct(arrow_type):
+ return any(
+ _contains_timezone_aware_timestamp(field.type) for field in
arrow_type
+ )
+ if pa.types.is_list(arrow_type):
+ return _contains_timezone_aware_timestamp(arrow_type.value_type)
+ if pa.types.is_map(arrow_type):
+ return (
+ _contains_timezone_aware_timestamp(arrow_type.key_type)
+ or _contains_timezone_aware_timestamp(arrow_type.item_type)
+ )
+ return False
+
+
@PublicEvolving()
class TableEnvironment(object):
"""
@@ -1469,11 +1527,17 @@ class TableEnvironment(object):
elements = [schema.to_sql_type(element) for element in elements]
return self._from_elements(elements, schema)
- def _from_elements(self, elements: List, schema: DataType) -> Table:
+ def _from_elements(
+ self,
+ elements: List,
+ schema: DataType,
+ table_schema: Schema = None) -> Table:
"""
Creates a table from a collection of elements.
:param elements: The elements to create a table from.
+ :param schema: Data type used to serialize the elements.
+ :param table_schema: Optional declarative schema for the resulting
source table.
:return: The result :class:`~pyflink.table.Table`.
"""
# serializes to a file, and we read the file in java
@@ -1482,7 +1546,8 @@ class TableEnvironment(object):
try:
with temp_file:
serializer.serialize(elements, temp_file)
- j_schema = _to_java_data_type(schema)
+ j_schema = (table_schema._j_schema if table_schema is not None
+ else _to_java_data_type(schema))
gateway = get_gateway()
PythonTableUtils = gateway.jvm \
.org.apache.flink.table.utils.python.PythonTableUtils
@@ -1492,6 +1557,94 @@ class TableEnvironment(object):
finally:
atexit.register(lambda: os.unlink(temp_file.name))
+ def _from_arrow(
+ self,
+ table,
+ row_type: RowType,
+ table_schema: Schema = None,
+ splits_num: int = 1) -> Table:
+ """Creates a table from a PyArrow Table through the Arrow table
source."""
+ import pyarrow as pa
+
+ if not isinstance(table, pa.Table):
+ raise TypeError(f"table must be a pyarrow.Table, but was
{type(table).__name__}")
+ if isinstance(splits_num, bool) or not isinstance(splits_num, int):
+ raise TypeError("splits_num must be an integer")
+ if splits_num <= 0:
+ raise ValueError("splits_num must be greater than 0")
+
+ field_names = row_type.field_names()
+ field_types = row_type.field_types()
+ try:
+ compatible_table = table.rename_columns(field_names)
+ for index, (field, data_type) in enumerate(zip(table.schema,
field_types)):
+ source_column = compatible_table.column(index)
+ column = source_column
+ target_type = column.type
+ if isinstance(data_type, TimestampType):
+ target_type = to_arrow_type(data_type)
+ if column.type.tz is not None:
+ target_type = pa.timestamp(
+ target_type.unit, tz=column.type.tz)
+ if column.type != target_type:
+ if pa.types.is_timestamp(column.type):
+ column = _cast_arrow_timestamp(column, target_type)
+ else:
+ column = column.cast(target_type, safe=False)
+ if column is source_column:
+ continue
+ target_field = pa.field(
+ field_names[index],
+ target_type,
+ field.nullable,
+ field.metadata,
+ )
+ compatible_table = compatible_table.set_column(
+ index,
+ target_field,
+ column,
+ )
+ except (
+ pa.ArrowInvalid,
+ pa.ArrowNotImplementedError,
+ pa.ArrowTypeError,
+ TypeError,
+ ValueError,
+ ) as e:
+ raise TypeError(
+ f"Could not convert pyarrow.Table to the inferred Flink
schema: {row_type}"
+ ) from e
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with tempfile.NamedTemporaryFile(delete=False, dir=temp_dir) as
temp_file:
+ with pa.ipc.new_stream(temp_file, compatible_table.schema) as
writer:
+ if compatible_table.num_rows > 0:
+ max_chunksize = -(-compatible_table.num_rows //
splits_num)
+ writer.write_table(
+ compatible_table, max_chunksize=max_chunksize)
+
+ jvm = get_gateway().jvm
+ if table_schema is None:
+ source_schema = _to_java_data_type(row_type).notNull()
+ source_schema = source_schema.bridgedTo(
+ load_java_class('org.apache.flink.table.data.RowData'))
+ else:
+ source_schema = table_schema._j_schema
+ create_descriptor =
jvm.org.apache.flink.table.runtime.arrow.ArrowUtils \
+ .createArrowTableSourceDesc
+ if any(
+ _contains_timezone_aware_timestamp(field.type)
+ for field in compatible_table.schema
+ ):
+ descriptor = create_descriptor(
+ source_schema,
+ temp_file.name,
+ self.get_config().get_local_timezone(),
+ )
+ else:
+ descriptor = create_descriptor(source_schema, temp_file.name)
+ return Table(getattr(self._j_tenv, "from")(descriptor), self)
+
def from_pandas(self, pdf: 'pandas.DataFrame',
schema: Union[RowType, List[str], Tuple[str],
List[DataType],
Tuple[DataType]] = None,
diff --git a/flink-python/pyflink/table/tests/test_pandas_conversion.py
b/flink-python/pyflink/table/tests/test_pandas_conversion.py
index 9cc0f8ccdf6..6ebce2f1005 100644
--- a/flink-python/pyflink/table/tests/test_pandas_conversion.py
+++ b/flink-python/pyflink/table/tests/test_pandas_conversion.py
@@ -113,6 +113,27 @@ class PandasConversionTests(PandasConversionTestBase):
table = self.t_env.from_pandas(self.pdf, schema=tuple(new_types))
self.assertEqual(new_types, table.get_schema().get_field_data_types())
+ def test_from_pandas_with_nested_timezone_aware_timestamps(self):
+ import pandas as pd
+
+ timestamp = pd.Timestamp("2026-01-01T00:00:00Z")
+ pdf = pd.DataFrame(
+ {
+ "payload": [{"ts": timestamp}],
+ "timestamps": [[timestamp]],
+ }
+ )
+
+ table = self.t_env.from_pandas(pdf)
+
+ self.assertEqual(
+ [
+ DataTypes.ROW([DataTypes.FIELD("ts", DataTypes.TIMESTAMP(6))]),
+ DataTypes.ARRAY(DataTypes.TIMESTAMP(6)),
+ ],
+ table.get_schema().get_field_data_types(),
+ )
+
class PandasConversionITTests(PandasConversionTestBase):
diff --git a/flink-python/pyflink/table/tests/test_table_environment_api.py
b/flink-python/pyflink/table/tests/test_table_environment_api.py
index 505d1dbd26f..492c20bb8ff 100644
--- a/flink-python/pyflink/table/tests/test_table_environment_api.py
+++ b/flink-python/pyflink/table/tests/test_table_environment_api.py
@@ -17,9 +17,13 @@
################################################################################
import datetime
import decimal
+import os
import sys
+import tempfile
import unittest
+from unittest.mock import MagicMock, patch
+import pyarrow as pa
from py4j.protocol import Py4JJavaError
from typing import Iterable
@@ -38,6 +42,7 @@ from pyflink.table.catalog import ObjectPath,
CatalogBaseTable, CatalogDescripto
from pyflink.table.explain_detail import ExplainDetail
from pyflink.table.expressions import col, source_watermark
from pyflink.table.table_descriptor import TableDescriptor
+from pyflink.table.table_environment import TableEnvironment
from pyflink.table.types import RowType, Row, UserDefinedType
from pyflink.table.udf import udf
from pyflink.testing import source_sink_utils
@@ -46,6 +51,118 @@ from pyflink.testing.test_case_utils import
(PyFlinkStreamTableTestCase, PyFlink
from pyflink.util.java_utils import get_j_env_configuration
+class ArrowTimestampConversionTests(unittest.TestCase):
+
+ def _write_from_arrow(
+ self,
+ table,
+ row_type,
+ splits_num=1):
+ table_environment = object.__new__(TableEnvironment)
+ table_environment._j_tenv = MagicMock()
+ setattr(table_environment._j_tenv, 'from',
MagicMock(return_value=object()))
+
+ batches = []
+ arrow_schemas = []
+ gateway = MagicMock()
+ descriptor_factory = gateway.jvm.org.apache.flink.table.runtime.arrow \
+ .ArrowUtils.createArrowTableSourceDesc
+
+ def capture_batches(source_schema, file_name, *args):
+ with pa.ipc.open_stream(file_name) as reader:
+ arrow_schemas.append(reader.schema)
+ batches.extend(reader)
+ return object()
+
+ descriptor_factory.side_effect = capture_batches
+ with patch(
+ 'pyflink.table.table_environment.get_gateway', return_value=gateway
+ ):
+ table_environment._from_arrow(
+ table,
+ row_type,
+ table_schema=MagicMock(),
+ splits_num=splits_num,
+ )
+ return batches, arrow_schemas[0]
+
+ def test_from_arrow_preserves_existing_batches(self):
+ table = pa.table({
+ 'id': pa.chunked_array(
+ [pa.array([value], type=pa.int64()) for value in range(4)]
+ )
+ })
+ row_type = DataTypes.ROW([DataTypes.FIELD('id', DataTypes.BIGINT())])
+ batches, _ = self._write_from_arrow(
+ table, row_type, splits_num=2)
+
+ self.assertEqual([batch.num_rows for batch in batches], [1, 1, 1, 1])
+
+ def test_from_arrow_handles_split_boundaries(self):
+ row_type = DataTypes.ROW([DataTypes.FIELD('id', DataTypes.BIGINT())])
+ cases = [
+ (pa.table({'id': range(5)}), 2, [3, 2]),
+ (pa.table({'id': range(2)}), 5, [1, 1]),
+ ]
+ for table, splits_num, expected_batch_sizes in cases:
+ with self.subTest(splits_num=splits_num, rows=table.num_rows):
+ batches, _ = self._write_from_arrow(
+ table, row_type, splits_num=splits_num)
+ self.assertEqual(
+ [batch.num_rows for batch in batches],
expected_batch_sizes)
+
+ def test_from_arrow_rejects_invalid_split_counts(self):
+ table = pa.table({'id': [1]})
+ row_type = DataTypes.ROW([DataTypes.FIELD('id', DataTypes.BIGINT())])
+ invalid_splits = [
+ (True, TypeError, 'must be an integer'),
+ (1.5, TypeError, 'must be an integer'),
+ (0, ValueError, 'must be greater than 0'),
+ (-1, ValueError, 'must be greater than 0'),
+ ]
+ for splits_num, error_type, message in invalid_splits:
+ with self.subTest(splits_num=splits_num):
+ with self.assertRaisesRegex(error_type, message):
+ self._write_from_arrow(table, row_type,
splits_num=splits_num)
+
+ def test_from_arrow_writes_schema_only_empty_stream(self):
+ table = pa.table({
+ 'id': pa.array([], type=pa.int64()),
+ })
+ row_type = DataTypes.ROW([DataTypes.FIELD('id', DataTypes.BIGINT())])
+
+ batches, arrow_schema = self._write_from_arrow(
+ table, row_type, splits_num=5)
+
+ self.assertEqual(batches, [])
+ self.assertEqual(arrow_schema, table.schema)
+
+ def test_from_arrow_floors_pre_epoch_timestamp_precision(self):
+ table = pa.table({
+ 'ts': pa.array([-1], type=pa.timestamp('us')),
+ })
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD('ts', DataTypes.TIMESTAMP(3)),
+ ])
+
+ batches, _ = self._write_from_arrow(table, row_type)
+
+ self.assertEqual(
+ batches[0].column(0).to_pylist(),
+ [datetime.datetime.fromisoformat('1969-12-31T23:59:59.999')],
+ )
+
+ def test_from_arrow_cleans_temporary_directory(self):
+ table = pa.table({'id': [1]})
+ row_type = DataTypes.ROW([DataTypes.FIELD('id', DataTypes.BIGINT())])
+
+ with tempfile.TemporaryDirectory() as temp_root:
+ with patch('pyflink.table.table_environment.tempfile.tempdir',
temp_root):
+ self._write_from_arrow(table, row_type)
+
+ self.assertEqual(os.listdir(temp_root), [])
+
+
class TableEnvironmentTest(PyFlinkUTTestCase):
def test_set_sys_executable_for_local_mode(self):
diff --git a/flink-python/pyflink/table/tests/test_types.py
b/flink-python/pyflink/table/tests/test_types.py
index 400a3729ee9..80d08d5b1f0 100644
--- a/flink-python/pyflink/table/tests/test_types.py
+++ b/flink-python/pyflink/table/tests/test_types.py
@@ -24,6 +24,8 @@ import sys
import tempfile
import unittest
+import pyarrow as pa
+
from pyflink.pyflink_gateway_server import on_windows
from pyflink.serializers import BatchedSerializer, PickleSerializer
@@ -35,7 +37,7 @@ from pyflink.table.types import (_infer_schema_from_data,
_infer_type,
_create_type_verifier, UserDefinedType,
DataTypes, Row, RowField,
RowType, ArrayType, BigIntType, VarCharType,
MapType, DataType,
_from_java_data_type, ZonedTimestampType,
- LocalZonedTimestampType, _to_java_data_type)
+ LocalZonedTimestampType, _to_java_data_type,
from_arrow_type)
from pyflink.testing.test_case_utils import PyFlinkTestCase
@@ -126,6 +128,36 @@ class UTCOffsetTimezone(datetime.tzinfo):
return self.OFFSET
+class ArrowTypeConversionTests(unittest.TestCase):
+
+ def test_nested_field_nullability(self):
+ try:
+ arrow_map_type = pa.map_(
+ pa.string(),
+ pa.field("value", pa.int64(), nullable=False),
+ )
+ except TypeError:
+ arrow_map_type = pa.map_(pa.string(), pa.int64())
+
+ map_type = from_arrow_type(arrow_map_type)
+ self.assertFalse(map_type.key_type._nullable)
+ item_field = getattr(arrow_map_type, "item_field", None)
+ expected_item_nullable = (
+ item_field.nullable if item_field is not None else True
+ )
+ self.assertEqual(expected_item_nullable, map_type.value_type._nullable)
+
+ array_type = from_arrow_type(
+ pa.list_(pa.field("item", pa.int64(), nullable=False))
+ )
+ self.assertFalse(array_type.element_type._nullable)
+
+ row_type = from_arrow_type(
+ pa.struct([pa.field("value", pa.int64())]), nullable=False
+ )
+ self.assertFalse(row_type._nullable)
+
+
class TypesTests(PyFlinkTestCase):
def test_row_type_repr_includes_nullability(self):
diff --git a/flink-python/pyflink/table/types.py
b/flink-python/pyflink/table/types.py
index 316bb44b55e..16d92c3c1ff 100644
--- a/flink-python/pyflink/table/types.py
+++ b/flink-python/pyflink/table/types.py
@@ -2272,21 +2272,28 @@ def from_arrow_type(arrow_type, nullable: bool = True)
-> DataType:
else:
return TimestampType(9, nullable)
elif types.is_map(arrow_type):
- return MapType(from_arrow_type(arrow_type.key_type),
- from_arrow_type(arrow_type.item_type),
- nullable)
+ item_field = getattr(arrow_type, 'item_field', None)
+ item_nullable = item_field.nullable if item_field is not None else True
+ key_type = from_arrow_type(arrow_type.key_type, nullable=False)
+ value_type = from_arrow_type(arrow_type.item_type,
nullable=item_nullable)
+ return MapType(key_type, value_type, nullable)
elif types.is_list(arrow_type):
- return ArrayType(from_arrow_type(arrow_type.value_type), nullable)
+ return ArrayType(
+ from_arrow_type(
+ arrow_type.value_type, nullable=arrow_type.value_field.nullable
+ ),
+ nullable,
+ )
elif types.is_struct(arrow_type):
if any(types.is_struct(field.type) for field in arrow_type):
raise TypeError("Nested RowType is not supported in conversion
from Arrow: " +
str(arrow_type))
return RowType([RowField(field.name, from_arrow_type(field.type,
field.nullable))
- for field in arrow_type])
+ for field in arrow_type], nullable)
elif types.is_null(arrow_type):
return NullType()
else:
- raise TypeError("Unsupported data type to convert to Arrow type: " +
str(dt))
+ raise TypeError("Unsupported data type to convert from Arrow type: " +
str(arrow_type))
def to_arrow_type(data_type: DataType):
diff --git
a/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/ArrowUtils.java
b/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/ArrowUtils.java
index d8b9dcdf772..87f70e8a0c2 100644
---
a/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/ArrowUtils.java
+++
b/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/ArrowUtils.java
@@ -128,12 +128,15 @@ import org.apache.arrow.vector.ValueVector;
import org.apache.arrow.vector.VarBinaryVector;
import org.apache.arrow.vector.VarCharVector;
import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.VectorUnloader;
import org.apache.arrow.vector.complex.ListVector;
import org.apache.arrow.vector.complex.MapVector;
import org.apache.arrow.vector.complex.StructVector;
+import org.apache.arrow.vector.ipc.ArrowStreamReader;
import org.apache.arrow.vector.ipc.ArrowStreamWriter;
import org.apache.arrow.vector.ipc.ReadChannel;
import org.apache.arrow.vector.ipc.WriteChannel;
+import org.apache.arrow.vector.ipc.message.ArrowRecordBatch;
import org.apache.arrow.vector.ipc.message.MessageMetadataResult;
import org.apache.arrow.vector.ipc.message.MessageSerializer;
import org.apache.arrow.vector.types.DateUnit;
@@ -154,6 +157,9 @@ import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.zone.ZoneRules;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -481,6 +487,15 @@ public final class ArrowUtils {
}
public static TableDescriptor createArrowTableSourceDesc(DataType
dataType, String fileName) {
+ return createArrowTableSourceDesc(createTableSchema(dataType),
fileName);
+ }
+
+ public static TableDescriptor createArrowTableSourceDesc(
+ DataType dataType, String fileName, String localTimeZoneId) {
+ return createArrowTableSourceDesc(createTableSchema(dataType),
fileName, localTimeZoneId);
+ }
+
+ private static org.apache.flink.table.api.Schema
createTableSchema(DataType dataType) {
List<String> fieldNames = getFieldNames(dataType);
List<DataType> fieldTypes = dataType.getChildren();
org.apache.flink.table.api.Schema.Builder schemaBuilder =
@@ -488,20 +503,109 @@ public final class ArrowUtils {
for (int i = 0; i < fieldNames.size(); i++) {
schemaBuilder.column(fieldNames.get(i), fieldTypes.get(i));
}
+ return schemaBuilder.build();
+ }
+ public static TableDescriptor createArrowTableSourceDesc(
+ org.apache.flink.table.api.Schema schema, String fileName) {
try {
- byte[][] data = readArrowBatches(fileName);
- return
TableDescriptor.forConnector(ArrowTableSourceFactory.IDENTIFIER)
- .option(
- ArrowTableSourceOptions.DATA,
- ByteArrayUtils.twoDimByteArrayToString(data))
- .schema(schemaBuilder.build())
- .build();
+ return createArrowTableSourceDesc(schema,
readArrowBatches(fileName));
} catch (Throwable e) {
throw new TableException("Failed to read the arrow data from " +
fileName, e);
}
}
+ public static TableDescriptor createArrowTableSourceDesc(
+ org.apache.flink.table.api.Schema schema, String fileName, String
localTimeZoneId) {
+ try {
+ return createArrowTableSourceDesc(
+ schema, readArrowBatchesInLocalTimeZone(fileName,
ZoneId.of(localTimeZoneId)));
+ } catch (Throwable e) {
+ throw new TableException("Failed to read the arrow data from " +
fileName, e);
+ }
+ }
+
+ private static TableDescriptor createArrowTableSourceDesc(
+ org.apache.flink.table.api.Schema schema, byte[][] data) throws
IOException {
+ return TableDescriptor.forConnector(ArrowTableSourceFactory.IDENTIFIER)
+ .option(ArrowTableSourceOptions.DATA,
ByteArrayUtils.twoDimByteArrayToString(data))
+ .schema(schema)
+ .build();
+ }
+
+ private static byte[][] readArrowBatchesInLocalTimeZone(String fileName,
ZoneId localTimeZone)
+ throws IOException {
+ checkArrowUsable();
+ List<byte[]> results = new ArrayList<>();
+ try (BufferAllocator allocator =
+ getRootAllocator()
+ .newChildAllocator(
+
"ArrowUtils#readArrowBatchesInLocalTimeZone",
+ 0,
+ Long.MAX_VALUE);
+ FileInputStream inputStream = new FileInputStream(fileName);
+ ArrowStreamReader reader = new ArrowStreamReader(inputStream,
allocator)) {
+ while (reader.loadNextBatch()) {
+ VectorSchemaRoot root = reader.getVectorSchemaRoot();
+ convertTimezoneAwareTimestampsToLocalTime(root, localTimeZone);
+ try (ArrowRecordBatch batch = new
VectorUnloader(root).getRecordBatch();
+ ByteArrayOutputStream outputStream = new
ByteArrayOutputStream()) {
+ MessageSerializer.serialize(
+ new
WriteChannel(Channels.newChannel(outputStream)), batch);
+ results.add(outputStream.toByteArray());
+ }
+ }
+ }
+ return results.toArray(new byte[0][]);
+ }
+
+ static void convertTimezoneAwareTimestampsToLocalTime(
+ VectorSchemaRoot root, ZoneId localTimeZone) {
+ ZoneRules rules = localTimeZone.getRules();
+ for (FieldVector vector : root.getFieldVectors()) {
+ convertTimezoneAwareTimestampsToLocalTime(vector, rules);
+ }
+ }
+
+ private static void convertTimezoneAwareTimestampsToLocalTime(
+ FieldVector vector, ZoneRules rules) {
+ if (vector instanceof TimeStampVector
+ && ((ArrowType.Timestamp)
vector.getField().getType()).getTimezone() != null) {
+ TimeStampVector timestampVector = (TimeStampVector) vector;
+ TimeUnit unit = ((ArrowType.Timestamp)
vector.getField().getType()).getUnit();
+ long unitsPerSecond = getTimestampUnitsPerSecond(unit);
+ for (int i = 0; i < timestampVector.getValueCount(); i++) {
+ if (timestampVector.isNull(i)) {
+ continue;
+ }
+ long value = timestampVector.get(i);
+ Instant instant = Instant.ofEpochSecond(Math.floorDiv(value,
unitsPerSecond));
+ long offset = rules.getOffset(instant).getTotalSeconds();
+ timestampVector.set(
+ i, Math.addExact(value, Math.multiplyExact(offset,
unitsPerSecond)));
+ }
+ return;
+ }
+ for (FieldVector child : vector.getChildrenFromFields()) {
+ convertTimezoneAwareTimestampsToLocalTime(child, rules);
+ }
+ }
+
+ private static long getTimestampUnitsPerSecond(TimeUnit unit) {
+ switch (unit) {
+ case SECOND:
+ return 1L;
+ case MILLISECOND:
+ return 1_000L;
+ case MICROSECOND:
+ return 1_000_000L;
+ case NANOSECOND:
+ return 1_000_000_000L;
+ default:
+ throw new IllegalArgumentException("Unsupported timestamp
unit: " + unit);
+ }
+ }
+
public static byte[][] readArrowBatches(String fileName) throws
IOException {
try (FileInputStream fis = new FileInputStream(fileName)) {
return readArrowBatches(fis.getChannel());
diff --git
a/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/vectors/ArrowTimestampColumnVector.java
b/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/vectors/ArrowTimestampColumnVector.java
index 297f21d3978..612056db14f 100644
---
a/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/vectors/ArrowTimestampColumnVector.java
+++
b/flink-python/src/main/java/org/apache/flink/table/runtime/arrow/vectors/ArrowTimestampColumnVector.java
@@ -49,15 +49,18 @@ public final class ArrowTimestampColumnVector implements
TimestampColumnVector {
@Override
public TimestampData getTimestamp(int i, int precision) {
if (valueVector instanceof TimeStampSecVector) {
- return TimestampData.fromEpochMillis(((TimeStampSecVector)
valueVector).get(i) * 1000);
+ return TimestampData.fromEpochMillis(
+ Math.multiplyExact(((TimeStampSecVector)
valueVector).get(i), 1000));
} else if (valueVector instanceof TimeStampMilliVector) {
return TimestampData.fromEpochMillis(((TimeStampMilliVector)
valueVector).get(i));
} else if (valueVector instanceof TimeStampMicroVector) {
long micros = ((TimeStampMicroVector) valueVector).get(i);
- return TimestampData.fromEpochMillis(micros / 1000, (int) (micros
% 1000) * 1000);
+ return TimestampData.fromEpochMillis(
+ Math.floorDiv(micros, 1000), (int) Math.floorMod(micros,
1000) * 1000);
} else {
long nanos = ((TimeStampNanoVector) valueVector).get(i);
- return TimestampData.fromEpochMillis(nanos / 1_000_000, (int)
(nanos % 1_000_000));
+ return TimestampData.fromEpochMillis(
+ Math.floorDiv(nanos, 1_000_000), (int)
Math.floorMod(nanos, 1_000_000));
}
}
diff --git
a/flink-python/src/main/java/org/apache/flink/table/utils/python/PythonTableUtils.java
b/flink-python/src/main/java/org/apache/flink/table/utils/python/PythonTableUtils.java
index 01dfab186dd..3681d0664ae 100644
---
a/flink-python/src/main/java/org/apache/flink/table/utils/python/PythonTableUtils.java
+++
b/flink-python/src/main/java/org/apache/flink/table/utils/python/PythonTableUtils.java
@@ -100,11 +100,27 @@ public final class PythonTableUtils {
*/
public static Table createTableFromElement(
TableEnvironment tEnv, String filePath, DataType schema, boolean
batched) {
+ return createTableFromElement(
+ tEnv, filePath,
Schema.newBuilder().fromRowDataType(schema).build(), batched);
+ }
+
+ /**
+ * Create a table from {@link PythonDynamicTableSource} that reads data
from an input file with
+ * the given declarative {@link Schema}.
+ *
+ * @param tEnv The TableEnvironment to create the table.
+ * @param filePath the file path of the input data.
+ * @param schema the schema of the table, including time attributes when
present.
+ * @param batched Whether to read data in a batch.
+ * @return Table backed by the input file.
+ */
+ public static Table createTableFromElement(
+ TableEnvironment tEnv, String filePath, Schema schema, boolean
batched) {
TableDescriptor.Builder builder =
TableDescriptor.forConnector(PythonDynamicTableFactory.IDENTIFIER)
.option(PythonDynamicTableOptions.INPUT_FILE_PATH,
filePath)
.option(PythonDynamicTableOptions.BATCH_MODE, batched)
-
.schema(Schema.newBuilder().fromRowDataType(schema).build());
+ .schema(schema);
return tEnv.from(builder.build());
}
diff --git
a/flink-python/src/test/java/org/apache/flink/table/runtime/arrow/ArrowUtilsTest.java
b/flink-python/src/test/java/org/apache/flink/table/runtime/arrow/ArrowUtilsTest.java
index e7b3a81d1f4..971e0f560fb 100644
---
a/flink-python/src/test/java/org/apache/flink/table/runtime/arrow/ArrowUtilsTest.java
+++
b/flink-python/src/test/java/org/apache/flink/table/runtime/arrow/ArrowUtilsTest.java
@@ -21,6 +21,7 @@ package org.apache.flink.table.runtime.arrow;
import org.apache.flink.api.java.tuple.Tuple5;
import org.apache.flink.table.data.GenericRowData;
import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.TimestampData;
import org.apache.flink.table.data.columnar.vector.ColumnVector;
import org.apache.flink.table.runtime.arrow.vectors.ArrowArrayColumnVector;
import org.apache.flink.table.runtime.arrow.vectors.ArrowBigIntColumnVector;
@@ -74,13 +75,16 @@ import org.apache.flink.table.types.logical.VarCharType;
import org.apache.flink.shaded.guava33.com.google.common.collect.Lists;
import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.TimeStampVector;
import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.complex.StructVector;
import org.apache.arrow.vector.ipc.ArrowStreamWriter;
import org.apache.arrow.vector.types.DateUnit;
import org.apache.arrow.vector.types.FloatingPointPrecision;
import org.apache.arrow.vector.types.TimeUnit;
import org.apache.arrow.vector.types.pojo.ArrowType;
import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
import org.apache.arrow.vector.types.pojo.Schema;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -89,11 +93,16 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.channels.Channels;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Tests for {@link ArrowUtils}. */
class ArrowUtilsTest {
@@ -352,6 +361,156 @@ class ArrowUtilsTest {
}
}
+ @Test
+ void testCreateArrowReaderForPreEpochSubMillisecondTimestamps() {
+ List<Field> fields =
+ Arrays.asList(
+ new Field(
+ "micros",
+ FieldType.nullable(
+ new
ArrowType.Timestamp(TimeUnit.MICROSECOND, null)),
+ null),
+ new Field(
+ "nanos",
+ FieldType.nullable(
+ new
ArrowType.Timestamp(TimeUnit.NANOSECOND, null)),
+ null));
+ RowType timestampRowType = RowType.of(new TimestampType(6), new
TimestampType(9));
+
+ try (VectorSchemaRoot root = VectorSchemaRoot.create(new
Schema(fields), allocator)) {
+ TimeStampVector micros = (TimeStampVector)
root.getVector("micros");
+ TimeStampVector nanos = (TimeStampVector) root.getVector("nanos");
+ micros.setSafe(0, -1);
+ nanos.setSafe(0, -1);
+ micros.setValueCount(1);
+ nanos.setValueCount(1);
+ root.setRowCount(1);
+
+ RowData row = ArrowUtils.createArrowReader(root,
timestampRowType).read(0);
+
+ assertThat(row.getTimestamp(0, 6))
+ .isEqualTo(TimestampData.fromEpochMillis(-1, 999_000));
+ assertThat(row.getTimestamp(1, 9))
+ .isEqualTo(TimestampData.fromEpochMillis(-1, 999_999));
+ }
+ }
+
+ @Test
+ void testCreateArrowReaderRejectsSecondTimestampOverflow() {
+ Field timestampField =
+ new Field(
+ "ts",
+ FieldType.nullable(new
ArrowType.Timestamp(TimeUnit.SECOND, null)),
+ null);
+ RowType timestampRowType = RowType.of(new TimestampType(0));
+
+ try (VectorSchemaRoot root =
+ VectorSchemaRoot.create(
+ new Schema(Collections.singletonList(timestampField)),
allocator)) {
+ TimeStampVector timestamp = (TimeStampVector) root.getVector("ts");
+ timestamp.setValueCount(1);
+ root.setRowCount(1);
+ ArrowReader reader = ArrowUtils.createArrowReader(root,
timestampRowType);
+
+ for (long value : new long[] {Long.MIN_VALUE, Long.MAX_VALUE}) {
+ timestamp.setSafe(0, value);
+ assertThatThrownBy(() -> reader.read(0).getTimestamp(0, 0))
+ .isInstanceOf(ArithmeticException.class);
+ }
+ }
+ }
+
+ @Test
+ void testConvertTimezoneAwareTimestampsUsingJavaZoneRules() {
+ Field timestampField =
+ new Field(
+ "ts",
+ FieldType.nullable(new
ArrowType.Timestamp(TimeUnit.SECOND, "UTC")),
+ null);
+ Field nestedField =
+ new Field(
+ "nested",
+ FieldType.nullable(ArrowType.Struct.INSTANCE),
+ Collections.singletonList(timestampField));
+ long instant = Instant.parse("2050-01-01T00:00:00Z").getEpochSecond();
+ ZoneId localTimeZone = ZoneId.of("America/Vancouver");
+
+ try (VectorSchemaRoot root =
+ VectorSchemaRoot.create(
+ new Schema(Arrays.asList(timestampField,
nestedField)), allocator)) {
+ root.allocateNew();
+ TimeStampVector timestamp = (TimeStampVector) root.getVector("ts");
+ timestamp.setSafe(0, instant);
+ timestamp.setValueCount(1);
+ StructVector nested = (StructVector) root.getVector("nested");
+ TimeStampVector nestedTimestamp = (TimeStampVector)
nested.getChild("ts");
+ nestedTimestamp.setSafe(0, instant);
+ nestedTimestamp.setValueCount(1);
+ nested.setIndexDefined(0);
+ nested.setValueCount(1);
+ root.setRowCount(1);
+
+ ArrowUtils.convertTimezoneAwareTimestampsToLocalTime(root,
localTimeZone);
+
+ long expected =
+ Instant.ofEpochSecond(instant)
+ .atZone(localTimeZone)
+ .toLocalDateTime()
+ .toEpochSecond(ZoneOffset.UTC);
+ assertThat(timestamp.get(0)).isEqualTo(expected);
+ assertThat(nestedTimestamp.get(0)).isEqualTo(expected);
+ }
+ }
+
+ @Test
+ void testConvertTimezoneAwareTimestampsWithJavaOnlyTimeZoneId() {
+ Field timestampField =
+ new Field(
+ "ts",
+ FieldType.nullable(new
ArrowType.Timestamp(TimeUnit.MILLISECOND, "UTC")),
+ null);
+
+ try (VectorSchemaRoot root =
+ VectorSchemaRoot.create(
+ new Schema(Collections.singletonList(timestampField)),
allocator)) {
+ root.allocateNew();
+ TimeStampVector timestamp = (TimeStampVector) root.getVector("ts");
+ timestamp.setSafe(0, 0);
+ timestamp.setValueCount(1);
+ root.setRowCount(1);
+
+ ArrowUtils.convertTimezoneAwareTimestampsToLocalTime(
+ root, ZoneId.of("SystemV/PST8PDT"));
+
+ assertThat(timestamp.get(0)).isEqualTo(-8 * 60 * 60 * 1000L);
+ }
+ }
+
+ @Test
+ void testRejectTimezoneConversionOverflow() {
+ Field timestampField =
+ new Field(
+ "ts",
+ FieldType.nullable(new
ArrowType.Timestamp(TimeUnit.NANOSECOND, "UTC")),
+ null);
+
+ try (VectorSchemaRoot root =
+ VectorSchemaRoot.create(
+ new Schema(Collections.singletonList(timestampField)),
allocator)) {
+ root.allocateNew();
+ TimeStampVector timestamp = (TimeStampVector) root.getVector("ts");
+ timestamp.setSafe(0, Long.MAX_VALUE);
+ timestamp.setValueCount(1);
+ root.setRowCount(1);
+
+ assertThatThrownBy(
+ () ->
+
ArrowUtils.convertTimezoneAwareTimestampsToLocalTime(
+ root, ZoneId.of("Asia/Shanghai")))
+ .isInstanceOf(ArithmeticException.class);
+ }
+ }
+
@Test
void testCreateArrowWriter() {
VectorSchemaRoot root =