blob: ac4c12d24d533e41a47e59c71b9994619d30de8a [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.
################################################################################
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
TypeVar,
Union,
overload,
)
if TYPE_CHECKING:
import pandas
from pyflink.table.table_schema import TableSchema
from pyflink.common import Row
from pyflink.dataframe.datatype import DataType
from pyflink.table.expression import Expression
from pyflink.table.expressions import (
and_,
call_sql,
col as table_col,
lit as table_lit,
)
from pyflink.table.table import Table
from pyflink.util.api_stability_decorators import PublicEvolving
__all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
T = TypeVar("T")
@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()
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 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}])
>>> result = df.with_column(
... "total", lambda current: current["left"] + current["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))
# ======================== 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")
# ======================== 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)
# ======================== 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 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 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_generic(self, connector: str, *, options: Dict[str, str]) -> None:
"""
Write this DataFrame using a connector and its raw Table connector options.
The connector must be available through Flink's factory discovery mechanism. The write is
submitted immediately and waits for completion when using local or MiniCluster execution.
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.
:raises TypeError: If an argument has an invalid type.
:raises ValueError: If the connector or an option key is empty, or if ``options`` contains
the reserved ``connector`` key.
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
"""
from pyflink.dataframe.io import _build_generic_descriptor
descriptor = _build_generic_descriptor(connector, options)
result = self._table.execute_insert(descriptor)
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_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