blob: b43a58d3206a057cb49bbbacdb996cbe09936200 [file]
################################################################################
# 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
import keyword
from contextlib import ExitStack
from decimal import Decimal
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
overload,
)
if TYPE_CHECKING:
import pandas
import pyarrow
from pyflink.dataframe.udf import _DataTypeLike
from pyflink.dataframe.udtf import _DataFrameUDTFCall, _DataFrameUDTFWrapper
from pyflink.dataframe.llm import LLMAccessor
from pyflink.table.table_environment import TableEnvironment
from pyflink.table.table_schema import TableSchema
from pyflink.common import Row
from pyflink.dataframe.datatype import _INT_MAX, DataType
from pyflink.dataframe.iteration import (
_BATCH_FORMATS,
CloseableIterator,
_build_arrow_schema,
_iterate_batches,
_iterate_rows,
_row_to_dict,
_rows_to_batch,
_take_rows,
_validate_row_kind_field,
)
from pyflink.dataframe.validation import _require_choice, _require_int, _require_number
from pyflink.java_gateway import get_gateway
from pyflink.table.expression import Expression, _get_java_expression
from pyflink.table.expressions import (
and_,
call_sql,
col as table_col,
if_then_else,
is_nan,
lit as table_lit,
not_,
or_,
)
from pyflink.table.table import Table
from pyflink.table.statement_set import StatementSet
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
__all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
T = TypeVar("T")
def _validate_row_count(n: int) -> None:
_require_int(n, "n", 0)
if n > _INT_MAX:
raise ValueError(f"n must be less than or equal to {_INT_MAX}")
@PublicEvolving()
def col(name: str) -> Expression:
"""
Create a column reference expression.
:param name: Name of the referenced column.
:return: An expression referencing the column.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1, "name": "Alice"}])
>>> result = df.select(pf.col("name"))
.. versionadded:: 2.4.0
"""
return table_col(name)
@PublicEvolving()
def lit(value: Any, data_type: Optional[DataType] = None) -> Expression:
"""
Create a literal expression.
The data type is inferred from ``value`` when ``data_type`` is omitted. Otherwise, the
declared data type is applied during literal construction.
:param value: Literal value.
:param data_type: Optional data type for the literal.
:return: A literal expression.
:raises TypeError: If ``data_type`` is not a :class:`DataType`.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}])
>>> result = df.select("id", status=pf.lit("active"))
.. versionadded:: 2.4.0
"""
if data_type is None:
return table_lit(value)
if not isinstance(data_type, DataType):
raise TypeError("data_type must be a pyflink.dataframe.DataType")
table_data_type = data_type._to_table_data_type()
if value is None:
return table_lit(value, table_data_type)
return table_lit(value, table_data_type.not_null())
@PublicEvolving()
def proctime() -> Expression:
"""Return a processing-time attribute for a lookup :meth:`DataFrame.join_asof`.
.. versionadded:: 2.4.0
"""
return call_sql("PROCTIME()")
@PublicEvolving()
class DataFrame:
"""
A modern DataFrame API for PyFlink.
DataFrame provides a Pythonic interface for data transformations. It supports fluent chaining
of operations and provides a familiar DataFrame-style API.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_dict({"id": [1, 2], "name": ["a", "b"]})
>>> result = df.select("id", "name") \\
... .with_column("id_doubled", pf.col("id") * 2) \\
... .filter(pf.col("id") > 0)
.. versionadded:: 2.4.0
"""
def __init__(self, table: Table):
self._table = table
# ======================== Core Operations ========================
@PublicEvolving()
def map(self, func, *, return_dtype=None) -> "DataFrame":
"""
Apply a function to each row, producing a new DataFrame.
Pass a Python function, a callable class or instance, a ``ScalarFunction`` or
``AsyncScalarFunction`` class or instance, or a UDF created with
:func:`pyflink.dataframe.udf`. The function (or its ``__call__`` or ``eval`` method)
receives a dictionary whose keys are input column names and whose values are the
values in that row. It must return one dictionary whose keys name the output columns
and whose values form the output row. The output replaces the input columns.
Specify the output schema with ``return_dtype``, or omit it when the callable has a
``TypedDict`` return annotation. For an already declared UDF, its schema is reused
and ``return_dtype`` must be omitted. Output columns follow the schema's order;
extra dictionary keys are ignored, and missing output fields raise an error.
:param func: A callable or a DataFrame UDF declared with ``func_type="general"``.
See :func:`pyflink.dataframe.udf` for callable lifecycle behavior.
:param return_dtype: Output schema as a struct DataType, TypedDict type, or SQL ROW
type string. May be inferred from a callable's return annotation.
:return: A new DataFrame with one output row per input row.
Examples:
A Python function with an explicit output schema::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, "alice")], schema=["id", "name"])
>>> output_type = pf.DataType.struct({"label": pf.DataType.string()})
>>> result = df.map(
... lambda row: {"label": row["name"].upper()},
... return_dtype=output_type,
... )
Infer the output schema from a TypedDict return annotation::
>>> from typing import TypedDict
>>> class Output(TypedDict):
... label: str
>>> def uppercase(row) -> Output:
... return {"label": row["name"].upper()}
>>> result = df.map(uppercase)
Pass a callable class, or configure an instance before passing it::
>>> class PrefixName:
... def __init__(self, prefix="user_"):
... self.prefix = prefix
... def __call__(self, row) -> Output:
... return {"label": self.prefix + row["name"]}
>>> result = df.map(PrefixName)
>>> result = df.map(PrefixName("member_"))
Use a ScalarFunction's eval method::
>>> from pyflink.table.udf import ScalarFunction
>>> class UppercaseName(ScalarFunction):
... def eval(self, row) -> Output:
... return {"label": row["name"].upper()}
>>> result = df.map(UppercaseName())
Reuse a declared DataFrame UDF without repeating its output schema::
>>> declared_uppercase = pf.udf(uppercase)
>>> result = df.map(declared_uppercase)
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.udf import _resolve_map_udf
function = _resolve_map_udf(func, return_dtype, "general", self.schema.to_row_data_type())
return DataFrame(self._table.map(function(*[table_col(name) for name in self.columns])))
@PublicEvolving()
def map_batches(self, func, *, return_dtype=None, batch_format: str) -> "DataFrame":
"""
Apply a function to batches of rows, producing a new DataFrame.
Flink automatically groups input rows into batches and calls ``func`` once per batch.
Pass a Python function, a callable class or instance, a ``ScalarFunction`` class or
instance, or a UDF created with :func:`pyflink.dataframe.udf`. The callable receives
and returns a dictionary keyed by column name: each non-ROW value is a ``pandas.Series``
when ``batch_format="pandas"``, or a ``pyarrow.Array`` when ``batch_format="arrow"``.
Input values contain the batched column values; returned values form the output
columns, replacing the input columns.
Each output row must correspond to the input row at the same position, preserving
the number and order of rows. Specify
the output struct schema with ``return_dtype``; for a declared UDF, omit it to reuse
that UDF's schema. Output columns follow the schema's order; extra dictionary keys
are ignored, and missing output fields raise an error. Pandas output columns align
by index.
.. note::
For ROW input and output columns, use ``pandas.DataFrame`` in pandas mode
and ``pyarrow.StructArray`` in Arrow mode.
:param func: A callable or a DataFrame UDF declared for the selected batch format.
:param return_dtype: Output struct schema. Required for raw callables in both formats;
must be omitted for an already declared UDF.
:param batch_format: ``"pandas"`` or ``"arrow"``. Required even for declared UDFs.
:return: A new DataFrame with the same number of rows as the input.
Examples:
Transform pandas batches with a Python function::
>>> import pandas as pd
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1,), (2,), (3,)], schema=["value"])
>>> output_type = pf.DataType.struct({"doubled": pf.DataType.int64()})
>>> def double_pandas(batch: dict[str, pd.Series]) -> dict[str, pd.Series]:
... return {"doubled": batch["value"] * 2}
>>> result = df.map_batches(
... double_pandas, batch_format="pandas", return_dtype=output_type,
... )
Use a callable class to transform Arrow batches::
>>> import pyarrow as pa
>>> import pyarrow.compute as pc
>>> class DoubleArrow:
... def __call__(self, batch: dict[str, pa.Array]) -> dict[str, pa.Array]:
... return {"doubled": pc.multiply(batch["value"], 2)}
>>> result = df.map_batches(
... DoubleArrow, batch_format="arrow", return_dtype=output_type,
... )
Reuse a declared UDF, specifying its batch format at the call site::
>>> declared_double = pf.udf(
... double_pandas, return_dtype=output_type, func_type="pandas",
... )
>>> result = df.map_batches(declared_double, batch_format="pandas")
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.udf import _resolve_map_udf
if batch_format not in ("pandas", "arrow"):
raise ValueError("batch_format must be 'pandas' or 'arrow'.")
function = _resolve_map_udf(
func, return_dtype, batch_format, self.schema.to_row_data_type()
)
return DataFrame(self._table.map(function(*[table_col(name) for name in self.columns])))
@PublicEvolving()
def filter(
self,
*predicates: Union[
Expression, str, Callable[["DataFrame"], Expression]
],
**constraints: Any,
) -> "DataFrame":
"""
Keep rows that satisfy every predicate and equality constraint.
Predicates may be boolean expressions, SQL expression strings, or callables that receive
this DataFrame and return a boolean expression. A constraint value of ``None`` selects
rows where the corresponding column is null.
:param predicates: Conditions used to test each row.
:param constraints: Values keyed by the column names that must equal them.
:return: A new filtered DataFrame.
:raises TypeError: If a predicate has an unsupported type or callable result.
:raises ValueError: If no predicates or constraints are provided.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([
... {"name": "Alice", "age": 30, "status": "active"},
... {"name": "Bob", "age": 17, "status": "active"},
... ])
>>> adults = df.filter(pf.col("age") >= 18, status="active")
>>> adults = df.filter(lambda current: current["age"] >= 18)
>>> missing_status = df.filter(status=None)
.. versionadded:: 2.4.0
"""
if not predicates and not constraints:
raise ValueError(
"filter() requires at least one predicate or equality constraint"
)
conditions: List[Expression] = []
for predicate in predicates:
if isinstance(predicate, str):
conditions.append(call_sql(predicate))
elif isinstance(predicate, Expression):
conditions.append(predicate)
elif callable(predicate) and not isinstance(predicate, type):
condition = predicate(self)
if not isinstance(condition, Expression):
raise TypeError(
"filter() callable predicates must return an Expression"
)
conditions.append(condition)
else:
raise TypeError(
"predicate must be an Expression, SQL string, or callable"
)
for name, value in constraints.items():
column = table_col(name)
conditions.append(column.is_null if value is None else column == table_lit(value))
condition = conditions[0] if len(conditions) == 1 else and_(*conditions)
return DataFrame(self._table.filter(condition))
where = filter
@PublicEvolving()
def with_column(
self,
name: str,
expr: Union[Expression, Callable[["DataFrame"], Expression]],
) -> "DataFrame":
"""
Add a column, or replace an existing column with the same name.
``expr`` may be an expression or a callable that receives this DataFrame and returns an
expression.
:param name: Name of the added or replaced column.
:param expr: Expression or callable used to compute the column value.
:return: A new DataFrame with the requested column.
:raises TypeError: If ``name`` is not a string or ``expr`` does not produce an expression.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"left": 1, "right": 2}])
>>> with_expression = df.with_column(
... "total", lambda current: current["left"] + current["right"]
... )
>>> @pf.udf
... def add(left: int, right: int) -> int:
... return left + right
>>> with_udf = df.with_column(
... "total", add(pf.col("left"), pf.col("right"))
... )
.. versionadded:: 2.4.0
"""
if not isinstance(name, str):
raise TypeError("name must be a string")
if isinstance(expr, Expression):
expression = expr
elif callable(expr) and not isinstance(expr, type):
expression = expr(self)
else:
raise TypeError("expr must be an Expression")
if not isinstance(expression, Expression):
raise TypeError("expr must be an Expression")
return DataFrame(self._table.add_or_replace_columns(expression.alias(name)))
@PublicEvolving()
def with_columns(
self,
*exprs: Expression,
**named_exprs: Expression,
) -> "DataFrame":
"""
Add or replace multiple columns in one call.
Positional expressions are applied first and must carry their desired output names. Named
expressions are appended afterward and are aliased to their keyword names.
:param exprs: Expressions to add or replace.
:param named_exprs: Expressions keyed by their output column names.
:return: A new DataFrame with the requested columns.
:raises TypeError: If a positional or named value is not an expression.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(2, 3)], schema=["left", "right"])
>>> df.with_columns(
... (pf.col("left") + 1).alias("left_plus_one"),
... (pf.col("right") + 1).alias("right_plus_one"),
... )
>>> df.with_columns(
... left_plus_one=pf.col("left") + 1,
... right_plus_one=pf.col("right") + 1,
... )
.. versionadded:: 2.4.0
"""
expressions: List[Expression] = []
for expression in exprs:
if not isinstance(expression, Expression):
raise TypeError("exprs must be expressions")
expressions.append(expression)
for name, expression in named_exprs.items():
if not isinstance(expression, Expression):
raise TypeError("named_exprs must be expressions")
expressions.append(expression.alias(name))
return DataFrame(self._table.add_or_replace_columns(*expressions))
@PublicEvolving()
def drop_columns(
self,
*columns: Union[str, Expression],
strict: bool = True,
) -> "DataFrame":
"""
Remove columns from this DataFrame.
String column names are checked against the current schema. When ``strict`` is ``False``,
names that are not present are ignored. Expression arguments are validated by the Table
API.
:param columns: Column names or expressions to remove.
:param strict: Whether a missing column name raises an error.
:return: A new DataFrame without the requested columns, or this DataFrame if no columns
remain to be dropped.
:raises TypeError: If ``strict`` is not a boolean or a column has an unsupported type.
:raises ValueError: If a named column is missing in strict mode.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, "debug")], schema=["id", "temporary"])
>>> result = df.drop_columns("temporary")
>>> unchanged = df.drop("missing", strict=False)
.. versionadded:: 2.4.0
"""
if not isinstance(strict, bool):
raise TypeError("strict must be a boolean")
existing_columns = set(self.columns)
expressions: List[Expression] = []
for column in columns:
if isinstance(column, str):
if column not in existing_columns:
if strict:
raise ValueError(f"Column '{column}' not found in schema")
continue
expressions.append(table_col(column))
elif isinstance(column, Expression):
expressions.append(column)
else:
raise TypeError("columns must be strings or expressions")
if not expressions:
return self
return DataFrame(self._table.drop_columns(*expressions))
drop = drop_columns
@PublicEvolving()
def rename_columns(
self,
*args: Any,
mapping: Optional[
Union[Dict[str, str], Callable[[str], str]]
] = None,
) -> "DataFrame":
"""
Rename one or more columns.
Use exactly one of the following forms:
* A dictionary defines mappings from existing column names to new names. It can be supplied
as the only positional argument or through the keyword-only ``mapping`` parameter.
Entries whose existing column name is not present are ignored.
* An even number of positional string arguments is interpreted as alternating old and new
column name pairs.
* A function or lambda expression is applied to every current column name and must return
the new name as a string. It can be supplied as the only positional argument or through
``mapping``.
:param args: One dictionary or callable, or an even number of alternating old/new names.
:param mapping: Keyword-only alternative for passing a dictionary or callable.
:return: A new DataFrame with renamed columns, or this DataFrame if no names change.
:raises TypeError: If the mapping, a name, or a callable result has an unsupported type.
:raises ValueError: If positional pairs are incomplete or ``mapping`` is combined with
positional arguments.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, "Alice")], schema=["id", "name"])
>>> by_mapping = df.rename_columns({"id": "user_id"})
>>> by_keyword = df.rename_columns(mapping={"id": "user_id"})
>>> by_pairs = df.rename("id", "user_id", "name", "user_name")
>>> by_function = df.rename(str.upper)
>>> by_lambda = df.rename(lambda name: name.upper())
.. versionadded:: 2.4.0
"""
if args and mapping is not None:
raise ValueError(
"rename_columns() accepts either positional arguments or mapping, not both"
)
rename_spec: Any = mapping
if len(args) == 1:
rename_spec = args[0]
elif args:
if len(args) % 2 != 0:
raise ValueError(
"rename_columns() positional arguments must be old/new name pairs"
)
positional_mapping: Dict[str, str] = {}
for index in range(0, len(args), 2):
old_name, new_name = args[index], args[index + 1]
if not isinstance(old_name, str) or not isinstance(new_name, str):
raise TypeError("column names must be strings")
positional_mapping[old_name] = new_name
rename_spec = positional_mapping
current_columns = self.columns
rename_expressions: List[Expression] = []
if isinstance(rename_spec, dict):
for old_name, new_name in rename_spec.items():
if not isinstance(old_name, str) or not isinstance(new_name, str):
raise TypeError("mapping keys and values must be strings")
if old_name in current_columns and new_name != old_name:
rename_expressions.append(table_col(old_name).alias(new_name))
elif callable(rename_spec):
for old_name in current_columns:
new_name = rename_spec(old_name)
if not isinstance(new_name, str):
raise TypeError("rename_columns() callable must return a string")
if new_name != old_name:
rename_expressions.append(table_col(old_name).alias(new_name))
else:
raise TypeError("mapping must be a dictionary or callable")
if not rename_expressions:
return self
return DataFrame(self._table.rename_columns(*rename_expressions))
rename = rename_columns
@PublicEvolving()
def select(
self,
*columns: Union[
str,
Expression,
List[Union[str, Expression]],
Tuple[Union[str, Expression], ...],
],
**projections: Expression,
) -> "DataFrame":
"""
Select columns and compute named projections.
Column names and expressions are included in the supplied order. A list or tuple may be
used to group column names and expressions. Named projections are appended after the
positional columns.
:param columns: Column names and expressions to select.
:param projections: Expressions keyed by their result column names.
:return: A new DataFrame containing the selected columns and projections.
:raises TypeError: If a column or projection is not a supported value.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1, "name": "Alice"}])
>>> result = df.select(
... ("name", "id"), doubled=pf.col("id") * 2
... )
.. versionadded:: 2.4.0
"""
expressions: List[Expression] = []
for column in columns:
values = column if isinstance(column, (list, tuple)) else [column]
for value in values:
if isinstance(value, str):
expressions.append(table_col(value))
elif isinstance(value, Expression):
expressions.append(value)
else:
raise TypeError(
"columns must be strings, expressions, or lists or tuples of them"
)
for name, projection in projections.items():
if not isinstance(projection, Expression):
raise TypeError("projections must be expressions")
expressions.append(projection.alias(name))
return DataFrame(self._table.select(*expressions))
@PublicEvolving()
def drop_duplicates(
self,
subset: Union[str, List[str]] = None,
*,
keep: str = "first",
order_by: Union[str, Expression, List[Union[str, Expression]]] = None,
nulls_first: Union[bool, List[bool]] = None,
) -> "DataFrame":
"""
Remove duplicate rows.
When ``subset`` is omitted, fully identical rows are dropped, equivalent to a whole-row
``DISTINCT``. When ``subset`` is given, rows are deduplicated by those key columns, keeping
one row per key; ``order_by`` together with ``keep`` decides which row survives. When
``order_by`` is omitted, processing time is used, so ``keep`` keeps the first or last row to
arrive.
:param subset: Column name or list of column names that define a duplicate. When omitted,
all columns are considered.
:param keep: ``"first"`` keeps the earliest row, ``"last"`` the latest, in ``order_by``
order. Ignored when ``subset`` is omitted.
:param order_by: Column name or expression (or a list of them) defining the order in which
``keep`` selects the surviving row. When omitted, processing time is used.
:param nulls_first: Where NULLs rank in ``order_by``: a single boolean applied to every key,
or a list with one boolean per key. When omitted, the engine default applies. Requires
``order_by``.
:return: A new DataFrame with duplicate rows removed.
:raises ValueError: If ``keep`` is not ``"first"`` or ``"last"``, if ``order_by`` or
``nulls_first`` is combined with an omitted ``subset``, if ``nulls_first`` is given
without ``order_by`` or with a mismatched length, or if a named column does not exist.
:raises TypeError: If ``subset``, ``order_by`` or ``nulls_first`` has an unsupported type.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1, "ts": 1}, {"id": 1, "ts": 2}])
>>> unique_rows = df.drop_duplicates()
>>> latest_per_id = df.drop_duplicates("id", order_by="ts", keep="last")
.. versionadded:: 2.4.0
"""
if keep not in ("first", "last"):
raise ValueError('keep must be "first" or "last"')
subset_keys = _normalize_subset(subset)
order_keys = _normalize_order_by(order_by)
if nulls_first is not None and order_keys is None:
raise ValueError("nulls_first requires order_by")
nulls = _normalize_nulls_first(
nulls_first, len(order_keys) if order_keys else 0
)
if subset_keys is None:
if order_keys is not None:
raise ValueError(
"order_by requires subset; whole-row duplicates cannot be ordered"
)
return DataFrame(self._table.distinct())
columns = self._table.get_resolved_schema().get_column_names()
for name in subset_keys:
if name not in columns:
raise ValueError(
"subset column '%s' does not exist, available columns: %s" % (name, columns)
)
order_list = order_keys if order_keys is not None else [None]
descending_flags = [keep == "last"] * len(order_list)
rank_nulls = nulls if nulls is not None else [None] * len(order_list)
return DataFrame(
_build_rank_sql(self._table, subset_keys, order_list, descending_flags, rank_nulls, 1))
@PublicEvolving()
def top_n(
self,
n: int,
*,
partition_by: Union[str, List[str]] = None,
order_by: Union[str, Expression, List[Union[str, Expression]]] = None,
descending: Union[bool, List[bool]] = False,
nulls_first: Union[bool, List[bool]] = None,
) -> "DataFrame":
"""
Keep the top ``n`` rows per group, ordered by ``order_by``.
With ``partition_by`` the ranking is per group; without it the ranking is global.
``top_n(1, ...)`` keeps a single row per group.
The default ranking direction is ascending (``descending=False``), so ``top_n`` keeps
the SMALLEST ``n`` rows by ``order_by``. Pass ``descending=True`` to keep the largest.
:param n: Number of rows to keep per group. Must be >= 1.
:param partition_by: Column name or list of column names defining the groups.
:param order_by: Column name or expression (or a list of them) defining the ranking order.
:param descending: Ranking direction (default ``False``); a bool or one per ``order_by``.
:param nulls_first: Where NULLs rank in ``order_by``: a single boolean applied to every key,
or a list with one boolean per key. When omitted, the engine default applies. Requires
``order_by``.
:return: A new DataFrame with the top ``n`` rows per group.
:raises ValueError: If ``n`` is not an int or is < 1, if a ``descending``
or ``nulls_first`` list has a length different from ``order_by``, or if a named
column does not exist.
:raises TypeError: If an argument has an unsupported type, or ``order_by`` is missing
or empty.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"cat": "a", "amount": 10}])
>>> top3 = df.top_n(3, partition_by="cat", order_by="amount", descending=True)
.. versionadded:: 2.4.0
"""
if not isinstance(n, int) or n < 1:
raise ValueError("n must be an integer >= 1")
if order_by is None or (isinstance(order_by, (list, tuple)) and not order_by):
raise TypeError("top_n requires a non-empty order_by")
order_keys = _normalize_order_by(order_by)
if partition_by is None or (isinstance(partition_by, (list, tuple)) and not partition_by):
partition_keys = []
else:
partition_keys = _normalize_subset(partition_by, "partition_by")
descending_flags = _normalize_descending(descending, len(order_keys))
nulls = _normalize_nulls_first(nulls_first, len(order_keys))
rank_nulls = nulls if nulls is not None else [None] * len(order_keys)
return DataFrame(
_build_rank_sql(self._table, partition_keys, order_keys, descending_flags, rank_nulls,
n))
distinct = drop_duplicates
unique = drop_duplicates
@PublicEvolving()
def flat_map(
self,
func: Union[Callable[[Dict[str, Any]], Any], Type, "_DataFrameUDTFWrapper"],
*,
return_dtype: Optional["_DataTypeLike"] = None,
) -> "DataFrame":
"""
Apply a function to each row, emitting zero or more output rows.
The function receives a dictionary keyed by column name, including when
declared with :func:`pyflink.dataframe.udtf`.
Output column names come from a ``TypedDict`` or an explicit named struct;
scalar outputs use ``f0``. Multi-field outputs require named fields.
:param func: Row-based callable, a callable class with a zero-argument constructor,
or a declaration created with ``pf.udtf``. Callable classes are
instantiated on workers.
:param return_dtype: Emitted row type, inferred from annotations when omitted.
Required if inference is not possible; must be omitted
for a UDTF declaration.
:return: A DataFrame containing only the emitted output columns.
Example::
>>> from typing import Any, Dict, Iterator, TypedDict
>>> import pyflink.dataframe as pf
>>> class Token(TypedDict):
... word: str
>>> def split(record: Dict[str, Any]) -> Iterator[Token]:
... for word in record["text"].split():
... yield {"word": word}
>>> df = pf.from_dict({"text": ["hello world", "flink"]})
>>> result = df.flat_map(split)
>>> result.columns
['word']
The decorator is optional for plain callables. Use it to attach reusable
metadata, such as the output schema, instead of repeating it in each
``flat_map`` call::
>>> @pf.udtf(return_dtype="ROW<word STRING>")
... def tokenize(record: Dict[str, Any]):
... yield from record["text"].split()
>>> result = df.flat_map(tokenize)
>>> result.columns
['word']
An explicit output type can be supplied for unannotated callables::
>>> words = df.flat_map(lambda record: record["text"].split(), return_dtype=str)
>>> words.columns
['f0']
>>> named = df.flat_map(
... lambda record: record["text"].split(),
... return_dtype="ROW<word STRING>")
>>> named.columns
['word']
Callable classes can be passed directly and are instantiated on workers::
>>> class SplitWords:
... def __call__(self, record: Dict[str, Any]) -> Iterator[str]:
... yield from record["text"].split()
>>> words = df.flat_map(SplitWords)
``TableFunction`` classes are declared with :func:`pyflink.dataframe.udtf`::
>>> from pyflink.table.udf import TableFunction
>>> class SplitWordsFunction(TableFunction):
... def eval(self, record: Dict[str, Any]) -> Iterator[str]:
... yield from record["text"].split()
>>> words = df.flat_map(pf.udtf(SplitWordsFunction))
See :func:`pyflink.dataframe.udtf` for more details.
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.udtf import _resolve_flat_map_udtf
expression, output_columns = _resolve_flat_map_udtf(func, return_dtype, self.columns)
table = self._table.flat_map(expression)
# Table UDTFs expose positional field names, so restore the declared names.
return DataFrame(table.alias(output_columns[0], *output_columns[1:]))
@PublicEvolving()
def explode(
self,
column: Union[str, Expression, List[Union[str, Expression]]],
*,
output_column: Optional[Union[str, List[str]]] = None,
ignore_empty_and_null: bool = False,
) -> "DataFrame":
"""
Expand an ARRAY, MAP, or MULTISET into rows, preserving duplicate occurrences.
A referenced input column is replaced in place by the expanded element. For a computed
collection expression, all input columns are retained and the expanded element is
appended. MAP values yield key and value fields. ROW elements remain a single ROW column;
use :attr:`pyflink.table.Expression.flatten` in a subsequent :meth:`select` to expand its
fields. Empty and null collections produce a row with null output fields unless
``ignore_empty_and_null`` is true.
A list of multiple columns or expressions expands ARRAYs together by position. Their lengths
must match in each input row, treating empty and null arrays as length zero; otherwise
execution fails. Null elements count toward the length. When all arrays are empty or
null, ``ignore_empty_and_null`` controls whether to drop the row or retain one row with
null expanded fields. Each array produces one output column, including ARRAYs of ROWs.
A one-element list follows the single-input behavior.
.. warning::
Single-input expansion of an ARRAY containing null ROW elements may drop those
elements or fail during execution, regardless of ``ignore_empty_and_null``.
A non-null ROW whose fields are all null is not affected.
:param column: Collection column name or row-wise expression to expand, or a non-empty
list of ARRAY column names or row-wise expressions to expand together.
:param output_column: Output name or list of names. Required for multiple fields;
a single field defaults to the selected column name. Names must be unique and must
not conflict with retained input columns. For multiple ARRAYs, names default to
the resolved input names and each ARRAY requires one output name.
:param ignore_empty_and_null: Whether to drop rows with empty or null collections.
:return: A new DataFrame with the expanded rows.
:raises TypeError: If an argument has an unsupported type or the input is not a collection.
:raises ValueError: If the expression is not row-wise, selects multiple columns,
or output names are invalid.
Examples:
Explode an ARRAY of scalar values::
>>> import pyflink.dataframe as pf
>>> df = pf.from_dict({"id": [1, 2], "tags": [["a", "b"], []]})
>>> result = df.explode(
... "tags", output_column="tag", ignore_empty_and_null=True)
Explode an ARRAY of ROW values, then expand the ROW fields explicitly::
>>> import pyflink.dataframe as pf
>>> from typing import NamedTuple
>>> class Item(NamedTuple):
... name: str
... quantity: int
>>> orders = pf.from_records(
... [(1, [Item("apple", 2)])], schema=["id", "items"])
>>> exploded = orders.explode("items", ignore_empty_and_null=True)
>>> flattened = exploded.select("id", pf.col("items").flatten)
>>> flattened.columns
['id', 'items$name', 'items$quantity']
Expand multiple ARRAYs together, matching their elements by position::
>>> df = pf.from_dict({"items": [["apple", "pear"]], "counts": [[2, 3]]})
>>> result = df.explode(["items", "counts"])
.. versionadded:: 2.4.0
"""
if isinstance(column, list):
if not column:
raise ValueError("column must not be empty")
if not all(isinstance(value, (str, Expression)) for value in column):
raise TypeError("column must contain only column names or expressions")
if len(column) == 1:
column = column[0]
elif not isinstance(column, (str, Expression)):
raise TypeError("column must be a column name, expression, or list of these")
if not isinstance(ignore_empty_and_null, bool):
raise TypeError("ignore_empty_and_null must be a boolean")
if output_column is not None and not isinstance(output_column, (str, list)):
raise TypeError("output_column must be a string or list of strings")
if isinstance(column, list):
return self._explode_columns(column, output_column, ignore_empty_and_null)
expression = table_col(column) if isinstance(column, str) else column
selected = self._table.select(expression)
schema = selected.get_resolved_schema()
if len(schema.get_column_names()) != 1:
raise ValueError("column must select a single column")
projection = selected._j_table.getQueryOperation()
# Aggregates insert an intermediate operation whose field indexes refer to its result.
if not projection.getChildren().get(0).equals(self._table._j_table.getQueryOperation()):
raise ValueError("column must be a row-wise expression, not an aggregation")
data_type = schema.get_column_data_types()[0]
element_row_type = None
element_row_type_sql = None
if isinstance(data_type, MapType):
field_count = 2
elif isinstance(data_type, (ArrayType, MultisetType)):
element_type = data_type.element_type
element_row_type = element_type if isinstance(element_type, RowType) else None
if element_row_type is not None:
collection_type = schema._j_resolved_schema.getColumnDataTypes().get(0)
element_row_type_sql = (
collection_type.getChildren()
.get(0)
.getLogicalType()
.copy(True)
.asSerializableString()
)
field_count = 1
else:
raise TypeError("column must have an ARRAY, MAP, or MULTISET type")
if output_column is None:
if field_count != 1:
raise ValueError("output_column is required for multiple output fields")
output_names = [schema.get_column_names()[0]]
else:
output_names = [output_column] if isinstance(output_column, str) else output_column
if not all(isinstance(name, str) for name in output_names):
raise TypeError("output_column must contain only strings")
if len(output_names) != field_count:
raise ValueError("output_column must contain %d name(s)" % field_count)
if any(not name for name in output_names) or len(set(output_names)) != field_count:
raise ValueError("output_column names must be non-empty and unique")
table = self._table
columns = list(table.get_resolved_schema().get_column_names())
resolved = projection.getProjectList().get(0)
# Resolve the input field by index so an alias does not hide the column to remove.
if resolved.getClass().getSimpleName() == "FieldReferenceExpression":
output_index = resolved.getFieldIndex()
collection_name = columns.pop(output_index)
else:
output_index = len(columns)
taken = set(columns) | set(output_names)
collection_name = _unique_name("__pf_explode", taken)
table = table.add_columns(expression.alias(collection_name))
if set(output_names).intersection(columns):
raise ValueError("output_column names conflict with retained input columns")
unnest_output_names = output_names
ordinality_name = None
if element_row_type is not None:
# SQL UNNEST expands ROW fields. Reassemble them below, using ordinality to
# distinguish an outer-join placeholder from a real ROW whose fields are all null.
taken = set(columns) | set(output_names) | {collection_name}
unnest_output_names = []
for index in range(len(element_row_type.fields)):
name = _unique_name("__pf_explode_field_%d" % index, taken)
taken.add(name)
unnest_output_names.append(name)
if not ignore_empty_and_null:
ordinality_name = _unique_name("__pf_explode_ordinality", taken)
if element_row_type is None:
explode_projection = [
"expanded." + _quote_identifier(name) for name in unnest_output_names
]
else:
row_value = "CAST(ROW(%s) AS %s)" % (
", ".join(
"expanded." + _quote_identifier(name) for name in unnest_output_names
),
element_row_type_sql,
)
if ordinality_name is not None:
row_value = "CASE WHEN expanded.%s IS NULL THEN CAST(NULL AS %s) ELSE %s END" % (
_quote_identifier(ordinality_name),
element_row_type_sql,
row_value,
)
explode_projection = [row_value + " AS " + _quote_identifier(output_names[0])]
projections = ["src." + _quote_identifier(name) for name in columns]
projections[output_index:output_index] = explode_projection
query = "SELECT %s FROM %s AS src %s UNNEST(src.%s)%s AS expanded(%s)%s" % (
", ".join(projections),
_quote_identifier(str(table)),
"CROSS JOIN" if ignore_empty_and_null else "LEFT JOIN",
_quote_identifier(collection_name),
" WITH ORDINALITY" if ordinality_name is not None else "",
", ".join(
_quote_identifier(name)
for name in unnest_output_names
+ ([ordinality_name] if ordinality_name is not None else [])
),
"" if ignore_empty_and_null else " ON TRUE",
)
return DataFrame(table._t_env.sql_query(query))
def _explode_columns(
self,
columns: List[Union[str, Expression]],
output_column: Optional[Union[str, List[str]]],
ignore_empty_and_null: bool,
) -> "DataFrame":
table = self._table
input_names = table.get_resolved_schema().get_column_names()
expressions: List[Expression] = []
for value in columns:
if isinstance(value, str):
expressions.append(table_col(value))
else:
expressions.append(value)
resolved_names = []
input_indexes = []
for expression in expressions:
selected = table.select(expression)
schema = selected.get_resolved_schema()
if len(schema.get_column_names()) != 1:
raise ValueError("each column must select a single column")
projection = selected._j_table.getQueryOperation()
if not projection.getChildren().get(0).equals(table._j_table.getQueryOperation()):
raise ValueError("column must be a row-wise expression, not an aggregation")
if not isinstance(schema.get_column_data_types()[0], ArrayType):
raise TypeError("multiple columns must all have an ARRAY type")
resolved_names.append(schema.get_column_names()[0])
resolved = projection.getProjectList().get(0)
index: Optional[int] = None
if resolved.getClass().getSimpleName() == "FieldReferenceExpression":
index = resolved.getFieldIndex()
if index is not None and index in input_indexes:
raise ValueError("column must not contain duplicate input columns")
input_indexes.append(index)
if output_column is None:
output_names = resolved_names
elif isinstance(output_column, str):
output_names = [output_column]
else:
output_names = output_column
if not all(isinstance(name, str) for name in output_names):
raise TypeError("output_column must contain only strings")
if len(output_names) != len(columns):
raise ValueError("output_column must contain %d name(s)" % len(columns))
if any(not name for name in output_names) or len(set(output_names)) != len(columns):
raise ValueError("output_column names must be non-empty and unique")
retained = []
for index, name in enumerate(input_names):
if index not in input_indexes:
retained.append(name)
if set(output_names).intersection(retained):
raise ValueError("output_column names conflict with retained input columns")
taken = set(input_names) | set(output_names)
array_names = []
computed = []
for expression, index in zip(expressions, input_indexes):
if index is None:
name = _unique_name("__pf_explode", taken)
taken.add(name)
computed.append(expression.alias(name))
else:
name = input_names[index]
array_names.append(name)
if computed:
table = table.add_columns(*computed)
ordinal_name = _unique_name("__pf_explode_ordinality", taken)
gateway = get_gateway()
# Validate before producing ordinals, including when the first array is empty.
function = gateway.jvm.org.apache.flink.table.utils.python.ExplodeOrdinalityFunction()
lengths = to_jarray(
gateway.jvm.Object,
[_get_java_expression(table_col(name).cardinality) for name in array_names],
)
ordinals: Expression = Expression(
gateway.jvm.Expressions.call(function, lengths)
).alias(ordinal_name)
if ignore_empty_and_null:
table = table.join_lateral(ordinals)
else:
table = table.left_outer_join_lateral(ordinals)
expanded = [
table_col(name).at(table_col(ordinal_name)).alias(output)
for name, output in zip(array_names, output_names)
]
replacements = {}
for index, value in zip(input_indexes, expanded):
if index is not None:
replacements[index] = value
projections: List[Expression] = []
for index, name in enumerate(input_names):
if index in replacements:
projections.append(replacements[index])
else:
projections.append(table_col(name))
for index, value in zip(input_indexes, expanded):
if index is None:
projections.append(value)
return DataFrame(table.select(*projections))
# ======================== Joins ========================
@PublicEvolving()
def join(
self,
other: "DataFrame",
*,
on=None,
how: str = "inner",
left_on=None,
right_on=None,
) -> "DataFrame":
"""
Join this DataFrame with another DataFrame.
Use ``on`` when both sides share the same named join keys, or pass a boolean expression as
the complete join predicate. Use ``left_on`` and ``right_on`` together when the key names
differ. In streaming mode, a join without equality keys may use singleton distribution
(a single parallel instance) and can be expensive.
Shared named keys occur once in the result; other duplicate column names must be renamed
before joining. ``semi`` and ``anti`` joins return only columns from this DataFrame, while
``cross`` performs a Cartesian product and accepts no join keys.
:param other: DataFrame on the right side of the join.
:param on: Shared column name, list of shared column names, or a boolean join expression.
:param how: Join type: ``"inner"``, ``"left"``, ``"right"``, ``"full"``, ``"outer"``,
``"semi"``, ``"anti"``, or ``"cross"``.
:param left_on: Column name, expression, or list of column names from this DataFrame.
:param right_on: Column name, expression, or list of column names from ``other``.
:return: A new DataFrame containing the join result.
:raises TypeError: If an argument has an unsupported type.
:raises ValueError: If the join type, keys, schemas, or argument combination is invalid.
Example::
>>> import pyflink.dataframe as pf
>>> orders = pf.from_records([(1, 10)], schema=["customer_id", "amount"])
>>> customers = pf.from_records([(1, "Alice")], schema=["customer_id", "name"])
>>> matched = orders.join(customers, on="customer_id")
>>> matched = orders.join(
... customers.rename_columns({"customer_id": "id"}),
... left_on="customer_id",
... right_on="id",
... how="left",
... )
Expression-based predicates support equality, compound conditions, and non-equi joins::
>>> customers_by_id = customers.rename_columns({"customer_id": "id"})
>>> matched = orders.join(
... customers_by_id, on=pf.col("customer_id") == pf.col("id"))
>>> rules = pf.from_records([(1, 5)], schema=["rule_customer_id", "min_amount"])
>>> matched = orders.join(
... rules,
... on=(pf.col("customer_id") == pf.col("rule_customer_id"))
... & (pf.col("amount") >= pf.col("min_amount")),
... )
>>> matched = orders.join(rules, on=pf.col("amount") >= pf.col("min_amount"))
>>> unmatched = orders.join(
... customers_by_id,
... on=pf.col("customer_id") == pf.col("id"),
... how="anti",
... )
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a pyflink.dataframe.DataFrame")
if self._table._t_env._j_tenv != other._table._t_env._j_tenv:
raise ValueError("DataFrames must belong to the same TableEnvironment")
join_type = _normalize_join_type(how)
if join_type == "cross":
if on is not None or left_on is not None or right_on is not None:
raise ValueError("cross join does not accept on, left_on, or right_on")
_validate_join_column_conflicts(self.columns, other.columns, set())
return DataFrame(self._table.join(other._table))
(
left_table,
right_table,
predicate,
shared_keys,
) = _prepare_join(
self._table,
other._table,
on,
left_on,
right_on,
validate_column_conflicts=join_type not in ("semi", "anti"),
)
with _JoinSqlFactory(self._table._t_env) as sql_factory:
if join_type in ("semi", "anti"):
return DataFrame(
_build_semi_anti_join_sql(
left_table,
right_table,
predicate,
self.columns,
join_type,
sql_factory,
)
)
return DataFrame(
_build_regular_join_sql(
left_table,
right_table,
predicate,
self.columns,
other.columns,
shared_keys,
join_type,
sql_factory,
)
)
@PublicEvolving()
def join_asof(
self,
other: "DataFrame",
*,
how: str = "inner",
on: Optional[Union[str, Expression]] = None,
by: Optional[Union[str, Expression, List[Union[str, Expression]]]] = None,
left_by: Optional[Union[str, Expression, List[Union[str, Expression]]]] = None,
right_by: Optional[Union[str, Expression, List[Union[str, Expression]]]] = None,
) -> "DataFrame":
"""
Join a dimension table as of each left row's time attribute.
Use :func:`proctime` or a left processing-time column for a lookup join. The
right table must support lookups. Use a left event-time column for a temporal
join; both inputs need watermarks, and the right table's primary key must be
covered by the equality keys. These requirements follow the underlying Table API.
Shared named keys occur once in the result. Other duplicate column names must
be renamed before joining. The time attribute may also appear in equality keys
and residual conditions.
Conditions require an equality between the inputs, optionally AND-combined
with other conditions; OR conditions and purely non-equi conditions are rejected.
:param other: Dimension DataFrame in the same TableEnvironment.
:param how: Join type, ``"inner"`` or ``"left"``.
:param on: Required left time-attribute column or :func:`proctime` expression.
:param by: Shared key name, boolean join expression, or a list of these.
:param left_by: Left key name, expression, or list of names or expressions.
:param right_by: Right keys paired with ``left_by``, instead of ``by``.
:return: A new DataFrame containing the join result.
:raises TypeError: If an argument has an unsupported type.
:raises ValueError: If the time attribute, keys, or argument combination is invalid.
Example::
>>> enriched = orders.join_asof(
... customers, on=pf.proctime(), by="customer_id", how="left")
>>> historical = orders.join_asof(
... rates, on="event_time", left_by="currency", right_by="currency_id")
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a pyflink.dataframe.DataFrame")
if self._table._t_env._j_tenv != other._table._t_env._j_tenv:
raise ValueError("DataFrames must belong to the same TableEnvironment")
if not isinstance(how, str):
raise TypeError("how must be a string")
if how not in ("inner", "left"):
raise ValueError('how must be "inner" or "left"')
if on is None:
raise ValueError("on must specify a left time attribute")
if not isinstance(on, (str, Expression)):
raise TypeError("on must be a column name or expression")
if by is not None and (left_by is not None or right_by is not None):
raise ValueError("by cannot be combined with left_by or right_by")
if by is None and (left_by is None or right_by is None):
raise ValueError("join_asof requires by or left_by and right_by together")
left_table, time_name = _prepare_asof_time(self._table, on, other.columns)
join_keys: Union[List[str], Expression, None] = None
extra_conditions = []
named_keys = []
if by is not None:
for key in _normalize_join_keys(by, "by", allow_expressions=True):
if isinstance(key, str):
named_keys.append(key)
else:
extra_conditions.append(key)
if named_keys:
join_keys = named_keys
else:
join_keys = (and_(*extra_conditions)
if len(extra_conditions) > 1 else extra_conditions[0])
extra_conditions = []
left_table, right_table, predicate, shared_keys = _prepare_join(
left_table, other._table, join_keys, left_by, right_by,
validate_column_conflicts=True, asof_time=time_name,
)
if extra_conditions:
predicate = and_(predicate, *extra_conditions)
with _JoinSqlFactory(self._table._t_env) as sql_factory:
return DataFrame(_build_regular_join_sql(
left_table, right_table, predicate, self.columns, other.columns,
shared_keys, how, sql_factory, asof_time=time_name,
))
@PublicEvolving()
def join_lateral(
self,
table_function_call: Union["_DataFrameUDTFCall", Expression],
*,
on: Optional[Expression[bool]] = None,
ignore_empty: bool = True,
) -> "DataFrame":
"""
Join each input row with the rows emitted by a table function call.
The function receives the explicit arguments supplied in the call. The result
retains the input columns followed by the function's output columns. Output
names come from ``alias`` or a named ``TypedDict`` or struct ``return_dtype``.
Raw Table API expressions must specify output names with ``alias``. Output
names must be non-empty, unique, and distinct from the input column names.
:param table_function_call: A call to a function declared with
:func:`pyflink.dataframe.udtf`, or a Table API table function expression.
:param on: Optional boolean join predicate. Left outer lateral joins currently
require an omitted or literal ``True`` predicate, as in the Table API.
:param ignore_empty: If ``True`` (default), use an inner lateral join. If
``False``, use a left outer lateral join, preserving input rows with no
emitted rows and filling the output columns with NULLs.
:return: A new DataFrame containing the lateral join result.
Example::
>>> from typing import Iterator, TypedDict
>>> import pyflink.dataframe as pf
>>> @pf.udtf
... def chars(text: str) -> Iterator[str]:
... yield from text
>>> df = pf.from_records([(1, "ab"), (2, "")], schema=["id", "text"])
>>> expanded = df.join_lateral(chars(pf.col("text")).alias("ch"))
>>> preserved = df.join_lateral(
... chars(pf.col("text")).alias("ch"), ignore_empty=False)
>>> filtered = df.join_lateral(
... chars(pf.col("text")).alias("ch"), on=pf.col("id") > 0)
Named multi-field outputs do not require aliases::
>>> class Token(TypedDict):
... word: str
... length: int
>>> @pf.udtf
... def tokenize(text: str) -> Iterator[Token]:
... for word in text.split():
... yield {"word": word, "length": len(word)}
>>> tokens = df.join_lateral(tokenize(pf.col("text")))
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.udtf import _resolve_lateral_expression
if not isinstance(ignore_empty, bool):
raise TypeError("ignore_empty must be a bool")
if on is not None and not isinstance(on, Expression):
raise TypeError("on must be a boolean expression")
expression = _resolve_lateral_expression(table_function_call, self.columns)
if ignore_empty:
table = self._table.join_lateral(expression)
if on is not None:
# Filtering an inner lateral join is equivalent to its ON predicate and
# avoids the Table API's equi-join validation for ordinary joins.
table = table.filter(on)
else:
table = self._table.left_outer_join_lateral(expression, on)
return DataFrame(table)
# ======================== Filtering & Ordering ========================
@PublicEvolving()
def sort(
self,
by: Union[str, Expression, List[Union[str, Expression]]],
*,
descending: Union[bool, List[bool]] = False,
nulls_first: Union[bool, List[bool]] = None,
) -> "DataFrame":
"""
Sort rows globally by one or more columns or expressions.
This method builds a new DataFrame plan without executing a Flink job. The ``by``
expressions must not already specify ``asc`` or ``desc``; use ``descending`` to control
their direction. When ``nulls_first`` is omitted, the Table API default is used: NULLs
are ordered last for ascending keys and first for descending keys.
The result is globally sorted across all parallel partitions. For unbounded tables, the
first sort key must be an ascending time attribute unless the sort is followed by
:meth:`limit`.
:param by: Column name or expression, or a list of them, used as sort keys.
:param descending: Whether to sort in descending order, either for all keys or once per
key.
:param nulls_first: Whether to place NULLs first, either for all keys or once per key. When
omitted, the Table API default applies.
:return: A new sorted DataFrame.
:raises TypeError: If ``by``, ``descending`` or ``nulls_first`` has an unsupported type.
:raises ValueError: If ``by`` is empty, option lengths do not match, a column does not
exist, or an expression already specifies ``asc`` or ``desc``.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records(
... [(2, "b"), (1, "a")], schema=["id", "name"]
... )
>>> ascending = df.sort("id")
>>> mixed = df.sort(["id", "name"], descending=[False, True])
.. versionadded:: 2.4.0
"""
order_keys = _normalize_order_by(by, "by")
if order_keys is None:
raise TypeError("by must be a string, an expression, or a list or tuple of them")
columns = self._table.get_resolved_schema().get_column_names()
for key in order_keys:
if isinstance(key, str) and key not in columns:
raise ValueError(
"by column '%s' does not exist, available columns: %s" % (key, columns)
)
if isinstance(key, Expression) and _contains_ordering_expression(key):
raise ValueError(
"sort() expressions must not specify asc or desc; use descending instead"
)
descending_values = _normalize_descending(descending, len(order_keys))
nulls_values: List[Optional[bool]] = (
[None] * len(order_keys)
if nulls_first is None
else _normalize_nulls_first(nulls_first, len(order_keys))
)
if any(value is not None for value in nulls_values):
return DataFrame(
_build_sort_sql(self._table, order_keys, descending_values, nulls_values)
)
order_expressions = []
for key, is_descending in zip(order_keys, descending_values):
expression = table_col(key) if isinstance(key, str) else key
order_expressions.append(expression.desc if is_descending else expression.asc)
return DataFrame(self._table.order_by(*order_expressions))
# ======================== Set Operations ========================
@PublicEvolving()
def union(self, other: "DataFrame") -> "DataFrame":
"""
Return rows from either DataFrame, removing duplicate rows (SQL ``UNION``).
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with compatible types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
:param other: The DataFrame to combine with this DataFrame.
:return: A new DataFrame containing the distinct rows from both inputs.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.union(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.union(other._table))
@PublicEvolving()
def union_all(self, other: "DataFrame") -> "DataFrame":
"""
Return rows from both DataFrames, retaining all duplicates (SQL ``UNION ALL``).
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with compatible types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
:param other: The DataFrame to combine with this DataFrame.
:return: A new DataFrame containing all rows from both inputs.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.union_all(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.union_all(other._table))
@PublicEvolving()
def intersect(self, other: "DataFrame") -> "DataFrame":
"""
Return rows present in both DataFrames, removing duplicates (SQL ``INTERSECT``).
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with matching types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
Cast differing column types explicitly before calling this method.
:param other: The DataFrame to intersect with this DataFrame.
:return: A new DataFrame containing the distinct rows common to both inputs.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.intersect(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.intersect(other._table))
@PublicEvolving()
def intersect_all(self, other: "DataFrame") -> "DataFrame":
"""
Return rows present in both DataFrames, retaining duplicates (SQL ``INTERSECT ALL``).
A row occurring ``n`` times in this DataFrame and ``m`` times in ``other`` is returned
``min(n, m)`` times.
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with matching types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
Cast differing column types explicitly before calling this method.
:param other: The DataFrame to intersect with this DataFrame.
:return: A new DataFrame containing the common rows with their shared multiplicities.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.intersect_all(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.intersect_all(other._table))
@PublicEvolving()
def minus(self, other: "DataFrame") -> "DataFrame":
"""
Return rows absent from ``other``, removing duplicate rows (SQL ``EXCEPT``).
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with matching types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
Cast differing column types explicitly before calling this method.
:param other: The DataFrame whose rows are excluded from this DataFrame.
:return: A new DataFrame containing the distinct rows present only in this DataFrame.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.minus(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.minus(other._table))
@PublicEvolving()
def minus_all(self, other: "DataFrame") -> "DataFrame":
"""
Subtract the occurrences of rows in ``other`` from this DataFrame (SQL ``EXCEPT ALL``).
A row occurring ``n`` times in this DataFrame and ``m`` times in ``other`` is returned
``max(n - m, 0)`` times.
Both DataFrames must belong to the same TableEnvironment and have the same number
of columns with matching types at each position. Columns are matched by position,
not by name; the result uses this DataFrame's column names.
Cast differing column types explicitly before calling this method.
:param other: The DataFrame whose row occurrences are subtracted.
:return: A new DataFrame containing the remaining row occurrences.
:raises TypeError: If ``other`` is not a DataFrame.
Example::
>>> result = left.minus_all(right)
.. versionadded:: 2.4.0
"""
if not isinstance(other, DataFrame):
raise TypeError("other must be a DataFrame")
return DataFrame(self._table.minus_all(other._table))
# ======================== Windowing ========================
@PublicEvolving()
def tumble(
self,
*,
on: Union[str, Expression],
size: Union["datetime.timedelta", Expression],
) -> "DataFrame":
"""
Assign rows to fixed-size, non-overlapping (tumbling) windows.
Appends ``window_start``, ``window_end`` and ``window_time`` and returns an ordinary
DataFrame.
:param on: An existing event-time or processing-time column.
:param size: Window length.
:return: A new DataFrame with the window columns appended.
:raises TypeError: If ``on`` or ``size`` has an unsupported type.
Example::
>>> import pyflink.dataframe as pf
>>> from datetime import timedelta
>>> windowed = df.tumble(on="event_time", size=timedelta(minutes=10))
.. versionadded:: 2.4.0
"""
time_col = _resolve_window_time_column(on)
return _window_dataframe(
self._table, "TUMBLE", time_col, _to_interval_expression(size)
)
@PublicEvolving()
def hop(
self,
*,
on: Union[str, Expression],
slide: Union["datetime.timedelta", Expression],
size: Union["datetime.timedelta", Expression],
) -> "DataFrame":
"""
Assign rows to overlapping fixed-size (hopping/sliding) windows of length ``size`` starting
every ``slide``. Appends ``window_start``/``window_end``/``window_time``.
:param on: An existing event-time or processing-time column.
:param slide: Interval between successive window starts.
:param size: Window length.
:return: A new DataFrame with the window columns appended.
Example::
>>> import pyflink.dataframe as pf
>>> from datetime import timedelta
>>> windowed = df.hop(
... on="event_time",
... slide=timedelta(minutes=5),
... size=timedelta(minutes=10),
... )
.. versionadded:: 2.4.0
"""
time_col = _resolve_window_time_column(on)
return _window_dataframe(
self._table,
"HOP",
time_col,
_to_interval_expression(slide),
_to_interval_expression(size),
)
@PublicEvolving()
def cumulate(
self,
*,
on: Union[str, Expression],
step: Union["datetime.timedelta", Expression],
size: Union["datetime.timedelta", Expression],
) -> "DataFrame":
"""
Assign rows to cumulating windows that share a start and grow by ``step`` up to ``size``.
Appends ``window_start``/``window_end``/``window_time``.
:param on: An existing event-time or processing-time column.
:param step: Interval by which each window grows.
:param size: Maximum window length (a whole multiple of ``step``).
:return: A new DataFrame with the window columns appended.
Example::
>>> import pyflink.dataframe as pf
>>> from datetime import timedelta
>>> windowed = df.cumulate(
... on="event_time",
... step=timedelta(minutes=5),
... size=timedelta(minutes=10),
... )
.. versionadded:: 2.4.0
"""
time_col = _resolve_window_time_column(on)
return _window_dataframe(
self._table,
"CUMULATE",
time_col,
_to_interval_expression(step),
_to_interval_expression(size),
)
@PublicEvolving()
def session(
self,
*,
on: Union[str, Expression],
gap: Union["datetime.timedelta", Expression],
partition_by: Optional[
Union[str, Expression, List[Union[str, Expression]]]
] = None,
) -> "DataFrame":
"""
Assign rows to activity-based (session) windows that close after ``gap`` of inactivity.
Appends ``window_start``/``window_end``/``window_time``.
When ``partition_by`` is given, sessions are computed independently per key, so a gap in
one key's activity does not close another key's session.
:param on: An existing event-time or processing-time column.
:param gap: Inactivity gap that closes a session.
:param partition_by: Optional column name or expression or list of column names or
expressions to compute per-key sessions. ``None`` means a global session.
:return: A new DataFrame with the window columns appended.
:raises TypeError: If ``partition_by`` contains an unsupported element type.
Example::
>>> import pyflink.dataframe as pf
>>> from datetime import timedelta
>>> windowed = df.session(
... on="event_time",
... gap=timedelta(minutes=10),
... partition_by="id",
... )
.. versionadded:: 2.4.0
"""
time_col = _resolve_window_time_column(on)
partition_cols = _resolve_partition_columns(partition_by)
return _window_dataframe(
self._table,
"SESSION",
time_col,
_to_interval_expression(gap),
partition_cols=partition_cols,
)
# ======================== Slicing ========================
@PublicEvolving()
def limit(self, n: int) -> "DataFrame":
"""
Keep at most the first ``n`` rows.
This method builds a new DataFrame plan without executing a Flink job. Execution is
triggered by an action such as :meth:`collect` or :meth:`to_pandas`. Without an explicit
ordering on the underlying table, the selected rows and their order are unspecified.
Changes to the underlying table content may also change the result.
:param n: Maximum number of rows to keep.
:return: A new DataFrame containing at most ``n`` rows.
:raises TypeError: If ``n`` is not an integer.
:raises ValueError: If ``n`` is negative.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
>>> first_two = df.limit(2)
.. versionadded:: 2.4.0
"""
_validate_row_count(n)
return DataFrame(self._table.fetch(n))
@PublicEvolving()
def offset(self, n: int) -> "DataFrame":
"""
Skip the first ``n`` rows.
This method builds a new DataFrame plan without executing a Flink job. Execution is
triggered by an action such as :meth:`collect` or :meth:`to_pandas`. Without an explicit
ordering on the underlying table, the skipped rows and their order are unspecified.
Changes to the underlying table content may also change the result. Combine this method
with :meth:`limit` for pagination.
:param n: Number of rows to skip.
:return: A new DataFrame without the first ``n`` rows.
:raises TypeError: If ``n`` is not an integer.
:raises ValueError: If ``n`` is negative.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
>>> page = df.offset(1).limit(2)
.. versionadded:: 2.4.0
"""
_validate_row_count(n)
return DataFrame(self._table.offset(n))
@PublicEvolving()
def head(self, n: int) -> "DataFrame":
"""
Keep at most the first ``n`` rows.
This method builds a new DataFrame plan without executing a Flink job and delegates to
:meth:`limit`. Execution is triggered by an action such as :meth:`collect` or
:meth:`to_pandas`. Without an explicit ordering on the underlying table, the selected rows
and their order are unspecified. Changes to the underlying table content may also change
the result.
:param n: Maximum number of rows to keep.
:return: A new DataFrame containing at most ``n`` rows.
:raises TypeError: If ``n`` is not an integer.
:raises ValueError: If ``n`` is negative.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
>>> first_two = df.head(2)
.. versionadded:: 2.4.0
"""
return self.limit(n)
# ======================== Aggregation ========================
@PublicEvolving()
def group_by(self, *columns: Union[str, Expression]) -> "GroupedDataFrame":
"""
Group rows by one or more columns for aggregation.
String column names are converted to column expressions. Grouping keys are retained in
their supplied order and are included first in the result of
:meth:`GroupedDataFrame.agg`.
:param columns: Column names or expressions used as grouping keys.
:return: A grouped DataFrame that can be aggregated.
:raises TypeError: If a grouping key is not a string or expression.
:raises ValueError: If no grouping keys are provided.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([
... ("engineering", 10),
... ("engineering", 20),
... ("sales", 5),
... ], schema=["department", "amount"])
>>> totals = df.group_by("department").agg(
... pf.col("amount").sum.alias("total_amount"),
... row_count=pf.col("amount").count,
... )
>>> # totals schema: [department: STRING, total_amount: BIGINT,
>>> # row_count: BIGINT NOT NULL]
.. versionadded:: 2.4.0
"""
if not columns:
raise ValueError("group_by() requires at least one grouping key")
grouping_keys: List[Expression] = []
for column in columns:
if isinstance(column, str):
grouping_keys.append(table_col(column))
elif isinstance(column, Expression):
grouping_keys.append(column)
else:
raise TypeError(
"group_by() grouping keys must be strings or expressions"
)
return GroupedDataFrame(self, grouping_keys)
@PublicEvolving()
def agg(self, *aggs: Expression, **named_aggs: Expression) -> "DataFrame":
"""
Aggregate all rows in this DataFrame.
Positional aggregation expressions are followed by named aggregations in the result.
Each named aggregation is aliased to its keyword name.
:param aggs: Aggregation expressions.
:param named_aggs: Aggregation expressions keyed by their result column names.
:return: A DataFrame containing the global aggregation results.
:raises TypeError: If an aggregation is not an expression.
:raises ValueError: If no aggregations are provided.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([
... (1, 10), (2, 20)
... ], schema=["order_id", "amount"])
>>> summary = df.agg(
... pf.col("order_id").count.alias("order_count"),
... total_amount=pf.col("amount").sum,
... )
>>> # summary schema: [order_count: BIGINT NOT NULL, total_amount: BIGINT]
.. versionadded:: 2.4.0
"""
aggregations = _normalize_aggregations(aggs, named_aggs)
return DataFrame(self._table.group_by().select(*aggregations))
# ======================== Special Methods ========================
@overload
def __getitem__(self, key: str) -> Expression:
...
@overload
def __getitem__(
self, key: List[Union[str, Expression]]
) -> "DataFrame":
...
@overload
def __getitem__(
self, key: Tuple[Union[str, Expression], ...]
) -> "DataFrame":
...
@overload
def __getitem__(self, key: Expression) -> "DataFrame":
...
@PublicEvolving()
def __getitem__(
self,
key: Union[
str,
List[Union[str, Expression]],
Tuple[Union[str, Expression], ...],
Expression,
],
) -> Union["DataFrame", Expression]:
"""
Select a column, select multiple columns, or filter rows.
A string returns its column expression, a list or tuple returns a DataFrame containing the
listed columns, and a boolean expression returns a filtered DataFrame.
:param key: Column name, list or tuple of columns, or boolean expression.
:return: A column expression or a new DataFrame.
:raises TypeError: If ``key`` has an unsupported type.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1, "name": "Alice"}])
>>> identifier = df["id"]
>>> selected = df[("id", "name")]
>>> filtered = df[df["id"] > 0]
.. versionadded:: 2.4.0
"""
if isinstance(key, str):
return table_col(key)
if isinstance(key, (list, tuple)):
return self.select(key)
if isinstance(key, Expression):
return self.filter(key)
raise TypeError("key must be a string, list, tuple, or Expression")
@PublicEvolving()
def __getattr__(self, name: str) -> Expression:
"""
Return a column expression for an attribute name.
The name must be a valid Python identifier, must not start with an underscore, must not be
a Python keyword, and must identify an existing column. Existing DataFrame attributes take
precedence over columns. Use ``df["column name"]`` for names that cannot be accessed as
attributes, or ``df["select"]`` for columns that conflict with existing attributes.
This method resolves the schema without executing a Flink job.
:param name: Name of the referenced column.
:return: An expression referencing the column.
:raises AttributeError: If the name is invalid, private, or does not identify an existing
column.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1, "name": "Alice"}])
>>> selected = df.select(df.name)
>>> filtered = df.filter(df.id > 0)
.. versionadded:: 2.4.0
"""
if (
name.startswith("_")
or not name.isidentifier()
or keyword.iskeyword(name)
or any(name in cls.__dict__ for cls in type(self).__mro__)
):
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
# Avoid re-entering __getattr__ if the underlying table has not been initialized.
try:
table = object.__getattribute__(self, "_table")
except AttributeError:
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
) from None
if name not in table.get_resolved_schema().get_column_names():
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
return table_col(name)
# ======================== Composition ========================
@PublicEvolving()
def pipe(
self,
func: Callable[..., T],
*args: Any,
**kwargs: Any,
) -> T:
"""
Apply a function to this DataFrame for reusable functional composition.
This DataFrame is passed as the first argument, followed by ``args`` and ``kwargs``. The
function's return value is returned unchanged.
:param func: Function whose first argument receives this DataFrame.
:param args: Additional positional arguments passed to ``func``.
:param kwargs: Additional keyword arguments passed to ``func``.
:return: The value returned by ``func``.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, 2)], schema=["left", "right"])
>>> result = df.pipe(
... lambda current, name: current.with_column(
... name, pf.col("left") + pf.col("right")
... ),
... "total",
... )
.. versionadded:: 2.4.0
"""
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: (NOT is_nan(col) OR col IS NULL)
conditions = [
or_(not_(is_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()
def collect(self) -> List[Row]:
"""
Execute this DataFrame and return all rows.
The result iterator is always closed before this method returns or propagates an error.
:return: All result rows in collection order.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}, {"id": 2}])
>>> rows = df.collect()
.. versionadded:: 2.4.0
"""
with self._table.execute().collect() as rows:
return list(rows)
@PublicEvolving()
def iter_rows(
self,
*,
include_row_kind: bool = False,
row_kind_field: str = "__row_kind__",
) -> CloseableIterator[Dict[str, Any]]:
"""
Execute this DataFrame and iterate over its rows as they arrive, one dict per row.
Rows are fetched incrementally, so this also works on unbounded sources. The job runs until
the iterator is exhausted or closed; use it in a ``with`` block.
On an updating DataFrame, such as a streaming aggregation, every changelog entry is a
separate row. Set ``include_row_kind`` to tell insertions, updates and deletions apart.
TIMESTAMP_LTZ columns are not supported yet: the row transfer shared with :meth:`collect`
cannot serialize them.
:param include_row_kind: Whether to add each row's change kind, one of ``"+I"``,
``"-U"``, ``"+U"`` and ``"-D"``.
:param row_kind_field: Key under which the change kind is added. It must not clash with a
column name.
:return: An iterator of dicts mapping column names to values.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([{"id": 1}, {"id": 2}])
>>> with df.iter_rows() as rows:
... for row in rows:
... print(row["id"])
.. versionadded:: 2.4.0
"""
columns = self.columns
_validate_row_kind_field(columns, include_row_kind, row_kind_field)
return _iterate_rows(self._table, columns, row_kind_field if include_row_kind else None)
@PublicEvolving()
def iter_batches(
self,
*,
batch_size: int = 1000,
batch_format: Literal["pandas", "pyarrow"] = "pandas",
include_row_kind: bool = False,
row_kind_field: str = "__row_kind__",
) -> CloseableIterator[Union["pandas.DataFrame", "pyarrow.Table"]]:
"""
Execute this DataFrame and iterate over its rows in batches of ``batch_size``.
Every batch except possibly the last holds exactly ``batch_size`` rows, so on a slow
unbounded source a batch is only emitted once enough rows have arrived. The job runs until
the iterator is exhausted or closed; use it in a ``with`` block.
TIMESTAMP_LTZ columns are not supported yet: the row transfer shared with :meth:`collect`
cannot serialize them.
:param batch_size: Number of rows per batch.
:param batch_format: ``"pandas"`` for pandas DataFrames or ``"pyarrow"`` for PyArrow
Tables.
:param include_row_kind: Whether to add a column with each row's change kind, one of
``"+I"``, ``"-U"``, ``"+U"`` and ``"-D"``.
:param row_kind_field: Name of the change kind column. It must not clash with a column
name.
:return: An iterator of batches in the requested format.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.range(10)
>>> with df.iter_batches(batch_size=4) as batches:
... for batch in batches:
... print(len(batch))
.. versionadded:: 2.4.0
"""
_require_int(batch_size, "batch_size", 1)
_require_choice(batch_format, "batch_format", _BATCH_FORMATS)
schema = self._table.get_resolved_schema()
columns = schema.get_column_names()
data_types = schema.get_column_data_types()
_validate_row_kind_field(columns, include_row_kind, row_kind_field)
row_kind_field = row_kind_field if include_row_kind else None
return _iterate_batches(
self._table,
batch_size,
batch_format,
data_types,
_build_arrow_schema(columns, data_types, row_kind_field),
row_kind_field,
)
@PublicEvolving()
def take(
self,
n: int,
*,
timeout: Optional[float] = None,
include_row_kind: bool = False,
row_kind_field: str = "__row_kind__",
) -> List[Dict[str, Any]]:
"""
Execute this DataFrame and return its first ``n`` rows as dicts.
The job is cancelled once ``n`` rows have arrived if it is still running, so this is a
safe way to peek at an unbounded source. Fewer rows are returned if the result ends first
or ``timeout`` expires.
The query itself is not limited: on an updating DataFrame the rows are the first ``n``
changelog entries.
TIMESTAMP_LTZ columns are not supported yet: the row transfer shared with :meth:`collect`
cannot serialize them.
:param n: Maximum number of rows to return.
:param timeout: Maximum number of seconds to wait for rows once the job is submitted.
``None`` waits until ``n`` rows arrive or the result ends.
:param include_row_kind: Whether to add each row's change kind, one of ``"+I"``,
``"-U"``, ``"+U"`` and ``"-D"``.
:param row_kind_field: Key under which the change kind is added. It must not clash with a
column name.
:return: Up to ``n`` dicts mapping column names to values.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.range(100)
>>> df.take(2)
[{'id': 0}, {'id': 1}]
.. versionadded:: 2.4.0
"""
_validate_row_count(n)
if timeout is not None:
_require_number(timeout, "timeout", 0)
columns = self.columns
_validate_row_kind_field(columns, include_row_kind, row_kind_field)
row_kind_field = row_kind_field if include_row_kind else None
return [
_row_to_dict(row, columns, row_kind_field)
for row in _take_rows(self._table, n, timeout)
]
@PublicEvolving()
def take_batch(
self,
n: int,
*,
timeout: Optional[float] = None,
batch_format: Literal["pandas", "pyarrow"] = "pandas",
include_row_kind: bool = False,
row_kind_field: str = "__row_kind__",
) -> Union["pandas.DataFrame", "pyarrow.Table"]:
"""
Execute this DataFrame and return its first ``n`` rows as a single batch.
Behaves like :meth:`take` but returns a pandas DataFrame or PyArrow Table.
TIMESTAMP_LTZ columns are not supported yet: the row transfer shared with :meth:`collect`
cannot serialize them.
:param n: Maximum number of rows to return.
:param timeout: Maximum number of seconds to wait for rows once the job is submitted.
``None`` waits until ``n`` rows arrive or the result ends.
:param batch_format: ``"pandas"`` for a pandas DataFrame or ``"pyarrow"`` for a PyArrow
Table.
:param include_row_kind: Whether to add a column with each row's change kind, one of
``"+I"``, ``"-U"``, ``"+U"`` and ``"-D"``.
:param row_kind_field: Name of the change kind column. It must not clash with a column
name.
:return: A batch of up to ``n`` rows in the requested format.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.range(100)
>>> pdf = df.take_batch(5)
.. versionadded:: 2.4.0
"""
_validate_row_count(n)
if timeout is not None:
_require_number(timeout, "timeout", 0)
_require_choice(batch_format, "batch_format", _BATCH_FORMATS)
schema = self._table.get_resolved_schema()
columns = schema.get_column_names()
data_types = schema.get_column_data_types()
_validate_row_kind_field(columns, include_row_kind, row_kind_field)
row_kind_field = row_kind_field if include_row_kind else None
arrow_schema = _build_arrow_schema(columns, data_types, row_kind_field)
return _rows_to_batch(
_take_rows(self._table, n, timeout),
data_types,
arrow_schema,
batch_format,
row_kind_field,
)
@PublicEvolving()
def to_table(self) -> Table:
"""
Return the underlying PyFlink Table without copying or converting it.
This method does not trigger job execution.
:return: The exact Table wrapped by this DataFrame.
Example::
>>> import pyflink.dataframe as pf
>>> table = table_env.from_elements([(1,)], ["id"])
>>> dataframe = pf.from_table(table)
>>> dataframe.to_table() is table
True
.. versionadded:: 2.4.0
"""
return self._table
@PublicEvolving()
def to_pandas(self) -> "pandas.DataFrame":
"""
Execute this DataFrame and collect its rows into a pandas DataFrame.
All results are transferred to the client and must fit in client memory.
:return: A pandas DataFrame containing all result rows.
Example::
>>> import pyflink.dataframe as pf
>>> dataframe = pf.from_records([{"id": 1}, {"id": 2}])
>>> pdf = dataframe.to_pandas()
.. versionadded:: 2.4.0
"""
return self._table.to_pandas()
# ======================== Properties ========================
@property
@PublicEvolving()
def llm(self) -> "LLMAccessor":
"""
Access model inference operations for this DataFrame.
Example::
>>> result = df.llm.predict("question", provider="chat", model="gpt-6-luna")
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.llm import LLMAccessor
return LLMAccessor(self)
@property
@PublicEvolving()
def schema(self) -> "TableSchema":
"""
Return this DataFrame's schema.
:return: The TableSchema exposed by the underlying Table.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, "Alice")], schema=["id", "name"])
>>> df.schema.get_field_names()
['id', 'name']
.. versionadded:: 2.4.0
"""
return self._table.get_schema()
@property
@PublicEvolving()
def columns(self) -> List[str]:
"""
Return this DataFrame's column names in schema order.
:return: A new list containing the column names.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([(1, "Alice")], schema=["id", "name"])
>>> df.columns
['id', 'name']
.. versionadded:: 2.4.0
"""
return list(self._table.get_resolved_schema().get_column_names())
# ======================== I/O ========================
@PublicEvolving()
def write_parquet(
self,
path: str,
*,
mode: str = "append",
partition_by: Optional[Union[str, List[str]]] = None,
compression: Optional[str] = None,
utc_timezone: Optional[bool] = None,
sink_parallelism: Optional[int] = None,
sink_shuffle_by_partition: Optional[bool] = None,
auto_compaction: Optional[bool] = None,
compaction_file_size: Optional[str] = None,
rolling_policy_file_size: Optional[str] = None,
rolling_policy_rollover_interval: Optional[str] = None,
rolling_policy_inactivity_interval: Optional[str] = None,
rolling_policy_check_interval: Optional[str] = None,
partition_commit_trigger: Optional[str] = None,
partition_commit_delay: Optional[str] = None,
partition_commit_policy_kind: Optional[str] = None,
connector_options: Optional[Dict[str, str]] = None,
format_options: Optional[Dict[str, str]] = None,
statement_set: Optional[StatementSet] = None,
) -> None:
"""
Write Parquet files using Flink's filesystem connector.
The filesystem connector and Parquet format must be available to Flink. By default,
the write is submitted immediately and waits for completion for local or MiniCluster
execution. Passing ``statement_set`` stages the write without executing it.
Sink columns are derived from this DataFrame's schema. Writes default to append in both
batch and streaming execution. Explicit overwrite requires batch execution and is rejected
by the filesystem connector in streaming execution. Partitioned overwrite replaces only
partitions present in the input, retaining other partitions.
Optional connector and format parameters use ``None`` to leave the option unspecified.
If neither a parameter nor its dictionary option is set, the connector or format factory
supplies the default.
Rolling policies apply to streaming sinks. Parquet also rolls files on checkpoints;
continuous writes require checkpointing to finish files. Partition commit in streaming
requires ``partition_by`` and a commit policy. ``partition-time`` additionally requires
upstream watermarks and a partition time extractor, configured via ``connector_options``.
For a TIMESTAMP_LTZ watermark, set ``sink.partition-commit.watermark-time-zone`` in
``connector_options`` to the session time zone; its default is UTC.
:param path: Output directory URI supported by Flink's filesystem implementations.
:param mode: ``"append"`` (default) adds files; ``"overwrite"`` replaces existing data
in batch execution only.
:param partition_by: Partition column name or non-empty list of names in directory order.
Values are stored in Hive-style partition paths rather than in the Parquet records.
:param compression: Parquet compression codec. The format default is currently
``"SNAPPY"``.
:param utc_timezone: Use UTC for Parquet timestamp conversion. The format default is
currently ``False``, which uses the JVM default time zone, independently of the
session time zone.
:param sink_parallelism: Sink parallelism. The connector default is the upstream
parallelism.
:param sink_shuffle_by_partition: Shuffle rows by dynamic partition fields before writing.
This can reduce the number of files but may cause data skew. The connector default
is currently ``False``.
:param auto_compaction: Automatically compact files in streaming execution after
checkpoints complete. Files remain invisible until compaction finishes. The connector
default is currently ``False``.
:param compaction_file_size: Target file size for automatic compaction, for example
``"128mb"``. The connector default is the rolling policy file size.
:param rolling_policy_file_size: Part file size threshold for rolling, not a hard upper
bound. The connector default is currently ``"128mb"``.
:param rolling_policy_rollover_interval: Part file open-time threshold. The connector
default is currently ``"30min"``.
:param rolling_policy_inactivity_interval: Part file inactivity threshold. The connector
default is currently ``"30min"``.
:param rolling_policy_check_interval: Interval for checking time-based rolling policies.
The connector default is currently ``"1min"``.
:param partition_commit_trigger: Partition commit trigger: ``"process-time"`` or
``"partition-time"``. The connector default is currently ``"process-time"``.
:param partition_commit_delay: Delay before committing a partition. The connector
default is currently ``"0s"``.
:param partition_commit_policy_kind: Optional comma-separated policies, such as
``"success-file"`` or ``"custom"``. The ``metastore`` policy requires a Hive table.
:param connector_options: Additional filesystem options with string keys and values.
The ``connector``, ``path`` and ``format`` keys are reserved. Format options belong in
``format_options``. Explicit parameter and dictionary values must agree when both
are set. Unspecified options are left to the connector factory.
:param format_options: Parquet options with string values, with or without the
``parquet.`` prefix. Duplicate normalized keys are rejected. ``None`` parameters
leave dictionary values unchanged; conflicting explicit values are rejected.
For INT64 timestamp encoding, use ``{"write.int64.timestamp": "true",
"timestamp.time.unit": "micros"}``. The default encoding is INT96.
:param statement_set: Optional :class:`~pyflink.table.StatementSet` to stage the write in.
It must use the same TableEnvironment as this DataFrame. Call its ``execute()``
method to submit all staged writes together.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If the path is empty, the write mode is unsupported, the partition
specification is empty or contains empty or duplicate names, or options conflict
or contain reserved, empty or duplicate keys, or the statement set belongs to a
different TableEnvironment.
Example::
>>> import pyflink.dataframe as pf
>>> _ = pf.config.set("execution.runtime-mode", "batch")
>>> events = pf.from_records([(1, "login")], schema=["id", "event"])
>>> events.write_parquet("file:///tmp/events", compression="GZIP")
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.io import (
_boolean_option,
_build_filesystem_options,
_parallelism_option,
)
options = _build_filesystem_options(
path,
"parquet",
connector_parameters={
"sink.parallelism": _parallelism_option(sink_parallelism),
"sink.shuffle-by-partition.enable": _boolean_option(
sink_shuffle_by_partition, "sink_shuffle_by_partition"),
"auto-compaction": _boolean_option(auto_compaction, "auto_compaction"),
"compaction.file-size": compaction_file_size,
"sink.rolling-policy.file-size": rolling_policy_file_size,
"sink.rolling-policy.rollover-interval": rolling_policy_rollover_interval,
"sink.rolling-policy.inactivity-interval": rolling_policy_inactivity_interval,
"sink.rolling-policy.check-interval": rolling_policy_check_interval,
"sink.partition-commit.trigger": partition_commit_trigger,
"sink.partition-commit.delay": partition_commit_delay,
"sink.partition-commit.policy.kind": partition_commit_policy_kind,
},
format_parameters={
"compression": compression,
"utc-timezone": _boolean_option(utc_timezone, "utc_timezone"),
},
extra_connector_options=connector_options,
extra_format_options=format_options,
)
self._write(
"filesystem",
options,
mode=mode,
partition_by=partition_by,
statement_set=statement_set,
)
@PublicEvolving()
def write_json(
self,
path: str,
*,
mode: str = "append",
partition_by: Optional[Union[str, List[str]]] = None,
timestamp_format: Optional[str] = None,
ignore_null_fields: Optional[bool] = None,
decimal_as_plain_number: Optional[bool] = None,
sink_parallelism: Optional[int] = None,
sink_shuffle_by_partition: Optional[bool] = None,
auto_compaction: Optional[bool] = None,
compaction_file_size: Optional[str] = None,
rolling_policy_file_size: Optional[str] = None,
rolling_policy_rollover_interval: Optional[str] = None,
rolling_policy_inactivity_interval: Optional[str] = None,
rolling_policy_check_interval: Optional[str] = None,
partition_commit_trigger: Optional[str] = None,
partition_commit_delay: Optional[str] = None,
partition_commit_policy_kind: Optional[str] = None,
connector_options: Optional[Dict[str, str]] = None,
format_options: Optional[Dict[str, str]] = None,
statement_set: Optional[StatementSet] = None,
) -> None:
"""
Write newline-delimited JSON files using Flink's filesystem connector.
The filesystem connector and JSON format must be available to Flink. By default,
the write is submitted immediately and waits for completion for local or MiniCluster
execution. Passing ``statement_set`` stages the write without executing it.
Sink columns are derived from this DataFrame's schema. Writes default to append in both
batch and streaming execution. Explicit overwrite requires batch execution and is rejected
by the filesystem connector in streaming execution. Partitioned overwrite replaces only
partitions present in the input, retaining other partitions.
Optional connector and format parameters use ``None`` to leave the option unspecified.
If neither a parameter nor its dictionary option is set, the connector or format factory
supplies the default.
Rolling policies apply to streaming sinks. Continuous writes require both file rolling
and checkpointing to finish files. With automatic compaction, files also roll on
checkpoints. Partition commit in streaming requires ``partition_by``
and a commit policy. ``partition-time`` additionally requires upstream watermarks and a
partition time extractor, configured via ``connector_options``.
For a TIMESTAMP_LTZ watermark, set ``sink.partition-commit.watermark-time-zone`` in
``connector_options`` to the session time zone; its default is UTC.
:param path: Output directory URI supported by Flink's filesystem implementations.
:param mode: ``"append"`` (default) adds files; ``"overwrite"`` replaces existing data
in batch execution only.
:param partition_by: Partition column name or non-empty list of names in directory order.
Values are stored in Hive-style partition paths rather than in the JSON records.
:param timestamp_format: Timestamp representation, ``"SQL"`` or ``"ISO-8601"``.
The format default is currently ``"SQL"``.
:param ignore_null_fields: Omit fields with null values from JSON objects. The format
default is currently ``False``. This does not control Map entries with null keys;
configure ``map-null-key.mode`` through ``format_options`` for those entries.
:param decimal_as_plain_number: Encode DECIMAL values as plain numbers rather than
scientific notation, retaining JSON numeric values. The format default is currently
``False``.
:param sink_parallelism: Sink parallelism. The connector default is the upstream
parallelism.
:param sink_shuffle_by_partition: Shuffle rows by dynamic partition fields before writing.
This can reduce the number of files but may cause data skew. The connector default
is currently ``False``.
:param auto_compaction: Automatically compact files in streaming execution after
checkpoints complete. Files remain invisible until compaction finishes. The connector
default is currently ``False``.
:param compaction_file_size: Target file size for automatic compaction, for example
``"128mb"``. The connector default is the rolling policy file size.
:param rolling_policy_file_size: Part file size threshold for rolling, not a hard upper
bound. The connector default is currently ``"128mb"``.
:param rolling_policy_rollover_interval: Part file open-time threshold. The connector
default is currently ``"30min"``.
:param rolling_policy_inactivity_interval: Part file inactivity threshold. The connector
default is currently ``"30min"``.
:param rolling_policy_check_interval: Interval for checking time-based rolling policies.
The connector default is currently ``"1min"``.
:param partition_commit_trigger: Partition commit trigger: ``"process-time"`` or
``"partition-time"``. The connector default is currently ``"process-time"``.
:param partition_commit_delay: Delay before committing a partition. The connector
default is currently ``"0s"``.
:param partition_commit_policy_kind: Optional comma-separated policies, such as
``"success-file"`` or ``"custom"``. The ``metastore`` policy requires a Hive table.
:param connector_options: Additional filesystem options with string keys and values.
The ``connector``, ``path`` and ``format`` keys are reserved. Format options belong in
``format_options``. Explicit parameter and dictionary values must agree when both
are set. Unspecified options are left to the connector factory.
:param format_options: JSON format options with string values. Keys may include or omit
the ``json.`` prefix, for example ``{"timestamp-format.standard": "ISO-8601"}``.
``None`` parameters leave dictionary values unchanged; conflicting explicit values
are rejected.
:param statement_set: Optional :class:`~pyflink.table.StatementSet` to stage the write in.
It must use the same TableEnvironment as this DataFrame. Call its ``execute()``
method to submit all staged writes together.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If the path is empty, the write mode is unsupported, the partition
specification is empty or contains empty or duplicate names, or options conflict
or contain reserved, empty or duplicate keys, or the statement set belongs to a
different TableEnvironment.
Example::
>>> import pyflink.dataframe as pf
>>> events = pf.from_records([(1, "login")], schema=["id", "event"])
>>> events.write_json("file:///tmp/events")
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.io import (
_boolean_option,
_build_filesystem_options,
_parallelism_option,
)
options = _build_filesystem_options(
path,
"json",
connector_parameters={
"sink.parallelism": _parallelism_option(sink_parallelism),
"sink.shuffle-by-partition.enable": _boolean_option(
sink_shuffle_by_partition, "sink_shuffle_by_partition"),
"auto-compaction": _boolean_option(auto_compaction, "auto_compaction"),
"compaction.file-size": compaction_file_size,
"sink.rolling-policy.file-size": rolling_policy_file_size,
"sink.rolling-policy.rollover-interval": rolling_policy_rollover_interval,
"sink.rolling-policy.inactivity-interval": rolling_policy_inactivity_interval,
"sink.rolling-policy.check-interval": rolling_policy_check_interval,
"sink.partition-commit.trigger": partition_commit_trigger,
"sink.partition-commit.delay": partition_commit_delay,
"sink.partition-commit.policy.kind": partition_commit_policy_kind,
},
format_parameters={
"timestamp-format.standard": timestamp_format,
"encode.ignore-null-fields": _boolean_option(
ignore_null_fields, "ignore_null_fields"),
"encode.decimal-as-plain-number": _boolean_option(
decimal_as_plain_number, "decimal_as_plain_number"),
},
extra_connector_options=connector_options,
extra_format_options=format_options,
)
self._write(
"filesystem",
options,
mode=mode,
partition_by=partition_by,
statement_set=statement_set,
)
@PublicEvolving()
def write_kafka(
self,
bootstrap_servers: str,
*,
topic: str,
format: str = "json",
format_options: Optional[Dict[str, str]] = None,
key_format: Optional[str] = None,
key_format_options: Optional[Dict[str, str]] = None,
key_fields: Optional[List[str]] = None,
value_format: Optional[str] = None,
value_format_options: Optional[Dict[str, str]] = None,
value_fields_include: Literal["ALL", "EXCEPT_KEY"] = "ALL",
delivery_guarantee: Literal[
"none", "at-least-once", "exactly-once"
] = "at-least-once",
transactional_id_prefix: Optional[str] = None,
partitioner: Optional[str] = None,
properties: Optional[Dict[str, str]] = None,
parallelism: Optional[int] = None,
options: Optional[Dict[str, str]] = None,
statement_set: Optional[StatementSet] = None,
) -> None:
"""
Write this DataFrame to Kafka using Flink's Kafka SQL connector.
The Kafka connector must be available to Flink. By default, the write is
submitted immediately and waits for completion for local or MiniCluster
execution. Passing ``statement_set`` stages the write without executing it.
Sink columns are derived from this DataFrame's schema.
``format`` and ``value_format`` are aliases; ``value_format`` takes
precedence when both are set. ``format_options`` and ``value_format_options`` are both
normalized to ``value.<format>.<key>``; conflicting values are rejected.
:param bootstrap_servers: Comma-separated Kafka bootstrap server
addresses.
:param topic: Kafka topic to write to.
:param format: Value format, for example ``"json"``, ``"csv"``,
``"avro"``.
:param format_options: Value format options with string values, with
or without the ``<format>.`` prefix.
:param key_format: Key format, for example ``"json"`` or ``"avro"``.
:param key_format_options: Key format options with string values, with
or without the ``key.<key_format>.`` prefix.
:param key_fields: List of column names that make up the key.
:param value_format: Alternative to ``format`` for specifying the
value format.
:param value_format_options: Alternative to ``format_options`` for
specifying value format options.
:param value_fields_include: Whether value fields include key fields:
``"ALL"`` or ``"EXCEPT_KEY"``.
:param delivery_guarantee: Delivery guarantee: ``"none"``,
``"at-least-once"``, or ``"exactly-once"``. Exactly-once requires
checkpointing and a transaction id prefix.
:param transactional_id_prefix: Prefix for Kafka transaction identifiers.
Required for ``"exactly-once"``, unless set via ``options``. Use a unique
prefix for each concurrently running Kafka sink.
:param partitioner: Kafka sink partitioner, for example ``"default"``, ``"fixed"``,
``"round-robin"``, or the fully qualified name of a custom
``FlinkKafkaPartitioner`` subclass. ``None`` uses the connector default.
:param properties: Additional Kafka producer properties with string
keys and values. Keys may be supplied with or without the
``properties.`` prefix. ``properties.bootstrap.servers`` must use
the dedicated argument.
:param parallelism: Sink parallelism. The connector default is the
upstream parallelism.
:param options: Additional raw Kafka connector options with string keys and values,
excluding ``connector``. Keys are passed through unchanged. Conflicts with
options generated by other arguments (including defaults) raise ``ValueError``.
:param statement_set: Optional :class:`~pyflink.table.StatementSet` to stage the write in.
It must use the same TableEnvironment as this DataFrame. Call its ``execute()``
method to submit all staged writes together.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If required arguments are missing, mutually
exclusive arguments conflict, or an option is invalid.
Example::
>>> import pyflink.dataframe as pf
>>> events = pf.from_records(
... [(1, "login")], schema=["user_id", "event"])
>>> events.write_kafka(
... "localhost:9092",
... topic="output-events",
... format="json",
... delivery_guarantee="at-least-once",
... )
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.io import _build_kafka_sink_options
kafka_options = _build_kafka_sink_options(
bootstrap_servers,
topic=topic,
value_format=value_format if value_format is not None else format,
format=format,
format_options=format_options,
value_format_options=value_format_options,
key_format=key_format,
key_format_options=key_format_options,
key_fields=key_fields,
value_fields_include=value_fields_include,
delivery_guarantee=delivery_guarantee,
transactional_id_prefix=transactional_id_prefix,
partitioner=partitioner,
properties=properties,
parallelism=parallelism,
extra_options=options,
)
self._write("kafka", kafka_options, statement_set=statement_set)
@PublicEvolving()
def write_generic(
self,
connector: str,
*,
options: Dict[str, str],
statement_set: Optional[StatementSet] = None,
) -> None:
"""
Write this DataFrame using a connector and its raw Table connector options.
The connector must be available through Flink's factory discovery mechanism. By default,
the write is submitted immediately and waits for completion for local or MiniCluster
execution. Passing ``statement_set`` stages the write without executing it.
Sink columns are derived from this DataFrame's output schema.
:param connector: Factory identifier used as the ``connector`` Table option.
:param options: Connector options, excluding the reserved ``connector`` option.
:param statement_set: Optional :class:`~pyflink.table.StatementSet` to stage the write in.
It must use the same TableEnvironment as this DataFrame. Call its ``execute()``
method to submit all staged writes together.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If the connector or an option key is empty, if ``options`` contains
the reserved ``connector`` key, if Flink rejects the connector or its options, or
if the statement set belongs to a different TableEnvironment.
Example::
>>> import pyflink.dataframe as pf
>>> events = pf.from_records([(1, "login")], schema=["id", "event"])
>>> events.write_generic(
... "filesystem",
... options={
... "path": "file:///tmp/events",
... "format": "csv",
... },
... )
.. versionadded:: 2.4.0
"""
self._write(connector, options, statement_set=statement_set)
def _write(
self,
connector: str,
options: Dict[str, str],
*,
mode: str = "append",
partition_by: Optional[Union[str, List[str]]] = None,
statement_set: Optional[StatementSet] = None,
) -> None:
from pyflink.dataframe.errors import _raise_as_value_error
from pyflink.dataframe.io import _build_generic_descriptor
if not isinstance(mode, str):
raise TypeError("mode must be a string")
if mode not in ("append", "overwrite"):
raise ValueError("mode must be 'append' or 'overwrite'")
descriptor = _build_generic_descriptor(connector, options, partition_by=partition_by)
try:
self._execute_insert(descriptor, mode == "overwrite", statement_set=statement_set)
except Exception as error:
_raise_as_value_error(error)
@PublicEvolving()
def write_catalog_table(
self,
path: str,
*,
overwrite: bool = False,
statement_set: Optional[StatementSet] = None,
) -> None:
"""
Write this DataFrame to a table registered in a catalog.
``path`` is ``table_name``, ``db_name.table_name``, or ``catalog_name.db_name.table_name``.
Missing parts are resolved against the current catalog and database, see
:func:`~pyflink.dataframe.use_catalog` and :func:`~pyflink.dataframe.use_database`. The
write runs right away by default. On a local or MiniCluster setup the call blocks until
the write is done. Passing ``statement_set`` stages the write without executing it.
:param path: Path of the catalog table.
:param overwrite: Whether existing data should be replaced, like ``INSERT OVERWRITE``.
Not every connector supports overwriting.
:param statement_set: Optional :class:`~pyflink.table.StatementSet` to stage the write in.
It must use the same TableEnvironment as this DataFrame. Call its ``execute()``
method to submit all staged writes together.
:raises TypeError: If ``path`` is not a string, ``overwrite`` is not a bool, or
``statement_set`` is neither a StatementSet nor ``None``.
:raises ValueError: If ``path`` is empty, malformed, or does not name a table, or if the
DataFrame's columns do not match the table, or the statement set belongs to a
different TableEnvironment.
Example::
>>> import pyflink.dataframe as pf
>>> events = pf.from_records([(1, "login")], schema=["id", "event"])
>>> events.write_catalog_table("my_catalog.my_database.events")
>>> pf.use_catalog("my_catalog")
>>> events.write_catalog_table("my_database.events", overwrite=True)
.. versionadded:: 2.4.0
"""
from pyflink.dataframe.catalog import _validate_name
from pyflink.dataframe.errors import _raise_as_value_error
_validate_name(path, "path")
if not isinstance(overwrite, bool):
raise TypeError("overwrite must be a bool")
try:
self._execute_insert(path, overwrite, statement_set=statement_set)
except Exception as error:
_raise_as_value_error(error)
def _execute_insert(
self,
target: Union[str, TableDescriptor],
overwrite: bool = False,
*,
statement_set: Optional[StatementSet] = None,
) -> None:
if statement_set is not None:
if not isinstance(statement_set, StatementSet):
raise TypeError("statement_set must be a StatementSet or None")
if self._table._t_env._j_tenv != statement_set._t_env._j_tenv:
raise ValueError(
"DataFrame and statement_set must belong to the same TableEnvironment"
)
statement_set.add_insert(target, self._table, overwrite=overwrite)
return
result = self._table.execute_insert(target, overwrite=overwrite)
execution_target = self._table._t_env.get_config().get(
"execution.target", None
)
if execution_target in ("local", "minicluster"):
result.wait()
@PublicEvolving()
class GroupedDataFrame:
"""
A DataFrame grouped by one or more keys and ready for aggregation.
Instances are created by :meth:`DataFrame.group_by`.
.. versionadded:: 2.4.0
"""
def __init__(self, dataframe: DataFrame, grouping_keys: List[Expression]):
self._dataframe = dataframe
self._grouping_keys = grouping_keys
@PublicEvolving()
def agg(self, *aggs: Expression, **named_aggs: Expression) -> DataFrame:
"""
Aggregate the rows in each group.
Grouping keys are included first in their supplied order, followed by positional
aggregation expressions and then named aggregations. Each named aggregation is aliased to
its keyword name.
:param aggs: Aggregation expressions.
:param named_aggs: Aggregation expressions keyed by their result column names.
:return: A DataFrame containing the grouping keys and aggregation results.
:raises TypeError: If an aggregation is not an expression.
:raises ValueError: If no aggregations are provided.
Example::
>>> import pyflink.dataframe as pf
>>> df = pf.from_records([
... ("engineering", 10),
... ("engineering", 20),
... ("sales", 5),
... ], schema=["department", "amount"])
>>> totals = df.group_by("department").agg(
... pf.col("amount").sum.alias("total_amount"),
... row_count=pf.col("amount").count,
... )
>>> # totals schema: [department: STRING, total_amount: BIGINT,
>>> # row_count: BIGINT NOT NULL]
.. versionadded:: 2.4.0
"""
aggregations = _normalize_aggregations(aggs, named_aggs)
grouped_table = self._dataframe._table.group_by(*self._grouping_keys)
return DataFrame(grouped_table.select(*self._grouping_keys, *aggregations))
# ======================== Internal Helpers ========================
def _normalize_join_type(how: str) -> str:
if not isinstance(how, str):
raise TypeError("how must be a string")
aliases = {
"inner": "inner",
"left": "left",
"right": "right",
"full": "full",
"outer": "full",
"semi": "semi",
"anti": "anti",
"cross": "cross",
}
if how not in aliases:
raise ValueError(
'how must be one of "inner", "left", "right", "full", "outer", '
'"semi", "anti", or "cross"'
)
return aliases[how]
def _normalize_join_keys(
value, parameter_name: str, *, allow_expressions: bool = False
) -> List[Union[str, Expression]]:
if isinstance(value, (str, Expression)):
return [value]
if isinstance(value, list):
if not value:
raise ValueError("%s must not be empty" % parameter_name)
key_types = (str, Expression) if allow_expressions else (str,)
if not all(isinstance(key, key_types) for key in value):
raise TypeError(
"%s must be a string, an expression, or a list of strings" % parameter_name
)
names = set()
for key in value:
if isinstance(key, str):
if key in names:
raise ValueError("%s must not contain duplicate column names" % parameter_name)
names.add(key)
return value
raise TypeError(
"%s must be a string, an expression, or a list of strings" % parameter_name
)
def _validate_join_columns(
keys: List[Union[str, Expression]], columns: List[str], parameter_name: str
) -> None:
for key in keys:
if isinstance(key, str) and key not in columns:
raise ValueError(
"%s column '%s' does not exist, available columns: %s"
% (parameter_name, key, columns)
)
def _validate_join_column_conflicts(
left_columns: List[str], right_columns: List[str], shared_keys: Set[str]
) -> None:
conflicts = sorted((set(left_columns) & set(right_columns)) - shared_keys)
if conflicts:
raise ValueError(
"join() found duplicate non-key columns %s; rename them with rename_columns() "
"before joining" % conflicts
)
def _prepare_join(
left_table: Table,
right_table: Table,
on,
left_on,
right_on,
*,
validate_column_conflicts: bool,
asof_time: Optional[str] = None,
) -> Tuple[Table, Table, Expression, Dict[str, str]]:
left_columns = list(left_table.get_resolved_schema().get_column_names())
right_columns = list(right_table.get_resolved_schema().get_column_names())
if on is not None:
if left_on is not None or right_on is not None:
raise ValueError("on cannot be combined with left_on or right_on")
if isinstance(on, Expression):
if validate_column_conflicts:
_validate_join_column_conflicts(left_columns, right_columns, set())
return left_table, right_table, on, {}
left_keys = _normalize_join_keys(on, "on")
right_keys = list(left_keys)
_validate_join_columns(left_keys, left_columns, "on")
_validate_join_columns(right_keys, right_columns, "on")
else:
if left_on is None and right_on is None:
raise ValueError("join() requires on or both left_on and right_on")
if left_on is None or right_on is None:
raise ValueError("left_on and right_on must be provided together")
left_keys = _normalize_join_keys(
left_on, "left_on", allow_expressions=asof_time is not None)
right_keys = _normalize_join_keys(
right_on, "right_on", allow_expressions=asof_time is not None)
if len(left_keys) != len(right_keys):
raise ValueError("left_on and right_on must have the same number of keys")
_validate_join_columns(left_keys, left_columns, "left_on")
_validate_join_columns(right_keys, right_columns, "right_on")
if asof_time is not None:
# Preserve direct field references so temporal joins retain the right primary key.
left_keys, right_keys = list(left_keys), list(right_keys)
for table, keys, columns in (
(left_table, left_keys, left_columns), (right_table, right_keys, right_columns)
):
for index, key in enumerate(keys):
if isinstance(key, Expression):
selected = table.select(key)
if len(selected.get_resolved_schema().get_column_names()) != 1:
raise ValueError("each join key must select a single column")
projection = selected._j_table.getQueryOperation()
if not projection.getChildren().get(0).equals(
table._j_table.getQueryOperation()
):
raise ValueError("join keys must be row-wise expressions, not aggregations")
resolved = projection.getProjectList().get(0)
_asof_expression_info(resolved, len(columns))
field_index = _asof_field_index(resolved)
if field_index is not None:
keys[index] = columns[field_index]
shared_names = {
left_key
for left_key, right_key in zip(left_keys, right_keys)
if isinstance(left_key, str)
and isinstance(right_key, str)
and left_key == right_key
}
if validate_column_conflicts:
_validate_join_column_conflicts(left_columns, right_columns, shared_names)
taken = set(left_columns) | set(right_columns)
shared_keys: Dict[str, str] = {}
right_rename_expressions: List[Expression] = []
for name in left_columns:
if name in shared_names:
temporary_name = _unique_name("__pf_join_right_%s" % name, taken)
taken.add(temporary_name)
shared_keys[name] = temporary_name
right_rename_expressions.append(table_col(name).alias(temporary_name))
left_key_names: List[str] = []
right_key_names: List[str] = []
left_computed_keys: List[Expression] = []
right_computed_keys: List[Expression] = []
for index, (left_key, right_key) in enumerate(zip(left_keys, right_keys)):
if isinstance(left_key, str):
left_key_names.append(left_key)
else:
temporary_name = _unique_name("__pf_join_left_key_%d" % index, taken)
taken.add(temporary_name)
left_key_names.append(temporary_name)
left_computed_keys.append(left_key.alias(temporary_name))
if isinstance(right_key, str):
right_key_names.append(shared_keys.get(right_key, right_key))
else:
temporary_name = _unique_name("__pf_join_right_key_%d" % index, taken)
taken.add(temporary_name)
right_key_names.append(temporary_name)
right_computed_keys.append(right_key.alias(temporary_name))
if left_computed_keys:
left_table = left_table.add_columns(*left_computed_keys)
if right_computed_keys:
right_table = right_table.add_columns(*right_computed_keys)
if right_rename_expressions:
right_table = right_table.rename_columns(*right_rename_expressions)
conditions = [
table_col(left_name) == table_col(right_name)
for left_name, right_name in zip(left_key_names, right_key_names)
]
predicate = conditions[0] if len(conditions) == 1 else and_(*conditions)
return (
left_table,
right_table,
predicate,
shared_keys,
)
def _asof_field_index(expression) -> Optional[int]:
definitions = get_gateway().jvm.org.apache.flink.table.functions.BuiltInFunctionDefinitions
checks = get_gateway().jvm.org.apache.flink.table.types.logical.utils.LogicalTypeChecks
while (expression.getClass().getSimpleName() == "CallExpression" and
expression.getFunctionDefinition().equals(definitions.CAST)):
child = expression.getResolvedChildren().get(0)
# DataType equality ignores time-attribute kinds; casting materializes such attributes.
if (not expression.getOutputDataType().equals(child.getOutputDataType()) or
checks.isTimeAttribute(child.getOutputDataType().getLogicalType())):
return None
expression = child
if expression.getClass().getSimpleName() == "FieldReferenceExpression":
return expression.getFieldIndex()
if expression.getClass().getSimpleName() == "RexNodeExpression":
node = expression.getRexNode()
while node.getKind().name() == "CAST":
child = node.getOperands().get(0)
if not node.getType().equals(child.getType()):
return None
node = child
if node.getKind().name() == "INPUT_REF":
return node.getIndex()
return None
def _prepare_asof_time(table: Table, on, right_columns: List[str]) -> Tuple[Table, str]:
expression = table_col(on) if isinstance(on, str) else on
selected = table.select(expression)
projection = selected._j_table.getQueryOperation()
if not projection.getChildren().get(0).equals(table._j_table.getQueryOperation()):
raise ValueError("on must be a row-wise time attribute, not an aggregation")
schema = selected._j_table.getResolvedSchema()
checks = get_gateway().jvm.org.apache.flink.table.types.logical.utils.LogicalTypeChecks
if schema.getColumnCount() != 1 or not checks.isTimeAttribute(
schema.getColumnDataTypes().get(0).getLogicalType()
):
raise ValueError("on must be a left processing-time or event-time attribute")
resolved = projection.getProjectList().get(0)
columns = list(table.get_resolved_schema().get_column_names())
_asof_expression_info(resolved, len(columns))
field_index = _asof_field_index(resolved)
if field_index is not None:
return table, columns[field_index]
name = _unique_name("__pf_asof_time", set(columns) | set(right_columns))
return table.add_columns(expression.alias(name)), name
def _asof_expression_info(expression, left_arity: int) -> Tuple[Set[int], bool]:
"""Return referenced inputs and whether an equi-join key is present."""
class_name = expression.getClass().getSimpleName()
if class_name == "RexNodeExpression":
return _asof_expression_info(expression.getRexNode(), left_arity)
if class_name == "FieldReferenceExpression":
return {expression.getInputIndex()}, False
operator = None
if expression.getClass().getName().startswith("org.apache.calcite.rex."):
# SQL expressions use flattened input indices rather than Table API input/field pairs.
operator = expression.getKind().name()
if operator == "INPUT_REF":
index = expression.getIndex()
return {0 if index < left_arity else 1}, False
if operator == "LITERAL":
return set(), False
if operator == "FIELD_ACCESS":
children = [expression.getReferenceExpr()]
else:
if expression.getOperator().isAggregator():
raise ValueError("join expressions must be row-wise, not aggregations")
children = expression.getOperands()
else:
children = expression.getResolvedChildren()
if class_name == "CallExpression":
definitions = (
get_gateway().jvm.org.apache.flink.table.functions.BuiltInFunctionDefinitions
)
for name in ("AND", "OR", "EQUALS"):
if expression.getFunctionDefinition().equals(getattr(definitions, name)):
operator = name
break
if operator == "OR":
raise ValueError("join_asof does not support OR conditions")
child_info = []
references = set()
for child in children:
info = _asof_expression_info(child, left_arity)
references.update(info[0])
child_info.append(info)
has_equality = False
if operator == "AND":
has_equality = any(info[1] for info in child_info)
elif operator == "EQUALS":
left, right = child_info[0][0], child_info[1][0]
has_equality = (left == {0} and right == {1}) or (left == {1} and right == {0})
return references, has_equality
class _JoinSqlFactory:
"""Keep inline UDFs registered until the join SQL has been resolved."""
def __init__(self, t_env: "TableEnvironment"):
self._t_env = t_env
self._functions: Dict[Any, str] = {}
self._taken_names: Optional[Set[str]] = None
self._cleanup = ExitStack()
def __enter__(self):
return self
def __exit__(self, *exc_info):
return self._cleanup.__exit__(*exc_info)
def serializeInlineFunction(self, definition):
if definition not in self._functions:
if self._taken_names is None:
self._taken_names = {name.lower() for name in self._t_env.list_functions()}
name = _unique_name("__pf_join_udf", self._taken_names)
self._t_env._j_tenv.createTemporarySystemFunction(name, definition)
self._cleanup.callback(self._t_env.drop_temporary_system_function, name)
self._taken_names.add(name)
self._functions[definition] = name
return _quote_identifier(self._functions[definition])
class Java:
implements = ["org.apache.flink.table.expressions.SqlFactory"]
def _serialize_join_predicate(
left_table: Table,
right_table: Table,
predicate: Expression,
sql_factory: _JoinSqlFactory,
asof_time: Optional[str] = None,
) -> Tuple[str, str, str]:
left_alias, right_alias = "__pf_join_left", "__pf_join_right"
operation_tree_builder = (
left_table._j_table.getTableEnvironment().getOperationTreeBuilder()
)
gateway = get_gateway()
query_operations = to_jarray(
gateway.jvm.org.apache.flink.table.operations.QueryOperation,
[
left_table._j_table.getQueryOperation(),
right_table._j_table.getQueryOperation(),
],
)
resolved_predicate = operation_tree_builder.resolveExpression(
_get_java_expression(predicate), query_operations
)
if asof_time is not None:
columns = list(left_table.get_resolved_schema().get_column_names())
_, has_equality = _asof_expression_info(resolved_predicate, len(columns))
if not has_equality:
raise ValueError("join_asof requires an AND-combined equality between the inputs")
aliases = gateway.jvm.java.util.HashMap()
aliases.put(0, left_alias)
aliases.put(1, right_alias)
operation_expression_utils = (
gateway.jvm.org.apache.flink.table.operations.utils.OperationExpressionsUtils
)
predicate_sql = operation_expression_utils.scopeReferencesWithAlias(
aliases, resolved_predicate
).asSerializableString(sql_factory)
return left_alias, right_alias, predicate_sql
def _build_regular_join_sql(
left_table: Table,
right_table: Table,
predicate: Expression,
left_output_columns: List[str],
right_output_columns: List[str],
shared_keys: Dict[str, str],
join_type: str,
sql_factory: _JoinSqlFactory,
*,
asof_time: Optional[str] = None,
) -> Table:
left_alias, right_alias, predicate_sql = _serialize_join_predicate(
left_table, right_table, predicate, sql_factory, asof_time
)
left_alias_sql = _quote_identifier(left_alias)
right_alias_sql = _quote_identifier(right_alias)
projections = []
for name in left_output_columns:
left_field = "%s.%s" % (left_alias_sql, _quote_identifier(name))
if name in shared_keys and join_type in ("right", "full"):
right_field = "%s.%s" % (
right_alias_sql,
_quote_identifier(shared_keys[name]),
)
expression = (
right_field
if join_type == "right"
else "COALESCE(%s, %s)" % (left_field, right_field)
)
else:
expression = left_field
projections.append("%s AS %s" % (expression, _quote_identifier(name)))
projections.extend(
"%s.%s AS %s"
% (right_alias_sql, _quote_identifier(name), _quote_identifier(name))
for name in right_output_columns
if name not in shared_keys
)
join_keyword = {
"inner": "INNER JOIN",
"left": "LEFT OUTER JOIN",
"right": "RIGHT OUTER JOIN",
"full": "FULL OUTER JOIN",
}[join_type]
temporal_clause = ""
if asof_time is not None:
temporal_clause = " FOR SYSTEM_TIME AS OF %s.%s" % (
left_alias_sql, _quote_identifier(asof_time))
query = (
"SELECT %s FROM %s AS %s %s %s%s AS %s ON %s"
% (
", ".join(projections),
_quote_identifier(str(left_table)),
left_alias_sql,
join_keyword,
_quote_identifier(str(right_table)),
temporal_clause,
right_alias_sql,
predicate_sql,
)
)
return left_table._t_env.sql_query(query)
def _build_semi_anti_join_sql(
left_table: Table,
right_table: Table,
predicate: Expression,
output_columns: List[str],
join_type: str,
sql_factory: _JoinSqlFactory,
) -> Table:
left_alias, right_alias, predicate_sql = _serialize_join_predicate(
left_table, right_table, predicate, sql_factory
)
left_alias_sql = _quote_identifier(left_alias)
right_alias_sql = _quote_identifier(right_alias)
select_list = ", ".join(
"%s.%s" % (left_alias_sql, _quote_identifier(name)) for name in output_columns
)
left_source_sql = _quote_identifier(str(left_table))
right_source_sql = _quote_identifier(str(right_table))
existence_predicate = {"semi": "EXISTS", "anti": "NOT EXISTS"}[join_type]
query = (
"SELECT %s FROM %s AS %s WHERE %s ("
"SELECT 1 FROM %s AS %s WHERE %s)"
% (
select_list,
left_source_sql,
left_alias_sql,
existence_predicate,
right_source_sql,
right_alias_sql,
predicate_sql,
)
)
return left_table._t_env.sql_query(query)
def _normalize_subset(
subset: Union[str, List[str], None], parameter_name: str = "subset"
) -> Optional[List[str]]:
if subset is None:
return None
if isinstance(subset, str):
return [subset]
if isinstance(subset, (list, tuple)):
if not subset:
raise ValueError("%s must not be empty" % parameter_name)
for name in subset:
if not isinstance(name, str):
raise TypeError(
"%s must be a string or a list of strings" % parameter_name
)
return list(subset)
raise TypeError("%s must be a string or a list of strings" % parameter_name)
def _normalize_order_by(
order_by: Union[str, Expression, List[Union[str, Expression]], None],
parameter_name: str = "order_by",
) -> Optional[List[Union[str, Expression]]]:
if order_by is None:
return None
values = order_by if isinstance(order_by, (list, tuple)) else [order_by]
keys: List[Union[str, Expression]] = []
for value in values:
if isinstance(value, (str, Expression)):
keys.append(value)
else:
raise TypeError(
"%s must be a string, an expression, or a list or tuple of them" % parameter_name
)
if not keys:
raise ValueError("%s must not be empty" % parameter_name)
return keys
def _contains_ordering_expression(expression: Expression) -> bool:
gateway = get_gateway()
api_expression_utils = gateway.jvm.org.apache.flink.table.expressions.ApiExpressionUtils
built_in_functions = gateway.jvm.org.apache.flink.table.functions.BuiltInFunctionDefinitions
def contains_ordering(j_expression) -> bool:
if api_expression_utils.isFunction(
j_expression, built_in_functions.ORDER_ASC
) or api_expression_utils.isFunction(j_expression, built_in_functions.ORDER_DESC):
return True
return any(contains_ordering(child) for child in j_expression.getChildren())
return contains_ordering(expression._j_expr.toExpr())
def _normalize_nulls_first(
nulls_first: Union[bool, List[bool], None], order_len: int
) -> Optional[List[bool]]:
if nulls_first is None:
return None
if isinstance(nulls_first, bool):
values = [nulls_first] * order_len
elif isinstance(nulls_first, (list, tuple)):
for value in nulls_first:
if not isinstance(value, bool):
raise TypeError("nulls_first must be a boolean or a list of booleans")
values = list(nulls_first)
else:
raise TypeError("nulls_first must be a boolean or a list of booleans")
if len(values) != order_len:
raise ValueError("nulls_first must have the same length as the sort keys")
return values
def _normalize_descending(descending, order_len):
if isinstance(descending, bool):
return [descending] * order_len
if isinstance(descending, (list, tuple)):
if len(descending) != order_len:
raise ValueError("descending must have the same length as the sort keys")
if not all(isinstance(v, bool) for v in descending):
raise TypeError("descending must be a boolean or a list of booleans")
return list(descending)
raise TypeError("descending must be a boolean or a list of booleans")
def _build_sort_sql(table, order_keys, descending_flags, nulls) -> Table:
columns = table.get_resolved_schema().get_column_names()
taken = set(columns)
order_terms = []
for index, key in enumerate(order_keys):
direction = "DESC" if descending_flags[index] else "ASC"
if isinstance(key, str):
if key not in columns:
raise ValueError(
"by column '%s' does not exist, available columns: %s" % (key, columns)
)
expression_sql = _quote_identifier(key)
else:
name = _unique_name("__pf_order_%d" % index, taken)
taken.add(name)
table = table.add_columns(key.alias(name))
expression_sql = _quote_identifier(name)
term = "%s %s" % (expression_sql, direction)
if nulls[index] is not None:
term += " NULLS FIRST" if nulls[index] else " NULLS LAST"
order_terms.append(term)
select_list = ", ".join(_quote_identifier(name) for name in columns)
query = "SELECT %s FROM %s ORDER BY %s" % (
select_list,
_quote_identifier(str(table)),
", ".join(order_terms),
)
return table._t_env.sql_query(query)
def _build_rank_sql(table, partition_keys, order_keys, descending_flags, nulls, n) -> Table:
columns = table.get_resolved_schema().get_column_names()
for name in partition_keys:
if name not in columns:
raise ValueError(
"partition_by column '%s' does not exist, available columns: %s" % (name, columns))
taken = set(columns)
order_terms = []
for index, key in enumerate(order_keys):
direction = "DESC" if descending_flags[index] else "ASC"
if key is None:
expr_sql = "PROCTIME()"
elif isinstance(key, str):
if key not in columns:
raise ValueError(
"order_by column '%s' does not exist, available columns: %s" % (key, columns))
expr_sql = _quote_identifier(key)
else:
name = _unique_name("__pf_order_%d" % index, taken)
taken.add(name)
table = table.add_columns(key.alias(name))
expr_sql = _quote_identifier(name)
term = expr_sql + " " + direction
if nulls is not None and nulls[index] is not None:
term += " NULLS FIRST" if nulls[index] else " NULLS LAST"
order_terms.append(term)
rank_column = _quote_identifier(_unique_name("__pf_row_number", taken))
source = _quote_identifier(str(table))
select_list = ", ".join(_quote_identifier(name) for name in columns)
over_clause = "ORDER BY " + ", ".join(order_terms)
if partition_keys:
over_clause = (
"PARTITION BY %s " % ", ".join(_quote_identifier(k) for k in partition_keys)
) + over_clause
rank_filter = "= 1" if n == 1 else "<= %d" % n
query = (
"SELECT %s FROM (\n"
" SELECT *, ROW_NUMBER() OVER (%s) AS %s\n"
" FROM %s\n"
") WHERE %s %s"
% (select_list, over_clause, rank_column, source, rank_column, rank_filter)
)
return table._t_env.sql_query(query)
def _unique_name(base: str, taken: Set[str]) -> str:
name = base
while name in taken:
name += "_"
return name
def _quote_identifier(name: str) -> str:
return "`" + name.replace("`", "``") + "`"
def _resolve_window_time_column(on: Union[str, Expression]) -> Expression:
if isinstance(on, str):
return table_col(on)
if isinstance(on, Expression):
return on
raise TypeError("on must be a column name or expression")
def _resolve_partition_columns(
partition_by: Optional[Union[str, Expression, List[Union[str, Expression]]]]
) -> List[Expression]:
if partition_by is None:
return []
if isinstance(partition_by, (str, Expression)):
candidates: List[Union[str, Expression]] = [partition_by]
else:
candidates = partition_by
columns: List[Expression] = []
for candidate in candidates:
if isinstance(candidate, str):
columns.append(table_col(candidate))
elif isinstance(candidate, Expression):
columns.append(candidate)
else:
raise TypeError(
"partition_by must be a column name, expression, or a list of them"
)
return columns
def _window_dataframe(
table: Table,
window_kind: str,
time_col: Expression,
*interval_exprs: Any,
partition_cols: List[Expression] = [],
) -> "DataFrame":
jvm = get_gateway().jvm
kind = getattr(
jvm.org.apache.flink.table.operations.WindowTableFunctionQueryOperation.WindowKind,
window_kind,
)
intervals = jvm.java.util.ArrayList()
for interval in interval_exprs:
intervals.add(interval)
partition_list = jvm.java.util.ArrayList()
for partition_col in partition_cols:
partition_list.add(partition_col._j_expr)
operation_tree_builder = table._t_env._j_tenv.getOperationTreeBuilder()
window_op = operation_tree_builder.windowTableFunction(
kind,
time_col._j_expr,
intervals,
partition_list,
table._j_table.getQueryOperation(),
)
j_table = table._t_env._j_tenv.createTable(window_op)
return DataFrame(Table(j_table, table._t_env))
def _to_interval_expression(value: Union["datetime.timedelta", Expression]) -> Any:
if isinstance(value, datetime.timedelta):
millis = value // datetime.timedelta(milliseconds=1)
return (
get_gateway()
.jvm.org.apache.flink.table.expressions.ApiExpressionUtils.intervalOfMillis(
millis
)
)
if isinstance(value, Expression):
return value._j_expr
raise TypeError("interval must be a datetime.timedelta or Expression")
def _normalize_aggregations(
aggs: Tuple[Expression, ...], named_aggs: Dict[str, Expression]
) -> List[Expression]:
if not aggs and not named_aggs:
raise ValueError("agg() requires at least one aggregation")
aggregations: List[Expression] = []
for aggregation in aggs:
if not isinstance(aggregation, Expression):
raise TypeError("agg() aggregations must be expressions")
aggregations.append(aggregation)
for name, aggregation in named_aggs.items():
if not isinstance(aggregation, Expression):
raise TypeError("agg() aggregations must be expressions")
aggregations.append(aggregation.alias(name))
return aggregations