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 b810155feca [FLINK-40300][python] Fix literal expression in Python
Table API (#28924)
b810155feca is described below
commit b810155feca84317d9cbfa1c1db8335b70d3c0d1
Author: Liu Liu <[email protected]>
AuthorDate: Tue Aug 18 19:16:56 2026 +0800
[FLINK-40300][python] Fix literal expression in Python Table API (#28924)
---
flink-python/pyflink/dataframe/dataframe.py | 11 -
.../pyflink/dataframe/tests/test_dataframe.py | 235 ++++++++-
flink-python/pyflink/table/expressions.py | 13 +-
flink-python/pyflink/table/literal.py | 234 +++++++++
.../pyflink/table/tests/test_expression.py | 77 +++
flink-python/pyflink/table/tests/test_literal.py | 536 +++++++++++++++++++++
.../flink/table/utils/python/PythonTableUtils.java | 494 ++++++++++++++++++-
.../table/utils/python/PythonTableUtilsTest.java | 325 +++++++++++++
8 files changed, 1895 insertions(+), 30 deletions(-)
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 35f03af35f8..793f074f8da 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -31,7 +31,6 @@ from pyflink.table.expressions import (
lit as table_lit,
)
from pyflink.table.table import Table
-from pyflink.table.types import DataTypes as TableDataTypes
from pyflink.util.api_stability_decorators import PublicEvolving
__all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
@@ -84,16 +83,6 @@ def lit(value: Any, data_type: Optional[DataType] = None) ->
Expression:
table_data_type = data_type._to_table_data_type()
if value is None:
return table_lit(value, table_data_type)
- if (
- table_data_type.nullable() == TableDataTypes.BIGINT()
- and isinstance(value, int)
- and not isinstance(value, bool)
- and -(1 << 31) <= value < (1 << 31)
- ):
- # Py4J sends Python integers in this range as java.lang.Integer, but a
typed BIGINT
- # literal requires java.lang.Long. Match BIGINT independently of its
nullability, then
- # cast a typed INT literal to the originally declared BIGINT type.
- return table_lit(value,
TableDataTypes.INT().not_null()).cast(table_data_type)
return table_lit(value, table_data_type.not_null())
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index a3d095f2ab8..0e6651e44d7 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -16,8 +16,10 @@
# limitations under the License.
################################################################################
+import array
+import decimal
import unittest
-from datetime import datetime, timezone
+from datetime import date, datetime, time, timedelta, timezone
from typing import NamedTuple
import pandas as pd
@@ -630,30 +632,142 @@ class DataFrameLiteralTests(PyFlinkDataFrameUTTestCase):
super().setUp()
self.dataframe = pf.from_records([(1,)], schema=["id"])
- def test_lit_supports_inferred_and_explicit_types(self):
+ def test_lit_infers_supported_python_types(self):
+ literal_values = {
+ "inferred_bool": True,
+ "inferred_int": 2,
+ "inferred_bigint": 1 << 40,
+ "inferred_float": 1.25,
+ "inferred_string": "x",
+ "inferred_bytes": b"x",
+ "inferred_bytearray": bytearray(b"x"),
+ "inferred_decimal": decimal.Decimal("1.25"),
+ "inferred_date": date(2026, 8, 3),
+ "inferred_time": time(1, 2, 3),
+ "inferred_timestamp": datetime(2026, 8, 3, 1, 2, 3),
+ "inferred_aware_timestamp": datetime(
+ 2026, 8, 3, 1, 2, 3, tzinfo=timezone.utc
+ ),
+ "inferred_timedelta": timedelta(days=1, seconds=2,
microseconds=3000),
+ "inferred_list": ["abc"],
+ "inferred_nested_list": [[date(2026, 8, 3)]],
+ "inferred_tuple": (1, 2),
+ "inferred_array": array.array("h", [1, 2]),
+ }
+ result = self.dataframe.select(
+ **{name: pf.lit(value) for name, value in literal_values.items()}
+ )
+
+ self.assert_dataframe_schema(
+ result,
+ list(literal_values),
+ [
+ TableDataTypes.BOOLEAN().not_null(),
+ TableDataTypes.INT().not_null(),
+ TableDataTypes.BIGINT().not_null(),
+ TableDataTypes.DOUBLE().not_null(),
+ TableDataTypes.CHAR(1).not_null(),
+ TableDataTypes.BINARY(1).not_null(),
+ TableDataTypes.BINARY(1).not_null(),
+ TableDataTypes.DECIMAL(3, 2).not_null(),
+ TableDataTypes.DATE().not_null(),
+ TableDataTypes.TIME().not_null(),
+ TableDataTypes.TIMESTAMP(0).not_null(),
+ TableDataTypes.TIMESTAMP(0).not_null(),
+ TableDataTypes.INTERVAL(
+ TableDataTypes.DAY(1), TableDataTypes.SECOND(3)
+ ),
+ TableDataTypes.ARRAY(TableDataTypes.CHAR(3)).not_null(),
+ TableDataTypes.ARRAY(
+ TableDataTypes.ARRAY(TableDataTypes.DATE())
+ ).not_null(),
+ TableDataTypes.ARRAY(TableDataTypes.INT()).not_null(),
+ TableDataTypes.ARRAY(TableDataTypes.SMALLINT()).not_null(),
+ ],
+ )
+
+ def test_lit_supports_explicit_types(self):
+ list_type = pf.DataType.list(pf.DataType.int16())
+ map_type = pf.DataType.map(pf.DataType.int16(), pf.DataType.float32())
+ struct_type = pf.DataType.struct(
+ {
+ "small_value": pf.DataType.int16(),
+ "float_value": pf.DataType.float32(),
+ }
+ )
result = self.dataframe.select(
- inferred_int=pf.lit(2),
- inferred_string=pf.lit("x"),
- explicit_int=pf.lit(3, pf.DataType.int64()),
- explicit_large_int=pf.lit(1 << 40, pf.DataType.int64()),
+ explicit_int8=pf.lit(3, pf.DataType.int8()),
+ explicit_int16=pf.lit(3, pf.DataType.int16()),
+ explicit_int32=pf.lit(3, pf.DataType.int32()),
+ explicit_int64=pf.lit(3, pf.DataType.int64()),
+ explicit_float32=pf.lit(1.25, pf.DataType.float32()),
+ explicit_float64=pf.lit(1.25, pf.DataType.float64()),
+ explicit_decimal=pf.lit(decimal.Decimal("1.25"),
pf.DataType.decimal(3, 2)),
+ explicit_bool=pf.lit(True, pf.DataType.bool()),
explicit_string=pf.lit("y", pf.DataType.string()),
+ explicit_fixed_string=pf.lit("y",
pf.DataType.fixed_size_string(1)),
+ explicit_binary=pf.lit(b"y", pf.DataType.binary()),
+ explicit_fixed_binary=pf.lit(b"y",
pf.DataType.fixed_size_binary(1)),
+ explicit_date=pf.lit(date(2026, 8, 3), pf.DataType.date()),
+ explicit_time=pf.lit(time(1, 2, 3, 4000), pf.DataType.time(6)),
+ explicit_timestamp=pf.lit(
+ datetime(2026, 8, 3, 1, 2, 3, 4000),
+ pf.DataType.timestamp(6),
+ ),
+ explicit_timestamp_ltz=pf.lit(
+ datetime(
+ 2026, 8, 3, 1, 2, 3, 4000, tzinfo=timezone.utc
+ ),
+ pf.DataType.timestamp_ltz(6),
+ ),
+ explicit_list=pf.lit([1, 2], list_type),
+ explicit_map=pf.lit({1: 1.25}, map_type),
+ explicit_struct=pf.lit((1, 1.25), struct_type),
)
self.assert_dataframe_schema(
result,
[
- "inferred_int",
- "inferred_string",
- "explicit_int",
- "explicit_large_int",
+ "explicit_int8",
+ "explicit_int16",
+ "explicit_int32",
+ "explicit_int64",
+ "explicit_float32",
+ "explicit_float64",
+ "explicit_decimal",
+ "explicit_bool",
"explicit_string",
+ "explicit_fixed_string",
+ "explicit_binary",
+ "explicit_fixed_binary",
+ "explicit_date",
+ "explicit_time",
+ "explicit_timestamp",
+ "explicit_timestamp_ltz",
+ "explicit_list",
+ "explicit_map",
+ "explicit_struct",
],
[
+ TableDataTypes.TINYINT().not_null(),
+ TableDataTypes.SMALLINT().not_null(),
TableDataTypes.INT().not_null(),
- TableDataTypes.CHAR(1).not_null(),
- TableDataTypes.BIGINT().not_null(),
TableDataTypes.BIGINT().not_null(),
+ TableDataTypes.FLOAT().not_null(),
+ TableDataTypes.DOUBLE().not_null(),
+ TableDataTypes.DECIMAL(3, 2).not_null(),
+ TableDataTypes.BOOLEAN().not_null(),
TableDataTypes.STRING().not_null(),
+ TableDataTypes.CHAR(1).not_null(),
+ TableDataTypes.BYTES().not_null(),
+ TableDataTypes.BINARY(1).not_null(),
+ TableDataTypes.DATE().not_null(),
+ TableDataTypes.TIME(6).not_null(),
+ TableDataTypes.TIMESTAMP(6).not_null(),
+ TableDataTypes.TIMESTAMP_LTZ(6).not_null(),
+ list_type._to_table_data_type().not_null(),
+ map_type._to_table_data_type().not_null(),
+ struct_type._to_table_data_type().not_null(),
],
)
@@ -661,12 +775,33 @@ class DataFrameLiteralTests(PyFlinkDataFrameUTTestCase):
result = self.dataframe.select(
null_int=pf.lit(None, pf.DataType.int64()),
null_string=pf.lit(None, pf.DataType.string()),
+ null_list=pf.lit(None, pf.DataType.list(pf.DataType.int16())),
+ null_map=pf.lit(
+ None, pf.DataType.map(pf.DataType.int16(),
pf.DataType.float32())
+ ),
+ null_struct=pf.lit(
+ None, pf.DataType.struct({"value": pf.DataType.int16()})
+ ),
)
self.assert_dataframe_schema(
result,
- ["null_int", "null_string"],
- [TableDataTypes.BIGINT(), TableDataTypes.STRING()],
+ [
+ "null_int",
+ "null_string",
+ "null_list",
+ "null_map",
+ "null_struct",
+ ],
+ [
+ TableDataTypes.BIGINT(),
+ TableDataTypes.STRING(),
+ TableDataTypes.ARRAY(TableDataTypes.SMALLINT()),
+ TableDataTypes.MAP(TableDataTypes.SMALLINT(),
TableDataTypes.FLOAT()),
+ TableDataTypes.ROW(
+ [TableDataTypes.FIELD("value", TableDataTypes.SMALLINT())]
+ ),
+ ],
)
def test_lit_supports_small_int_for_non_nullable_bigint(self):
@@ -684,6 +819,7 @@ class DataFrameLiteralTests(PyFlinkDataFrameUTTestCase):
(3.14, pf.DataType.int64()),
("abc", pf.DataType.int64()),
(42, pf.DataType.string()),
+ ([1.25], pf.DataType.list(pf.DataType.int16())),
]
for value, data_type in incompatible_values:
with self.subTest(value=value, data_type=data_type):
@@ -801,6 +937,39 @@ class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
[Row(1, "Alice"), Row(2, "Bob")],
)
+ def
test_watermark_precision_normalization_floors_pre_epoch_timestamps(self):
+ original_timezone = self.t_env.get_config().get_local_timezone()
+ self.t_env.get_config().set_local_timezone("UTC")
+ try:
+ timestamp = datetime(1969, 12, 31, 23, 59, 59, 999999)
+ creators = [
+ (
+ "from_dict",
+ lambda: pf.from_dict(
+ {"ts": [timestamp]},
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ ),
+ (
+ "from_records",
+ lambda: pf.from_records(
+ [{"ts": timestamp}],
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ ),
+ ),
+ ]
+
+ for name, creator in creators:
+ with self.subTest(creator=name):
+ result = creator().select(
+ ts=pf.col("ts").cast(TableDataTypes.STRING())
+ )
+ self.assertEqual(
+ result.collect(), [Row("1969-12-31 23:59:59.999")]
+ )
+ finally:
+ self.t_env.get_config().set_local_timezone(original_timezone)
+
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")
@@ -839,6 +1008,44 @@ class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
finally:
self.t_env.get_config().set_local_timezone(original_timezone)
+ def test_lit_supports_inferred_and_explicit_types(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ map_type = pf.DataType.map(pf.DataType.int16(), pf.DataType.float32())
+ struct_type = pf.DataType.struct(
+ {
+ "small_value": pf.DataType.int16(),
+ "float_value": pf.DataType.float32(),
+ }
+ )
+
+ result = dataframe.select(
+ inferred_date=pf.lit(date(2026, 8, 3)),
+ inferred_list=pf.lit(["abc"]),
+ explicit_small_int=pf.lit(1, pf.DataType.int16()),
+ explicit_float=pf.lit(1.25, pf.DataType.float32()),
+ explicit_list=pf.lit(
+ [1, 2],
+ pf.DataType.list(pf.DataType.int16()),
+ ),
+ explicit_map=pf.lit({1: 1.25}, map_type),
+ explicit_struct=pf.lit((1, 1.25), struct_type),
+ )
+
+ self.assertEqual(
+ result.collect(),
+ [
+ Row(
+ date(2026, 8, 3),
+ ["abc"],
+ 1,
+ 1.25,
+ [1, 2],
+ {1: 1.25},
+ Row(1, 1.25),
+ )
+ ],
+ )
+
def test_basic_functionality(self):
df = pf.from_dict(
{
diff --git a/flink-python/pyflink/table/expressions.py
b/flink-python/pyflink/table/expressions.py
index b7c48954ba7..42f3d587499 100644
--- a/flink-python/pyflink/table/expressions.py
+++ b/flink-python/pyflink/table/expressions.py
@@ -20,6 +20,7 @@ from typing import Union
from pyflink import add_version_doc
from pyflink.java_gateway import get_gateway
from pyflink.table.expression import Expression, _get_java_expression,
TimePointUnit, JsonOnNull
+from pyflink.table.literal import _to_java_literal_value
from pyflink.table.types import _to_java_data_type, DataType
from pyflink.table.udf import UserDefinedFunctionWrapper
from pyflink.util.api_stability_decorators import PublicEvolving
@@ -115,10 +116,14 @@ def lit(v, data_type: DataType = None) -> Expression:
>>> tab.select(col("key"), lit("abc"))
"""
- if data_type is None:
- return _unary_op("lit", v)
- else:
- return _binary_op("lit", v, _to_java_data_type(data_type))
+ gateway = get_gateway()
+ j_data_type = _to_java_data_type(data_type) if data_type is not None else
None
+ _j_literal_value = _to_java_literal_value(v, data_type)
+ return Expression(
+
gateway.jvm.org.apache.flink.table.utils.python.PythonTableUtils.createLiteral(
+ _j_literal_value, j_data_type
+ )
+ )
@PublicEvolving()
diff --git a/flink-python/pyflink/table/literal.py
b/flink-python/pyflink/table/literal.py
new file mode 100644
index 00000000000..87b88b7465e
--- /dev/null
+++ b/flink-python/pyflink/table/literal.py
@@ -0,0 +1,234 @@
+################################################################################
+# 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.
+################################################################################
+
+import datetime
+from array import array
+
+from pyflink.common import Row
+from pyflink.java_gateway import get_gateway
+from pyflink.table.types import (
+ _array_type_mappings,
+ _to_java_data_type,
+ ArrayType,
+ DataType,
+ DateType,
+ DayTimeIntervalType,
+ LocalZonedTimestampType,
+ MapType,
+ MultisetType,
+ RowType,
+ TimeType,
+ TimestampType,
+)
+from pyflink.util.api_stability_decorators import Internal
+
+
+@Internal()
+def _to_java_literal_value(value, data_type: DataType = None):
+ """Converts Python-only literal values into objects accepted by Py4J."""
+ if data_type is None:
+ return _to_java_inferred_literal_value(value)
+ return _to_java_typed_literal_value(value, data_type)
+
+
+def _to_java_inferred_literal_value(value):
+ if value is None:
+ return value
+
+ gateway = get_gateway()
+ jvm = gateway.jvm
+ if isinstance(value, datetime.datetime):
+ return _to_java_typed_literal_value(value, TimestampType())
+ elif isinstance(value, datetime.date):
+ return _to_java_typed_literal_value(value, DateType())
+ elif isinstance(value, datetime.time):
+ return _to_java_typed_literal_value(value, TimeType())
+ elif isinstance(value, datetime.timedelta):
+ return _to_java_typed_literal_value(
+ value,
+
DayTimeIntervalType(DayTimeIntervalType.DayTimeResolution.DAY_TO_SECOND),
+ )
+ elif isinstance(value, array):
+ if value.typecode not in _array_type_mappings:
+ raise TypeError(f"not supported type: array({value.typecode})")
+ element_data_type =
_to_java_data_type(_array_type_mappings[value.typecode])
+ j_array = jvm.java.lang.reflect.Array.newInstance(
+ element_data_type.getConversionClass(), len(value)
+ )
+ for pos, element in enumerate(value):
+ j_array[pos] = element
+ if not value:
+ array_data_type =
ArrayType(_array_type_mappings[value.typecode]).not_null()
+ return (
+ jvm.org.apache.flink.table.utils.python.PythonTableUtils
+ .createInferredArrayValue(j_array,
_to_java_data_type(array_data_type))
+ )
+ return j_array
+ elif isinstance(value, (list, tuple)):
+ j_values = jvm.java.util.ArrayList()
+ for element in value:
+ j_values.add(_to_java_inferred_literal_value(element))
+ return j_values
+ elif isinstance(value, Row):
+ return _to_java_row(value)
+ return value
+
+
+def _to_java_instant(value, jvm):
+ utc_value = value.astimezone(datetime.timezone.utc)
+ epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
+ delta = utc_value - epoch
+ # Avoid floating-point timestamp conversion for pre-epoch and subsecond
values.
+ seconds = delta.days * 86400 + delta.seconds
+ return jvm.java.time.Instant.ofEpochSecond(seconds, utc_value.microsecond
* 1000)
+
+
+def _to_java_local_datetime(value, jvm):
+ return jvm.java.time.LocalDateTime.ofInstant(
+ _to_java_instant(value, jvm), jvm.java.time.ZoneId.systemDefault()
+ )
+
+
+def _to_java_typed_literal_value(value, data_type: DataType):
+ if value is None or data_type._conversion_cls:
+ return value
+
+ jvm = get_gateway().jvm
+ if isinstance(data_type, DateType) and isinstance(value,
datetime.datetime):
+ value = value.date()
+ if isinstance(data_type, DateType) and isinstance(value, datetime.date):
+ return jvm.java.time.LocalDate.of(value.year, value.month, value.day)
+ elif isinstance(data_type, TimeType) and isinstance(value, datetime.time):
+ if value.utcoffset() is not None:
+ # TIME has no date. Use the Unix epoch date to match PyFlink's
existing transport
+ # conversion when rendering an offset time in the client JVM's
local time zone.
+ date_time = datetime.datetime.combine(datetime.date(1970, 1, 1),
value)
+ return _to_java_local_datetime(date_time, jvm).toLocalTime()
+ return jvm.java.time.LocalTime.of(
+ value.hour, value.minute, value.second, value.microsecond * 1000
+ )
+ elif isinstance(data_type, TimestampType) and isinstance(value,
datetime.datetime):
+ if value.utcoffset() is not None:
+ # Match PyFlink's TIMESTAMP transport conversion by rendering the
instant in the
+ # client JVM's local time zone before dropping the zone
information.
+ return _to_java_local_datetime(value, jvm)
+ return jvm.java.time.LocalDateTime.of(
+ value.year,
+ value.month,
+ value.day,
+ value.hour,
+ value.minute,
+ value.second,
+ value.microsecond * 1000,
+ )
+ elif isinstance(data_type, LocalZonedTimestampType) and isinstance(
+ value, datetime.datetime
+ ):
+ if value.utcoffset() is None:
+ value = value.astimezone()
+ return _to_java_instant(value, jvm)
+ elif isinstance(data_type, DayTimeIntervalType) and isinstance(
+ value, datetime.timedelta
+ ):
+ seconds = value.days * 86400 + value.seconds
+ return jvm.java.time.Duration.ofSeconds(seconds, value.microseconds *
1000)
+ elif isinstance(data_type, ArrayType) and isinstance(value, (list, tuple,
array)):
+ j_values = jvm.java.util.ArrayList()
+ for element in value:
+ j_values.add(_to_java_typed_literal_value(element,
data_type.element_type))
+ return j_values
+ elif isinstance(data_type, MultisetType) and isinstance(value, dict):
+ j_values = jvm.java.util.HashMap()
+ for element, count in value.items():
+ j_values.put(
+ _to_java_typed_literal_value(element, data_type.element_type),
count
+ )
+ return j_values
+ elif isinstance(data_type, MapType) and isinstance(value, dict):
+ j_values = jvm.java.util.HashMap()
+ for key, map_value in value.items():
+ j_values.put(
+ _to_java_typed_literal_value(key, data_type.key_type),
+ _to_java_typed_literal_value(map_value, data_type.value_type),
+ )
+ return j_values
+ elif isinstance(data_type, RowType):
+ if isinstance(value, Row):
+ return _to_java_row(value, data_type)
+ elif isinstance(value, dict):
+ j_values = jvm.java.util.HashMap()
+ for field in data_type.fields:
+ j_values.put(
+ field.name,
+ _to_java_typed_literal_value(value.get(field.name),
field.data_type),
+ )
+ return j_values
+ elif isinstance(value, (list, tuple)):
+ j_values = jvm.java.util.ArrayList()
+ for pos, field_value in enumerate(value):
+ if pos < len(data_type.fields):
+ field_value = _to_java_typed_literal_value(
+ field_value, data_type.fields[pos].data_type
+ )
+ else:
+ field_value = _to_java_inferred_literal_value(field_value)
+ j_values.add(field_value)
+ return j_values
+ return value
+
+
+def _to_java_row(value: Row, data_type: RowType = None):
+ jvm = get_gateway().jvm
+ if hasattr(value, "_fields"):
+ j_row =
jvm.org.apache.flink.types.Row.withNames(value.get_row_kind().to_j_row_kind())
+ field_names = (
+ value._fields
+ if data_type is None
+ else [field.name for field in data_type.fields]
+ )
+ for pos, field_name in enumerate(field_names):
+ field_value = value[field_name]
+ if data_type is not None:
+ field_value = _to_java_typed_literal_value(
+ field_value, data_type.fields[pos].data_type
+ )
+ else:
+ field_value = _to_java_inferred_literal_value(field_value)
+ j_row.setField(field_name, field_value)
+ if data_type is not None:
+ # Keep undeclared fields so Java validates the original Row arity.
+ for field_name in value._fields:
+ if field_name not in field_names:
+ j_row.setField(
+ field_name,
+ _to_java_inferred_literal_value(value[field_name]),
+ )
+ return j_row
+
+ j_row = jvm.org.apache.flink.types.Row.withPositions(
+ value.get_row_kind().to_j_row_kind(), len(value)
+ )
+ for pos, field_value in enumerate(value):
+ if data_type is not None and pos < len(data_type.fields):
+ field_value = _to_java_typed_literal_value(
+ field_value, data_type.fields[pos].data_type
+ )
+ else:
+ field_value = _to_java_inferred_literal_value(field_value)
+ j_row.setField(pos, field_value)
+ return j_row
diff --git a/flink-python/pyflink/table/tests/test_expression.py
b/flink-python/pyflink/table/tests/test_expression.py
index 2b957e7aea6..f50fb6309ce 100644
--- a/flink-python/pyflink/table/tests/test_expression.py
+++ b/flink-python/pyflink/table/tests/test_expression.py
@@ -15,8 +15,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################
+import datetime
import unittest
+from py4j.protocol import Py4JJavaError
+
from pyflink.table import DataTypes
from pyflink.table.expression import TimeIntervalUnit, TimePointUnit,
JsonExistsOnError, \
JsonValueOnEmptyOrError, JsonType, JsonQueryWrapper,
JsonQueryOnEmptyOrError
@@ -378,6 +381,80 @@ class PyFlinkBatchExpressionTests(PyFlinkTestCase):
self.assertEqual('withColumns(a, b, c)', str(with_columns(expr1,
expr2, expr3)))
self.assertEqual('a.b.c(a)', str(call('a.b.c', expr1)))
+ def test_lit_converts_python_values_to_declared_data_types(self):
+ test_cases = [
+ (1, DataTypes.TINYINT().not_null(), "TINYINT"),
+ (1, DataTypes.SMALLINT().not_null(), "SMALLINT"),
+ (1, DataTypes.BIGINT().not_null(), "BIGINT"),
+ (1.25, DataTypes.FLOAT().not_null(), "FLOAT"),
+ (datetime.date(2026, 8, 3), DataTypes.DATE().not_null(), "DATE"),
+ (datetime.time(1, 2, 3, 4000), DataTypes.TIME(6).not_null(),
+ "TIME_WITHOUT_TIME_ZONE"),
+ (datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ DataTypes.TIMESTAMP(6).not_null(), "TIMESTAMP_WITHOUT_TIME_ZONE"),
+ (datetime.datetime(2026, 8, 3, 1, 2, 3, 4000,
datetime.timezone.utc),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
"TIMESTAMP_WITH_LOCAL_TIME_ZONE"),
+ (datetime.timedelta(days=1, seconds=2, microseconds=3000),
+ DataTypes.INTERVAL(DataTypes.DAY(),
DataTypes.SECOND(6)).not_null(),
+ "INTERVAL_DAY_TIME"),
+ ]
+
+ for value, data_type, expected_type_root in test_cases:
+ with self.subTest(value=value, data_type=data_type):
+ literal = lit(value, data_type)._j_expr.toExpr()
+ actual_type_root = literal.getOutputDataType() \
+ .getLogicalType().getTypeRoot().name()
+ self.assertEqual(expected_type_root, actual_type_root)
+
+ def test_lit_converts_python_values_for_inferred_data_types(self):
+ test_cases = [
+ (datetime.date(2026, 8, 3), "DATE NOT NULL"),
+ (datetime.time(1, 2, 3, 4000), "TIME(3) NOT NULL"),
+ (datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ "TIMESTAMP(3) NOT NULL"),
+ (datetime.datetime(2026, 8, 3, 1, 2, 3, 4000,
datetime.timezone.utc),
+ "TIMESTAMP(3) NOT NULL"),
+ (datetime.timedelta(days=1, seconds=2, microseconds=3000),
+ "INTERVAL DAY(1) TO SECOND(3) NOT NULL"),
+ ]
+
+ for value, expected_data_type in test_cases:
+ with self.subTest(value=value):
+ literal = lit(value)._j_expr.toExpr()
+ self.assertEqual(expected_data_type,
str(literal.getOutputDataType()))
+
+ def test_lit_rejects_unsupported_data_type_before_value_conversion(self):
+ with self.assertRaisesRegex(TypeError, "Unsupported data type: INT"):
+ lit(1, "INT")
+
+ def test_lit_rejects_binary_scalar_as_constructed_value(self):
+ array_type = DataTypes.ARRAY(DataTypes.TINYINT()).not_null()
+ row_type = DataTypes.ROW(
+ [
+ DataTypes.FIELD("a", DataTypes.TINYINT()),
+ DataTypes.FIELD("b", DataTypes.TINYINT()),
+ ]
+ ).not_null()
+ for value in [b"a", bytearray(b"a")]:
+ with self.subTest(value=value):
+ with self.assertRaises(Py4JJavaError):
+ lit([value, []])
+ with self.assertRaises(Py4JJavaError):
+ lit([[value], [[]]])
+ with self.assertRaises(Py4JJavaError):
+ lit(value, array_type)
+ with self.assertRaises(Py4JJavaError):
+ lit(value, row_type)
+
+ def test_lit_rejects_out_of_range_integer_values(self):
+ for value, data_type in [
+ (128, DataTypes.TINYINT().not_null()),
+ (32768, DataTypes.SMALLINT().not_null()),
+ ]:
+ with self.subTest(value=value, data_type=data_type):
+ with self.assertRaises(Py4JJavaError):
+ lit(value, data_type)
+
if __name__ == "__main__":
try:
diff --git a/flink-python/pyflink/table/tests/test_literal.py
b/flink-python/pyflink/table/tests/test_literal.py
new file mode 100644
index 00000000000..ccfea78df57
--- /dev/null
+++ b/flink-python/pyflink/table/tests/test_literal.py
@@ -0,0 +1,536 @@
+################################################################################
+# 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.
+################################################################################
+
+import array
+import datetime
+import decimal
+
+from py4j.protocol import Py4JJavaError
+
+from pyflink.common import Row, RowKind
+from pyflink.java_gateway import get_gateway
+from pyflink.table import DataTypes
+from pyflink.table.expressions import lit
+from pyflink.table.types import _array_type_mappings
+from pyflink.testing.test_case_utils import PyFlinkBatchTableTestCase
+
+
+# Keep the fold test independent of the system's IANA time zone database.
+class _FoldAwareTimezone(datetime.tzinfo):
+ def utcoffset(self, value):
+ return datetime.timedelta(hours=-4 if value.fold == 0 else -5)
+
+ def dst(self, value):
+ return datetime.timedelta(hours=1 if value.fold == 0 else 0)
+
+
+class LiteralITCase(PyFlinkBatchTableTestCase):
+ def test_timezone_aware_datetime_literals(self):
+ source = self.t_env.from_elements([(1,)], ["id"])
+ same_instant_with_different_offsets = (
+ datetime.datetime(
+ 2026, 8, 3, 12,
tzinfo=datetime.timezone(datetime.timedelta(hours=8))
+ ),
+ datetime.datetime(2026, 8, 3, 4, tzinfo=datetime.timezone.utc),
+ )
+ fractional_offset_instant = (
+ datetime.datetime(
+ 1970,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 100000,
+
tzinfo=datetime.timezone(datetime.timedelta(microseconds=500000)),
+ ),
+ datetime.datetime(
+ 1969, 12, 31, 23, 59, 59, 600000, tzinfo=datetime.timezone.utc
+ ),
+ )
+ ambiguous_local_time = datetime.datetime(
+ 2026, 11, 1, 1, 30, tzinfo=_FoldAwareTimezone()
+ )
+
+ result = source.select(
+ lit(same_instant_with_different_offsets[0])
+ == lit(same_instant_with_different_offsets[1]),
+ lit(
+ same_instant_with_different_offsets[0],
+ DataTypes.TIMESTAMP(6).not_null(),
+ )
+ == lit(
+ same_instant_with_different_offsets[1],
+ DataTypes.TIMESTAMP(6).not_null(),
+ ),
+ lit(fractional_offset_instant[0]) ==
lit(fractional_offset_instant[1]),
+ lit(
+ fractional_offset_instant[0],
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ )
+ == lit(
+ fractional_offset_instant[1],
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ ),
+ lit(
+ ambiguous_local_time.replace(fold=0),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ )
+ == lit(
+ datetime.datetime(2026, 11, 1, 5, 30,
tzinfo=datetime.timezone.utc),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ ),
+ lit(
+ ambiguous_local_time.replace(fold=1),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ )
+ == lit(
+ datetime.datetime(2026, 11, 1, 6, 30,
tzinfo=datetime.timezone.utc),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ ),
+ lit(
+ ambiguous_local_time.replace(fold=0),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ )
+ != lit(
+ ambiguous_local_time.replace(fold=1),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ ),
+ )
+
+ self.assertEqual(
+ list(result.execute().collect()),
+ [Row(True, True, True, True, True, True, True)],
+ )
+
+ def test_aware_temporal_literals_use_client_timezone(self):
+ jvm = get_gateway().jvm
+ original_timezone = jvm.java.util.TimeZone.getDefault()
+ try:
+ jvm.java.util.TimeZone.setDefault(
+ jvm.java.util.TimeZone.getTimeZone("Asia/Shanghai")
+ )
+ source = self.t_env.from_elements([(1,)], ["id"])
+ aware_time = datetime.time(4, tzinfo=datetime.timezone.utc)
+ aware_timestamp = datetime.datetime(
+ 2026, 8, 3, 4, tzinfo=datetime.timezone.utc
+ )
+ inferred_time = lit(aware_time)
+ explicit_time = lit(aware_time, DataTypes.TIME().not_null())
+ inferred_timestamp = lit(aware_timestamp)
+ explicit_timestamp = lit(
+ aware_timestamp, DataTypes.TIMESTAMP().not_null()
+ )
+
+ local_time_class =
jvm.java.lang.Class.forName("java.time.LocalTime")
+ local_datetime_class = jvm.java.lang.Class.forName(
+ "java.time.LocalDateTime"
+ )
+ expected_time = jvm.java.time.LocalTime.of(12, 0)
+ expected_timestamp = jvm.java.time.LocalDateTime.of(2026, 8, 3,
12, 0)
+ for expression in (inferred_time, explicit_time):
+ literal = expression._j_expr.toExpr()
+ self.assertEqual(expected_time,
literal.getValueAs(local_time_class).get())
+ for expression in (inferred_timestamp, explicit_timestamp):
+ literal = expression._j_expr.toExpr()
+ self.assertEqual(
+ expected_timestamp,
+ literal.getValueAs(local_datetime_class).get(),
+ )
+
+ result = source.select(
+ inferred_time == explicit_time,
+ inferred_timestamp == explicit_timestamp,
+ )
+ self.assertEqual(
+ list(result.execute().collect()),
+ [Row(True, True)],
+ )
+ finally:
+ jvm.java.util.TimeZone.setDefault(original_timezone)
+
+ def test_scalar_literals_can_be_executed(self):
+ source = self.t_env.from_elements([(1,)], ["id"])
+
+ result = source.select(
+ lit(True),
+ lit(2),
+ lit(1.25),
+ lit("x"),
+ lit(b"x"),
+ lit(bytearray(b"y")),
+ lit(b"x", DataTypes.BINARY(1).not_null()),
+ lit(bytearray(b"y"), DataTypes.BINARY(1).not_null()),
+ lit(decimal.Decimal("1.25")),
+ lit(datetime.date(2026, 8, 3)),
+ lit(datetime.time(1, 2, 3, 4000)),
+ lit(datetime.datetime(2026, 8, 3, 1, 2, 3, 4000)),
+ lit(datetime.timedelta(days=1, seconds=2,
microseconds=3000)).is_not_null,
+ lit(1, DataTypes.TINYINT().not_null()),
+ lit(1, DataTypes.SMALLINT().not_null()),
+ lit(1, DataTypes.BIGINT().not_null()),
+ lit(1.25, DataTypes.FLOAT().not_null()),
+ lit(
+ datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ DataTypes.DATE().not_null(),
+ ),
+ lit(
+ datetime.time(1, 2, 3, 4000),
+ DataTypes.TIME(6).not_null(),
+ ),
+ lit(
+ datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ DataTypes.TIMESTAMP(6).not_null(),
+ ),
+ lit(
+ datetime.datetime(
+ 2026,
+ 8,
+ 3,
+ 1,
+ 2,
+ 3,
+ 4000,
+ datetime.timezone.utc,
+ ),
+ DataTypes.TIMESTAMP_LTZ(6).not_null(),
+ ).is_not_null,
+ lit(
+ datetime.timedelta(days=1, seconds=2, microseconds=3000),
+ DataTypes.INTERVAL(DataTypes.DAY(),
DataTypes.SECOND(6)).not_null(),
+ ).is_not_null,
+ lit(
+ 14,
+ DataTypes.INTERVAL(DataTypes.YEAR(),
DataTypes.MONTH()).not_null(),
+ ).is_not_null,
+ lit(None, DataTypes.ARRAY(DataTypes.SMALLINT())),
+ lit(None, DataTypes.MAP(DataTypes.SMALLINT(), DataTypes.FLOAT())),
+ lit(
+ None,
+ DataTypes.ROW(
+ [DataTypes.FIELD("small_value", DataTypes.SMALLINT())]
+ ),
+ ),
+ lit(None, DataTypes.MULTISET(DataTypes.SMALLINT())),
+ )
+
+ self.assertEqual(
+ list(result.execute().collect()),
+ [
+ Row(
+ True,
+ 2,
+ 1.25,
+ "x",
+ b"x",
+ b"y",
+ b"x",
+ b"y",
+ decimal.Decimal("1.25"),
+ datetime.date(2026, 8, 3),
+ datetime.time(1, 2, 3, 4000),
+ datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ True,
+ 1,
+ 1,
+ 1,
+ 1.25,
+ datetime.date(2026, 8, 3),
+ datetime.time(1, 2, 3, 4000),
+ datetime.datetime(2026, 8, 3, 1, 2, 3, 4000),
+ True,
+ True,
+ True,
+ None,
+ None,
+ None,
+ None,
+ )
+ ],
+ )
+
+ def test_constructed_literals_can_be_executed(self):
+ source = self.t_env.from_elements([(1,)], ["id"])
+ row_type = DataTypes.ROW(
+ [
+ DataTypes.FIELD("small_value", DataTypes.SMALLINT()),
+ DataTypes.FIELD("float_value", DataTypes.FLOAT()),
+ ]
+ ).not_null()
+ map_type = DataTypes.MAP(
+ DataTypes.SMALLINT(),
+ DataTypes.FLOAT(),
+ ).not_null()
+ nested_type = DataTypes.ARRAY(
+ DataTypes.ROW(
+ [
+ DataTypes.FIELD("values",
DataTypes.ARRAY(DataTypes.SMALLINT())),
+ DataTypes.FIELD("mapping", map_type),
+ ]
+ )
+ ).not_null()
+
+ result = source.select(
+ lit(["abc"]),
+ lit([[datetime.date(2026, 8, 3)]]),
+ lit((1, 2)),
+ lit([1, 2], DataTypes.ARRAY(DataTypes.SMALLINT()).not_null()),
+ lit(
+ [b"x"],
+ DataTypes.ARRAY(DataTypes.BINARY(1)).not_null(),
+ ),
+ lit((1, 1.25), row_type),
+ lit({1: 1.25}, map_type),
+ lit([([1, 2], {3: 1.25})], nested_type),
+ lit(
+ [],
+ DataTypes.ARRAY(DataTypes.SMALLINT().not_null()).not_null(),
+ ),
+ lit({}, map_type),
+ lit(array.array("h")),
+ *(lit(array.array(typecode, [1, 2])) for typecode in "bhilfd"),
+ )
+
+ self.assertIsInstance(result.explain(), str)
+ self.assertEqual(
+ list(result.execute().collect()),
+ [
+ Row(
+ ["abc"],
+ [[datetime.date(2026, 8, 3)]],
+ [1, 2],
+ [1, 2],
+ [b"x"],
+ Row(1, 1.25),
+ {1: 1.25},
+ [Row([1, 2], {3: 1.25})],
+ [],
+ {},
+ [],
+ [1, 2],
+ [1, 2],
+ [1, 2],
+ [1, 2],
+ [1.0, 2.0],
+ [1.0, 2.0],
+ )
+ ],
+ )
+
+ def test_inferred_nested_arrays_use_sibling_types(self):
+ source = self.t_env.from_elements([(1,)], ["id"])
+ empty_inner_array = lit([[1], []])
+ null_only_inner_array = lit([[1], [None]])
+ char_empty_inner_array = lit([["a"], []])
+ char_null_only_inner_array = lit([["a"], [None]])
+ decimal_empty_inner_array = lit([[decimal.Decimal("1.20")], []])
+ time_empty_inner_array = lit([[datetime.time(12, 0, 0, 123000)], []])
+ binary_empty_inner_array = lit([[b"a"], []])
+ nested_array_type =
DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.INT())).not_null()
+
+ literal_table = source.select(
+ empty_inner_array,
+ null_only_inner_array,
+ char_empty_inner_array,
+ char_null_only_inner_array,
+ decimal_empty_inner_array,
+ time_empty_inner_array,
+ binary_empty_inner_array,
+ )
+ self.assertEqual(
+ literal_table.get_resolved_schema().get_column_data_types(),
+ [
+ nested_array_type,
+ nested_array_type,
+ DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.CHAR(1))).not_null(),
+ DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.CHAR(1))).not_null(),
+ DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.DECIMAL(3,
2))).not_null(),
+ DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.TIME(3))).not_null(),
+
DataTypes.ARRAY(DataTypes.ARRAY(DataTypes.BINARY(1))).not_null(),
+ ],
+ )
+ self.assertIsInstance(literal_table.explain(), str)
+
+ result = source.select(
+ empty_inner_array.cardinality,
+ empty_inner_array.at(1).cardinality,
+ empty_inner_array.at(2).cardinality,
+ empty_inner_array.at(1).at(1),
+ null_only_inner_array.at(2).cardinality,
+ null_only_inner_array.at(2).at(1).is_null,
+ char_empty_inner_array.at(1).at(1),
+ char_empty_inner_array.at(2).cardinality,
+ char_null_only_inner_array.at(2).at(1).is_null,
+ decimal_empty_inner_array.at(1).at(1),
+ decimal_empty_inner_array.at(2).cardinality,
+ time_empty_inner_array.at(1).at(1),
+ time_empty_inner_array.at(2).cardinality,
+ binary_empty_inner_array.at(1).at(1),
+ binary_empty_inner_array.at(2).cardinality,
+ )
+ self.assertEqual(
+ list(result.execute().collect()),
+ [
+ Row(
+ 2,
+ 1,
+ 0,
+ 1,
+ 1,
+ True,
+ "a",
+ 0,
+ True,
+ decimal.Decimal("1.20"),
+ 0,
+ datetime.time(12, 0, 0, 123000),
+ 0,
+ b"a",
+ 0,
+ )
+ ],
+ )
+
+ def test_unsupported_constructed_literals_are_rejected(self):
+ with self.assertRaisesRegex(Py4JJavaError, "Non-null MULTISET literals
are not supported"):
+ lit({1: 2}, DataTypes.MULTISET(DataTypes.SMALLINT()).not_null())
+
+ with self.assertRaisesRegex(Py4JJavaError, "Non-null empty ROW
literals are not supported"):
+ lit((), DataTypes.ROW([]).not_null())
+
+ with self.assertRaises(Py4JJavaError):
+ lit([1.25], DataTypes.ARRAY(DataTypes.SMALLINT()).not_null())
+
+ with self.assertRaisesRegex(Py4JJavaError, "ROW literal has arity 2"):
+ lit(
+ (1, 2),
+ DataTypes.ROW([DataTypes.FIELD("value",
DataTypes.INT())]).not_null(),
+ )
+
+ with self.assertRaisesRegex(Py4JJavaError, "ROW literal has arity 2"):
+ lit(
+ Row(a=1, b=2),
+ DataTypes.ROW([DataTypes.FIELD("a",
DataTypes.INT())]).not_null(),
+ )
+
+ with self.assertRaisesRegex(Py4JJavaError, "Unsupported kind
'DELETE'"):
+ lit(
+ Row.of_kind(RowKind.DELETE, 1),
+ DataTypes.ROW([DataTypes.FIELD("value",
DataTypes.INT())]).not_null(),
+ )
+
+ def test_primitive_java_arrays_can_be_executed(self):
+ gateway = get_gateway()
+ jvm = gateway.jvm
+ primitive_values = [
+ (jvm.boolean, True, DataTypes.BOOLEAN().not_null()),
+ (jvm.short, 1, DataTypes.SMALLINT().not_null()),
+ (jvm.int, 1, DataTypes.INT().not_null()),
+ (jvm.long, 1, DataTypes.BIGINT().not_null()),
+ (jvm.float, 1.0, DataTypes.FLOAT().not_null()),
+ (jvm.double, 1.0, DataTypes.DOUBLE().not_null()),
+ ]
+ expressions = []
+ for primitive_class, value, _ in primitive_values:
+ j_array = gateway.new_array(primitive_class, 1)
+ j_array[0] = value
+ expressions.append(lit(j_array))
+
+ source = self.t_env.from_elements([(1,)], ["id"])
+ result = source.select(*expressions)
+ self.assertEqual(
+ result.get_resolved_schema().get_column_data_types(),
+ [
+ DataTypes.ARRAY(element_data_type).not_null()
+ for _, _, element_data_type in primitive_values
+ ],
+ )
+ self.assertIsInstance(result.explain(), str)
+ self.assertEqual(
+ list(result.execute().collect()),
+ [Row([True], [1], [1], [1], [1.0], [1.0])],
+ )
+
+ j_int_array = gateway.new_array(jvm.int, 1)
+ j_int_array[0] = 1
+ nested_expression = lit([j_int_array, []])
+ nested_result = source.select(
+ nested_expression.cardinality,
+ nested_expression.at(1).cardinality,
+ nested_expression.at(2).cardinality,
+ nested_expression.at(1).at(1),
+ )
+ self.assertIsInstance(nested_result.explain(), str)
+ self.assertEqual(
+ list(nested_result.execute().collect()),
+ [Row(2, 1, 0, 1)],
+ )
+
+ def test_empty_python_arrays_preserve_typecodes(self):
+ typecodes = sorted(_array_type_mappings)
+ source = self.t_env.from_elements([(1,)], ["id"])
+ result = source.select(*(lit(array.array(typecode)) for typecode in
typecodes))
+
+ expected_types = [
+ DataTypes.ARRAY(_array_type_mappings[typecode]).not_null()
+ for typecode in typecodes
+ ]
+ self.assertEqual(result.get_resolved_schema().get_column_data_types(),
expected_types)
+ self.assertEqual(list(result.execute().collect()), [Row(*([[]] *
len(typecodes)))])
+
+ def test_empty_unicode_array_typecode_propagates_to_sibling(self):
+ if "u" not in _array_type_mappings:
+ self.skipTest("Unicode arrays are not supported on this Python
version")
+
+ source = self.t_env.from_elements([(1,)], ["id"])
+ expression = lit([array.array("u"), []])
+ result = source.select(expression)
+
+ self.assertEqual(
+ result.get_resolved_schema().get_column_data_types(),
+ [
+ DataTypes.ARRAY(
+ DataTypes.ARRAY(_array_type_mappings["u"])
+ ).not_null()
+ ],
+ )
+ self.assertIsInstance(result.explain(), str)
+ self.assertEqual(
+ list(
+ source.select(
+ expression.at(1).cardinality,
+ expression.at(2).cardinality,
+ )
+ .execute()
+ .collect()
+ ),
+ [Row(0, 0)],
+ )
+
+ def test_unicode_python_array_can_be_executed(self):
+ if "u" not in _array_type_mappings:
+ self.skipTest("Unicode arrays are not supported on this Python
version")
+
+ source = self.t_env.from_elements([(1,)], ["id"])
+ result = source.select(lit(array.array("u", "ab")))
+
+ self.assertEqual(
+ result.get_resolved_schema().get_column_data_types(),
+ [DataTypes.ARRAY(DataTypes.CHAR(1)).not_null()],
+ )
+ self.assertEqual(list(result.execute().collect()), [Row(["a", "b"])])
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 3681d0664ae..aa1121e737f 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
@@ -21,10 +21,14 @@ package org.apache.flink.table.utils.python;
import org.apache.flink.annotation.Internal;
import org.apache.flink.api.common.io.InputFormat;
import org.apache.flink.streaming.api.legacy.io.CollectionInputFormat;
+import org.apache.flink.table.api.ApiExpression;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Expressions;
import org.apache.flink.table.api.Schema;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.TableDescriptor;
import org.apache.flink.table.api.TableEnvironment;
+import org.apache.flink.table.api.ValidationException;
import org.apache.flink.table.api.dataview.ListView;
import org.apache.flink.table.api.dataview.MapView;
import org.apache.flink.table.data.DecimalData;
@@ -34,8 +38,10 @@ import org.apache.flink.table.data.GenericRowData;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.data.StringData;
import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
import org.apache.flink.table.runtime.typeutils.InternalSerializers;
import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.extraction.ExtractionUtils;
import org.apache.flink.table.types.logical.ArrayType;
import org.apache.flink.table.types.logical.BigIntType;
import org.apache.flink.table.types.logical.BinaryType;
@@ -62,8 +68,12 @@ import org.apache.flink.table.types.logical.VarBinaryType;
import org.apache.flink.table.types.logical.VarCharType;
import org.apache.flink.table.types.logical.YearMonthIntervalType;
import org.apache.flink.table.types.logical.ZonedTimestampType;
+import org.apache.flink.table.types.utils.ValueDataTypeConverter;
+import org.apache.flink.types.Row;
import org.apache.flink.types.RowKind;
+import javax.annotation.Nullable;
+
import java.lang.reflect.Array;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
@@ -71,6 +81,7 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
+import java.time.Period;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collection;
@@ -143,6 +154,405 @@ public final class PythonTableUtils {
dataCollection,
InternalSerializers.create(dataType.getLogicalType()));
}
+ /** Retains an empty Python array's typecode-derived data type across the
Py4J boundary. */
+ public static Object createInferredArrayValue(final Object array, final
DataType dataType) {
+ if (!array.getClass().isArray()
+ || !(dataType.getLogicalType() instanceof ArrayType)
+ || Array.getLength(array) != 0
+ || array.getClass() != dataType.getConversionClass()) {
+ throw new IllegalArgumentException(
+ "An inferred array value requires an empty array matching
the ARRAY conversion class.");
+ }
+ return new InferredArrayValue(array, dataType);
+ }
+
+ /**
+ * Creates a literal from a value received through Py4J.
+ *
+ * <p>Py4J represents Python numeric values as {@link Integer}, {@link
Long}, or {@link Double},
+ * which does not preserve the boxed Java classes required by some {@link
DataType}s. This
+ * method adapts the value to the data type's external representation and
creates the literal in
+ * the same JVM call so that the adapted value is not converted by Py4J
again. If {@code
+ * dataType} is absent, Java literal inference remains authoritative.
Constructed values such as
+ * arrays, maps, and rows are represented as constructor calls because
Calcite cannot plan them
+ * as single value literals.
+ *
+ * @param value the literal value received through Py4J
+ * @param dataType the declared data type, or {@code null} for type
inference
+ * @return the literal expression
+ * @throws ValidationException if the constructed value has no plannable
literal expression
+ */
+ public static ApiExpression createLiteral(
+ final Object value, @Nullable final DataType dataType) {
+ if (value instanceof InferredArrayValue) {
+ final InferredArrayValue inferredArrayValue = (InferredArrayValue)
value;
+ return createTypedLiteral(
+ inferredArrayValue.getArray(),
+ dataType == null ? inferredArrayValue.getDataType() :
dataType);
+ }
+ if (dataType != null) {
+ return createTypedLiteral(value, dataType);
+ }
+
+ if (value instanceof List) {
+ final InferredArrayMaterialization materializedArray =
+ materializeInferredArray((List<?>) value);
+ if (materializedArray.getState() == InferredArrayState.CONCRETE) {
+ return createTypedLiteral(
+ materializedArray.getArray(),
materializedArray.getDataType());
+ }
+ return Expressions.lit(materializedArray.getArray());
+ }
+
+ final ApiExpression literal = Expressions.lit(value);
+ final DataType inferredDataType =
+ ((ValueLiteralExpression)
literal.toExpr()).getOutputDataType();
+ // Raw array literals can be inferred but not planned. Rebuild the
array as a constructor
+ // expression while preserving the data type inferred by Java.
+ if (inferredDataType.getLogicalType() instanceof ArrayType) {
+ return createTypedLiteral(value, inferredDataType);
+ }
+ return literal;
+ }
+
+ private static ApiExpression createTypedLiteral(final Object value, final
DataType dataType) {
+ if (value == null) {
+ // A typed null carries no composite payload and is directly
plannable.
+ return Expressions.lit(value, dataType);
+ }
+ if (dataType.getLogicalType().isNullable()) {
+ // Delegate the invalid non-null value/nullable type combination
to Java validation.
+ return Expressions.lit(value, dataType);
+ }
+ if (!usesDefaultLiteralConversion(dataType)) {
+ // Custom conversion classes are opaque to this bridge; use native
literal handling.
+ return Expressions.lit(value, dataType);
+ }
+
+ if (dataType.getLogicalType() instanceof ArrayType) {
+ if (!(value instanceof List) && !isLiteralJavaArray(value)) {
+ // Delegate incompatible ARRAY representations to standard
literal validation.
+ return Expressions.lit(value, dataType);
+ }
+ final int length = getLiteralArrayLength(value);
+ if (length == 0) {
+ return createEmptyArray(dataType);
+ }
+ final DataType elementDataType = dataType.getChildren().get(0);
+ final Object[] tail = new Object[length - 1];
+ for (int pos = 1; pos < length; pos++) {
+ tail[pos - 1] =
+ createNestedLiteral(getLiteralArrayElement(value,
pos), elementDataType);
+ }
+ return Expressions.array(
+ createNestedLiteral(getLiteralArrayElement(value,
0), elementDataType),
+ tail)
+ .cast(dataType);
+ }
+ if (dataType.getLogicalType() instanceof RowType) {
+ if (!isLiteralRow(value)) {
+ // Delegate incompatible ROW representations to standard
literal validation.
+ return Expressions.lit(value, dataType);
+ }
+ if (value instanceof Row && ((Row) value).getKind() !=
RowKind.INSERT) {
+ final Row row = (Row) value;
+ throw new ValidationException(
+ String.format(
+ "Unsupported kind '%s' of a row [%s]. Only
rows with 'INSERT' kind are supported when"
+ + " converting to an expression.",
+ row.getKind(), row));
+ }
+ final RowType rowType = (RowType) dataType.getLogicalType();
+ final List<DataType> fieldDataTypes = dataType.getChildren();
+ if (fieldDataTypes.isEmpty()) {
+ throw new ValidationException("Non-null empty ROW literals are
not supported.");
+ }
+ if (!(value instanceof Map)) {
+ final int valueArity = getLiteralRowArity(value);
+ if (valueArity != fieldDataTypes.size()) {
+ throw new ValidationException(
+ String.format(
+ "ROW literal has arity %d but the data
type has arity %d.",
+ valueArity, fieldDataTypes.size()));
+ }
+ }
+ final List<String> fieldNames = rowType.getFieldNames();
+ final Object[] tail = new Object[fieldDataTypes.size() - 1];
+ for (int pos = 1; pos < fieldDataTypes.size(); pos++) {
+ tail[pos - 1] =
+ createNestedLiteral(
+ getLiteralRowField(value, pos,
fieldNames.get(pos)),
+ fieldDataTypes.get(pos));
+ }
+ return Expressions.row(
+ createNestedLiteral(
+ getLiteralRowField(value, 0,
fieldNames.get(0)),
+ fieldDataTypes.get(0)),
+ tail)
+ .cast(dataType);
+ }
+ if (dataType.getLogicalType() instanceof MultisetType) {
+ throw new ValidationException("Non-null MULTISET literals are not
supported.");
+ }
+ if (dataType.getLogicalType() instanceof MapType) {
+ if (!(value instanceof Map)) {
+ // Delegate incompatible MAP representations to standard
literal validation.
+ return Expressions.lit(value, dataType);
+ }
+ final Map<?, ?> map = (Map<?, ?>) value;
+ final DataType keyDataType = dataType.getChildren().get(0);
+ final DataType valueDataType = dataType.getChildren().get(1);
+ if (map.isEmpty()) {
+ return Expressions.mapFromArrays(
+
createEmptyArray(DataTypes.ARRAY(keyDataType).notNull()),
+
createEmptyArray(DataTypes.ARRAY(valueDataType).notNull()))
+ .cast(dataType);
+ }
+ final Object[] arguments = new Object[map.size() * 2];
+ int pos = 0;
+ for (final Map.Entry<?, ?> entry : map.entrySet()) {
+ arguments[pos++] = createNestedLiteral(entry.getKey(),
keyDataType);
+ arguments[pos++] = createNestedLiteral(entry.getValue(),
valueDataType);
+ }
+ return Expressions.map(
+ arguments[0],
+ arguments[1],
+ Arrays.copyOfRange(arguments, 2, arguments.length))
+ .cast(dataType);
+ }
+ // After normalizing Py4J numerics, let Java perform final scalar
validation.
+ return Expressions.lit(scalarLiteralConverter(dataType).apply(value),
dataType);
+ }
+
+ private static ApiExpression createEmptyArray(final DataType dataType) {
+ final DataType elementDataType = dataType.getChildren().get(0);
+ // The ARRAY constructor requires an argument, so slice a typed
one-element array to empty.
+ return Expressions.array(Expressions.lit(null,
elementDataType.nullable()))
+ .arraySlice(2, 1)
+ .cast(dataType);
+ }
+
+ private static ApiExpression createNestedLiteral(
+ final Object value, final DataType declaredDataType) {
+ // Java requires non-null literals to use a NOT NULL type. Preserve
declared nullability for
+ // null values so that invalid nulls remain rejected.
+ DataType literalDataType = value == null ? declaredDataType :
declaredDataType.notNull();
+ if (value != null &&
literalDataType.getConversionClass().isPrimitive()) {
+ // Reflection boxes primitive array elements before they reach
literal validation.
+ literalDataType =
+ literalDataType.bridgedTo(
+ ExtractionUtils.primitiveToWrapper(
+ literalDataType.getConversionClass()));
+ }
+ return createTypedLiteral(value, literalDataType);
+ }
+
+ private static InferredArrayMaterialization materializeInferredArray(final
List<?> values) {
+ final Object[] convertedValues = new Object[values.size()];
+ final InferredArrayMaterialization[] nestedArrays =
+ new InferredArrayMaterialization[values.size()];
+ DataType elementDataType = null;
+ boolean compatible = true;
+
+ for (int pos = 0; pos < values.size(); pos++) {
+ final Object value = values.get(pos);
+ final Optional<DataType> possibleValueDataType;
+ if (value instanceof InferredArrayValue) {
+ final InferredArrayValue inferredArrayValue =
(InferredArrayValue) value;
+ convertedValues[pos] = inferredArrayValue.getArray();
+ possibleValueDataType =
Optional.of(inferredArrayValue.getDataType());
+ } else if (value instanceof List) {
+ final InferredArrayMaterialization nestedArray =
+ materializeInferredArray((List<?>) value);
+ nestedArrays[pos] = nestedArray;
+ convertedValues[pos] = nestedArray.getArray();
+ if (nestedArray.getState() == InferredArrayState.INCOMPATIBLE)
{
+ compatible = false;
+ continue;
+ } else if (nestedArray.getState() ==
InferredArrayState.WILDCARD) {
+ continue;
+ }
+ possibleValueDataType = Optional.of(nestedArray.getDataType());
+ } else {
+ convertedValues[pos] = value;
+ if (value == null) {
+ continue;
+ }
+ possibleValueDataType =
ValueDataTypeConverter.extractDataType(value);
+ }
+
+ if (!possibleValueDataType.isPresent()) {
+ compatible = false;
+ continue;
+ }
+ final DataType valueDataType =
possibleValueDataType.get().nullable();
+ if (elementDataType == null) {
+ elementDataType = valueDataType;
+ } else if (!elementDataType.equals(valueDataType)) {
+ compatible = false;
+ }
+ }
+
+ if (!compatible) {
+ return InferredArrayMaterialization.incompatible(convertedValues);
+ }
+ if (elementDataType == null) {
+ return InferredArrayMaterialization.wildcard(convertedValues);
+ }
+
+ // The sibling's inferred data type preserves the shape and
value-dependent logical details
+ // of empty and null-only nested lists.
+ for (int pos = 0; pos < nestedArrays.length; pos++) {
+ final InferredArrayMaterialization nestedArray = nestedArrays[pos];
+ if (nestedArray == null || nestedArray.getState() !=
InferredArrayState.WILDCARD) {
+ continue;
+ }
+ final Object convertedNestedArray =
+ materializeWildcardArray((Object[])
nestedArray.getArray(), elementDataType);
+ if (convertedNestedArray == null) {
+ return
InferredArrayMaterialization.incompatible(convertedValues);
+ }
+ convertedValues[pos] = convertedNestedArray;
+ }
+
+ final Class<?> componentClass = elementDataType.getConversionClass();
+ final Object convertedArray = Array.newInstance(componentClass,
convertedValues.length);
+ for (int pos = 0; pos < convertedValues.length; pos++) {
+ Array.set(convertedArray, pos, convertedValues[pos]);
+ }
+
+ final DataType inferredDataType =
DataTypes.ARRAY(elementDataType).notNull();
+ return InferredArrayMaterialization.concrete(convertedArray,
inferredDataType);
+ }
+
+ private static @Nullable Object materializeWildcardArray(
+ final Object[] values, final DataType expectedArrayDataType) {
+ final Class<?> expectedArrayClass =
expectedArrayDataType.getConversionClass();
+ // An array conversion class can also represent a scalar type such as
BINARY.
+ if (!(expectedArrayDataType.getLogicalType() instanceof ArrayType)
+ || !expectedArrayClass.isArray()) {
+ return null;
+ }
+
+ final Class<?> componentClass = expectedArrayClass.getComponentType();
+ final DataType elementDataType =
expectedArrayDataType.getChildren().get(0);
+ final Object convertedArray = Array.newInstance(componentClass,
values.length);
+ for (int pos = 0; pos < values.length; pos++) {
+ final Object value = values[pos];
+ final Object convertedValue;
+ if (value instanceof Object[]) {
+ convertedValue = materializeWildcardArray((Object[]) value,
elementDataType);
+ if (convertedValue == null) {
+ return null;
+ }
+ } else if (value == null && !componentClass.isPrimitive()) {
+ convertedValue = null;
+ } else {
+ return null;
+ }
+ Array.set(convertedArray, pos, convertedValue);
+ }
+ return convertedArray;
+ }
+
+ private enum InferredArrayState {
+ CONCRETE,
+ WILDCARD,
+ INCOMPATIBLE
+ }
+
+ private static final class InferredArrayValue {
+
+ private final Object array;
+ private final DataType dataType;
+
+ private InferredArrayValue(final Object array, final DataType
dataType) {
+ this.array = array;
+ this.dataType = dataType;
+ }
+
+ private Object getArray() {
+ return array;
+ }
+
+ private DataType getDataType() {
+ return dataType;
+ }
+ }
+
+ private static final class InferredArrayMaterialization {
+
+ private final InferredArrayState state;
+ private final Object array;
+ private final @Nullable DataType dataType;
+
+ private InferredArrayMaterialization(
+ final InferredArrayState state,
+ final Object array,
+ @Nullable final DataType dataType) {
+ this.state = state;
+ this.array = array;
+ this.dataType = dataType;
+ }
+
+ private static InferredArrayMaterialization concrete(
+ final Object array, final DataType dataType) {
+ return new
InferredArrayMaterialization(InferredArrayState.CONCRETE, array, dataType);
+ }
+
+ private static InferredArrayMaterialization wildcard(final Object
array) {
+ return new
InferredArrayMaterialization(InferredArrayState.WILDCARD, array, null);
+ }
+
+ private static InferredArrayMaterialization incompatible(final Object
array) {
+ return new
InferredArrayMaterialization(InferredArrayState.INCOMPATIBLE, array, null);
+ }
+
+ private InferredArrayState getState() {
+ return state;
+ }
+
+ private Object getArray() {
+ return array;
+ }
+
+ private DataType getDataType() {
+ if (dataType == null) {
+ throw new IllegalStateException("Only concrete arrays retain
their data type.");
+ }
+ return dataType;
+ }
+ }
+
+ private static int getLiteralArrayLength(final Object value) {
+ return value instanceof List ? ((List<?>) value).size() :
Array.getLength(value);
+ }
+
+ private static Object getLiteralArrayElement(final Object value, final int
pos) {
+ return value instanceof List ? ((List<?>) value).get(pos) :
Array.get(value, pos);
+ }
+
+ private static boolean isLiteralRow(final Object value) {
+ return value instanceof Row
+ || value instanceof List
+ || value instanceof Map
+ || isLiteralJavaArray(value);
+ }
+
+ private static boolean isLiteralJavaArray(final Object value) {
+ // ValueDataTypeConverter treats byte[] as scalar BINARY rather than
ARRAY.
+ return value.getClass().isArray() && !(value instanceof byte[]);
+ }
+
+ private static int getLiteralRowArity(final Object value) {
+ if (value instanceof Row) {
+ return ((Row) value).getArity();
+ } else if (value instanceof List) {
+ return ((List<?>) value).size();
+ }
+ return Array.getLength(value);
+ }
+
private static BiFunction<Integer, Function<Integer, Object>, Object>
arrayConstructor(
final LogicalType elementType) {
if (elementType instanceof BooleanType) {
@@ -375,7 +785,8 @@ public final class PythonTableUtils {
return c ->
c instanceof Integer || c instanceof Long
? TimestampData.fromInstant(
- Instant.ofEpochMilli(((Number)
c).longValue() / 1000))
+ Instant.ofEpochMilli(
+ Math.floorDiv(((Number)
c).longValue(), 1000)))
: null;
}
@@ -503,6 +914,87 @@ public final class PythonTableUtils {
throw new IllegalStateException("Failed to get converter for
LogicalType: " + logicalType);
}
+ private static Function<Object, Object> scalarLiteralConverter(final
DataType dataType) {
+ if (!usesDefaultLiteralConversion(dataType)) {
+ return Function.identity();
+ }
+
+ final LogicalType logicalType = dataType.getLogicalType();
+ if (logicalType instanceof TinyIntType) {
+ return value -> {
+ if (!isIntegral(value)) {
+ return value;
+ }
+ final long number = ((Number) value).longValue();
+ return number >= Byte.MIN_VALUE && number <= Byte.MAX_VALUE ?
(byte) number : value;
+ };
+ }
+ if (logicalType instanceof SmallIntType) {
+ return value -> {
+ if (!isIntegral(value)) {
+ return value;
+ }
+ final long number = ((Number) value).longValue();
+ return number >= Short.MIN_VALUE && number <= Short.MAX_VALUE
+ ? (short) number
+ : value;
+ };
+ }
+ if (logicalType instanceof BigIntType) {
+ return value -> isIntegral(value) ? ((Number) value).longValue() :
value;
+ }
+ if (logicalType instanceof FloatType) {
+ return value -> value instanceof Double ? ((Double)
value).floatValue() : value;
+ }
+ if (logicalType instanceof YearMonthIntervalType) {
+ return value -> {
+ if (!isIntegral(value)) {
+ return value;
+ }
+ final long number = ((Number) value).longValue();
+ return number >= Integer.MIN_VALUE && number <=
Integer.MAX_VALUE
+ ? Period.ofMonths((int) number)
+ : value;
+ };
+ }
+ return Function.identity();
+ }
+
+ private static boolean usesDefaultLiteralConversion(final DataType
dataType) {
+ final LogicalType logicalType = dataType.getLogicalType();
+ final Class<?> conversionClass = dataType.getConversionClass();
+ if (logicalType instanceof ArrayType) {
+ return conversionClass.isArray();
+ } else if (logicalType instanceof MapType || logicalType instanceof
MultisetType) {
+ return Map.class.isAssignableFrom(conversionClass);
+ } else if (logicalType instanceof RowType) {
+ return conversionClass == Row.class;
+ }
+ return conversionClass == logicalType.getDefaultConversion();
+ }
+
+ private static boolean isIntegral(final Object value) {
+ return value instanceof Byte
+ || value instanceof Short
+ || value instanceof Integer
+ || value instanceof Long;
+ }
+
+ private static Object getLiteralRowField(
+ final Object value, final int pos, final String fieldName) {
+ if (value instanceof Row) {
+ final Row row = (Row) value;
+ return row.getFieldNames(false) == null ? row.getField(pos) :
row.getField(fieldName);
+ } else if (value instanceof List) {
+ return ((List<?>) value).get(pos);
+ } else if (value instanceof Map) {
+ return ((Map<?, ?>) value).get(fieldName);
+ } else if (value != null && isLiteralJavaArray(value)) {
+ return Array.get(value, pos);
+ }
+ return value;
+ }
+
private static int getOffsetFromLocalMillis(final long millisLocal) {
TimeZone localZone = TimeZone.getDefault();
int result = localZone.getRawOffset();
diff --git
a/flink-python/src/test/java/org/apache/flink/table/utils/python/PythonTableUtilsTest.java
b/flink-python/src/test/java/org/apache/flink/table/utils/python/PythonTableUtilsTest.java
new file mode 100644
index 00000000000..fe88205ba2a
--- /dev/null
+++
b/flink-python/src/test/java/org/apache/flink/table/utils/python/PythonTableUtilsTest.java
@@ -0,0 +1,325 @@
+/*
+ * 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.
+ */
+
+package org.apache.flink.table.utils.python;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.types.Row;
+import org.apache.flink.types.RowKind;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalTime;
+import java.time.Period;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PythonTableUtilsTest {
+
+ @Test
+ void testCreateLiteralConvertsPy4JNumbers() {
+ assertThat(literalValue(1,
DataTypes.TINYINT().notNull())).isInstanceOf(Byte.class);
+ assertThat(literalValue(1,
DataTypes.SMALLINT().notNull())).isInstanceOf(Short.class);
+ assertThat(literalValue(1,
DataTypes.BIGINT().notNull())).isInstanceOf(Long.class);
+ assertThat(literalValue(1.25,
DataTypes.FLOAT().notNull())).isInstanceOf(Float.class);
+ assertThat(
+ literalValue(
+ 14,
+ DataTypes.INTERVAL(DataTypes.YEAR(),
DataTypes.MONTH()).notNull()))
+ .isEqualTo(Period.ofMonths(14));
+ }
+
+ @Test
+ void testCreateLiteralRejectsIncompatibleNestedValue() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Collections.singletonList(1.25),
+
DataTypes.ARRAY(DataTypes.SMALLINT()).notNull()))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testCreateLiteralRejectsNonNullMultiset() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Collections.singletonMap(1, 2),
+
DataTypes.MULTISET(DataTypes.SMALLINT()).notNull()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessage("Non-null MULTISET literals are not supported.");
+ }
+
+ @Test
+ void testCreateLiteralSupportsTypedNull() {
+ final DataType dataType = DataTypes.ARRAY(DataTypes.SMALLINT());
+ final ValueLiteralExpression literal =
+ (ValueLiteralExpression) PythonTableUtils.createLiteral(null,
dataType).toExpr();
+
+ assertThat(literal.getOutputDataType()).isEqualTo(dataType);
+ assertThat(literal.getValueAs(Object.class)).isEmpty();
+ }
+
+ @Test
+ void testCreateLiteralUsesJavaArrayInferenceRules() {
+ assertThatThrownBy(() ->
PythonTableUtils.createLiteral(Collections.emptyList(), null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(() ->
PythonTableUtils.createLiteral(Arrays.asList("a", "bb"), null))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testCreateLiteralInfersNestedArraysFromSiblings() {
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(Arrays.asList(1),
Collections.emptyList()), null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(Collections.emptyList(),
Arrays.asList(1)), null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(Arrays.asList(1),
Collections.singletonList(null)),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+
Collections.singletonList(Collections.singletonList(1)),
+
Collections.singletonList(Collections.emptyList())),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.<Object>asList(new Integer[] {1},
Collections.emptyList()),
+ null))
+ .isNotNull();
+ }
+
+ @Test
+ void testCreateLiteralInfersValueDependentNestedArraysFromSiblings() {
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList("a"),
Collections.emptyList()),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList("a"),
+ Collections.singletonList(null)),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList(new
BigDecimal("1.20")),
+ Collections.emptyList()),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList(
+ LocalTime.of(12, 0, 0,
123_000_000)),
+ Collections.emptyList()),
+ null))
+ .isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList(new byte[]
{1}),
+ Collections.emptyList()),
+ null))
+ .isNotNull();
+ }
+
+ @Test
+ void testCreateLiteralSupportsPrimitiveArrays() {
+ for (final Object value :
+ Arrays.<Object>asList(
+ new boolean[] {true},
+ new short[] {1},
+ new int[] {1},
+ new long[] {1},
+ new float[] {1},
+ new double[] {1})) {
+ assertThat(PythonTableUtils.createLiteral(value,
null)).isNotNull();
+ }
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(new int[] {1},
Collections.emptyList()), null))
+ .isNotNull();
+ }
+
+ @Test
+ void testCreateLiteralUsesInferredArrayTypeHint() {
+ final Object inferredArrayValue =
+ PythonTableUtils.createInferredArrayValue(
+ new String[0],
DataTypes.ARRAY(DataTypes.CHAR(4)).notNull());
+
+ assertThat(PythonTableUtils.createLiteral(inferredArrayValue,
null)).isNotNull();
+ assertThat(
+ PythonTableUtils.createLiteral(
+ Arrays.asList(inferredArrayValue,
Collections.emptyList()), null))
+ .isNotNull();
+ }
+
+ @Test
+ void testCreateInferredArrayValueValidatesCarrier() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createInferredArrayValue(
+ new String[] {"a"},
+
DataTypes.ARRAY(DataTypes.CHAR(1)).notNull()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(
+ "An inferred array value requires an empty array
matching the ARRAY "
+ + "conversion class.");
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createInferredArrayValue(
+ new String[0],
DataTypes.ARRAY(DataTypes.INT()).notNull()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage(
+ "An inferred array value requires an empty array
matching the ARRAY "
+ + "conversion class.");
+ }
+
+ @Test
+ void testCreateLiteralRejectsArraysWithoutCommonSiblingType() {
+ assertThatThrownBy(
+ () ->
PythonTableUtils.createLiteral(Collections.singletonList(null), null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.emptyList(),
+
Collections.singletonList(null)),
+ null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList(1),
+ Collections.singletonList(1L)),
+ null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList("a"),
+
Collections.singletonList("bb")),
+ null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(1,
Collections.emptyList()), null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.<Object>asList(
+ new byte[] {1},
Collections.emptyList()),
+ null))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(
+ Collections.singletonList(new
byte[] {1}),
+
Collections.singletonList(Collections.emptyList())),
+ null))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testCreateLiteralRejectsBinaryAsConstructedValue() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ new byte[] {1},
+
DataTypes.ARRAY(DataTypes.TINYINT()).notNull()))
+ .isInstanceOf(ValidationException.class);
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ new byte[] {1, 2},
+ DataTypes.ROW(
+ DataTypes.FIELD("a",
DataTypes.TINYINT()),
+ DataTypes.FIELD("b",
DataTypes.TINYINT()))
+ .notNull()))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ @Test
+ void testCreateLiteralRejectsEmptyRow() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Collections.emptyList(),
DataTypes.ROW().notNull()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessage("Non-null empty ROW literals are not supported.");
+ }
+
+ @Test
+ void testCreateLiteralRejectsWrongRowArity() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Arrays.asList(1, 2),
+ DataTypes.ROW(DataTypes.FIELD("value",
DataTypes.INT()))
+ .notNull()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessage("ROW literal has arity 2 but the data type has
arity 1.");
+ }
+
+ @Test
+ void testCreateLiteralRejectsNonInsertRow() {
+ assertThatThrownBy(
+ () ->
+ PythonTableUtils.createLiteral(
+ Row.ofKind(RowKind.DELETE, 1),
+ DataTypes.ROW(DataTypes.FIELD("value",
DataTypes.INT()))
+ .notNull()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessage(
+ "Unsupported kind 'DELETE' of a row [-D[1]]. "
+ + "Only rows with 'INSERT' kind are supported
when converting "
+ + "to an expression.");
+ }
+
+ private static Object literalValue(final Object value, final DataType
dataType) {
+ final ValueLiteralExpression literal =
+ (ValueLiteralExpression) PythonTableUtils.createLiteral(value,
dataType).toExpr();
+ return
literal.getValueAs(Object.class).orElseThrow(AssertionError::new);
+ }
+}