This is an automated email from the ASF dual-hosted git repository.
gurwls223 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 05c0fa5 [SPARK-36813][SQL][PYTHON] Propose an infrastructure of as-of
join and imlement ps.merge_asof
05c0fa5 is described below
commit 05c0fa573881b49d8ead9a5e16071190e5841e1b
Author: Takuya UESHIN <[email protected]>
AuthorDate: Wed Sep 29 09:27:38 2021 +0900
[SPARK-36813][SQL][PYTHON] Propose an infrastructure of as-of join and
imlement ps.merge_asof
### What changes were proposed in this pull request?
Proposes an infrastructure for as-of join and implements `ps.merge_asof`
here.
1. Introduce `AsOfJoin` logical plan
2. Rewrite the plan in the optimize phase:
- From something like (SQL syntax is not determied):
```sql
SELECT * FROM left ASOF JOIN right ON (condition, as_of on(left.t,
right.t), tolerance)
```
- To
```sql
SELECT left.*, __right__.*
FROM (
SELECT
left.*,
(
SELECT MIN_BY(STRUCT(right.*), left.t - right.t) AS
__nearest_right__
FROM right
WHERE condition AND left.t >= right.t AND right.t >= left.t
- tolerance
) as __right__
FROM left
)
WHERE __right__ IS NOT NULL
```
3. The rewritten scalar-subquery will be handled by the existing
decorrelation framework.
Note: APIs on SQL DataFrames and SQL syntax are TBD (e.g.,
[SPARK-22947](https://issues.apache.org/jira/browse/SPARK-22947)), although
there are temporary APIs added here.
### Why are the changes needed?
Pandas' `merge_asof` or as-of join for SQL/DataFrame is useful for time
series analysis.
### Does this PR introduce _any_ user-facing change?
Yes. `ps.merge_asof` can be used.
```py
>>> quotes
time ticker bid ask
0 2016-05-25 13:30:00.023 GOOG 720.50 720.93
1 2016-05-25 13:30:00.023 MSFT 51.95 51.96
2 2016-05-25 13:30:00.030 MSFT 51.97 51.98
3 2016-05-25 13:30:00.041 MSFT 51.99 52.00
4 2016-05-25 13:30:00.048 GOOG 720.50 720.93
5 2016-05-25 13:30:00.049 AAPL 97.99 98.01
6 2016-05-25 13:30:00.072 GOOG 720.50 720.88
7 2016-05-25 13:30:00.075 MSFT 52.01 52.03
>>> trades
time ticker price quantity
0 2016-05-25 13:30:00.023 MSFT 51.95 75
1 2016-05-25 13:30:00.038 MSFT 51.95 155
2 2016-05-25 13:30:00.048 GOOG 720.77 100
3 2016-05-25 13:30:00.048 GOOG 720.92 100
4 2016-05-25 13:30:00.048 AAPL 98.00 100
>>> ps.merge_asof(
... trades, quotes, on="time", by="ticker"
... ).sort_values(["time", "ticker", "price"]).reset_index(drop=True)
time ticker price quantity bid ask
0 2016-05-25 13:30:00.023 MSFT 51.95 75 51.95 51.96
1 2016-05-25 13:30:00.038 MSFT 51.95 155 51.97 51.98
2 2016-05-25 13:30:00.048 AAPL 98.00 100 NaN NaN
3 2016-05-25 13:30:00.048 GOOG 720.77 100 720.50 720.93
4 2016-05-25 13:30:00.048 GOOG 720.92 100 720.50 720.93
>>> ps.merge_asof(
... trades,
... quotes,
... on="time",
... by="ticker",
... tolerance=F.expr("INTERVAL 2 MILLISECONDS") # pd.Timedelta("2ms")
... ).sort_values(["time", "ticker", "price"]).reset_index(drop=True)
time ticker price quantity bid ask
0 2016-05-25 13:30:00.023 MSFT 51.95 75 51.95 51.96
1 2016-05-25 13:30:00.038 MSFT 51.95 155 NaN NaN
2 2016-05-25 13:30:00.048 AAPL 98.00 100 NaN NaN
3 2016-05-25 13:30:00.048 GOOG 720.77 100 720.50 720.93
4 2016-05-25 13:30:00.048 GOOG 720.92 100 720.50 720.93
```
Note: As `IntervalType` literal is not supported yet, we have to specify
the `IntervalType` value with `F.expr` as a workaround.
### How was this patch tested?
Added tests.
Closes #34053 from ueshin/issues/SPARK-36813/merge_asof.
Authored-by: Takuya UESHIN <[email protected]>
Signed-off-by: Hyukjin Kwon <[email protected]>
---
python/pyspark/pandas/namespace.py | 497 +++++++++++++++++++++
python/pyspark/pandas/tests/test_reshape.py | 138 ++++++
python/pyspark/sql/dataframe.py | 117 +++++
.../sql/catalyst/analysis/CheckAnalysis.scala | 23 +
.../catalyst/analysis/DeduplicateRelations.scala | 6 +-
.../spark/sql/catalyst/optimizer/Optimizer.scala | 6 +-
.../sql/catalyst/optimizer/RewriteAsOfJoin.scala | 89 ++++
.../spark/sql/catalyst/plans/joinTypes.scala | 21 +
.../plans/logical/basicLogicalOperators.scala | 116 +++++
.../sql/catalyst/rules/RuleIdCollection.scala | 1 +
.../spark/sql/catalyst/trees/TreePatterns.scala | 1 +
.../catalyst/optimizer/RewriteAsOfJoinSuite.scala | 289 ++++++++++++
.../main/scala/org/apache/spark/sql/Dataset.scala | 117 +++--
.../apache/spark/sql/DataFrameAsOfJoinSuite.scala | 169 +++++++
14 files changed, 1560 insertions(+), 30 deletions(-)
diff --git a/python/pyspark/pandas/namespace.py
b/python/pyspark/pandas/namespace.py
index 1219b1b..8df5d2c 100644
--- a/python/pyspark/pandas/namespace.py
+++ b/python/pyspark/pandas/namespace.py
@@ -73,6 +73,7 @@ from pyspark.pandas.utils import (
align_diff_frames,
default_session,
is_name_like_tuple,
+ is_name_like_value,
name_like_string,
same_anchor,
scol_for,
@@ -83,11 +84,13 @@ from pyspark.pandas.internal import (
InternalFrame,
DEFAULT_SERIES_NAME,
HIDDEN_COLUMNS,
+ SPARK_INDEX_NAME_FORMAT,
)
from pyspark.pandas.series import Series, first_series
from pyspark.pandas.spark import functions as SF
from pyspark.pandas.spark.utils import as_nullable_spark_type,
force_decimal_precision_scale
from pyspark.pandas.indexes import Index, DatetimeIndex
+from pyspark.pandas.indexes.multi import MultiIndex
__all__ = [
@@ -115,6 +118,7 @@ __all__ = [
"read_sql",
"read_json",
"merge",
+ "merge_asof",
"to_numeric",
"broadcast",
"read_orc",
@@ -2747,6 +2751,499 @@ def merge(
)
+def merge_asof(
+ left: Union[DataFrame, Series],
+ right: Union[DataFrame, Series],
+ on: Optional[Name] = None,
+ left_on: Optional[Name] = None,
+ right_on: Optional[Name] = None,
+ left_index: bool = False,
+ right_index: bool = False,
+ by: Optional[Union[Name, List[Name]]] = None,
+ left_by: Optional[Union[Name, List[Name]]] = None,
+ right_by: Optional[Union[Name, List[Name]]] = None,
+ suffixes: Tuple[str, str] = ("_x", "_y"),
+ tolerance: Optional[Any] = None,
+ allow_exact_matches: bool = True,
+ direction: str = "backward",
+) -> DataFrame:
+ """
+ Perform an asof merge.
+
+ This is similar to a left-join except that we match on nearest
+ key rather than equal keys.
+
+ For each row in the left DataFrame:
+
+ - A "backward" search selects the last row in the right DataFrame whose
+ 'on' key is less than or equal to the left's key.
+
+ - A "forward" search selects the first row in the right DataFrame whose
+ 'on' key is greater than or equal to the left's key.
+
+ - A "nearest" search selects the row in the right DataFrame whose 'on'
+ key is closest in absolute distance to the left's key.
+
+ Optionally match on equivalent keys with 'by' before searching with 'on'.
+
+ .. versionadded:: 3.3.0
+
+ Parameters
+ ----------
+ left : DataFrame or named Series
+ right : DataFrame or named Series
+ on : label
+ Field name to join on. Must be found in both DataFrames.
+ The data MUST be ordered. Furthermore this must be a numeric column,
+ such as datetimelike, integer, or float. On or left_on/right_on
+ must be given.
+ left_on : label
+ Field name to join on in left DataFrame.
+ right_on : label
+ Field name to join on in right DataFrame.
+ left_index : bool
+ Use the index of the left DataFrame as the join key.
+ right_index : bool
+ Use the index of the right DataFrame as the join key.
+ by : column name or list of column names
+ Match on these columns before performing merge operation.
+ left_by : column name
+ Field names to match on in the left DataFrame.
+ right_by : column name
+ Field names to match on in the right DataFrame.
+ suffixes : 2-length sequence (tuple, list, ...)
+ Suffix to apply to overlapping column names in the left and right
+ side, respectively.
+ tolerance : int or Timedelta, optional, default None
+ Select asof tolerance within this range; must be compatible
+ with the merge index.
+ allow_exact_matches : bool, default True
+
+ - If True, allow matching with the same 'on' value
+ (i.e. less-than-or-equal-to / greater-than-or-equal-to)
+ - If False, don't match the same 'on' value
+ (i.e., strictly less-than / strictly greater-than).
+
+ direction : 'backward' (default), 'forward', or 'nearest'
+ Whether to search for prior, subsequent, or closest matches.
+
+ Returns
+ -------
+ merged : DataFrame
+
+ See Also
+ --------
+ merge : Merge with a database-style join.
+ merge_ordered : Merge with optional filling/interpolation.
+
+ Examples
+ --------
+ >>> left = ps.DataFrame({"a": [1, 5, 10], "left_val": ["a", "b", "c"]})
+ >>> left
+ a left_val
+ 0 1 a
+ 1 5 b
+ 2 10 c
+
+ >>> right = ps.DataFrame({"a": [1, 2, 3, 6, 7], "right_val": [1, 2, 3, 6,
7]})
+ >>> right
+ a right_val
+ 0 1 1
+ 1 2 2
+ 2 3 3
+ 3 6 6
+ 4 7 7
+
+ >>> ps.merge_asof(left, right,
on="a").sort_values("a").reset_index(drop=True)
+ a left_val right_val
+ 0 1 a 1
+ 1 5 b 3
+ 2 10 c 7
+
+ >>> ps.merge_asof(
+ ... left,
+ ... right,
+ ... on="a",
+ ... allow_exact_matches=False
+ ... ).sort_values("a").reset_index(drop=True)
+ a left_val right_val
+ 0 1 a NaN
+ 1 5 b 3.0
+ 2 10 c 7.0
+
+ >>> ps.merge_asof(
+ ... left,
+ ... right,
+ ... on="a",
+ ... direction="forward"
+ ... ).sort_values("a").reset_index(drop=True)
+ a left_val right_val
+ 0 1 a 1.0
+ 1 5 b 6.0
+ 2 10 c NaN
+
+ >>> ps.merge_asof(
+ ... left,
+ ... right,
+ ... on="a",
+ ... direction="nearest"
+ ... ).sort_values("a").reset_index(drop=True)
+ a left_val right_val
+ 0 1 a 1
+ 1 5 b 6
+ 2 10 c 7
+
+ We can use indexed DataFrames as well.
+
+ >>> left = ps.DataFrame({"left_val": ["a", "b", "c"]}, index=[1, 5, 10])
+ >>> left
+ left_val
+ 1 a
+ 5 b
+ 10 c
+
+ >>> right = ps.DataFrame({"right_val": [1, 2, 3, 6, 7]}, index=[1, 2, 3,
6, 7])
+ >>> right
+ right_val
+ 1 1
+ 2 2
+ 3 3
+ 6 6
+ 7 7
+
+ >>> ps.merge_asof(left, right, left_index=True,
right_index=True).sort_index()
+ left_val right_val
+ 1 a 1
+ 5 b 3
+ 10 c 7
+
+ Here is a real-world times-series example
+
+ >>> quotes = ps.DataFrame(
+ ... {
+ ... "time": [
+ ... pd.Timestamp("2016-05-25 13:30:00.023"),
+ ... pd.Timestamp("2016-05-25 13:30:00.023"),
+ ... pd.Timestamp("2016-05-25 13:30:00.030"),
+ ... pd.Timestamp("2016-05-25 13:30:00.041"),
+ ... pd.Timestamp("2016-05-25 13:30:00.048"),
+ ... pd.Timestamp("2016-05-25 13:30:00.049"),
+ ... pd.Timestamp("2016-05-25 13:30:00.072"),
+ ... pd.Timestamp("2016-05-25 13:30:00.075")
+ ... ],
+ ... "ticker": [
+ ... "GOOG",
+ ... "MSFT",
+ ... "MSFT",
+ ... "MSFT",
+ ... "GOOG",
+ ... "AAPL",
+ ... "GOOG",
+ ... "MSFT"
+ ... ],
+ ... "bid": [720.50, 51.95, 51.97, 51.99, 720.50, 97.99, 720.50,
52.01],
+ ... "ask": [720.93, 51.96, 51.98, 52.00, 720.93, 98.01, 720.88,
52.03]
+ ... }
+ ... )
+ >>> quotes
+ time ticker bid ask
+ 0 2016-05-25 13:30:00.023 GOOG 720.50 720.93
+ 1 2016-05-25 13:30:00.023 MSFT 51.95 51.96
+ 2 2016-05-25 13:30:00.030 MSFT 51.97 51.98
+ 3 2016-05-25 13:30:00.041 MSFT 51.99 52.00
+ 4 2016-05-25 13:30:00.048 GOOG 720.50 720.93
+ 5 2016-05-25 13:30:00.049 AAPL 97.99 98.01
+ 6 2016-05-25 13:30:00.072 GOOG 720.50 720.88
+ 7 2016-05-25 13:30:00.075 MSFT 52.01 52.03
+
+ >>> trades = ps.DataFrame(
+ ... {
+ ... "time": [
+ ... pd.Timestamp("2016-05-25 13:30:00.023"),
+ ... pd.Timestamp("2016-05-25 13:30:00.038"),
+ ... pd.Timestamp("2016-05-25 13:30:00.048"),
+ ... pd.Timestamp("2016-05-25 13:30:00.048"),
+ ... pd.Timestamp("2016-05-25 13:30:00.048")
+ ... ],
+ ... "ticker": ["MSFT", "MSFT", "GOOG", "GOOG", "AAPL"],
+ ... "price": [51.95, 51.95, 720.77, 720.92, 98.0],
+ ... "quantity": [75, 155, 100, 100, 100]
+ ... }
+ ... )
+ >>> trades
+ time ticker price quantity
+ 0 2016-05-25 13:30:00.023 MSFT 51.95 75
+ 1 2016-05-25 13:30:00.038 MSFT 51.95 155
+ 2 2016-05-25 13:30:00.048 GOOG 720.77 100
+ 3 2016-05-25 13:30:00.048 GOOG 720.92 100
+ 4 2016-05-25 13:30:00.048 AAPL 98.00 100
+
+ By default we are taking the asof of the quotes
+
+ >>> ps.merge_asof(
+ ... trades, quotes, on="time", by="ticker"
+ ... ).sort_values(["time", "ticker", "price"]).reset_index(drop=True)
+ time ticker price quantity bid ask
+ 0 2016-05-25 13:30:00.023 MSFT 51.95 75 51.95 51.96
+ 1 2016-05-25 13:30:00.038 MSFT 51.95 155 51.97 51.98
+ 2 2016-05-25 13:30:00.048 AAPL 98.00 100 NaN NaN
+ 3 2016-05-25 13:30:00.048 GOOG 720.77 100 720.50 720.93
+ 4 2016-05-25 13:30:00.048 GOOG 720.92 100 720.50 720.93
+
+ We only asof within 2ms between the quote time and the trade time
+
+ >>> ps.merge_asof(
+ ... trades,
+ ... quotes,
+ ... on="time",
+ ... by="ticker",
+ ... tolerance=F.expr("INTERVAL 2 MILLISECONDS") # pd.Timedelta("2ms")
+ ... ).sort_values(["time", "ticker", "price"]).reset_index(drop=True)
+ time ticker price quantity bid ask
+ 0 2016-05-25 13:30:00.023 MSFT 51.95 75 51.95 51.96
+ 1 2016-05-25 13:30:00.038 MSFT 51.95 155 NaN NaN
+ 2 2016-05-25 13:30:00.048 AAPL 98.00 100 NaN NaN
+ 3 2016-05-25 13:30:00.048 GOOG 720.77 100 720.50 720.93
+ 4 2016-05-25 13:30:00.048 GOOG 720.92 100 720.50 720.93
+
+ We only asof within 10ms between the quote time and the trade time
+ and we exclude exact matches on time. However *prior* data will
+ propagate forward
+
+ >>> ps.merge_asof(
+ ... trades,
+ ... quotes,
+ ... on="time",
+ ... by="ticker",
+ ... tolerance=F.expr("INTERVAL 10 MILLISECONDS"), #
pd.Timedelta("10ms")
+ ... allow_exact_matches=False
+ ... ).sort_values(["time", "ticker", "price"]).reset_index(drop=True)
+ time ticker price quantity bid ask
+ 0 2016-05-25 13:30:00.023 MSFT 51.95 75 NaN NaN
+ 1 2016-05-25 13:30:00.038 MSFT 51.95 155 51.97 51.98
+ 2 2016-05-25 13:30:00.048 AAPL 98.00 100 NaN NaN
+ 3 2016-05-25 13:30:00.048 GOOG 720.77 100 NaN NaN
+ 4 2016-05-25 13:30:00.048 GOOG 720.92 100 NaN NaN
+ """
+
+ def to_list(os: Optional[Union[Name, List[Name]]]) -> List[Label]:
+ if os is None:
+ return []
+ elif is_name_like_tuple(os):
+ return [os] # type: ignore
+ elif is_name_like_value(os):
+ return [(os,)]
+ else:
+ return [o if is_name_like_tuple(o) else (o,) for o in os]
+
+ if isinstance(left, Series):
+ left = left.to_frame()
+ if isinstance(right, Series):
+ right = right.to_frame()
+
+ if on:
+ if left_on or right_on:
+ raise ValueError(
+ 'Can only pass argument "on" OR "left_on" and "right_on", '
+ "not a combination of both."
+ )
+ left_as_of_names = list(map(left._internal.spark_column_name_for,
to_list(on)))
+ right_as_of_names = list(map(right._internal.spark_column_name_for,
to_list(on)))
+ else:
+ if left_index:
+ if isinstance(left.index, MultiIndex):
+ raise ValueError("left can only have one index")
+ left_as_of_names = left._internal.index_spark_column_names
+ else:
+ left_as_of_names = list(map(left._internal.spark_column_name_for,
to_list(left_on)))
+ if right_index:
+ if isinstance(right.index, MultiIndex):
+ raise ValueError("right can only have one index")
+ right_as_of_names = right._internal.index_spark_column_names
+ else:
+ right_as_of_names =
list(map(right._internal.spark_column_name_for, to_list(right_on)))
+
+ if left_as_of_names and not right_as_of_names:
+ raise ValueError("Must pass right_on or right_index=True")
+ if right_as_of_names and not left_as_of_names:
+ raise ValueError("Must pass left_on or left_index=True")
+ if not left_as_of_names and not right_as_of_names:
+ common = list(left.columns.intersection(right.columns))
+ if len(common) == 0:
+ raise ValueError(
+ "No common columns to perform merge on. Merge options: "
+ "left_on=None, right_on=None, left_index=False,
right_index=False"
+ )
+ left_as_of_names = list(map(left._internal.spark_column_name_for,
to_list(common)))
+ right_as_of_names =
list(map(right._internal.spark_column_name_for, to_list(common)))
+
+ if len(left_as_of_names) != 1:
+ raise ValueError("can only asof on a key for left")
+ if len(right_as_of_names) != 1:
+ raise ValueError("can only asof on a key for right")
+
+ if by:
+ if left_by or right_by:
+ raise ValueError('Can only pass argument "on" OR "left_by" and
"right_by".')
+ left_join_on_names = list(map(left._internal.spark_column_name_for,
to_list(by)))
+ right_join_on_names = list(map(right._internal.spark_column_name_for,
to_list(by)))
+ else:
+ left_join_on_names = list(map(left._internal.spark_column_name_for,
to_list(left_by)))
+ right_join_on_names = list(map(right._internal.spark_column_name_for,
to_list(right_by)))
+
+ if left_join_on_names and not right_join_on_names:
+ raise ValueError("missing right_by")
+ if right_join_on_names and not left_join_on_names:
+ raise ValueError("missing left_by")
+ if len(left_join_on_names) != len(right_join_on_names):
+ raise ValueError("left_by and right_by must be same length")
+
+ # We should distinguish the name to avoid ambiguous column name after
merging.
+ right_prefix = "__right_"
+ right_as_of_names = [right_prefix + right_as_of_name for right_as_of_name
in right_as_of_names]
+ right_join_on_names = [
+ right_prefix + right_join_on_name for right_join_on_name in
right_join_on_names
+ ]
+
+ left_as_of_name = left_as_of_names[0]
+ right_as_of_name = right_as_of_names[0]
+
+ def resolve(internal: InternalFrame, side: str) -> InternalFrame:
+ rename = lambda col: "__{}_{}".format(side, col)
+ internal = internal.resolved_copy
+ sdf = internal.spark_frame
+ sdf = sdf.select(
+ *[
+ scol_for(sdf, col).alias(rename(col))
+ for col in sdf.columns
+ if col not in HIDDEN_COLUMNS
+ ],
+ *HIDDEN_COLUMNS
+ )
+ return internal.copy(
+ spark_frame=sdf,
+ index_spark_columns=[
+ scol_for(sdf, rename(col)) for col in
internal.index_spark_column_names
+ ],
+ index_fields=[field.copy(name=rename(field.name)) for field in
internal.index_fields],
+ data_spark_columns=[
+ scol_for(sdf, rename(col)) for col in
internal.data_spark_column_names
+ ],
+ data_fields=[field.copy(name=rename(field.name)) for field in
internal.data_fields],
+ )
+
+ left_internal = left._internal.resolved_copy
+ right_internal = resolve(right._internal, "right")
+
+ left_table = left_internal.spark_frame.alias("left_table")
+ right_table = right_internal.spark_frame.alias("right_table")
+
+ left_as_of_column = scol_for(left_table, left_as_of_name)
+ right_as_of_column = scol_for(right_table, right_as_of_name)
+
+ if left_join_on_names:
+ left_join_on_columns = [scol_for(left_table, label) for label in
left_join_on_names]
+ right_join_on_columns = [scol_for(right_table, label) for label in
right_join_on_names]
+ on = reduce(
+ lambda l, r: l & r,
+ [l == r for l, r in zip(left_join_on_columns,
right_join_on_columns)],
+ )
+ else:
+ on = None
+
+ if tolerance is not None and not isinstance(tolerance, Column):
+ tolerance = SF.lit(tolerance)
+
+ as_of_joined_table = left_table._joinAsOf(
+ right_table,
+ leftAsOfColumn=left_as_of_column,
+ rightAsOfColumn=right_as_of_column,
+ on=on,
+ how="left",
+ tolerance=tolerance,
+ allowExactMatches=allow_exact_matches,
+ direction=direction,
+ )
+
+ # Unpack suffixes tuple for convenience
+ left_suffix = suffixes[0]
+ right_suffix = suffixes[1]
+
+ # Append suffixes to columns with the same name to avoid conflicts later
+ duplicate_columns = set(left_internal.column_labels) &
set(right_internal.column_labels)
+
+ exprs = []
+ data_columns = []
+ column_labels = []
+
+ left_scol_for = lambda label: scol_for(
+ as_of_joined_table, left_internal.spark_column_name_for(label)
+ )
+ right_scol_for = lambda label: scol_for(
+ as_of_joined_table, right_internal.spark_column_name_for(label)
+ )
+
+ for label in left_internal.column_labels:
+ col = left_internal.spark_column_name_for(label)
+ scol = left_scol_for(label)
+ if label in duplicate_columns:
+ spark_column_name = left_internal.spark_column_name_for(label)
+ if spark_column_name in (left_as_of_names + left_join_on_names)
and (
+ (right_prefix + spark_column_name) in (right_as_of_names +
right_join_on_names)
+ ):
+ pass
+ else:
+ col = col + left_suffix
+ scol = scol.alias(col)
+ label = tuple([str(label[0]) + left_suffix] + list(label[1:]))
+ exprs.append(scol)
+ data_columns.append(col)
+ column_labels.append(label)
+ for label in right_internal.column_labels:
+ # recover `right_prefix` here.
+ col = right_internal.spark_column_name_for(label)[len(right_prefix) :]
+ scol = right_scol_for(label).alias(col)
+ if label in duplicate_columns:
+ spark_column_name = left_internal.spark_column_name_for(label)
+ if spark_column_name in left_as_of_names + left_join_on_names and (
+ (right_prefix + spark_column_name) in right_as_of_names +
right_join_on_names
+ ):
+ continue
+ else:
+ col = col + right_suffix
+ scol = scol.alias(col)
+ label = tuple([str(label[0]) + right_suffix] + list(label[1:]))
+ exprs.append(scol)
+ data_columns.append(col)
+ column_labels.append(label)
+
+ # Retain indices if they are used for joining
+ if left_index or right_index:
+ index_spark_column_names = [
+ SPARK_INDEX_NAME_FORMAT(i) for i in
range(len(left_internal.index_spark_column_names))
+ ]
+ left_index_scols = [
+ scol.alias(name)
+ for scol, name in zip(left_internal.index_spark_columns,
index_spark_column_names)
+ ]
+ exprs.extend(left_index_scols)
+ index_names = left_internal.index_names
+ else:
+ index_spark_column_names = []
+ index_names = []
+
+ selected_columns = as_of_joined_table.select(*exprs)
+
+ internal = InternalFrame(
+ spark_frame=selected_columns,
+ index_spark_columns=[scol_for(selected_columns, col) for col in
index_spark_column_names],
+ index_names=index_names,
+ column_labels=column_labels,
+ data_spark_columns=[scol_for(selected_columns, col) for col in
data_columns],
+ )
+ return DataFrame(internal)
+
+
@no_type_check
def to_numeric(arg, errors="raise"):
"""
diff --git a/python/pyspark/pandas/tests/test_reshape.py
b/python/pyspark/pandas/tests/test_reshape.py
index 162ab78..f2a0cb1 100644
--- a/python/pyspark/pandas/tests/test_reshape.py
+++ b/python/pyspark/pandas/tests/test_reshape.py
@@ -24,6 +24,7 @@ import pandas as pd
from pyspark import pandas as ps
from pyspark.pandas.utils import name_like_string
+from pyspark.sql.utils import AnalysisException
from pyspark.testing.pandasutils import PandasOnSparkTestCase
@@ -283,6 +284,143 @@ class ReshapeTest(PandasOnSparkTestCase):
pd.get_dummies(pdf, columns=("x", 1),
dtype=np.int8).rename(columns=name_like_string),
)
+ def test_merge_asof(self):
+ pdf_left = pd.DataFrame(
+ {"a": [1, 5, 10], "b": ["x", "y", "z"], "left_val": ["a", "b",
"c"]}, index=[10, 20, 30]
+ )
+ pdf_right = pd.DataFrame(
+ {"a": [1, 2, 3, 6, 7], "b": ["v", "w", "x", "y", "z"],
"right_val": [1, 2, 3, 6, 7]},
+ index=[100, 101, 102, 103, 104],
+ )
+ psdf_left = ps.from_pandas(pdf_left)
+ psdf_right = ps.from_pandas(pdf_right)
+
+ self.assert_eq(
+ pd.merge_asof(pdf_left, pdf_right,
on="a").sort_values("a").reset_index(drop=True),
+ ps.merge_asof(psdf_left, psdf_right,
on="a").sort_values("a").reset_index(drop=True),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, left_on="a", right_on="a")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, left_on="a", right_on="a")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+ if LooseVersion(pd.__version__) >= LooseVersion("1.3"):
+ self.assert_eq(
+ pd.merge_asof(
+ pdf_left.set_index("a"), pdf_right, left_index=True,
right_on="a"
+ ).sort_index(),
+ ps.merge_asof(
+ psdf_left.set_index("a"), psdf_right, left_index=True,
right_on="a"
+ ).sort_index(),
+ )
+ else:
+ expected = pd.DataFrame(
+ {
+ "b_x": ["x", "y", "z"],
+ "left_val": ["a", "b", "c"],
+ "a": [1, 3, 7],
+ "b_y": ["v", "x", "z"],
+ "right_val": [1, 3, 7],
+ },
+ index=pd.Index([1, 5, 10], name="a"),
+ )
+ self.assert_eq(
+ expected,
+ ps.merge_asof(
+ psdf_left.set_index("a"), psdf_right, left_index=True,
right_on="a"
+ ).sort_index(),
+ )
+ self.assert_eq(
+ pd.merge_asof(
+ pdf_left, pdf_right.set_index("a"), left_on="a",
right_index=True
+ ).sort_index(),
+ ps.merge_asof(
+ psdf_left, psdf_right.set_index("a"), left_on="a",
right_index=True
+ ).sort_index(),
+ )
+ self.assert_eq(
+ pd.merge_asof(
+ pdf_left.set_index("a"), pdf_right.set_index("a"),
left_index=True, right_index=True
+ ).sort_index(),
+ ps.merge_asof(
+ psdf_left.set_index("a"),
+ psdf_right.set_index("a"),
+ left_index=True,
+ right_index=True,
+ ).sort_index(),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, on="a", by="b")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, on="a", by="b")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, on="a", tolerance=1)
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, on="a", tolerance=1)
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, on="a",
allow_exact_matches=False)
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, on="a",
allow_exact_matches=False)
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, on="a", direction="forward")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, on="a",
direction="forward")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+ self.assert_eq(
+ (
+ pd.merge_asof(pdf_left, pdf_right, on="a", direction="nearest")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ (
+ ps.merge_asof(psdf_left, psdf_right, on="a",
direction="nearest")
+ .sort_values("a")
+ .reset_index(drop=True)
+ ),
+ )
+
+ self.assertRaises(
+ AnalysisException, lambda: ps.merge_asof(psdf_left, psdf_right,
on="a", tolerance=-1)
+ )
+
if __name__ == "__main__":
import unittest
diff --git a/python/pyspark/sql/dataframe.py b/python/pyspark/sql/dataframe.py
index 5a2e8cf..de289e1 100644
--- a/python/pyspark/sql/dataframe.py
+++ b/python/pyspark/sql/dataframe.py
@@ -1357,6 +1357,123 @@ class DataFrame(PandasMapOpsMixin,
PandasConversionMixin):
jdf = self._jdf.join(other._jdf, on, how)
return DataFrame(jdf, self.sql_ctx)
+ # TODO(SPARK-22947): Fix the DataFrame API.
+ def _joinAsOf(
+ self,
+ other,
+ leftAsOfColumn,
+ rightAsOfColumn,
+ on=None,
+ how=None,
+ *,
+ tolerance=None,
+ allowExactMatches=True,
+ direction="backward",
+ ):
+ """
+ Perform an as-of join.
+
+ This is similar to a left-join except that we match on nearest
+ key rather than equal keys.
+
+ .. versionadded:: 3.3.0
+
+ Parameters
+ ----------
+ other : :class:`DataFrame`
+ Right side of the join
+ leftAsOfColumn : str or :class:`Column`
+ a string for the as-of join column name, or a Column
+ rightAsOfColumn : str or :class:`Column`
+ a string for the as-of join column name, or a Column
+ on : str, list or :class:`Column`, optional
+ a string for the join column name, a list of column names,
+ a join expression (Column), or a list of Columns.
+ If `on` is a string or a list of strings indicating the name of
the join column(s),
+ the column(s) must exist on both sides, and this performs an
equi-join.
+ how : str, optional
+ default ``inner``. Must be one of: ``inner`` and ``left``.
+ tolerance : :class:`Column`, optional
+ an asof tolerance within this range; must be compatible
+ with the merge index.
+ allowExactMatches : bool, optional
+ default ``True``.
+ direction : str, optional
+ default ``backward``. Must be one of: ``backward``, ``forward``,
and ``nearest``.
+
+ Examples
+ --------
+ The following performs an as-of join between ``left`` and ``right``.
+
+ >>> left = spark.createDataFrame([(1, "a"), (5, "b"), (10, "c")],
["a", "left_val"])
+ >>> right = spark.createDataFrame([(1, 1), (2, 2), (3, 3), (6, 6), (7,
7)],
+ ... ["a", "right_val"])
+ >>> left._joinAsOf(
+ ... right, leftAsOfColumn="a", rightAsOfColumn="a"
+ ... ).select(left.a, 'left_val', 'right_val').sort("a").collect()
+ [Row(a=1, left_val='a', right_val=1),
+ Row(a=5, left_val='b', right_val=3),
+ Row(a=10, left_val='c', right_val=7)]
+
+ >>> from pyspark.sql import functions as F
+ >>> left._joinAsOf(
+ ... right, leftAsOfColumn="a", rightAsOfColumn="a",
tolerance=F.lit(1)
+ ... ).select(left.a, 'left_val', 'right_val').sort("a").collect()
+ [Row(a=1, left_val='a', right_val=1)]
+
+ >>> left._joinAsOf(
+ ... right, leftAsOfColumn="a", rightAsOfColumn="a", how="left",
tolerance=F.lit(1)
+ ... ).select(left.a, 'left_val', 'right_val').sort("a").collect()
+ [Row(a=1, left_val='a', right_val=1),
+ Row(a=5, left_val='b', right_val=None),
+ Row(a=10, left_val='c', right_val=None)]
+
+ >>> left._joinAsOf(
+ ... right, leftAsOfColumn="a", rightAsOfColumn="a",
allowExactMatches=False
+ ... ).select(left.a, 'left_val', 'right_val').sort("a").collect()
+ [Row(a=5, left_val='b', right_val=3),
+ Row(a=10, left_val='c', right_val=7)]
+
+ >>> left._joinAsOf(
+ ... right, leftAsOfColumn="a", rightAsOfColumn="a",
direction="forward"
+ ... ).select(left.a, 'left_val', 'right_val').sort("a").collect()
+ [Row(a=1, left_val='a', right_val=1),
+ Row(a=5, left_val='b', right_val=6)]
+ """
+ if isinstance(leftAsOfColumn, str):
+ leftAsOfColumn = self[leftAsOfColumn]
+ left_as_of_jcol = leftAsOfColumn._jc
+ if isinstance(rightAsOfColumn, str):
+ rightAsOfColumn = other[rightAsOfColumn]
+ right_as_of_jcol = rightAsOfColumn._jc
+
+ if on is not None and not isinstance(on, list):
+ on = [on]
+
+ if on is not None:
+ if isinstance(on[0], str):
+ on = self._jseq(on)
+ else:
+ assert isinstance(on[0], Column), "on should be Column or list
of Column"
+ on = reduce(lambda x, y: x.__and__(y), on)
+ on = on._jc
+
+ if how is None:
+ how = "inner"
+ assert isinstance(how, str), "how should be a string"
+
+ if tolerance is not None:
+ assert isinstance(tolerance, Column), "tolerance should be Column"
+ tolerance = tolerance._jc
+
+ jdf = self._jdf.joinAsOf(
+ other._jdf,
+ left_as_of_jcol, right_as_of_jcol,
+ on,
+ how, tolerance, allowExactMatches, direction
+ )
+ return DataFrame(jdf, self.sql_ctx)
+
def sortWithinPartitions(self, *cols, **kwargs):
"""Returns a new :class:`DataFrame` with each partition sorted by the
specified column(s).
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
index b62e934..c8614b1 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
@@ -249,6 +249,20 @@ trait CheckAnalysis extends PredicateHelper with
LookupCatalog {
s"join condition '${condition.sql}' " +
s"of type ${condition.dataType.catalogString} is not a
boolean.")
+ case j @ AsOfJoin(_, _, _, Some(condition), _, _, _)
+ if condition.dataType != BooleanType =>
+ failAnalysis(
+ s"join condition '${condition.sql}' " +
+ s"of type ${condition.dataType.catalogString} is not a
boolean.")
+
+ case j @ AsOfJoin(_, _, _, _, _, _, Some(toleranceAssertion)) =>
+ if (!toleranceAssertion.foldable) {
+ failAnalysis("Input argument tolerance must be a constant.")
+ }
+ if (!toleranceAssertion.eval().asInstanceOf[Boolean]) {
+ failAnalysis("Input argument tolerance must be non-negative.")
+ }
+
case a @ Aggregate(groupingExprs, aggregateExprs, child) =>
def isAggregateExpression(expr: Expression): Boolean = {
expr.isInstanceOf[AggregateExpression] ||
PythonUDF.isGroupedAggPandasUDF(expr)
@@ -506,6 +520,15 @@ trait CheckAnalysis extends PredicateHelper with
LookupCatalog {
|Conflicting attributes:
${conflictingAttributes.mkString(",")}
""".stripMargin)
+ case j: AsOfJoin if !j.duplicateResolved =>
+ val conflictingAttributes =
j.left.outputSet.intersect(j.right.outputSet)
+ failAnalysis(
+ s"""
+ |Failure when resolving conflicting references in AsOfJoin:
+ |$plan
+ |Conflicting attributes:
${conflictingAttributes.mkString(",")}
+ |""".stripMargin)
+
// TODO: although map type is not orderable, technically map type
should be able to be
// used in equality comparison, remove this type check once we
support it.
case o if mapColumnInSetOperation(o).isDefined =>
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
index 7b37891..5dfed39 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/DeduplicateRelations.scala
@@ -41,7 +41,8 @@ case class ReferenceEqualPlanWrapper(plan: LogicalPlan) {
object DeduplicateRelations extends Rule[LogicalPlan] {
override def apply(plan: LogicalPlan): LogicalPlan = {
renewDuplicatedRelations(mutable.HashSet.empty,
plan)._1.resolveOperatorsUpWithPruning(
- _.containsAnyPattern(JOIN, LATERAL_JOIN, INTERSECT, EXCEPT, UNION,
COMMAND), ruleId) {
+ _.containsAnyPattern(JOIN, LATERAL_JOIN, AS_OF_JOIN, INTERSECT, EXCEPT,
UNION, COMMAND),
+ ruleId) {
case p: LogicalPlan if !p.childrenResolved => p
// To resolve duplicate expression IDs for Join.
case j @ Join(left, right, _, _, _) if !j.duplicateResolved =>
@@ -49,6 +50,9 @@ object DeduplicateRelations extends Rule[LogicalPlan] {
// Resolve duplicate output for LateralJoin.
case j @ LateralJoin(left, right, _, _) if right.resolved &&
!j.duplicateResolved =>
j.copy(right = right.withNewPlan(dedupRight(left, right.plan)))
+ // Resolve duplicate output for AsOfJoin.
+ case j @ AsOfJoin(left, right, _, _, _, _, _) if !j.duplicateResolved =>
+ j.copy(right = dedupRight(left, right))
// intersect/except will be rewritten to join at the beginning of
optimizer. Here we need to
// deduplicate the right side plan, so that we won't produce an invalid
self-join later.
case i @ Intersect(left, right, _) if !i.duplicateResolved =>
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
index ed16185..b8c7fe7 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala
@@ -159,7 +159,8 @@ abstract class Optimizer(catalogManager: CatalogManager)
PullOutGroupingExpressions,
ComputeCurrentTime,
ReplaceCurrentLike(catalogManager),
- SpecialDatetimeValues) ::
+ SpecialDatetimeValues,
+ RewriteAsOfJoin) ::
//////////////////////////////////////////////////////////////////////////////////////////
// Optimizer rules start here
//////////////////////////////////////////////////////////////////////////////////////////
@@ -282,7 +283,8 @@ abstract class Optimizer(catalogManager: CatalogManager)
RewritePredicateSubquery.ruleName ::
NormalizeFloatingNumbers.ruleName ::
ReplaceUpdateFieldsExpression.ruleName ::
- PullOutGroupingExpressions.ruleName :: Nil
+ PullOutGroupingExpressions.ruleName ::
+ RewriteAsOfJoin.ruleName :: Nil
/**
* Optimize all the subqueries inside expression.
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
new file mode 100644
index 0000000..bd93b50
--- /dev/null
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoin.scala
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.optimizer
+
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.aggregate._
+import org.apache.spark.sql.catalyst.plans._
+import org.apache.spark.sql.catalyst.plans.logical._
+import org.apache.spark.sql.catalyst.rules._
+import org.apache.spark.sql.catalyst.trees.TreePattern._
+
+/**
+ * Replaces logical [[AsOfJoin]] operator using a combination of Join and
Aggregate operator.
+ *
+ * Input Pseudo-Query:
+ * {{{
+ * SELECT * FROM left ASOF JOIN right ON (condition, as_of on(left.t,
right.t), tolerance)
+ * }}}
+ *
+ * Rewritten Query:
+ * {{{
+ * SELECT left.*, __right__.*
+ * FROM (
+ * SELECT
+ * left.*,
+ * (
+ * SELECT MIN_BY(STRUCT(right.*), left.t - right.t) AS
__nearest_right__
+ * FROM right
+ * WHERE condition AND left.t >= right.t AND right.t >=
left.t - tolerance
+ * ) as __right__
+ * FROM left
+ * )
+ * WHERE __right__ IS NOT NULL
+ * }}}
+ */
+object RewriteAsOfJoin extends Rule[LogicalPlan] {
+ def apply(plan: LogicalPlan): LogicalPlan = plan.transformWithPruning(
+ _.containsPattern(AS_OF_JOIN), ruleId) {
+ case AsOfJoin(left, right, asOfCondition, condition, joinType,
orderExpression, _) =>
+ val conditionWithOuterReference =
+ condition.map(And(_,
asOfCondition)).getOrElse(asOfCondition).transformUp {
+ case a: AttributeReference if left.outputSet.contains(a) =>
+ OuterReference(a)
+ }
+ val filtered = Filter(conditionWithOuterReference, right)
+
+ val orderExpressionWithOuterReference = orderExpression.transformUp {
+ case a: AttributeReference if left.outputSet.contains(a) =>
+ OuterReference(a)
+ }
+ val rightStruct = CreateStruct(right.output)
+ val nearestRight = MinBy(rightStruct, orderExpressionWithOuterReference)
+ .toAggregateExpression()
+ val aggExpr = Alias(nearestRight, "__nearest_right__")()
+ val aggregate = Aggregate(Seq.empty, Seq(aggExpr), filtered)
+
+ val projectWithScalarSubquery = Project(
+ left.output :+ Alias(ScalarSubquery(aggregate, left.output),
"__right__")(),
+ left)
+
+ val filterRight = joinType match {
+ case LeftOuter => projectWithScalarSubquery
+ case _ =>
+ Filter(IsNotNull(projectWithScalarSubquery.output.last),
projectWithScalarSubquery)
+ }
+
+ Project(
+ left.output ++ right.output.zipWithIndex.map {
+ case (out, idx) =>
+ Alias(GetStructField(filterRight.output.last, idx),
out.name)(exprId = out.exprId)
+ },
+ filterRight)
+ }
+}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/joinTypes.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/joinTypes.scala
index da3cfb4..eeec3cd7 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/joinTypes.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/joinTypes.scala
@@ -121,3 +121,24 @@ object LeftSemiOrAnti {
case _ => None
}
}
+
+object AsOfJoinDirection {
+
+ def apply(direction: String): AsOfJoinDirection = {
+ direction.toLowerCase(Locale.ROOT) match {
+ case "forward" => Forward
+ case "backward" => Backward
+ case "nearest" => Nearest
+ case _ =>
+ val supported = Seq("forward", "backward", "nearest")
+ throw new IllegalArgumentException(s"Unsupported as-of join direction
'$direction'. " +
+ "Supported as-of join direction include: " + supported.mkString("'",
"', '", "'") + ".")
+ }
+ }
+}
+
+sealed abstract class AsOfJoinDirection
+
+case object Forward extends AsOfJoinDirection
+case object Backward extends AsOfJoinDirection
+case object Nearest extends AsOfJoinDirection
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
index 269d18a..7b4c2bc 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala
@@ -1597,3 +1597,119 @@ case class LateralJoin(
copy(left = newChild)
}
}
+
+/**
+ * A logical plan for as-of join.
+ */
+case class AsOfJoin(
+ left: LogicalPlan,
+ right: LogicalPlan,
+ asOfCondition: Expression,
+ condition: Option[Expression],
+ joinType: JoinType,
+ orderExpression: Expression,
+ toleranceAssertion: Option[Expression]) extends BinaryNode {
+
+ require(Seq(Inner, LeftOuter).contains(joinType),
+ s"Unsupported as-of join type $joinType")
+
+ override protected def stringArgs: Iterator[Any] = super.stringArgs.take(5)
+
+ override def output: Seq[Attribute] = {
+ joinType match {
+ case LeftOuter =>
+ left.output ++ right.output.map(_.withNullability(true))
+ case _ =>
+ left.output ++ right.output
+ }
+ }
+
+ def duplicateResolved: Boolean =
left.outputSet.intersect(right.outputSet).isEmpty
+
+ override lazy val resolved: Boolean = {
+ childrenResolved &&
+ expressions.forall(_.resolved) &&
+ duplicateResolved &&
+ asOfCondition.dataType == BooleanType &&
+ condition.forall(_.dataType == BooleanType) &&
+ toleranceAssertion.forall { assertion =>
+ assertion.foldable && assertion.eval().asInstanceOf[Boolean]
+ }
+ }
+
+ final override val nodePatterns: Seq[TreePattern] = Seq(AS_OF_JOIN)
+
+ override protected def withNewChildrenInternal(
+ newLeft: LogicalPlan, newRight: LogicalPlan): AsOfJoin = {
+ copy(left = newLeft, right = newRight)
+ }
+}
+
+object AsOfJoin {
+
+ def apply(
+ left: LogicalPlan,
+ right: LogicalPlan,
+ leftAsOf: Expression,
+ rightAsOf: Expression,
+ condition: Option[Expression],
+ joinType: JoinType,
+ tolerance: Option[Expression],
+ allowExactMatches: Boolean,
+ direction: AsOfJoinDirection): AsOfJoin = {
+ val asOfCond = makeAsOfCond(leftAsOf, rightAsOf, tolerance,
allowExactMatches, direction)
+ val orderingExpr = makeOrderingExpr(leftAsOf, rightAsOf, direction)
+ AsOfJoin(left, right, asOfCond, condition, joinType,
+ orderingExpr, tolerance.map(t => GreaterThanOrEqual(t,
Literal.default(t.dataType))))
+ }
+
+ private def makeAsOfCond(
+ leftAsOf: Expression,
+ rightAsOf: Expression,
+ tolerance: Option[Expression],
+ allowExactMatches: Boolean,
+ direction: AsOfJoinDirection): Expression = {
+ val base = (allowExactMatches, direction) match {
+ case (true, Backward) => GreaterThanOrEqual(leftAsOf, rightAsOf)
+ case (false, Backward) => GreaterThan(leftAsOf, rightAsOf)
+ case (true, Forward) => LessThanOrEqual(leftAsOf, rightAsOf)
+ case (false, Forward) => LessThan(leftAsOf, rightAsOf)
+ case (true, Nearest) => Literal.TrueLiteral
+ case (false, Nearest) => Not(EqualTo(leftAsOf, rightAsOf))
+ }
+ tolerance match {
+ case Some(tolerance) =>
+ (allowExactMatches, direction) match {
+ case (true, Backward) =>
+ And(base, GreaterThanOrEqual(rightAsOf, Subtract(leftAsOf,
tolerance)))
+ case (false, Backward) =>
+ And(base, GreaterThan(rightAsOf, Subtract(leftAsOf, tolerance)))
+ case (true, Forward) =>
+ And(base, LessThanOrEqual(rightAsOf, Add(leftAsOf, tolerance)))
+ case (false, Forward) =>
+ And(base, LessThan(rightAsOf, Add(leftAsOf, tolerance)))
+ case (true, Nearest) =>
+ And(GreaterThanOrEqual(rightAsOf, Subtract(leftAsOf, tolerance)),
+ LessThanOrEqual(rightAsOf, Add(leftAsOf, tolerance)))
+ case (false, Nearest) =>
+ And(base,
+ And(GreaterThan(rightAsOf, Subtract(leftAsOf, tolerance)),
+ LessThan(rightAsOf, Add(leftAsOf, tolerance))))
+ }
+ case None => base
+ }
+ }
+
+ private def makeOrderingExpr(
+ leftAsOf: Expression,
+ rightAsOf: Expression,
+ direction: AsOfJoinDirection): Expression = {
+ direction match {
+ case Backward => Subtract(leftAsOf, rightAsOf)
+ case Forward => Subtract(rightAsOf, leftAsOf)
+ case Nearest =>
+ If(GreaterThan(leftAsOf, rightAsOf),
+ Subtract(leftAsOf, rightAsOf), Subtract(rightAsOf, leftAsOf))
+ }
+ }
+}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
index 2a05b85..d207ebc 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleIdCollection.scala
@@ -144,6 +144,7 @@ object RuleIdCollection {
"org.apache.spark.sql.catalyst.optimizer.ReplaceIntersectWithSemiJoin" ::
"org.apache.spark.sql.catalyst.optimizer.RewriteExceptAll" ::
"org.apache.spark.sql.catalyst.optimizer.RewriteIntersectAll" ::
+ "org.apache.spark.sql.catalyst.optimizer.RewriteAsOfJoin" ::
"org.apache.spark.sql.catalyst.optimizer.SimplifyBinaryComparison" ::
"org.apache.spark.sql.catalyst.optimizer.SimplifyCaseConversionExpressions" ::
"org.apache.spark.sql.catalyst.optimizer.SimplifyCasts" ::
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
index bb57e5a..6c1b64d 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/trees/TreePatterns.scala
@@ -91,6 +91,7 @@ object TreePattern extends Enumeration {
// Logical plan patterns (alphabetically ordered)
val AGGREGATE: Value = Value
+ val AS_OF_JOIN: Value = Value
val COMMAND: Value = Value
val CTE: Value = Value
val DISTINCT_LIKE: Value = Value
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoinSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoinSuite.scala
new file mode 100644
index 0000000..41f8e25
--- /dev/null
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/RewriteAsOfJoinSuite.scala
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.catalyst.optimizer
+
+import org.apache.spark.sql.catalyst.dsl.expressions._
+import org.apache.spark.sql.catalyst.dsl.plans._
+import org.apache.spark.sql.catalyst.expressions.{CreateStruct,
GetStructField, If, OuterReference, ScalarSubquery}
+import org.apache.spark.sql.catalyst.expressions.aggregate.MinBy
+import org.apache.spark.sql.catalyst.plans.{AsOfJoinDirection, Inner,
LeftOuter, PlanTest}
+import org.apache.spark.sql.catalyst.plans.logical.{AsOfJoin, LocalRelation}
+
+class RewriteAsOfJoinSuite extends PlanTest {
+
+ test("simple") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = None, allowExactMatches = true, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) >= right.output(0)
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("condition") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0),
+ Some(left.output(1) === right.output(1)), Inner,
+ tolerance = None, allowExactMatches = true, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(1)) === right.output(1) &&
+ OuterReference(left.output(0)) >= right.output(0)
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("left outer") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = None, allowExactMatches = true, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) >= right.output(0)
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("tolerance") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = Some(1), allowExactMatches = true, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) >= right.output(0) &&
+ right.output(0) >= OuterReference(left.output(0)) - 1
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("allowExactMatches = false") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
LeftOuter,
+ tolerance = None, allowExactMatches = false, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) > right.output(0)
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("tolerance & allowExactMatches = false") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = Some(1), allowExactMatches = false, direction =
AsOfJoinDirection("backward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) > right.output(0) &&
+ right.output(0) > OuterReference(left.output(0)) - 1
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = OuterReference(left.output(0)) - right.output(0)
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("direction = forward") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = None, allowExactMatches = true, direction =
AsOfJoinDirection("forward"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = OuterReference(left.output(0)) <= right.output(0)
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = right.output(0) - OuterReference(left.output(0))
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("direction = nearest") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = None, allowExactMatches = true, direction =
AsOfJoinDirection("nearest"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = true
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = If(OuterReference(left.output(0)) > right.output(0),
+ OuterReference(left.output(0)) - right.output(0),
+ right.output(0) - OuterReference(left.output(0)))
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+
+ test("tolerance & allowExactMatches = false & direction = nearest") {
+ val left = LocalRelation('a.int, 'b.int, 'c.int)
+ val right = LocalRelation('a.int, 'b.int, 'd.int)
+ val query = AsOfJoin(left, right, left.output(0), right.output(0), None,
Inner,
+ tolerance = Some(1), allowExactMatches = false, direction =
AsOfJoinDirection("nearest"))
+
+ val rewritten = RewriteAsOfJoin(query.analyze)
+
+ val filter = (!(OuterReference(left.output(0)) === right.output(0))) &&
+ ((right.output(0) > OuterReference(left.output(0)) - 1) &&
+ (right.output(0) < OuterReference(left.output(0)) + 1))
+ val rightStruct = CreateStruct(right.output)
+ val orderExpression = If(OuterReference(left.output(0)) > right.output(0),
+ OuterReference(left.output(0)) - right.output(0),
+ right.output(0) - OuterReference(left.output(0)))
+ val nearestRight = MinBy(rightStruct, orderExpression)
+ .toAggregateExpression().as("__nearest_right__")
+
+ val scalarSubquery = left.select(
+ left.output :+ ScalarSubquery(
+ right.where(filter).groupBy()(nearestRight),
+ left.output).as("__right__"): _*)
+ val correctAnswer = scalarSubquery
+ .where(scalarSubquery.output.last.isNotNull)
+ .select(left.output :+
+ GetStructField(scalarSubquery.output.last, 0).as("a") :+
+ GetStructField(scalarSubquery.output.last, 1).as("b") :+
+ GetStructField(scalarSubquery.output.last, 2).as("d"): _*)
+
+ comparePlans(rewritten, correctAnswer, checkAnalysis = false)
+ }
+}
diff --git a/sql/core/src/main/scala/org/apache/spark/sql/Dataset.scala
b/sql/core/src/main/scala/org/apache/spark/sql/Dataset.scala
index 1e85551..22e914e 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/Dataset.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/Dataset.scala
@@ -1047,30 +1047,12 @@ class Dataset[T] private[sql](
}
/**
- * Join with another `DataFrame`, using the given join expression. The
following performs
- * a full outer join between `df1` and `df2`.
- *
- * {{{
- * // Scala:
- * import org.apache.spark.sql.functions._
- * df1.join(df2, $"df1Key" === $"df2Key", "outer")
- *
- * // Java:
- * import static org.apache.spark.sql.functions.*;
- * df1.join(df2, col("df1Key").equalTo(col("df2Key")), "outer");
- * }}}
- *
- * @param right Right side of the join.
- * @param joinExprs Join expression.
- * @param joinType Type of join to perform. Default `inner`. Must be one of:
- * `inner`, `cross`, `outer`, `full`, `fullouter`,
`full_outer`, `left`,
- * `leftouter`, `left_outer`, `right`, `rightouter`,
`right_outer`,
- * `semi`, `leftsemi`, `left_semi`, `anti`, `leftanti`,
left_anti`.
- *
- * @group untypedrel
- * @since 2.0.0
+ * find the trivially true predicates and automatically resolves them to
both sides.
*/
- def join(right: Dataset[_], joinExprs: Column, joinType: String): DataFrame
= {
+ private def resolveSelfJoinCondition(
+ right: Dataset[_],
+ joinExprs: Option[Column],
+ joinType: String): Join = {
// Note that in this function, we introduce a hack in the case of
self-join to automatically
// resolve ambiguous join conditions into ones that might make sense
[SPARK-6231].
// Consider this case: df.join(df, df("key") === df("key"))
@@ -1082,27 +1064,56 @@ class Dataset[T] private[sql](
// Trigger analysis so in the case of self-join, the analyzer will clone
the plan.
// After the cloning, left and right side will have distinct expression
ids.
val plan = withPlan(
- Join(logicalPlan, right.logicalPlan, JoinType(joinType),
Some(joinExprs.expr), JoinHint.NONE))
+ Join(logicalPlan, right.logicalPlan,
+ JoinType(joinType), joinExprs.map(_.expr), JoinHint.NONE))
.queryExecution.analyzed.asInstanceOf[Join]
// If auto self join alias is disabled, return the plan.
if (!sparkSession.sessionState.conf.dataFrameSelfJoinAutoResolveAmbiguity)
{
- return withPlan(plan)
+ return plan
}
// If left/right have no output set intersection, return the plan.
val lanalyzed = this.queryExecution.analyzed
val ranalyzed = right.queryExecution.analyzed
if (lanalyzed.outputSet.intersect(ranalyzed.outputSet).isEmpty) {
- return withPlan(plan)
+ return plan
}
// Otherwise, find the trivially true predicates and automatically
resolves them to both sides.
// By the time we get here, since we have already run analysis, all
attributes should've been
// resolved and become AttributeReference.
+ resolveSelfJoinCondition(plan)
+ }
+
+ /**
+ * Join with another `DataFrame`, using the given join expression. The
following performs
+ * a full outer join between `df1` and `df2`.
+ *
+ * {{{
+ * // Scala:
+ * import org.apache.spark.sql.functions._
+ * df1.join(df2, $"df1Key" === $"df2Key", "outer")
+ *
+ * // Java:
+ * import static org.apache.spark.sql.functions.*;
+ * df1.join(df2, col("df1Key").equalTo(col("df2Key")), "outer");
+ * }}}
+ *
+ * @param right Right side of the join.
+ * @param joinExprs Join expression.
+ * @param joinType Type of join to perform. Default `inner`. Must be one of:
+ * `inner`, `cross`, `outer`, `full`, `fullouter`,
`full_outer`, `left`,
+ * `leftouter`, `left_outer`, `right`, `rightouter`,
`right_outer`,
+ * `semi`, `leftsemi`, `left_semi`, `anti`, `leftanti`,
left_anti`.
+ *
+ * @group untypedrel
+ * @since 2.0.0
+ */
+ def join(right: Dataset[_], joinExprs: Column, joinType: String): DataFrame
= {
withPlan {
- resolveSelfJoinCondition(plan)
+ resolveSelfJoinCondition(right, Some(joinExprs), joinType)
}
}
@@ -1232,6 +1243,58 @@ class Dataset[T] private[sql](
joinWith(other, condition, "inner")
}
+ // TODO(SPARK-22947): Fix the DataFrame API.
+ private[sql] def joinAsOf(
+ other: Dataset[_],
+ leftAsOf: Column,
+ rightAsOf: Column,
+ usingColumns: Seq[String],
+ joinType: String,
+ tolerance: Column,
+ allowExactMatches: Boolean,
+ direction: String): DataFrame = {
+ val joinExprs = usingColumns.map { column =>
+ EqualTo(resolve(column), other.resolve(column))
+ }.reduceOption(And).map(Column.apply).orNull
+
+ joinAsOf(other, leftAsOf, rightAsOf, joinExprs, joinType,
+ tolerance, allowExactMatches, direction)
+ }
+
+ // TODO(SPARK-22947): Fix the DataFrame API.
+ private[sql] def joinAsOf(
+ other: Dataset[_],
+ leftAsOf: Column,
+ rightAsOf: Column,
+ joinExprs: Column,
+ joinType: String,
+ tolerance: Column,
+ allowExactMatches: Boolean,
+ direction: String): DataFrame = {
+ val joined = resolveSelfJoinCondition(other, Option(joinExprs), joinType)
+ val leftAsOfExpr = leftAsOf.expr.transformUp {
+ case a: AttributeReference if logicalPlan.outputSet.contains(a) =>
+ val index = logicalPlan.output.indexWhere(_.exprId == a.exprId)
+ joined.left.output(index)
+ }
+ val rightAsOfExpr = rightAsOf.expr.transformUp {
+ case a: AttributeReference if other.logicalPlan.outputSet.contains(a) =>
+ val index = other.logicalPlan.output.indexWhere(_.exprId == a.exprId)
+ joined.right.output(index)
+ }
+ withPlan {
+ AsOfJoin(
+ joined.left, joined.right,
+ leftAsOfExpr, rightAsOfExpr,
+ joined.condition,
+ joined.joinType,
+ Option(tolerance).map(_.expr),
+ allowExactMatches,
+ AsOfJoinDirection(direction)
+ )
+ }
+ }
+
/**
* Returns a new Dataset with each partition sorted by the given expressions.
*
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAsOfJoinSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAsOfJoinSuite.scala
new file mode 100644
index 0000000..749efe9
--- /dev/null
+++ b/sql/core/src/test/scala/org/apache/spark/sql/DataFrameAsOfJoinSuite.scala
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql
+
+import scala.collection.JavaConverters._
+
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.functions._
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+
+class DataFrameAsOfJoinSuite extends QueryTest
+ with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+
+ def prepareForAsOfJoin(): (DataFrame, DataFrame) = {
+ val schema1 = StructType(
+ StructField("a", IntegerType, false) ::
+ StructField("b", StringType, false) ::
+ StructField("left_val", StringType, false) :: Nil)
+ val rowSeq1: List[Row] = List(Row(1, "x", "a"), Row(5, "y", "b"), Row(10,
"z", "c"))
+ val df1 = spark.createDataFrame(rowSeq1.asJava, schema1)
+
+ val schema2 = StructType(
+ StructField("a", IntegerType) ::
+ StructField("b", StringType) ::
+ StructField("right_val", IntegerType) :: Nil)
+ val rowSeq2: List[Row] = List(Row(1, "v", 1), Row(2, "w", 2), Row(3, "x",
3),
+ Row(6, "y", 6), Row(7, "z", 7))
+ val df2 = spark.createDataFrame(rowSeq2.asJava, schema2)
+
+ (df1, df2)
+ }
+
+ test("as-of join - simple") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(
+ df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = null, allowExactMatches = true,
direction = "backward"),
+ Seq(
+ Row(1, "x", "a", 1, "v", 1),
+ Row(5, "y", "b", 3, "x", 3),
+ Row(10, "z", "c", 7, "z", 7)
+ )
+ )
+ }
+
+ test("as-of join - usingColumns") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq("b"),
+ joinType = "inner", tolerance = null, allowExactMatches = true,
direction = "backward"),
+ Seq(
+ Row(10, "z", "c", 7, "z", 7)
+ )
+ )
+ }
+
+ test("as-of join - usingColumns, left outer") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq("b"),
+ joinType = "left", tolerance = null, allowExactMatches = true,
direction = "backward"),
+ Seq(
+ Row(1, "x", "a", null, null, null),
+ Row(5, "y", "b", null, null, null),
+ Row(10, "z", "c", 7, "z", 7)
+ )
+ )
+ }
+
+ test("as-of join - tolerance = 1") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = lit(1), allowExactMatches = true,
direction = "backward"),
+ Seq(
+ Row(1, "x", "a", 1, "v", 1)
+ )
+ )
+ }
+
+ test("as-of join - tolerance should be a constant") {
+ val (df1, df2) = prepareForAsOfJoin()
+ val errMsg = intercept[AnalysisException] {
+ df1.joinAsOf(
+ df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = df1.col("b"), allowExactMatches = true,
+ direction = "backward")
+ }.getMessage
+ assert(errMsg.contains("Input argument tolerance must be a constant."))
+ }
+
+ test("as-of join - tolerance should be non-negative") {
+ val (df1, df2) = prepareForAsOfJoin()
+ val errMsg = intercept[AnalysisException] {
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = lit(-1), allowExactMatches = true,
direction = "backward")
+ }.getMessage
+ assert(errMsg.contains("Input argument tolerance must be non-negative."))
+ }
+
+ test("as-of join - allowExactMatches = false") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = null, allowExactMatches = false,
direction = "backward"),
+ Seq(
+ Row(5, "y", "b", 3, "x", 3),
+ Row(10, "z", "c", 7, "z", 7)
+ )
+ )
+ }
+
+ test("as-of join - direction = \"forward\"") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = null, allowExactMatches = true,
direction = "forward"),
+ Seq(
+ Row(1, "x", "a", 1, "v", 1),
+ Row(5, "y", "b", 6, "y", 6)
+ )
+ )
+ }
+
+ test("as-of join - direction = \"nearest\"") {
+ val (df1, df2) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(df2, df1.col("a"), df2.col("a"), usingColumns = Seq.empty,
+ joinType = "inner", tolerance = null, allowExactMatches = true,
direction = "nearest"),
+ Seq(
+ Row(1, "x", "a", 1, "v", 1),
+ Row(5, "y", "b", 6, "y", 6),
+ Row(10, "z", "c", 7, "z", 7)
+ )
+ )
+ }
+
+ test("as-of join - self") {
+ val (df1, _) = prepareForAsOfJoin()
+ checkAnswer(
+ df1.joinAsOf(
+ df1, df1.col("a"), df1.col("a"), usingColumns = Seq.empty,
+ joinType = "left", tolerance = null, allowExactMatches = false,
direction = "nearest"),
+ Seq(
+ Row(1, "x", "a", 5, "y", "b"),
+ Row(5, "y", "b", 1, "x", "a"),
+ Row(10, "z", "c", 5, "y", "b")
+ )
+ )
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]