This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 14fa2718313 [FLINK-40192][python] Add as-of join support to DataFrame
API
14fa2718313 is described below
commit 14fa27183139cb9ec0a5f2b34f5d72f7477115c1
Author: beetle0915 <[email protected]>
AuthorDate: Mon Oct 19 01:11:25 2026 +0800
[FLINK-40192][python] Add as-of join support to DataFrame API
Support processing-time lookup and event-time temporal joins through the
existing join SQL helpers. Add validation, execution coverage, and API
documentation.
This closes #29513.
Generated-by: OpenAI Codex (codex-cli 0.160.0)
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 16 +-
flink-python/pyflink/dataframe/__init__.py | 3 +-
flink-python/pyflink/dataframe/dataframe.py | 255 +++++++++++++++-
.../pyflink/dataframe/tests/test_dataframe.py | 338 +++++++++++++++++++++
.../table/utils/TestCollectionTableFactory.java | 2 +-
5 files changed, 603 insertions(+), 11 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index e31a2bf176d..d6d575b4db8 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -113,8 +113,21 @@ Joins
:toctree: api/
DataFrame.join
+ DataFrame.join_asof
DataFrame.join_lateral
+Use ``join_asof`` to enrich rows from a dimension table at a left time
attribute.
+Processing-time lookup joins require a lookup-capable right table; event-time
temporal
+joins require watermarks on both inputs and the right primary key in the
equality keys::
+
+ 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")
+
+Only inner and left joins are supported. Conditions need at least one equality
between
+the inputs and may include additional AND-combined predicates, but not OR
conditions.
+The time attribute may also appear in equality keys and residual conditions.
+
Missing-Value Handling
----------------------
@@ -197,7 +210,7 @@ Windowing
Expressions
-----------
-Functions for constructing column references and literal expressions.
+Functions for constructing column references, literals, and processing-time
attributes.
.. currentmodule:: pyflink.dataframe
@@ -206,6 +219,7 @@ Functions for constructing column references and literal
expressions.
col
lit
+ proctime
OVER windows
------------
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index 561f2ea318f..6783c606225 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -62,7 +62,7 @@ from pyflink.dataframe.context import (
get_table_environment,
set_table_environment,
)
-from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
+from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit,
proctime
from pyflink.dataframe.dataframe_config import config
from pyflink.dataframe.datatype import DataType
from pyflink.dataframe.io import (
@@ -102,6 +102,7 @@ __all__ = [
"list_model_providers",
"col",
"lit",
+ "proctime",
"udf",
"udtf",
"udaf",
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index d54f3538a5f..7a90e1dfeb5 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -161,6 +161,15 @@ def lit(value: Any, data_type: Optional[DataType] = None)
-> Expression:
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:
"""
@@ -1320,6 +1329,95 @@ class DataFrame:
)
)
+ @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,
@@ -3321,18 +3419,25 @@ def _normalize_join_type(how: str) -> str:
return aliases[how]
-def _normalize_join_keys(value, parameter_name: str) -> List[Union[str,
Expression]]:
+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)
- if not all(isinstance(key, str) for key in value):
+ 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
)
- if len(set(value)) != len(value):
- raise ValueError("%s must not contain duplicate column names" %
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
@@ -3369,6 +3474,7 @@ def _prepare_join(
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())
@@ -3389,13 +3495,37 @@ def _prepare_join(
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")
- right_keys = _normalize_join_keys(right_on, "right_on")
+ 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)
@@ -3456,6 +3586,102 @@ def _prepare_join(
)
+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."""
@@ -3491,6 +3717,7 @@ def _serialize_join_predicate(
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 = (
@@ -3507,6 +3734,11 @@ def _serialize_join_predicate(
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)
@@ -3529,9 +3761,11 @@ def _build_regular_join_sql(
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
+ left_table, right_table, predicate, sql_factory, asof_time
)
left_alias_sql = _quote_identifier(left_alias)
right_alias_sql = _quote_identifier(right_alias)
@@ -3565,14 +3799,19 @@ def _build_regular_join_sql(
"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 AS %s ON %s"
+ "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,
)
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index ce906b405aa..35034b43c18 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -36,6 +36,7 @@ from pyflink.dataframe.dataframe import (
_to_interval_expression,
)
from pyflink.dataframe.datatype import DataType
+from pyflink.java_gateway import get_gateway
from pyflink.table import (
DataTypes as TableDataTypes,
EnvironmentSettings,
@@ -1210,6 +1211,234 @@ class
DataFrameRenameColumnsTests(PyFlinkDataFrameUTTestCase):
invalid_call()
+class DataFrameAsOfJoinTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.t_env.execute_sql(
+ "CREATE TABLE orders (id INT, amount INT, pt AS PROCTIME(), "
+ "ts TIMESTAMP(3), WATERMARK FOR ts AS ts) "
+ "WITH ('connector' = 'COLLECTION')"
+ )
+ self.t_env.execute_sql(
+ "CREATE TABLE rates (id INT, rate INT, rt TIMESTAMP(3), "
+ "WATERMARK FOR rt AS rt, PRIMARY KEY (id) NOT ENFORCED) "
+ "WITH ('connector' = 'COLLECTION')"
+ )
+ self.left = pf.from_table(self.t_env.from_path("orders"))
+ self.versions = pf.from_table(self.t_env.from_path("rates"))
+ self.t_env.execute_sql(
+ "CREATE TABLE dimension (id INT, rate INT, rt TIMESTAMP(3)) "
+ "WITH ('connector' = 'COLLECTION')"
+ )
+ self.right = pf.from_table(self.t_env.from_path("dimension"))
+
+ def test_time_attributes_and_lazy_construction(self):
+ original = self.left.to_table()
+ for on, operator in (
+ ("pt", "LookupJoin"), (pf.col("pt"), "LookupJoin"),
+ (pf.proctime(), "LookupJoin"), ("ts", "TemporalJoin"),
+ ):
+ with self.subTest(on=str(on)):
+ with patch.object(Table, "execute") as execute:
+ with patch.object(Table, "explain") as explain:
+ right = self.versions if operator == "TemporalJoin"
else self.right
+ result = self.left.join_asof(right, on=on, by="id",
how="left")
+ execute.assert_not_called()
+ explain.assert_not_called()
+ self.assert_dataframe_schema(result, ["id", "amount", "pt",
"ts", "rate", "rt"])
+ self.assertIs(self.left.to_table(), original)
+ plan = result.to_table().explain()
+ self.assertIn(operator, plan)
+
+ def test_key_forms_and_inline_functions(self):
+ right = self.right.rename_columns({"id": "d_id"})
+ identity = pf.udf(lambda value: value, return_dtype=int)
+ for keys in (
+ {"by": pf.col("id") == pf.col("d_id")},
+ {"by": [pf.col("id") == pf.col("d_id"), pf.col("amount") >
pf.col("rate")]},
+ {"left_by": "id", "right_by": "d_id"},
+ {"left_by": [pf.col("id") + 1], "right_by": [pf.col("d_id")]},
+ {"by": (pf.col("id") == pf.col("d_id")) &
(identity(pf.col("amount")) > 0)},
+ ):
+ with self.subTest(keys=str(keys)):
+ before = set(self.t_env.list_user_defined_functions())
+ result = self.left.join_asof(right, on="pt", **keys)
+ self.assertEqual(result.columns, self.left.columns +
right.columns)
+
self.assertEqual(set(self.t_env.list_user_defined_functions()), before)
+ self.assertIn("LookupJoin", result.to_table().explain())
+
+ result = self.left.join_asof(
+ self.right, on="pt", by=["id", pf.col("amount") > pf.col("rate")])
+ self.assertIn("LookupJoin", result.to_table().explain())
+ result = self.left.join_asof(
+ self.right.rename_columns({"rate": "amount"}), on="pt", by=["id",
"amount"])
+ self.assertEqual(result.columns, self.left.columns + ["rt"])
+ self.assertIn("LookupJoin", result.to_table().explain())
+
+ def test_invalid_arguments(self):
+ for kwargs, error, message in (
+ ({"on": None, "by": "id"}, ValueError, "on"),
+ ({"on": 1, "by": "id"}, TypeError, "on"),
+ ({"on": "pt", "by": "id", "how": "full"}, ValueError, "how"),
+ ({"on": "pt", "by": "id", "how": 1}, TypeError, "how"),
+ ({"on": "pt"}, ValueError, "by"),
+ ({"on": "pt", "by": []}, ValueError, "empty"),
+ ({"on": "pt", "by": [1]}, TypeError, "by"),
+ ({"on": "pt", "by": ["id", "id"]}, ValueError, "duplicate"),
+ ({"on": "pt", "by": "id", "left_by": "id"}, ValueError,
"combined"),
+ ({"on": "pt", "left_by": "id"}, ValueError, "together"),
+ ({"on": "pt", "left_by": ["id", "amount"], "right_by": "id"},
+ ValueError, "same number"),
+ ({"on": "amount", "by": "id"}, ValueError, "time attribute"),
+ ({"on": "pt", "left_by": pf.col("*"), "right_by": "id"},
+ ValueError, "single column"),
+ ({"on": "pt", "left_by": pf.col("id").sum, "right_by": "id"},
+ ValueError, "row-wise"),
+ ({"on": "pt", "left_by": "id", "right_by": pf.col("id").sum},
+ ValueError, "row-wise"),
+ ):
+ with self.subTest(kwargs=str(kwargs)):
+ with self.assertRaisesRegex(error, message):
+ self.left.join_asof(self.right, **kwargs)
+ with self.assertRaisesRegex(TypeError, "other"):
+ self.left.join_asof(object(), on="pt", by="id")
+ other_env =
TableEnvironment.create(EnvironmentSettings.in_streaming_mode())
+ with self.assertRaisesRegex(ValueError, "same TableEnvironment"):
+ self.left.join_asof(pf.from_table(other_env.from_elements([(1,)],
["id"])),
+ on="pt", by="id")
+
+ def test_predicate_requires_cross_input_equality_and_no_or(self):
+ right = self.right.rename_columns({"id": "d_id"})
+ for predicate in (
+ pf.col("id") > pf.col("d_id"),
+ pf.col("id") == pf.col("amount"),
+ (pf.col("id") == pf.col("d_id")) | (pf.col("amount") > 1),
+ (pf.col("id") == pf.col("d_id")) &
+ ((pf.col("amount") > 1) | (pf.col("rate") < 10)),
+ ):
+ with self.subTest(predicate=str(predicate)):
+ with self.assertRaises(ValueError):
+ self.left.join_asof(right, on="ts", by=predicate)
+
+ def test_time_attribute_in_equality_keys(self):
+ from pyflink.table.expressions import call_sql
+
+ right = self.versions.rename_columns({"id": "d_id"})
+ for keys in (
+ {"by": (pf.col("id") == pf.col("d_id")) & (pf.col("ts") ==
pf.col("rt"))},
+ {"by": call_sql("id = d_id AND ts = rt")},
+ {"left_by": ["id", "ts"], "right_by": ["d_id", "rt"]},
+ {"left_by": ["id", pf.col("ts").alias("time_key")],
+ "right_by": ["d_id", pf.col("rt").alias("dim_time_key")]},
+ {"left_by": ["id", call_sql("ts")], "right_by": ["d_id",
call_sql("rt")]},
+ {"left_by": ["id", pf.col("ts").cast(TableDataTypes.STRING())],
+ "right_by": ["d_id", pf.col("rt").cast(TableDataTypes.STRING())]},
+ ):
+ with self.subTest(keys=str(keys)):
+ result = self.left.join_asof(right, on="ts", **keys)
+ self.assertEqual(result.columns, self.left.columns +
right.columns)
+ self.assertIn("TemporalJoin", result.to_table().explain())
+
+ shared = self.versions.rename_columns({"rt": "ts"})
+ result = self.left.join_asof(shared, on="ts", by=["id", "ts"])
+ self.assertEqual(result.columns, self.left.columns + ["rate"])
+ self.assertIn("TemporalJoin", result.to_table().explain())
+
+ def test_quoted_keys_and_internal_name_conflicts(self):
+ left = self.left.rename_columns({"id":
"order`id"}).with_column("__pf_asof_time", pf.lit(1))
+ right = self.right.rename_columns({"id": "order`id"})
+ result = left.join_asof(right, on=pf.proctime(), by="order`id")
+ self.assertEqual(result.columns, left.columns + ["rate", "rt"])
+ self.assertIn("LookupJoin", result.to_table().explain())
+
+ def test_planner_validates_dimension_requirements(self):
+ with self.assertRaisesRegex(ValueError, "time attribute"):
+ self.left.join_asof(self.right,
on=pf.col("ts").cast(TableDataTypes.STRING()), by="id")
+ self.t_env.execute_sql(
+ "CREATE TABLE no_primary_key (id INT, rate INT, rt TIMESTAMP(3), "
+ "WATERMARK FOR rt AS rt) WITH ('connector' = 'COLLECTION')")
+ right = pf.from_table(self.t_env.from_path("no_primary_key"))
+ with self.assertRaisesRegex(Exception, "primary key"):
+ self.left.join_asof(right, on="ts", by="id").to_table().explain()
+ with self.assertRaisesRegex(Exception, "primary key|Processing-time
temporal join"):
+ self.left.join_asof(pf.from_records([(1,)], schema=["id"]),
+ on="pt", by="id").to_table().explain()
+
+ def test_event_time_expression_keys_preserve_primary_key(self):
+ from pyflink.table.expressions import call_sql
+
+ right = self.versions.rename_columns({"id": "d_id"})
+ for left_key, right_key in (
+ (pf.col("id"), pf.col("d_id")),
+ ([pf.col("id").alias("key")], [pf.col("d_id").alias("dim_key")]),
+ ("id", pf.col("d_id").cast(TableDataTypes.INT())),
+ ("id", pf.col("d_id").cast(TableDataTypes.INT()).alias("dim_key")),
+ ("id", call_sql("CAST(d_id AS INT)")),
+ ):
+ with self.subTest(right_key=str(right_key)):
+ result = self.left.join_asof(right, on="ts", left_by=left_key,
right_by=right_key)
+ self.assertIn("TemporalJoin", result.to_table().explain())
+
+ def test_time_attribute_in_residual_predicates(self):
+ from pyflink.table.expressions import call_sql
+
+ right = self.versions.rename_columns({"id": "d_id"})
+ for predicate in (
+ (pf.col("id") == pf.col("d_id")) & (pf.col("ts") > pf.col("rt")),
+ call_sql("id = d_id AND ts > rt"),
+ (pf.col("id") == pf.col("d_id")) & ~(pf.col("ts") == pf.col("rt")),
+ call_sql("id = d_id AND CASE WHEN ts = rt THEN FALSE ELSE TRUE
END"),
+ (pf.col("id") == pf.col("d_id")) &
+ (pf.col("ts").cast(TableDataTypes.STRING()) == "2024-01-01
00:00:02"),
+ ):
+ with self.subTest(predicate=str(predicate)):
+ result = self.left.join_asof(right, on="ts", by=predicate,
how="left")
+ self.assertIn("TemporalJoin", result.to_table().explain())
+
+ def test_type_changing_key_cast_is_preserved(self):
+ from pyflink.dataframe.dataframe import _asof_field_index
+ from pyflink.table.expressions import call_sql
+
+ for key in (
+ pf.col("id").cast(TableDataTypes.BIGINT()), call_sql("CAST(id AS
BIGINT)"),
+ pf.col("rt").cast(TableDataTypes.TIMESTAMP(3)), call_sql("CAST(rt
AS TIMESTAMP(3))"),
+ ):
+ with self.subTest(key=str(key)):
+ selected = self.versions.to_table().select(key)
+ resolved =
selected._j_table.getQueryOperation().getProjectList().get(0)
+ self.assertIsNone(_asof_field_index(resolved))
+
+ def test_sql_expressions_follow_the_same_validation(self):
+ from pyflink.table.expressions import call_sql
+
+ right = self.versions.rename_columns({"id": "d_id"})
+ result = self.left.join_asof(right, on=call_sql("ts"), by=call_sql("id
= d_id"))
+ self.assertIn("TemporalJoin", result.to_table().explain())
+ result = self.left.join_asof(
+ right, on="ts", left_by=call_sql("id"), right_by=call_sql("d_id"))
+ self.assertIn("TemporalJoin", result.to_table().explain())
+ for predicate in (
+ call_sql("id > d_id"),
+ call_sql("id = d_id OR amount > rate"),
+ (pf.col("id") == pf.col("d_id")) & call_sql("amount > rate OR
amount = 0"),
+ ):
+ with self.subTest(predicate=str(predicate)):
+ with self.assertRaises(ValueError):
+ self.left.join_asof(right, on=call_sql("ts"), by=predicate)
+ time_first = self.left.select("pt", "id", "ts")
+ for on in (pf.col("ts").max, call_sql("MAX(ts)")):
+ with self.subTest(on=str(on)):
+ with self.assertRaisesRegex(ValueError, "row-wise"):
+ time_first.join_asof(self.right, on=on, by="id")
+ for keys in (
+ {"left_by": call_sql("SUM(id) + 1"), "right_by": "d_id"},
+ {"left_by": "id", "right_by": call_sql("SUM(d_id)")},
+ ):
+ with self.subTest(keys=str(keys)):
+ with self.assertRaisesRegex(ValueError, "row-wise"):
+ self.left.join_asof(right, on="ts", **keys)
+
+
class DataFrameJoinTests(PyFlinkDataFrameUTTestCase):
def setUp(self):
super().setUp()
@@ -3967,6 +4196,115 @@ class
DataFrameExplodeStreamITTests(PyFlinkStreamDataFrameTestCase):
self.assertCountEqual(results[0].union_all(results[1]).collect(),
expected_rows)
+class DataFrameAsOfJoinITTests(PyFlinkStreamDataFrameTestCase):
+ def test_lookup_join(self):
+ gateway = get_gateway()
+ factory =
gateway.jvm.org.apache.flink.table.utils.TestCollectionTableFactory
+ factory.reset()
+ self.addCleanup(factory.reset)
+ rows = gateway.jvm.java.util.ArrayList()
+ for key, rate in [(1, 10), (2, 20)]:
+ row = gateway.jvm.org.apache.flink.types.Row(2)
+ row.setField(0, key)
+ row.setField(1, rate)
+ rows.add(row)
+ factory.initData(gateway.jvm.java.util.ArrayList(), rows, None)
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE lookup_rates (id INT, rate INT) "
+ "WITH ('connector' = 'COLLECTION')")
+ left = pf.from_table(self.t_env.sql_query(
+ "SELECT *, PROCTIME() AS pt FROM (VALUES (1, 15), (2, 5), (3, 30))
T(id, amount)"))
+ right = pf.from_table(self.t_env.from_path("lookup_rates"))
+ renamed = right.rename_columns({"id": "d_id"})
+ results = [
+ left.join_asof(right, on=pf.proctime(), by="id"),
+ left.join_asof(renamed, on="pt", left_by="id", right_by="d_id",
how="left"),
+ left.join_asof(right, on="pt", by=["id", pf.col("amount") >
pf.col("rate")],
+ how="left"),
+ ]
+ combined = results[0].select(pf.lit(0).alias("variant"), "id", "rate")
+ for index, result in enumerate(results[1:], 1):
+ combined = combined.union_all(result.select(
+ pf.lit(index).alias("variant"), "id", "rate"))
+ self.assertCountEqual(combined.collect(), [
+ Row(0, 1, 10), Row(0, 2, 20),
+ Row(1, 1, 10), Row(1, 2, 20), Row(1, 3, None),
+ Row(2, 1, 10), Row(2, 2, None), Row(2, 3, None),
+ ])
+
+ def test_event_time_join_selects_historical_versions(self):
+ orders_path = os.path.join(self.tempdir, "asof_orders.csv")
+ rates_path = os.path.join(self.tempdir, "asof_rates.csv")
+ with open(orders_path, "w", encoding="utf-8") as output:
+ output.write("1,2024-01-01 00:00:02\n2,2024-01-01 00:00:02\n"
+ "1,2024-01-01 00:00:06\n3,2024-01-01 00:00:06\n")
+ with open(rates_path, "w", encoding="utf-8") as output:
+ output.write("1,10,2024-01-01 00:00:01\n2,30,2024-01-01 00:00:03\n"
+ "1,20,2024-01-01 00:00:04\n")
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE asof_orders (id INT, ts TIMESTAMP(3), "
+ "WATERMARK FOR ts AS ts - INTERVAL '1' SECOND) "
+ "WITH ('connector' = 'filesystem', 'format' = 'csv', 'path' =
'%s')" % orders_path)
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE asof_rates (id INT, rate INT, rt
TIMESTAMP(3), "
+ "WATERMARK FOR rt AS rt - INTERVAL '1' SECOND, PRIMARY KEY (id)
NOT ENFORCED) "
+ "WITH ('connector' = 'filesystem', 'format' = 'csv', 'path' =
'%s')" % rates_path)
+ left = pf.from_table(self.t_env.from_path("asof_orders"))
+ right = pf.from_table(self.t_env.from_path("asof_rates"))
+ inner = left.join_asof(right, on="ts", by="id").select(
+ pf.lit("inner").alias("kind"), "id", "rate")
+ outer = left.join_asof(
+ right.rename_columns({"id": "d_id"}), on=pf.col("ts"),
+ left_by=pf.col("id"),
right_by=pf.col("d_id").cast(TableDataTypes.INT()),
+ how="left").select(
+ pf.lit("left").alias("kind"), "id", "rate")
+ residual = left.join_asof(
+ right.rename_columns({"id": "d_id"}), on="ts", how="left",
+ by=(pf.col("id") == pf.col("d_id")) &
+ (pf.col("ts") > pf.col("rt") + pf.lit(1).seconds)).select(
+ pf.lit("residual").alias("kind"), "id", "rate")
+
self.assertCountEqual(inner.union_all(outer).union_all(residual).collect(), [
+ Row("inner", 1, 10), Row("inner", 1, 20),
+ Row("left", 1, 10), Row("left", 1, 20),
+ Row("left", 2, None), Row("left", 3, None),
+ Row("residual", 1, None), Row("residual", 1, 20),
+ Row("residual", 2, None), Row("residual", 3, None),
+ ])
+
+ def test_event_time_equality_matches_only_exact_versions(self):
+ orders_path = os.path.join(self.tempdir, "time_key_orders.csv")
+ rates_path = os.path.join(self.tempdir, "time_key_rates.csv")
+ with open(orders_path, "w", encoding="utf-8") as output:
+ output.write("1,2024-01-01 00:00:02\n1,2024-01-01 00:00:05\n"
+ "1,2024-01-01 00:00:06\n2,2024-01-01 00:00:06\n")
+ with open(rates_path, "w", encoding="utf-8") as output:
+ output.write("1,10,2024-01-01 00:00:01\n1,20,2024-01-01
00:00:05\n")
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE time_key_orders (id INT, ts TIMESTAMP(3), "
+ "WATERMARK FOR ts AS ts - INTERVAL '1' SECOND) "
+ "WITH ('connector' = 'filesystem', 'format' = 'csv', 'path' =
'%s')" % orders_path)
+ self.t_env.execute_sql(
+ "CREATE TEMPORARY TABLE time_key_rates (d_id INT, rate INT, rt
TIMESTAMP(3), "
+ "WATERMARK FOR rt AS rt - INTERVAL '1' SECOND, PRIMARY KEY (d_id)
NOT ENFORCED) "
+ "WITH ('connector' = 'filesystem', 'format' = 'csv', 'path' =
'%s')" % rates_path)
+ left = pf.from_table(self.t_env.from_path("time_key_orders"))
+ right = pf.from_table(self.t_env.from_path("time_key_rates"))
+ inner = left.join_asof(
+ right, on="ts",
+ by=(pf.col("id") == pf.col("d_id")) & (pf.col("ts") ==
pf.col("rt"))).select(
+ pf.lit("inner").alias("kind"), "id", "ts", "rate")
+ outer = left.join_asof(
+ right, on="ts", left_by=["id", "ts"], right_by=["d_id", "rt"],
+ how="left").select(pf.lit("left").alias("kind"), "id", "ts",
"rate")
+ self.assertCountEqual(inner.union_all(outer).collect(), [
+ Row("inner", 1, datetime(2024, 1, 1, 0, 0, 5), 20),
+ Row("left", 1, datetime(2024, 1, 1, 0, 0, 2), None),
+ Row("left", 1, datetime(2024, 1, 1, 0, 0, 5), 20),
+ Row("left", 1, datetime(2024, 1, 1, 0, 0, 6), None),
+ Row("left", 2, datetime(2024, 1, 1, 0, 0, 6), None),
+ ])
+
+
class DataFrameWindowITTests(PyFlinkStreamDataFrameTestCase):
@classmethod
def setUpClass(cls):
diff --git
a/flink-python/src/test/java/org/apache/flink/table/utils/TestCollectionTableFactory.java
b/flink-python/src/test/java/org/apache/flink/table/utils/TestCollectionTableFactory.java
index d59ad693926..0b65b63b66e 100644
---
a/flink-python/src/test/java/org/apache/flink/table/utils/TestCollectionTableFactory.java
+++
b/flink-python/src/test/java/org/apache/flink/table/utils/TestCollectionTableFactory.java
@@ -260,7 +260,7 @@ public class TestCollectionTableFactory
}
}
- static class TemporalTableFetcher extends TableFunction<Row> {
+ public static class TemporalTableFetcher extends TableFunction<Row> {
private final LinkedList<Row> dimData;
private final int[] keyes;