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 6359419dbd2 [FLINK-40196][python] Add missing-value handling to
DataFrame API
6359419dbd2 is described below
commit 6359419dbd209fc1e82f5fe6adb0578ec4099031
Author: Matt Belle <[email protected]>
AuthorDate: Fri Aug 14 12:57:39 2026 -0400
[FLINK-40196][python] Add missing-value handling to DataFrame API
This closes #28979.
---
docs/data/sql_functions.yml | 6 +
docs/data/sql_functions_zh.yml | 6 +
.../docs/reference/pyflink.dataframe/dataframe.rst | 13 +
.../docs/reference/pyflink.table/expressions.rst | 4 +
flink-python/pyflink/dataframe/dataframe.py | 328 ++++++++++-
.../pyflink/dataframe/tests/test_dataframe.py | 646 +++++++++++++++++++++
flink-python/pyflink/table/expression.py | 18 +
flink-python/pyflink/table/expressions.py | 26 +-
.../org/apache/flink/table/api/Expressions.java | 20 +
.../flink/table/api/internal/BaseExpressions.java | 12 +
.../functions/BuiltInFunctionDefinitions.java | 22 +
.../runtime/functions/scalar/IsNanFunction.java | 63 ++
.../runtime/functions/scalar/IsNotNanFunction.java | 63 ++
tools/maven/suppressions.xml | 1 +
14 files changed, 1225 insertions(+), 3 deletions(-)
diff --git a/docs/data/sql_functions.yml b/docs/data/sql_functions.yml
index c53b6767ade..a474962d73a 100644
--- a/docs/data/sql_functions.yml
+++ b/docs/data/sql_functions.yml
@@ -40,6 +40,12 @@ comparison:
- sql: value IS NOT NULL
table: value.isNotNull
description: Returns TRUE if value is not NULL.
+ - sql: IS_NAN(value)
+ table: value.isNan
+ description: Returns TRUE if value is NaN; returns FALSE if value is a
non-NaN numeric value; returns NULL if value is NULL.
+ - sql: IS_NOT_NAN(value)
+ table: value.isNotNan
+ description: Returns TRUE if value is a non-NaN numeric value; returns
FALSE if value is NaN; returns NULL if value is NULL.
- sql: value1 IS DISTINCT FROM value2
description: Returns TRUE if two values are different. NULL values are
treated as identical here. E.g., 1 IS DISTINCT FROM NULL returns TRUE; NULL IS
DISTINCT FROM NULL returns FALSE.
- sql: value1 IS NOT DISTINCT FROM value2
diff --git a/docs/data/sql_functions_zh.yml b/docs/data/sql_functions_zh.yml
index 7c0a4e95d15..3225ce7ab40 100644
--- a/docs/data/sql_functions_zh.yml
+++ b/docs/data/sql_functions_zh.yml
@@ -40,6 +40,12 @@ comparison:
- sql: value IS NOT NULL
table: value.isNotNull
description: 如果值不为 `NULL` 返回 `TRUE`。
+ - sql: IS_NAN(value)
+ table: value.isNan
+ description: 如果值为 `NaN` 返回 `TRUE`;如果值是非 `NaN` 的数值返回 `FALSE`;如果值为 `NULL` 返回
`NULL`。
+ - sql: IS_NOT_NAN(value)
+ table: value.isNotNan
+ description: 如果值是非 `NaN` 的数值返回 `TRUE`;如果值为 `NaN` 返回 `FALSE`;如果值为 `NULL` 返回
`NULL`。
- sql: value1 IS DISTINCT FROM value2
description: |
A 和 B 的数据类型和值不完全相同返回 `TRUE`。A 和 B 的数据类型和值都相同返回 `FALSE`。`NULL` 视为与任何其他值相同。
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 46286497076..9f0f7764dcc 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -104,6 +104,19 @@ Joins
DataFrame.join
+Missing-Value Handling
+----------------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ DataFrame.drop_null
+ DataFrame.drop_nan
+ DataFrame.fill_null
+ DataFrame.fill_nan
+
Aggregations
------------
diff --git a/flink-python/docs/reference/pyflink.table/expressions.rst
b/flink-python/docs/reference/pyflink.table/expressions.rst
index a76320245a2..e2ee9777c58 100644
--- a/flink-python/docs/reference/pyflink.table/expressions.rst
+++ b/flink-python/docs/reference/pyflink.table/expressions.rst
@@ -35,6 +35,8 @@ Expressions
and_
or_
not_
+ is_nan
+ is_not_nan
current_database
current_date
current_time
@@ -120,6 +122,8 @@ arithmetic functions
Expression.if_null
Expression.is_null
Expression.is_not_null
+ Expression.is_nan
+ Expression.is_not_nan
Expression.is_true
Expression.is_false
Expression.is_not_true
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 8698dda1367..8418a90dad1 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -19,6 +19,7 @@
import datetime
import keyword
from contextlib import ExitStack
+from decimal import Decimal
from typing import (
TYPE_CHECKING,
Any,
@@ -63,12 +64,38 @@ from pyflink.table.expressions import (
and_,
call_sql,
col as table_col,
+ if_then_else,
+ is_nan,
+ is_not_nan,
lit as table_lit,
+ or_,
)
from pyflink.table.table import Table
from pyflink.table.statement_set import StatementSet
-from pyflink.table.types import ArrayType, MapType, MultisetType, RowType
from pyflink.table.table_descriptor import TableDescriptor
+from pyflink.table.types import (
+ ArrayType,
+ MapType,
+ MultisetType,
+ RowType,
+ FloatType,
+ DoubleType,
+ AtomicType,
+ IntegralType,
+ FractionalType,
+ DecimalType,
+ CharType,
+ VarCharType,
+ BooleanType,
+ BinaryType,
+ VarBinaryType,
+ DateType,
+ DayTimeIntervalType,
+ TimeType,
+ TimestampType,
+ LocalZonedTimestampType,
+ DataType as TableDataType,
+)
from pyflink.util.api_stability_decorators import PublicEvolving
from pyflink.util.java_utils import to_jarray
@@ -1709,6 +1736,305 @@ class DataFrame:
"""
return func(self, *args, **kwargs)
+ # ======================== Missing Value Handling ========================
+
+ def _validate_subset(self, subset: Optional[List[str]]) -> List[str]:
+ """
+ Validate and normalize the subset parameter.
+
+ :param subset: Column names to validate, or None for all columns.
+ :return: Validated list of column names.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+ """
+ schema = self._table.get_schema()
+ all_columns = schema.get_field_names()
+
+ if subset is None:
+ return all_columns
+
+ if not isinstance(subset, list) or not all(
+ isinstance(column, str) for column in subset
+ ):
+ raise TypeError("subset must be a list of strings")
+
+ # Empty subset is allowed - it's a no-op
+ if not subset:
+ return []
+
+ # Validate all column names exist
+ all_columns_set = set(all_columns)
+ invalid_columns = set(subset) - all_columns_set
+ if invalid_columns:
+ raise ValueError(f"Columns not found in DataFrame:
{sorted(invalid_columns)}")
+
+ return subset
+
+ def _is_type_compatible(self, value: Any, col_type: TableDataType) -> bool:
+ """
+ Check if a value's type is compatible with a column's data type.
+
+ Only atomic (primitive) types are supported. Complex types (arrays,
maps, rows)
+ are not compatible and will return False.
+
+ :param value: The value to check.
+ :param col_type: The column's Flink data type.
+ :return: True if types are compatible, False otherwise.
+ """
+ # Skip complex types
+ if not isinstance(col_type, AtomicType):
+ return False
+
+ # None is compatible with any atomic column type (assigning NULL)
+ if value is None:
+ return True
+
+ # Map Python types to compatible Flink types
+ type_map = {
+ bool: (BooleanType,),
+ int: (IntegralType, FractionalType, DecimalType),
+ float: (FractionalType, DecimalType),
+ str: (CharType, VarCharType),
+ Decimal: (DecimalType,),
+ bytes: (BinaryType, VarBinaryType),
+ bytearray: (BinaryType, VarBinaryType),
+ datetime.date: (DateType,),
+ datetime.time: (TimeType,),
+ datetime.datetime: (TimestampType, LocalZonedTimestampType),
+ datetime.timedelta: (DayTimeIntervalType,),
+ }
+
+ compatible_types = type_map.get(type(value))
+ if compatible_types is None:
+ return False
+
+ return isinstance(col_type, compatible_types)
+
+ def _filter_to_float_columns(self, subset: Optional[List[str]]) ->
List[str]:
+ """
+ Resolve the floating-point columns to operate on for NaN handling.
+
+ :param subset: Column names to check, or None for all columns.
+ :return: The subset's column names that are FLOAT or DOUBLE typed.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+ """
+ schema = self._table.get_schema()
+ subset = schema.get_field_names() if subset is None else
self._validate_subset(subset)
+
+ return [
+ col_name for col_name in subset
+ if isinstance(schema.get_field_data_type(col_name), (FloatType,
DoubleType))
+ ]
+
+ def _fill_values(
+ self,
+ value: Any,
+ subset: Optional[List[str]],
+ condition_fn: Callable[[Expression], Expression]
+ ) -> "DataFrame":
+ """
+ Helper method to fill values based on a condition.
+
+ :param value: The value to use as replacement.
+ :param subset: Column names to fill, or None for all columns.
+ :param condition_fn: Function that takes a column expression and
returns
+ a boolean expression indicating when to replace.
+ :return: A new DataFrame with values replaced.
+ """
+ subset = self._validate_subset(subset)
+
+ # Empty subset is a no-op - return self unchanged
+ if not subset:
+ return self
+
+ subset_set = set(subset)
+
+ schema = self._table.get_schema()
+ all_columns = schema.get_field_names()
+
+ expressions = []
+ for col_name in all_columns:
+ col_expr = table_col(col_name)
+ if col_name in subset_set:
+ col_type = schema.get_field_data_type(col_name)
+
+ # Only fill type-compatible columns
+ if self._is_type_compatible(value, col_type):
+ if value is None:
+ # A NULL literal cannot have its type inferred; it
must be
+ # given explicitly, and must be nullable.
+ typed_value = table_lit(None, col_type.nullable())
+ else:
+ typed_value = table_lit(value).cast(col_type)
+ filled_expr = if_then_else(
+ condition_fn(col_expr),
+ typed_value,
+ col_expr
+ ).alias(col_name)
+ expressions.append(filled_expr)
+ else:
+ expressions.append(col_expr)
+ else:
+ expressions.append(col_expr)
+
+ return DataFrame(self._table.select(*expressions))
+
+ @PublicEvolving()
+ def drop_null(self, subset: Optional[List[str]] = None) -> "DataFrame":
+ """
+ Remove rows containing NULL values.
+
+ This method uses three-valued logic: NULL values in the specified
columns
+ will cause the row to be filtered out. Rows where all checked columns
are
+ non-NULL will be retained.
+
+ :param subset: Column names to check. If None, checks all columns.
+ :return: A new DataFrame with rows containing NULL values removed.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... {"id": 1, "name": "Alice", "age": 30},
+ ... {"id": 2, "name": None, "age": 25},
+ ... {"id": 3, "name": "Bob", "age": None},
+ ... ])
+ >>> df.drop_null() # Drop rows with any NULL
+ >>> df.drop_null(subset=["age"]) # Drop rows where "age" is NULL
+
+ .. versionadded:: 2.4.0
+ """
+ subset = self._validate_subset(subset)
+
+ # Empty subset is a no-op - return self unchanged
+ if not subset:
+ return self
+
+ conditions = [table_col(col_name).is_not_null for col_name in subset]
+ condition = and_(*conditions) if len(conditions) > 1 else conditions[0]
+ return DataFrame(self._table.filter(condition))
+
+ @PublicEvolving()
+ def drop_nan(self, subset: Optional[List[str]] = None) -> "DataFrame":
+ """
+ Remove rows containing NaN values (for float/double columns).
+
+ This method uses three-valued logic: NaN values in the specified
columns
+ will cause the row to be filtered out. NULL values are preserved (not
+ treated as NaN). Only applies to floating-point numeric types.
+
+ :param subset: Column names to check. If None, checks all columns.
+ :return: A new DataFrame with rows containing NaN values removed.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... {"id": 1, "score": 0.95},
+ ... {"id": 2, "score": float('nan')},
+ ... ])
+ >>> df.drop_nan() # Drop rows with any NaN
+ >>> df.drop_nan(subset=["score"]) # Drop rows where "score" is NaN
+
+ .. versionadded:: 2.4.0
+ """
+ subset = self._filter_to_float_columns(subset)
+
+ # Empty subset is a no-op - return self unchanged
+ if not subset:
+ return self
+
+ # Preserve NULL values: (is_not_nan(col) OR col IS NULL)
+ conditions = [
+ or_(is_not_nan(table_col(col_name)), table_col(col_name).is_null)
+ for col_name in subset
+ ]
+ condition = and_(*conditions) if len(conditions) > 1 else conditions[0]
+ return DataFrame(self._table.filter(condition))
+
+ @PublicEvolving()
+ def fill_null(self, value: Any, subset: Optional[List[str]] = None) ->
"DataFrame":
+ """
+ Replace NULL values with a specified value.
+
+ This method uses three-valued logic: NULL values in the specified
columns
+ are replaced with the provided value, while non-NULL values are
preserved.
+ The replacement value is automatically cast to match each column's
data type.
+ Columns whose data type is not compatible with ``value`` are left
unchanged.
+ If ``value`` is ``None``, the DataFrame is returned unchanged.
+
+ :param value: The value to replace NULL with.
+ :param subset: Column names to fill. If None, fills all columns.
+ :return: A DataFrame with NULL values replaced, or this DataFrame
unchanged if ``value``
+ is ``None``.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... {"id": 1, "name": "Alice", "quantity": 10},
+ ... {"id": 2, "name": None, "quantity": None},
+ ... ])
+ >>> df.fill_null(0, subset=["quantity"])
+ >>> df.fill_null("unknown", subset=["name"])
+
+ .. versionadded:: 2.4.0
+ """
+ if value is None:
+ self._validate_subset(subset)
+ return self
+
+ return self._fill_values(value, subset, lambda col: col.is_null)
+
+ @PublicEvolving()
+ def fill_nan(self, value: Any, subset: Optional[List[str]] = None) ->
"DataFrame":
+ """
+ Replace NaN values with a specified value (for float/double columns).
+
+ This method uses three-valued logic: NaN values in the specified
columns
+ are replaced with the provided value, while non-NaN values are
preserved.
+ NULL values are preserved (not treated as NaN). The replacement value
is
+ automatically cast to match each column's data type. If ``value`` is
+ ``None``, NaN values are converted to NULL.
+
+ Non-floating-point columns in ``subset`` are ignored, since only
+ floating-point types support NaN. Columns whose data type is not
+ compatible with ``value`` are also left unchanged.
+
+ :param value: The value to replace NaN with. Can be ``None`` to convert
+ NaN values to NULL.
+ :param subset: Column names to fill. If None, fills all floating-point
+ columns.
+ :return: A new DataFrame with NaN values replaced.
+ :raises ValueError: If subset contains invalid column names.
+ :raises TypeError: If subset is not a list of strings.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([
+ ... {"id": 1, "score": 0.95},
+ ... {"id": 2, "score": float('nan')},
+ ... ])
+ >>> df.fill_nan(0.0, subset=["score"])
+
+ .. versionadded:: 2.4.0
+ """
+ subset = self._filter_to_float_columns(subset)
+
+ # Empty subset is a no-op - return self unchanged
+ if not subset:
+ return self
+
+ return self._fill_values(value, subset, lambda col: is_nan(col))
+
# ======================== Conversion ========================
@PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index b2833cfd8b5..5305802a406 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -19,6 +19,7 @@
import array
import decimal
import inspect
+import math
import os
import pandas as pd
import pyarrow as pa
@@ -43,6 +44,7 @@ from pyflink.table import (
TableSchema,
)
from pyflink.table.expression import Expression
+from pyflink.table.expressions import is_nan, is_not_nan
from pyflink.table.types import LocalZonedTimestampType, TimestampType
from pyflink.testing.test_case_utils import (
PyFlinkDataFrameUTTestCase,
@@ -2969,6 +2971,183 @@ class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
)
+class DataFrameDropNullTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": None, "age": 25},
+ {"id": 3, "name": "Bob", "age": None},
+ {"id": 4, "name": None, "age": None},
+ ],
+ schema=["id", "name", "age"],
+ )
+ self.expected_schema = [
+ TableDataTypes.BIGINT(),
+ TableDataTypes.STRING(),
+ TableDataTypes.BIGINT(),
+ ]
+
+ def test_drop_null_without_subset_checks_all_columns(self):
+ result = self.dataframe.drop_null()
+ self.assert_dataframe_schema(result, ["id", "name", "age"],
self.expected_schema)
+
+ def test_drop_null_with_single_column_subset(self):
+ result = self.dataframe.drop_null(subset=["age"])
+ self.assert_dataframe_schema(result, ["id", "name", "age"],
self.expected_schema)
+
+ def test_drop_null_with_multiple_column_subset(self):
+ result = self.dataframe.drop_null(subset=["name", "age"])
+ self.assert_dataframe_schema(result, ["id", "name", "age"],
self.expected_schema)
+
+ def test_drop_null_with_empty_subset_returns_unchanged(self):
+ result = self.dataframe.drop_null(subset=[])
+ self.assert_dataframe_schema(result, ["id", "name", "age"],
self.expected_schema)
+ # Verify it's a no-op by checking the result is the same DataFrame
+ self.assertEqual(result._table, self.dataframe._table)
+
+ def test_drop_null_with_invalid_column_raises_error(self):
+ with self.assertRaises(ValueError) as context:
+ self.dataframe.drop_null(subset=["invalid_column"])
+ self.assertIn("Columns not found in DataFrame", str(context.exception))
+
+ def test_drop_null_with_non_list_subset_raises_error(self):
+ with self.assertRaises(TypeError) as context:
+ self.dataframe.drop_null(subset="age")
+ self.assertIn("subset must be a list of strings",
str(context.exception))
+
+
+class DataFrameDropNanTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": float('nan')},
+ {"id": 3, "score": 0.85},
+ ],
+ schema=["id", "score"],
+ )
+ self.expected_schema = [TableDataTypes.BIGINT(),
TableDataTypes.DOUBLE()]
+
+ def test_drop_nan_without_subset_checks_all_columns(self):
+ result = self.dataframe.drop_nan()
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_drop_nan_with_subset(self):
+ result = self.dataframe.drop_nan(subset=["score"])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_drop_nan_with_multiple_column_subset(self):
+ result = self.dataframe.drop_nan(subset=["id", "score"])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_drop_nan_with_empty_subset_returns_unchanged(self):
+ result = self.dataframe.drop_nan(subset=[])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+ # Verify it's a no-op by checking the result is the same DataFrame
+ self.assertEqual(result._table, self.dataframe._table)
+
+ def test_drop_nan_with_invalid_column_raises_error(self):
+ with self.assertRaises(ValueError) as context:
+ self.dataframe.drop_nan(subset=["invalid_column"])
+ self.assertIn("Columns not found in DataFrame", str(context.exception))
+
+ def test_drop_nan_with_non_list_subset_raises_error(self):
+ with self.assertRaises(TypeError) as context:
+ self.dataframe.drop_nan(subset="score")
+ self.assertIn("subset must be a list of strings",
str(context.exception))
+
+
+class DataFrameFillNullTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "quantity": 10},
+ {"id": 2, "name": None, "quantity": None},
+ {"id": 3, "name": "Bob", "quantity": 5},
+ ],
+ schema=["id", "name", "quantity"],
+ )
+ self.expected_schema = [
+ TableDataTypes.BIGINT(),
+ TableDataTypes.STRING(),
+ TableDataTypes.BIGINT(),
+ ]
+
+ def test_fill_null_with_numeric_value(self):
+ result = self.dataframe.fill_null(0, subset=["quantity"])
+ self.assert_dataframe_schema(result, ["id", "name", "quantity"],
self.expected_schema)
+
+ def test_fill_null_with_string_value(self):
+ result = self.dataframe.fill_null("unknown", subset=["name"])
+ self.assert_dataframe_schema(result, ["id", "name", "quantity"],
self.expected_schema)
+
+ def test_fill_null_without_subset_fills_all_columns(self):
+ result = self.dataframe.fill_null(0)
+ self.assert_dataframe_schema(result, ["id", "name", "quantity"],
self.expected_schema)
+
+ def test_fill_null_with_empty_subset_returns_unchanged(self):
+ result = self.dataframe.fill_null(0, subset=[])
+ self.assert_dataframe_schema(result, ["id", "name", "quantity"],
self.expected_schema)
+ # Verify it's a no-op by checking the result is the same DataFrame
+ self.assertEqual(result._table, self.dataframe._table)
+
+ def test_fill_null_with_invalid_column_raises_error(self):
+ with self.assertRaises(ValueError) as context:
+ self.dataframe.fill_null(0, subset=["invalid_column"])
+ self.assertIn("Columns not found in DataFrame", str(context.exception))
+
+ def test_fill_null_with_non_list_subset_raises_error(self):
+ with self.assertRaises(TypeError) as context:
+ self.dataframe.fill_null(0, subset="quantity")
+ self.assertIn("subset must be a list of strings",
str(context.exception))
+
+
+class DataFrameFillNanTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": float('nan')},
+ {"id": 3, "score": 0.85},
+ ],
+ schema=["id", "score"],
+ )
+ self.expected_schema = [TableDataTypes.BIGINT(),
TableDataTypes.DOUBLE()]
+
+ def test_fill_nan_with_numeric_value(self):
+ result = self.dataframe.fill_nan(0.0, subset=["score"])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_fill_nan_without_subset_fills_all_columns(self):
+ result = self.dataframe.fill_nan(0.0)
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_fill_nan_with_multiple_column_subset(self):
+ result = self.dataframe.fill_nan(0.0, subset=["id", "score"])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+
+ def test_fill_nan_with_empty_subset_returns_unchanged(self):
+ result = self.dataframe.fill_nan(0.0, subset=[])
+ self.assert_dataframe_schema(result, ["id", "score"],
self.expected_schema)
+ # Verify it's a no-op by checking the result is the same DataFrame
+ self.assertEqual(result._table, self.dataframe._table)
+
+ def test_fill_nan_with_invalid_column_raises_error(self):
+ with self.assertRaises(ValueError) as context:
+ self.dataframe.fill_nan(0.0, subset=["invalid_column"])
+ self.assertIn("Columns not found in DataFrame", str(context.exception))
+
+ def test_fill_nan_with_non_list_subset_raises_error(self):
+ with self.assertRaises(TypeError) as context:
+ self.dataframe.fill_nan(0.0, subset="score")
+ self.assertIn("subset must be a list of strings",
str(context.exception))
+
+
class DataFrameBatchITTests(PyFlinkITTestCase):
def setUp(self):
previous_environment = pf.get_table_environment()
@@ -3764,5 +3943,472 @@ class
DataFrameWindowITTests(PyFlinkStreamDataFrameTestCase):
)
+class DataFrameNullNanITTests(PyFlinkStreamDataFrameTestCase):
+ def test_fill_null_type_compatibility(self):
+ # Create DataFrame with one column per type, each starting null in row
2
+ df = pf.from_records(
+ [
+ {
+ "id": 1,
+ "name": "Alice",
+ "score": 0.95,
+ "active": True,
+ "data": bytearray(b"payload"),
+ "joined": date(2024, 1, 1),
+ "checkin": time(9, 0),
+ },
+ {
+ "id": None,
+ "name": None,
+ "score": None,
+ "active": None,
+ "data": None,
+ "joined": None,
+ "checkin": None,
+ },
+ ],
+ schema=["id", "name", "score", "active", "data", "joined",
"checkin"],
+ )
+
+ # Each value should only fill the column(s) of a compatible type,
+ # leaving type-mismatched columns null.
+ fill_value_to_expected_columns = [
+ (0, {"id", "score"}),
+ ("unknown", {"name"}),
+ (False, {"active"}),
+ (bytearray(b"default"), {"data"}),
+ (date(2000, 1, 1), {"joined"}),
+ (time(0, 0), {"checkin"}),
+ ]
+
+ for value, filled_columns in fill_value_to_expected_columns:
+ rows = df.fill_null(value).collect()
+ for column in df.columns:
+ expected = value if column in filled_columns else None
+ self.assertEqual(rows[1][df.columns.index(column)], expected)
+
+ def test_fill_null_with_timestamp_column(self):
+ # This is separate from test_fill_null_type_compatibility because a
plain
+ # TIMESTAMP column can't be obtained directly from from_records(): it
always
+ # infers datetime.datetime values as TIMESTAMP_LTZ, and collect() on a
+ # TIMESTAMP_LTZ column hits an unrelated, pre-existing bug in the
Java/Python
+ # bridge (FLINK-38418). So we build the column as TIMESTAMP_LTZ and
then cast
+ # it to TIMESTAMP to exercise the TimestampType branch of
+ # _is_type_compatible's type map. Merge back into
+ # test_fill_null_type_compatibility once FLINK-38418 is fixed.
+ # Pin the local timezone so the LTZ -> TIMESTAMP cast doesn't shift the
+ # wall-clock value depending on the machine's system timezone.
+ original_timezone = self.t_env.get_config().get_local_timezone()
+ self.t_env.get_config().set_local_timezone("UTC")
+ try:
+ df = pf.from_records(
+ [
+ {"id": 1, "last_seen": datetime(2024, 1, 1, 9, 0)},
+ {"id": 2, "last_seen": None},
+ ],
+ schema=["id", "last_seen"],
+ )
+ df = df.with_column(
+ "last_seen",
pf.col("last_seen").cast(TableDataTypes.TIMESTAMP(3))
+ )
+
+ result = df.fill_null(datetime(2000, 1, 1)).collect()
+ self.assertEqual(result[0], Row(1, datetime(2024, 1, 1, 9, 0)))
+ self.assertEqual(result[1], Row(2, datetime(2000, 1, 1)))
+ finally:
+ self.t_env.get_config().set_local_timezone(original_timezone)
+
+ def test_drop_null_removes_rows_with_null_values(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": None, "age": 25},
+ {"id": 3, "name": "Bob", "age": None},
+ {"id": 4, "name": None, "age": None},
+ ],
+ schema=["id", "name", "age"],
+ )
+
+ result = df.drop_null().collect()
+ self.assertEqual(result, [Row(1, "Alice", 30)])
+
+ def
test_drop_null_with_subset_removes_rows_with_null_in_specified_columns(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "age": 30},
+ {"id": 2, "name": None, "age": 25},
+ {"id": 3, "name": "Bob", "age": None},
+ ],
+ schema=["id", "name", "age"],
+ )
+
+ result = df.drop_null(subset=["age"]).collect()
+ self.assertEqual(result, [Row(1, "Alice", 30), Row(2, None, 25)])
+
+ def test_drop_nan_removes_rows_with_nan_values(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": float('nan')},
+ {"id": 3, "score": 0.85},
+ ],
+ schema=["id", "score"],
+ )
+
+ result = df.drop_nan().collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertAlmostEqual(result[0][1], 0.95)
+ self.assertEqual(result[1][0], 3)
+ self.assertAlmostEqual(result[1][1], 0.85)
+
+ def
test_drop_nan_with_subset_removes_rows_with_nan_in_specified_columns(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "score": 0.95, "rating": 4.5},
+ {"id": 2, "score": float('nan'), "rating": 3.0},
+ {"id": 3, "score": 0.85, "rating": float('nan')},
+ ],
+ schema=["id", "score", "rating"],
+ )
+
+ result = df.drop_nan(subset=["score"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertAlmostEqual(result[0][1], 0.95)
+ self.assertEqual(result[1][0], 3)
+ self.assertAlmostEqual(result[1][1], 0.85)
+
+ def test_drop_nan_preserves_null_values(self):
+ """Test that drop_nan preserves NULL values (critical bug fix)."""
+ df = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": None}, # NULL should be preserved
+ {"id": 3, "score": float('nan')}, # NaN should be dropped
+ {"id": 4, "score": 0.85},
+ ],
+ schema=["id", "score"],
+ )
+
+ result = df.drop_nan(subset=["score"]).collect()
+ # Should have 3 rows: id=1, id=2 (NULL preserved), id=4
+ self.assertEqual(len(result), 3)
+ self.assertEqual(result[0][0], 1)
+ self.assertAlmostEqual(result[0][1], 0.95)
+ self.assertEqual(result[1][0], 2)
+ self.assertIsNone(result[1][1]) # NULL preserved
+ self.assertEqual(result[2][0], 4)
+ self.assertAlmostEqual(result[2][1], 0.85)
+
+ def test_drop_nan_with_mixed_schema_auto_filters_to_float_columns(self):
+ """Test that drop_nan with subset=None only checks FLOAT/DOUBLE
columns."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": 0.95, "rating": 4.5},
+ {"id": 2, "name": "Bob", "score": float('nan'), "rating": 3.0},
+ {"id": 3, "name": "Charlie", "score": 0.85, "rating":
float('nan')},
+ ],
+ schema=["id", "name", "score", "rating"],
+ )
+
+ # Should only check score and rating (FLOAT/DOUBLE), not id (INT) or
name (STRING)
+ result = df.drop_nan().collect()
+ # Only row with id=1 has no NaN in float columns
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[0][1], "Alice")
+ self.assertAlmostEqual(result[0][2], 0.95)
+ self.assertAlmostEqual(result[0][3], 4.5)
+
+ def test_drop_nan_with_no_float_columns_returns_unchanged(self):
+ """Test that drop_nan with no FLOAT/DOUBLE columns returns unchanged
DataFrame."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "active": True},
+ {"id": 2, "name": "Bob", "active": False},
+ ],
+ schema=["id", "name", "active"],
+ )
+
+ # No float columns, should return all rows unchanged
+ result = df.drop_nan().collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], Row(1, "Alice", True))
+ self.assertEqual(result[1], Row(2, "Bob", False))
+
+ def test_drop_nan_with_explicit_subset_ignores_non_float_columns(self):
+ """Test that drop_nan with an explicit subset still only checks
FLOAT/DOUBLE columns."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": 0.95},
+ {"id": 2, "name": "Bob", "score": float('nan')},
+ ],
+ schema=["id", "name", "score"],
+ )
+
+ # "name" is a STRING column and should be silently ignored, not raise
+ # or attempt to apply is_not_nan to it.
+ result = df.drop_nan(subset=["name", "score"]).collect()
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[0][1], "Alice")
+ self.assertAlmostEqual(result[0][2], 0.95)
+
+ def
test_drop_nan_with_explicit_subset_of_only_non_float_columns_returns_unchanged(self):
+ """Test that drop_nan with an explicit subset of only non-float
columns is a no-op."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": float('nan')},
+ {"id": 2, "name": "Bob", "score": 0.85},
+ ],
+ schema=["id", "name", "score"],
+ )
+
+ result = df.drop_nan(subset=["id", "name"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[1][0], 2)
+
+ def test_fill_nan_with_mixed_schema_auto_filters_to_float_columns(self):
+ """Test that fill_nan with subset=None only fills FLOAT/DOUBLE
columns."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": 0.95, "rating": 4.5},
+ {"id": 2, "name": "Bob", "score": float('nan'), "rating": 3.0},
+ {"id": 3, "name": "Charlie", "score": 0.85, "rating":
float('nan')},
+ ],
+ schema=["id", "name", "score", "rating"],
+ )
+
+ # Should only fill score and rating (FLOAT/DOUBLE), not id (INT) or
name (STRING)
+ result = df.fill_nan(0.0).collect()
+ self.assertEqual(len(result), 3)
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[0][1], "Alice")
+ self.assertAlmostEqual(result[0][2], 0.95)
+ self.assertAlmostEqual(result[0][3], 4.5)
+ self.assertEqual(result[1][0], 2)
+ self.assertEqual(result[1][1], "Bob")
+ self.assertAlmostEqual(result[1][2], 0.0) # NaN filled
+ self.assertAlmostEqual(result[1][3], 3.0)
+ self.assertEqual(result[2][0], 3)
+ self.assertEqual(result[2][1], "Charlie")
+ self.assertAlmostEqual(result[2][2], 0.85)
+ self.assertAlmostEqual(result[2][3], 0.0) # NaN filled
+
+ def test_fill_nan_with_no_float_columns_returns_unchanged(self):
+ """Test that fill_nan with no FLOAT/DOUBLE columns returns unchanged
DataFrame."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "active": True},
+ {"id": 2, "name": "Bob", "active": False},
+ ],
+ schema=["id", "name", "active"],
+ )
+
+ # No float columns, should return all rows unchanged
+ result = df.fill_nan(0.0).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], Row(1, "Alice", True))
+ self.assertEqual(result[1], Row(2, "Bob", False))
+
+ def test_fill_nan_with_explicit_subset_ignores_non_float_columns(self):
+ """Test that fill_nan with an explicit subset still only fills
FLOAT/DOUBLE columns."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": 0.95},
+ {"id": 2, "name": "Bob", "score": float('nan')},
+ ],
+ schema=["id", "name", "score"],
+ )
+
+ # "name" is a STRING column and should be silently ignored, not raise
+ # or attempt to apply is_nan to it.
+ result = df.fill_nan(0.0, subset=["name", "score"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], Row(1, "Alice", 0.95))
+ self.assertEqual(result[1][0], 2)
+ self.assertEqual(result[1][1], "Bob")
+ self.assertAlmostEqual(result[1][2], 0.0)
+
+ def
test_fill_nan_with_explicit_subset_of_only_non_float_columns_returns_unchanged(self):
+ """Test that fill_nan with an explicit subset of only non-float
columns is a no-op."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "score": float('nan')},
+ {"id": 2, "name": "Bob", "score": 0.85},
+ ],
+ schema=["id", "name", "score"],
+ )
+
+ result = df.fill_nan(0.0, subset=["id", "name"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertTrue(math.isnan(result[0][2]))
+ self.assertEqual(result[1][0], 2)
+ self.assertAlmostEqual(result[1][2], 0.85)
+
+ def test_fill_null_skips_incompatible_array_column(self):
+ """Test that fill_null with incompatible type (ARRAY) skips the
column."""
+ df = pf.from_records(
+ [
+ {"id": 1, "tags": ["python", "flink"]},
+ {"id": 2, "tags": None},
+ ],
+ schema=["id", "tags"],
+ )
+
+ # Should skip ARRAY column when value is INT
+ result = df.fill_null(0).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[0][1], ["python", "flink"])
+ self.assertEqual(result[1][0], 2)
+ self.assertIsNone(result[1][1]) # Should remain NULL
+
+ def test_fill_null_skips_incompatible_string_column(self):
+ """Test that fill_null with numeric value skips STRING columns."""
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice"},
+ {"id": 2, "name": None},
+ ],
+ schema=["id", "name"],
+ )
+
+ # Numeric value should skip STRING column
+ result = df.fill_null(0, subset=["name"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0], Row(1, "Alice"))
+ self.assertIsNone(result[1][1]) # Should remain NULL
+
+ def test_fill_null_replaces_null_with_specified_value(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice", "quantity": 10},
+ {"id": 2, "name": None, "quantity": None},
+ {"id": 3, "name": "Bob", "quantity": 5},
+ ],
+ schema=["id", "name", "quantity"],
+ )
+
+ result = df.fill_null(0, subset=["quantity"]).collect()
+ self.assertEqual(result, [Row(1, "Alice", 10), Row(2, None, 0), Row(3,
"Bob", 5)])
+
+ def test_fill_null_with_decimal_value(self):
+ """Regression test: the type-compatibility map must recognize Decimal
values,
+ since literals also support Decimal, binary, and temporal types beyond
+ bool/int/float/str."""
+ df = pf.from_records(
+ [
+ {"id": 1, "amount": decimal.Decimal("1.25")},
+ {"id": 2, "amount": None},
+ ],
+ schema=["id", "amount"],
+ )
+
+ result = df.fill_null(decimal.Decimal("0.00"),
subset=["amount"]).collect()
+ self.assertEqual(result[0], Row(1, decimal.Decimal("1.25")))
+ self.assertEqual(result[1], Row(2, decimal.Decimal("0.00")))
+
+ def test_fill_null_with_day_time_interval_value(self):
+ df = pf.from_table(
+ self.t_env.sql_query(
+ "SELECT * FROM (VALUES "
+ "(1, CAST(INTERVAL '1' DAY AS INTERVAL DAY TO SECOND)), "
+ "(2, CAST(NULL AS INTERVAL DAY TO SECOND))) "
+ "AS T(id, duration)"
+ )
+ )
+
+ result = (
+ df.fill_null(timedelta(0), subset=["duration"])
+ .select(
+ "id",
+ is_zero=pf.col("duration") == pf.lit(timedelta(0)),
+ )
+ .collect()
+ )
+ self.assertEqual(
+ result,
+ [Row(1, False), Row(2, True)],
+ )
+
+ def test_fill_null_with_string_value(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "name": "Alice"},
+ {"id": 2, "name": None},
+ ],
+ schema=["id", "name"],
+ )
+
+ result = df.fill_null("unknown", subset=["name"]).collect()
+ self.assertEqual(result, [Row(1, "Alice"), Row(2, "unknown")])
+
+ def test_fill_nan_replaces_nan_with_specified_value(self):
+ df = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": float('nan')},
+ {"id": 3, "score": 0.85},
+ ],
+ schema=["id", "score"],
+ )
+
+ result = df.fill_nan(0.0, subset=["score"]).collect()
+ self.assertEqual(len(result), 3)
+ self.assertEqual(result[0][0], 1)
+ self.assertAlmostEqual(result[0][1], 0.95)
+ self.assertEqual(result[1][0], 2)
+ self.assertAlmostEqual(result[1][1], 0.0)
+ self.assertEqual(result[2][0], 3)
+ self.assertAlmostEqual(result[2][1], 0.85)
+
+ def test_fill_nan_with_none_value_converts_nan_to_null(self):
+ """Regression test: fill_nan(None) must convert NaN to NULL, per the
fill_nan
+ docstring, rather than raising or leaving the NaN in place."""
+ df = pf.from_records(
+ [
+ {"id": 1, "score": 0.95},
+ {"id": 2, "score": float('nan')},
+ ],
+ schema=["id", "score"],
+ )
+
+ result = df.fill_nan(None, subset=["score"]).collect()
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0][0], 1)
+ self.assertAlmostEqual(result[0][1], 0.95)
+ self.assertEqual(result[1][0], 2)
+ self.assertIsNone(result[1][1])
+
+ def test_is_nan_and_is_not_nan_support_decimal_columns(self):
+ """Regression test: IsNanFunction/IsNotNanFunction must dispatch on
DecimalData,
+ not BigDecimal, since BuiltInScalarFunction receives internal
representations
+ and DECIMAL columns never matched an eval(BigDecimal) overload."""
+ df = pf.from_records(
+ [
+ {"id": 1, "amount": decimal.Decimal("1.25")},
+ {"id": 2, "amount": None},
+ ],
+ schema=["id", "amount"],
+ )
+
+ result = df.select(
+ "id",
+ is_nan_result=is_nan(pf.col("amount")),
+ is_not_nan_result=is_not_nan(pf.col("amount")),
+ ).collect()
+
+ self.assertEqual(result[0][0], 1)
+ self.assertEqual(result[0][1], False)
+ self.assertEqual(result[0][2], True)
+ self.assertEqual(result[1][0], 2)
+ self.assertIsNone(result[1][1])
+ self.assertIsNone(result[1][2])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/flink-python/pyflink/table/expression.py
b/flink-python/pyflink/table/expression.py
index 9c3fc1069dc..2b2ff232467 100644
--- a/flink-python/pyflink/table/expression.py
+++ b/flink-python/pyflink/table/expression.py
@@ -766,6 +766,24 @@ class Expression(Generic[T]):
"""
return _unary_op("isNotFalse")(self)
+ @property
+ def is_nan(self) -> 'Expression[bool]':
+ """
+ Returns true if the given numeric expression is NaN (Not-a-Number).
+
+ .. seealso:: :py:attr:`~Expression.is_not_nan`
+ """
+ return _unary_op("isNan")(self)
+
+ @property
+ def is_not_nan(self) -> 'Expression[bool]':
+ """
+ Returns true if the given numeric expression is not NaN (Not-a-Number).
+
+ .. seealso:: :py:attr:`~Expression.is_nan`
+ """
+ return _unary_op("isNotNan")(self)
+
@property
def distinct(self) -> 'Expression':
"""
diff --git a/flink-python/pyflink/table/expressions.py
b/flink-python/pyflink/table/expressions.py
index 9a6ffc600f2..560d1251596 100644
--- a/flink-python/pyflink/table/expressions.py
+++ b/flink-python/pyflink/table/expressions.py
@@ -26,8 +26,8 @@ from pyflink.table.udf import UserDefinedFunctionWrapper
from pyflink.util.api_stability_decorators import PublicEvolving
from pyflink.util.java_utils import to_jarray, load_java_class
-__all__ = ['if_then_else', 'lit', 'col', 'range_', 'and_', 'or_', 'not_',
'UNBOUNDED_ROW',
- 'UNBOUNDED_RANGE', 'CURRENT_ROW', 'CURRENT_RANGE',
'current_database',
+__all__ = ['if_then_else', 'lit', 'col', 'range_', 'and_', 'or_', 'not_',
'is_nan', 'is_not_nan',
+ 'UNBOUNDED_ROW', 'UNBOUNDED_RANGE', 'CURRENT_ROW', 'CURRENT_RANGE',
'current_database',
'current_date', 'current_time', 'current_timestamp',
'current_watermark', 'local_time', 'local_timestamp',
'temporal_overlaps', 'date_format', 'timestamp_diff', 'array',
'row', 'map_',
@@ -186,6 +186,28 @@ def not_(expression: Expression[bool]) -> Expression[bool]:
return _unary_op("not", expression)
+@PublicEvolving()
+def is_nan(expr) -> Expression[bool]:
+ """
+ Returns true if the given numeric expression is NaN (Not-a-Number).
+
+ This method supports a three-valued logic by preserving NULL. This means
if the
+ input expression is NULL, the result will also be NULL.
+ """
+ return _unary_op("isNan", expr)
+
+
+@PublicEvolving()
+def is_not_nan(expr) -> Expression[bool]:
+ """
+ Returns true if the given numeric expression is not NaN (Not-a-Number).
+
+ This method supports a three-valued logic by preserving NULL. This means
if the
+ input expression is NULL, the result will also be NULL.
+ """
+ return _unary_op("isNotNan", expr)
+
+
"""
Offset constant to be used in the `preceding` clause of unbounded
:class:`~pyflink.table.window.Over`. Use this constant for a time interval.
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Expressions.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Expressions.java
index c91441869f5..54bd18b8e71 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Expressions.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/Expressions.java
@@ -223,6 +223,26 @@ public final class Expressions {
return apiCall(BuiltInFunctionDefinitions.NOT, expression);
}
+ /**
+ * Returns true if the given numeric expression is NaN (Not-a-Number).
+ *
+ * <p>This method supports a three-valued logic by preserving {@code
NULL}. This means if the
+ * input expression is {@code NULL}, the result will also be {@code NULL}.
+ */
+ public static ApiExpression isNan(Object expression) {
+ return apiCall(BuiltInFunctionDefinitions.IS_NAN, expression);
+ }
+
+ /**
+ * Returns true if the given numeric expression is not NaN (Not-a-Number).
+ *
+ * <p>This method supports a three-valued logic by preserving {@code
NULL}. This means if the
+ * input expression is {@code NULL}, the result will also be {@code NULL}.
+ */
+ public static ApiExpression isNotNan(Object expression) {
+ return apiCall(BuiltInFunctionDefinitions.IS_NOT_NAN, expression);
+ }
+
/**
* Offset constant to be used in the {@code preceding} clause of unbounded
{@code Over} windows.
* Use this constant for a time interval. Unbounded over windows start
with the first row of a
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java
index d4ba8815385..52917c65638 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/api/internal/BaseExpressions.java
@@ -133,7 +133,9 @@ import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.INIT_C
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.INSTR;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_FALSE;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_JSON;
+import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NAN;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NOT_FALSE;
+import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NOT_NAN;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NOT_NULL;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NOT_TRUE;
import static
org.apache.flink.table.functions.BuiltInFunctionDefinitions.IS_NULL;
@@ -536,6 +538,16 @@ public abstract class BaseExpressions<InType, OutType> {
return toApiSpecificExpression(unresolvedCall(IS_NOT_FALSE, toExpr()));
}
+ /** Returns true if the given numeric expression is NaN (Not-a-Number).
Null if null. */
+ public OutType isNan() {
+ return toApiSpecificExpression(unresolvedCall(IS_NAN, toExpr()));
+ }
+
+ /** Returns true if the given numeric expression is not NaN
(Not-a-Number). Null if null. */
+ public OutType isNotNan() {
+ return toApiSpecificExpression(unresolvedCall(IS_NOT_NAN, toExpr()));
+ }
+
/**
* Similar to a SQL distinct aggregation clause such as COUNT(DISTINCT a),
declares that an
* aggregation function is only applied on distinct input values.
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
index 0bcb4c56649..e2f727092af 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
@@ -2426,6 +2426,28 @@ public final class BuiltInFunctionDefinitions {
.build();
//
--------------------------------------------------------------------------------------------
+
+ public static final BuiltInFunctionDefinition IS_NAN =
+ BuiltInFunctionDefinition.newBuilder()
+ .name("isNan")
+ .sqlName("IS_NAN")
+ .kind(SCALAR)
+
.inputTypeStrategy(sequence(logical(LogicalTypeFamily.NUMERIC)))
+
.outputTypeStrategy(nullableIfArgs(explicit(DataTypes.BOOLEAN())))
+
.runtimeClass("org.apache.flink.table.runtime.functions.scalar.IsNanFunction")
+ .build();
+
+ public static final BuiltInFunctionDefinition IS_NOT_NAN =
+ BuiltInFunctionDefinition.newBuilder()
+ .name("isNotNan")
+ .sqlName("IS_NOT_NAN")
+ .kind(SCALAR)
+
.inputTypeStrategy(sequence(logical(LogicalTypeFamily.NUMERIC)))
+
.outputTypeStrategy(nullableIfArgs(explicit(DataTypes.BOOLEAN())))
+ .runtimeClass(
+
"org.apache.flink.table.runtime.functions.scalar.IsNotNanFunction")
+ .build();
+
// Catalog functions
//
--------------------------------------------------------------------------------------------
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNanFunction.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNanFunction.java
new file mode 100644
index 00000000000..7c4bda3ba61
--- /dev/null
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNanFunction.java
@@ -0,0 +1,63 @@
+/*
+ * 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.runtime.functions.scalar;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.table.functions.SpecializedFunction.SpecializedContext;
+
+import javax.annotation.Nullable;
+
+/** Implementation of {@link BuiltInFunctionDefinitions#IS_NAN}. */
+@Internal
+public final class IsNanFunction extends BuiltInScalarFunction {
+
+ public IsNanFunction(SpecializedContext context) {
+ super(BuiltInFunctionDefinitions.IS_NAN, context);
+ }
+
+ public @Nullable Boolean eval(final @Nullable Byte value) {
+ return value == null ? null : false;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Short value) {
+ return value == null ? null : false;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Integer value) {
+ return value == null ? null : false;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Long value) {
+ return value == null ? null : false;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Float value) {
+ return value == null ? null : Float.isNaN(value);
+ }
+
+ public @Nullable Boolean eval(final @Nullable Double value) {
+ return value == null ? null : Double.isNaN(value);
+ }
+
+ public @Nullable Boolean eval(final @Nullable DecimalData value) {
+ return value == null ? null : false;
+ }
+}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNotNanFunction.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNotNanFunction.java
new file mode 100644
index 00000000000..5514c5cecf3
--- /dev/null
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/functions/scalar/IsNotNanFunction.java
@@ -0,0 +1,63 @@
+/*
+ * 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.runtime.functions.scalar;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.table.functions.SpecializedFunction.SpecializedContext;
+
+import javax.annotation.Nullable;
+
+/** Implementation of {@link BuiltInFunctionDefinitions#IS_NOT_NAN}. */
+@Internal
+public final class IsNotNanFunction extends BuiltInScalarFunction {
+
+ public IsNotNanFunction(SpecializedContext context) {
+ super(BuiltInFunctionDefinitions.IS_NOT_NAN, context);
+ }
+
+ public @Nullable Boolean eval(final @Nullable Byte value) {
+ return value == null ? null : true;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Short value) {
+ return value == null ? null : true;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Integer value) {
+ return value == null ? null : true;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Long value) {
+ return value == null ? null : true;
+ }
+
+ public @Nullable Boolean eval(final @Nullable Float value) {
+ return value == null ? null : !Float.isNaN(value);
+ }
+
+ public @Nullable Boolean eval(final @Nullable Double value) {
+ return value == null ? null : !Double.isNaN(value);
+ }
+
+ public @Nullable Boolean eval(final @Nullable DecimalData value) {
+ return value == null ? null : true;
+ }
+}
diff --git a/tools/maven/suppressions.xml b/tools/maven/suppressions.xml
index 957d80bf068..fb8cf9dc83e 100644
--- a/tools/maven/suppressions.xml
+++ b/tools/maven/suppressions.xml
@@ -41,6 +41,7 @@ under the License.
<suppress files="NFAITCase.java" checks="FileLength"/>
<suppress files="HyperLogLogPlusPlus.java" checks="FileLength"/>
<suppress files="BuiltInFunctionDefinitions.java"
checks="FileLength"/>
+ <suppress files="BaseExpressions.java" checks="FileLength"/>
<suppress files="ProcessTableFunctionTestHarnessTest.java"
checks="FileLength"/>
<!-- Legacy mockito usages -->