blob: 143e11a922db967a201e5a81d8d5930151618bf8 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
from enum import Enum
from itertools import chain
import datetime
import unittest
import uuid
from pyspark.sql import Column, Row
from pyspark.sql import functions as sf
from pyspark.sql.window import Window
from pyspark.sql.types import StructType, StructField, IntegerType, LongType, StringType
from pyspark.errors import AnalysisException, PySparkTypeError, PySparkValueError
from pyspark.testing.sqlutils import ReusedSQLTestCase
from pyspark.testing.utils import have_pandas, pandas_requirement_message
class ColumnTestsMixin:
def test_column_name_encoding(self):
"""Ensure that created columns has `str` type consistently."""
columns = self.spark.createDataFrame([("Alice", 1)], ["name", "age"]).columns
self.assertEqual(columns, ["name", "age"])
self.assertTrue(isinstance(columns[0], str))
self.assertTrue(isinstance(columns[1], str))
def test_and_in_expression(self):
self.assertEqual(4, self.df.filter((self.df.key <= 10) & (self.df.value <= "2")).count())
self.assertRaises(ValueError, lambda: (self.df.key <= 10) and (self.df.value <= "2"))
self.assertEqual(14, self.df.filter((self.df.key <= 3) | (self.df.value < "2")).count())
self.assertRaises(ValueError, lambda: self.df.key <= 3 or self.df.value < "2")
self.assertEqual(99, self.df.filter(~(self.df.key == 1)).count())
self.assertRaises(ValueError, lambda: not self.df.key == 1)
def test_validate_column_types(self):
from pyspark.sql.functions import udf, to_json
from pyspark.sql.classic.column import _to_java_column
self.assertTrue("Column" in _to_java_column("a").getClass().toString())
self.assertTrue("Column" in _to_java_column("a").getClass().toString())
self.assertTrue("Column" in _to_java_column(self.spark.range(1).id).getClass().toString())
with self.assertRaises(PySparkTypeError) as pe:
_to_java_column(1)
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={
"expected_type": "Column or str",
"arg_name": "col",
"arg_type": "int",
},
)
class A:
pass
self.assertRaises(TypeError, lambda: _to_java_column(A()))
self.assertRaises(TypeError, lambda: _to_java_column([]))
with self.assertRaises(PySparkTypeError) as pe:
udf(lambda x: x)(None)
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={
"expected_type": "Column or str",
"arg_name": "col",
"arg_type": "NoneType",
},
)
self.assertRaises(TypeError, lambda: to_json(1))
def test_column_operators(self):
ci = self.df.key
cs = self.df.value
ci == cs
self.assertTrue(isinstance((-ci - 1 - 2) % 3 * 2.5 / 3.5, Column))
rcc = (1 + ci), (1 - ci), (1 * ci), (1 / ci), (1 % ci), (1**ci), (ci**1)
self.assertTrue(all(isinstance(c, Column) for c in rcc))
cb = [ci == 5, ci != 0, ci > 3, ci < 4, ci >= 0, ci <= 7]
self.assertTrue(all(isinstance(c, Column) for c in cb))
cbool = (ci & ci), (ci | ci), (~ci)
self.assertTrue(all(isinstance(c, Column) for c in cbool))
css = (
cs.contains("a"),
cs.like("a"),
cs.rlike("a"),
cs.ilike("A"),
cs.asc(),
cs.desc(),
cs.startswith("a"),
cs.endswith("a"),
ci.eqNullSafe(cs),
sf.col("b") & sf.lit(True),
sf.col("b") & True,
sf.lit(True) & sf.col("b"),
True & sf.col("b"),
sf.col("b") | sf.lit(True),
sf.col("b") | True,
sf.lit(True) | sf.col("b"),
True | sf.col("b"),
)
self.assertTrue(all(isinstance(c, Column) for c in css))
self.assertTrue(isinstance(ci.cast(LongType()), Column))
self.assertRaisesRegex(
ValueError, "Cannot apply 'in' operator against a column", lambda: 1 in cs
)
def test_column_date_time_op(self):
query = """
SELECT * FROM VALUES
(TIME('00:00:00'), 1),
(TIME('01:02:03'), 2),
(TIME('11:12:13'), 3)
AS tab(t, i)
"""
df = self.spark.sql(query)
res1 = df.select("i").where(sf.col("t") < datetime.time(3, 0, 0))
self.assertEqual([r.i for r in res1.collect()], [1, 2])
res2 = df.select("i").where(sf.col("t") > datetime.time(1, 0, 0))
self.assertEqual([r.i for r in res2.collect()], [2, 3])
res3 = df.select("i").where(sf.col("t") == datetime.time(0, 0, 0))
self.assertEqual([r.i for r in res3.collect()], [1])
def test_column_accessor(self):
from pyspark.sql.functions import col
self.assertIsInstance(col("foo")[1:3], Column)
self.assertIsInstance(col("foo")[0], Column)
self.assertIsInstance(col("foo")["bar"], Column)
self.assertRaises(ValueError, lambda: col("foo")[0:10:2])
def test_column_select(self):
df = self.df
self.assertEqual(self.testData, df.select("*").collect())
self.assertEqual(self.testData, df.select(df.key, df.value).collect())
self.assertEqual([Row(value="1")], df.where(df.key == 1).select(df.value).collect())
def test_access_column(self):
df = self.df
self.assertTrue(isinstance(df.key, Column))
self.assertTrue(isinstance(df["key"], Column))
self.assertTrue(isinstance(df[0], Column))
self.assertRaises(IndexError, lambda: df[2])
self.assertRaises(TypeError, lambda: df[{}])
self.assertRaises(AnalysisException, lambda: df.select(df["bad_key"]).schema)
def test_column_name_with_non_ascii(self):
columnName = "数量"
self.assertTrue(isinstance(columnName, str))
schema = StructType([StructField(columnName, LongType(), True)])
df = self.spark.createDataFrame([(1,)], schema)
self.assertEqual(schema, df.schema)
self.assertEqual("DataFrame[数量: bigint]", str(df))
self.assertEqual([("数量", "bigint")], df.dtypes)
self.assertEqual(1, df.select("数量").first()[0])
self.assertEqual(1, df.select(df["数量"]).first()[0])
self.assertTrue(columnName in repr(df[columnName]))
def test_field_accessor(self):
df = self.spark.createDataFrame([Row(l=[1], r=Row(a=1, b="b"), d={"k": "v"})])
self.assertEqual(1, df.select(df.l[0]).first()[0])
self.assertEqual(1, df.select(df.r["a"]).first()[0])
self.assertEqual(1, df.select(df["r.a"]).first()[0])
self.assertEqual("b", df.select(df.r["b"]).first()[0])
self.assertEqual("b", df.select(df["r.b"]).first()[0])
self.assertEqual("v", df.select(df.d["k"]).first()[0])
def test_bitwise_operations(self):
from pyspark.sql import functions
row = Row(a=170, b=75)
df = self.spark.createDataFrame([row])
result = df.select(df.a.bitwiseAND(df.b)).collect()[0].asDict()
self.assertEqual(170 & 75, result["(a & b)"])
result = df.select(df.a.bitwiseOR(df.b)).collect()[0].asDict()
self.assertEqual(170 | 75, result["(a | b)"])
result = df.select(df.a.bitwiseXOR(df.b)).collect()[0].asDict()
self.assertEqual(170 ^ 75, result["(a ^ b)"])
result = df.select(functions.bitwiseNOT(df.b)).collect()[0].asDict()
self.assertEqual(~75, result["~b"])
result = df.select(functions.bitwise_not(df.b)).collect()[0].asDict()
self.assertEqual(~75, result["~b"])
def test_with_field(self):
from pyspark.sql.functions import lit, col
df = self.spark.createDataFrame([Row(a=Row(b=1, c=2))])
self.assertIsInstance(df["a"].withField("b", lit(3)), Column)
self.assertIsInstance(df["a"].withField("d", lit(3)), Column)
result = df.withColumn("a", df["a"].withField("d", lit(3))).collect()[0].asDict()
self.assertEqual(3, result["a"]["d"])
result = df.withColumn("a", df["a"].withField("b", lit(3))).collect()[0].asDict()
self.assertEqual(3, result["a"]["b"])
with self.assertRaises(PySparkTypeError) as pe:
df["a"].withField("b", 3)
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={"expected_type": "Column", "arg_name": "col", "arg_type": "int"},
)
with self.assertRaises(PySparkTypeError) as pe:
df["a"].withField(col("b"), lit(3))
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={
"expected_type": "str",
"arg_name": "fieldName",
"arg_type": "Column",
},
)
def test_drop_fields(self):
df = self.spark.createDataFrame([Row(a=Row(b=1, c=2, d=Row(e=3, f=4)))])
self.assertIsInstance(df["a"].dropFields("b"), Column)
self.assertIsInstance(df["a"].dropFields("b", "c"), Column)
self.assertIsInstance(df["a"].dropFields("d.e"), Column)
result = (
df.select(
df["a"].dropFields("b").alias("a1"),
df["a"].dropFields("d.e").alias("a2"),
)
.first()
.asDict(True)
)
self.assertTrue("b" not in result["a1"] and "c" in result["a1"] and "d" in result["a1"])
self.assertTrue("e" not in result["a2"]["d"] and "f" in result["a2"]["d"])
def test_getitem_column(self):
mapping = {"A": "20", "B": "28", "C": "34"}
mapping_expr = sf.create_map([sf.lit(x) for x in chain(*mapping.items())])
df = self.spark.createDataFrame(
data=[["A", "10"], ["B", "14"], ["C", "17"]],
schema=["key", "value"],
).withColumn("square_value", mapping_expr[sf.col("key")])
self.assertEqual(df.count(), 3)
def test_alias_metadata(self):
df = self.spark.createDataFrame([("",)], ["a"])
df = df.withMetadata("a", {"foo": "bar"})
self.assertEqual(df.schema["a"].metadata, {"foo": "bar"})
# SPARK-51426: Ensure setting metadata to '{]' clears it
df = df.select([sf.col("a").alias("a", metadata={})])
self.assertEqual(df.schema["a"].metadata, {})
df = df.withMetadata("a", {"baz": "burr"})
self.assertEqual(df.schema["a"].metadata, {"baz": "burr"})
df = df.withMetadata("a", {})
self.assertEqual(df.schema["a"].metadata, {})
def test_alias_negative(self):
with self.assertRaises(PySparkValueError) as pe:
self.spark.range(1).id.alias("a", "b", metadata={})
self.check_error(
exception=pe.exception,
errorClass="ONLY_ALLOWED_FOR_SINGLE_COLUMN",
messageParameters={"arg_name": "metadata"},
)
def test_cast_str_representation(self):
self.assertEqual(str(sf.col("a").cast("int")), "Column<'CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").cast("INT")), "Column<'CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").cast(IntegerType())), "Column<'CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").cast(LongType())), "Column<'CAST(a AS BIGINT)'>")
self.assertEqual(str(sf.col("a").try_cast("int")), "Column<'TRY_CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").try_cast("INT")), "Column<'TRY_CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").try_cast(IntegerType())), "Column<'TRY_CAST(a AS INT)'>")
self.assertEqual(str(sf.col("a").try_cast(LongType())), "Column<'TRY_CAST(a AS BIGINT)'>")
def test_cast_negative(self):
with self.assertRaises(PySparkTypeError) as pe:
self.spark.range(1).id.cast(123)
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={
"expected_type": "DataType or str",
"arg_name": "dataType",
"arg_type": "int",
},
)
def test_over_negative(self):
with self.assertRaises(PySparkTypeError) as pe:
self.spark.range(1).id.over(123)
self.check_error(
exception=pe.exception,
errorClass="NOT_EXPECTED_TYPE",
messageParameters={
"expected_type": "WindowSpec",
"arg_name": "window",
"arg_type": "int",
},
)
def test_eqnullsafe_classmethod_usage(self):
df = self.spark.range(1)
self.assertEqual(df.select(Column.eqNullSafe(df.id, df.id)).first()[0], True)
def test_isinstance_dataframe(self):
self.assertIsInstance(self.spark.range(1).id, Column)
def test_expr_str_representation(self):
expression = sf.expr("foo")
when_cond = sf.when(expression, sf.lit(None))
self.assertEqual(str(when_cond), "Column<'CASE WHEN foo THEN NULL END'>")
def test_col_field_ops_representation(self):
# SPARK-49894: Test string representation of columns
c = sf.col("c")
# getField
self.assertEqual(str(c.x), "Column<'c['x']'>")
self.assertEqual(str(c.x.y), "Column<'c['x']['y']'>")
self.assertEqual(str(c.x.y.z), "Column<'c['x']['y']['z']'>")
self.assertEqual(str(c["x"]), "Column<'c['x']'>")
self.assertEqual(str(c["x"]["y"]), "Column<'c['x']['y']'>")
self.assertEqual(str(c["x"]["y"]["z"]), "Column<'c['x']['y']['z']'>")
self.assertEqual(str(c.getField("x")), "Column<'c['x']'>")
self.assertEqual(
str(c.getField("x").getField("y")),
"Column<'c['x']['y']'>",
)
self.assertEqual(
str(c.getField("x").getField("y").getField("z")),
"Column<'c['x']['y']['z']'>",
)
self.assertEqual(str(c.getItem("x")), "Column<'c['x']'>")
self.assertEqual(
str(c.getItem("x").getItem("y")),
"Column<'c['x']['y']'>",
)
self.assertEqual(
str(c.getItem("x").getItem("y").getItem("z")),
"Column<'c['x']['y']['z']'>",
)
self.assertEqual(
str(c.x["y"].getItem("z")),
"Column<'c['x']['y']['z']'>",
)
self.assertEqual(
str(c["x"].getField("y").getItem("z")),
"Column<'c['x']['y']['z']'>",
)
self.assertEqual(
str(c.getField("x").getItem("y").z),
"Column<'c['x']['y']['z']'>",
)
self.assertEqual(
str(c["x"].y.getField("z")),
"Column<'c['x']['y']['z']'>",
)
# WithField
self.assertEqual(
str(c.withField("x", sf.col("y"))),
"Column<'update_field(c, x, y)'>",
)
self.assertEqual(
str(c.withField("x", sf.col("y")).withField("x", sf.col("z"))),
"Column<'update_field(update_field(c, x, y), x, z)'>",
)
# DropFields
self.assertEqual(str(c.dropFields("x")), "Column<'drop_field(c, x)'>")
self.assertEqual(
str(c.dropFields("x", "y")),
"Column<'drop_field(drop_field(c, x), y)'>",
)
self.assertEqual(
str(c.dropFields("x", "y", "z")),
"Column<'drop_field(drop_field(drop_field(c, x), y), z)'>",
)
def test_lit_time_representation(self):
dt = datetime.date(2021, 3, 4)
self.assertEqual(str(sf.lit(dt)), "Column<'2021-03-04'>")
ts = datetime.datetime(2021, 3, 4, 12, 34, 56, 1234)
self.assertEqual(str(sf.lit(ts)), "Column<'2021-03-04 12:34:56.001234'>")
ts = datetime.time(12, 34, 56, 1234)
self.assertEqual(str(sf.lit(ts)), "Column<'12:34:56.001234'>")
@unittest.skipIf(not have_pandas, pandas_requirement_message)
def test_lit_delta_representation(self):
for delta in [
datetime.timedelta(days=1),
datetime.timedelta(hours=2),
datetime.timedelta(minutes=3),
datetime.timedelta(seconds=4),
datetime.timedelta(microseconds=5),
datetime.timedelta(days=2, hours=21, microseconds=908),
datetime.timedelta(days=1, minutes=-3, microseconds=-1001),
datetime.timedelta(days=1, hours=2, minutes=3, seconds=4, microseconds=5),
]:
import pandas as pd
# Column<'PT69H0.000908S'> or Column<'P2DT21H0M0.000908S'>
s = str(sf.lit(delta))
# Parse the ISO string representation and compare
self.assertEqual(pd.Timedelta(s[8:-2]).to_pytimedelta(), delta)
def test_enum_literals(self):
class IntEnum(Enum):
X = 1
Y = 2
Z = 3
class BoolEnum(Enum):
T = True
class StrEnum(Enum):
X = "x"
id = sf.col("id")
s = sf.col("s")
b = sf.col("b")
cols, expected = list(
zip(
(id + IntEnum.X, 2),
(id - IntEnum.X, 0),
(id * IntEnum.X, 1),
(id / IntEnum.X, 1.0),
(id % IntEnum.X, 0),
(IntEnum.X + id, 2),
(IntEnum.X - id, 0),
(IntEnum.X * id, 1),
(IntEnum.X / id, 1.0),
(IntEnum.X % id, 0),
(id**IntEnum.X, 1.0),
(IntEnum.X**id, 1, 0),
(id == IntEnum.X, True),
(IntEnum.X == id, True),
(id < IntEnum.X, False),
(id <= IntEnum.X, True),
(id >= IntEnum.X, True),
(id > IntEnum.X, False),
(id.eqNullSafe(IntEnum.X), True),
(b & BoolEnum.T, True),
(b | BoolEnum.T, True),
(BoolEnum.T & b, True),
(BoolEnum.T | b, True),
(id.bitwiseOR(IntEnum.X), 1),
(id.bitwiseAND(IntEnum.X), 1),
(id.bitwiseXOR(IntEnum.X), 0),
(id.contains(IntEnum.X), True),
(s.startswith(StrEnum.X), False),
(s.endswith(StrEnum.X), False),
(s.like(StrEnum.X), False),
(s.rlike(StrEnum.X), False),
(s.ilike(StrEnum.X), False),
(s.substr(IntEnum.X, IntEnum.Y), "1"),
(sf.when(b, IntEnum.X).when(~b, IntEnum.Y).otherwise(IntEnum.Z), 1),
)
)
result = (
self.spark.range(1, 2)
.select(id, id.astype("string").alias("s"), id.astype("boolean").alias("b"))
.select(*cols)
.first()
)
for r, c, e in zip(result, cols, expected):
self.assertEqual(r, e, str(c))
def test_transform(self):
# Test with built-in functions
df = self.spark.createDataFrame([(" hello ",), (" world ",)], ["text"])
result = df.select(df.text.transform(sf.trim).transform(sf.upper)).collect()
self.assertEqual(result[0][0], "HELLO")
self.assertEqual(result[1][0], "WORLD")
# Test with lambda functions
df = self.spark.createDataFrame([(10,), (20,), (30,)], ["value"])
result = df.select(
df.value.transform(lambda c: c + 5)
.transform(lambda c: c * 2)
.transform(lambda c: c - 10)
).collect()
self.assertEqual(result[0][0], 20)
self.assertEqual(result[1][0], 40)
self.assertEqual(result[2][0], 60)
def test_self_join(self):
df1 = self.spark.range(10).withColumn("a", sf.lit(0))
df2 = df1.withColumnRenamed("a", "b")
df = df1.join(df2, df1["a"] == df2["b"])
self.assertEqual(df.count(), 100)
df = df2.join(df1, df2["b"] == df1["a"])
self.assertEqual(df.count(), 100)
def test_self_join_II(self):
df = self.spark.createDataFrame([(1, 2), (3, 4)], schema=["a", "b"])
df2 = df.select(df.a.alias("aa"), df.b)
df3 = df2.join(df, df2.b == df.b)
self.assertEqual(df3.columns, ["aa", "b", "a", "b"])
self.assertEqual(df3.count(), 2)
def test_self_join_III(self):
df1 = self.spark.range(10).withColumn("value", sf.lit(1))
df2 = df1.union(df1)
df3 = df1.join(df2, df1.id == df2.id, "left")
self.assertEqual(df3.columns, ["id", "value", "id", "value"])
self.assertEqual(df3.count(), 20)
def test_self_join_IV(self):
df1 = self.spark.range(10).withColumn("value", sf.lit(1))
df2 = df1.withColumn("value", sf.lit(2)).union(df1.withColumn("value", sf.lit(3)))
df3 = df1.join(df2, df1.id == df2.id, "right")
self.assertEqual(df3.columns, ["id", "value", "id", "value"])
self.assertEqual(df3.count(), 20)
def test_select_join_keys(self):
df1 = self.spark.range(10).withColumn("v1", sf.lit(1))
df2 = self.spark.range(10).withColumn("v2", sf.lit(2))
for how in ["inner", "left", "right", "full", "cross"]:
self.assertTrue(df1.join(df2, "id", how).select(df1["id"]).count() >= 0, how)
self.assertTrue(df1.join(df2, "id", how).select(df2["id"]).count() >= 0, how)
def test_select_regular_column_with_reused_dataframe_hidden_in_natural_join(self):
# A DataFrame appears both as a direct join side and inside a natural/USING
# join that hides one of its columns into `metadataOutput`. When resolving
# `df2["id"]`, two candidates match the plan id: one from `p.output` (the
# direct join side) and one only visible via `p.metadataOutput` (the reused
# `df2` nested under the USING-join wrapper). We should prefer the regular
# candidate and not throw AMBIGUOUS_COLUMN_REFERENCE.
df1 = self.spark.createDataFrame([(10, "T1"), (20, "T2")], ["key", "val"])
df2 = self.spark.createDataFrame([(10,), (20,), (30,)], ["id"])
# The second row's id (99) does not match any df2 row, so the USING
# left-join in `enriched` produces NULL on the df2 side for val "T2".
# If `df2["id"]` were resolved to the hidden (USING-wrapper) candidate,
# the second row would yield NULL instead of 20, and the assertion below
# would fail. This pins resolution to the direct-side `id`.
df3 = self.spark.createDataFrame([(10, "T1"), (99, "T2")], ["id", "val"])
enriched = df3.join(df2, "id", "left")
result = (
df1.join(df2, df1["key"] == df2["id"], "left")
.join(enriched, "val", "full_outer")
.sort("val")
.select(df2["id"])
)
self.assertEqual(
[r["id"] for r in result.collect()],
[10, 20],
)
def test_drop_notexistent_col(self):
df1 = self.spark.createDataFrame(
[("a", "b", "c")],
schema="colA string, colB string, colC string",
)
df2 = self.spark.createDataFrame(
[("c", "d", "e")],
schema="colC string, colD string, colE string",
)
df3 = df1.join(df2, df1["colC"] == df2["colC"]).withColumn(
"colB",
sf.when(
df1["colB"] == "b", sf.concat(df1["colB"].cast("string"), sf.lit("x"))
).otherwise(df1["colB"]),
)
df4 = df3.drop(df1["colB"])
self.assertEqual(df4.columns, ["colA", "colB", "colC", "colC", "colD", "colE"])
self.assertEqual(df4.count(), 1)
# --- Mixed-surface layered DataFrame programs ---------------------------
#
# These tests chain multiple DataFrame transformations - semi-joins
# (for SQL EXISTS/IN), window functions, cube aggregations, UDFs and
# struct field access - into 4-5 layer pipelines, then reference the
# final layered DataFrame's columns via ``layered.col`` in both filter
# and select at the outermost surface. The goal is to catch regressions
# in plan-id propagation across analyzer rules that single-operator
# tests miss when rules interact.
def test_layered_semijoin_groupby_window(self):
# 4-layer DataFrame pipeline: filter -> semi-join -> groupBy/agg
# -> window functions. ``layered.col`` references appear in both
# filter and select at the outermost surface.
events_data = [
(1, 1, "Books", 100.0, 2, True),
(2, 1, "Books", 50.0, 3, True),
(3, 2, "Electronics", 200.0, 1, True),
(4, 2, "Electronics", 300.0, 2, True),
(5, 3, "Home", 80.0, 4, True),
(6, 4, "Books", 60.0, 1, False),
]
users_data = [(1, 25), (2, 30), (3, 22), (4, 18)]
events_cols = ["id", "user_id", "category", "amount", "quantity", "is_active"]
users_cols = ["id", "age"]
events = self.spark.createDataFrame(events_data, events_cols)
users = self.spark.createDataFrame(users_data, users_cols)
# Layer 1: filter + semi-join (DataFrame-API equivalent of
# WHERE is_active AND EXISTS (user with age > 20)).
active = events.where(events.is_active).join(
users.where(users.age > 20),
events.user_id == users.id,
"left_semi",
)
# Layer 2: groupBy + agg, then post-agg filter (HAVING equivalent).
agg = active.groupBy("category").agg(
sf.sum(active.amount * active.quantity * sf.lit(0.1)).alias("total_amt"),
sf.sum(active.amount).alias("amount_sum"),
)
totals = agg.where(agg.amount_sum > 50).select("category", "total_amt")
# Layer 3: window functions on top of the aggregate.
running = Window.orderBy("total_amt").rowsBetween(-1, 1)
ranking = Window.orderBy(totals.total_amt.desc())
windowed = totals.select(
"category",
"total_amt",
sf.avg(totals.total_amt).over(running).alias("running_avg"),
sf.rank().over(ranking).alias("rank_num"),
)
# Layer 4: outer filter.
layered = windowed.where(windowed.rank_num <= 5)
rows = (
layered.filter(layered.rank_num <= 3)
.select(
layered.category,
layered.total_amt,
layered.running_avg,
layered.rank_num,
)
.collect()
)
result = sorted((r.category, r.rank_num) for r in rows)
self.assertEqual(result, [("Books", 2), ("Electronics", 1), ("Home", 3)])
def test_layered_struct_semijoin_cube_ntile(self):
# 5-layer DataFrame pipeline: filter -> semi-join -> struct field
# access -> cube aggregation -> window NTILE. ``layered.col``
# references appear in both filter and select at the outermost
# surface.
events_schema = StructType(
[
StructField("id", IntegerType()),
StructField("category", StringType()),
StructField("status", StringType()),
StructField("amount", IntegerType()),
StructField("quantity", IntegerType()),
StructField(
"detail",
StructType(
[
StructField("name", StringType()),
StructField("nested", StructType([StructField("x", IntegerType())])),
]
),
),
]
)
events_data = [
(1, "Books", "A", 100, 5, ("alpha", (1,))),
(2, "Electronics", "B", 200, 3, ("beta", (2,))),
(3, "Books", "A", 50, 7, ("alpha", (1,))),
(4, "Electronics", "B", 300, 4, ("beta", (2,))),
(5, "Home", "C", 80, 2, ("gamma", (3,))),
]
categories_data = [("Books", 1), ("Electronics", 2), ("Home", 3), ("Toys", 5)]
categories_cols = ["name", "priority"]
events = self.spark.createDataFrame(events_data, events_schema)
categories = self.spark.createDataFrame(categories_data, categories_cols)
# Layer 1: filter + semi-join (DataFrame-API equivalent of
# WHERE quantity > 1 AND category IN (SELECT ...)).
filtered = events.where(events.quantity > 1).join(
categories.where(categories.priority <= 3),
events.category == categories.name,
"left_semi",
)
# Layer 2: project with struct field access (struct subfields use
# bracket access since ``detail.name`` would hit ``Column.name``).
base = filtered.select(
filtered.id,
filtered.category,
filtered.status,
filtered.amount,
filtered.detail["name"].alias("detail_name"),
filtered.detail["nested"]["x"].alias("nx"),
)
# Layer 3: cube aggregation (mixed grouping levels - similar
# surface area to SQL GROUPING SETS without an exact equivalent
# in the DataFrame API).
agg = base.cube("category", "status", "detail_name").agg(
sf.sum(base.amount).alias("total"), sf.count(sf.lit(1)).alias("cnt")
)
grouped = agg.where(agg.category.isNotNull() & agg.status.isNotNull())
# Layer 4: NTILE window.
tiled = grouped.withColumn("tile", sf.ntile(2).over(Window.orderBy(grouped.total.desc())))
# Layer 5: outer filter.
layered = tiled.where(tiled.tile <= 2)
rows = (
layered.filter(layered.tile >= 1)
.select(
layered.category,
layered.status,
layered.detail_name,
layered.total,
layered.cnt,
layered.tile,
)
.collect()
)
# Cube emits one (category, status, detail_name) group per distinct
# combination plus one (category, status, NULL) subtotal per distinct
# (category, status) pair. The where filter keeps both.
self.assertEqual(len(rows), 6)
self.assertEqual({r.category for r in rows}, {"Books", "Electronics", "Home"})
self.assertEqual({r.total for r in rows}, {80, 150, 500})
self.assertEqual({r.tile for r in rows}, {1, 2})
def test_layered_window_window_udf(self):
# 4-layer DataFrame pipeline: filter -> running-total window ->
# per-partition max window -> UDF wrap. ``layered.col`` references
# appear in both filter and select at the outermost surface.
data = [
(1, "A", 100),
(2, "A", 200),
(3, "B", 150),
(4, "B", 250),
(5, "C", 50),
]
cols = ["id", "category", "amount"]
df = self.spark.createDataFrame(data, cols)
# Layer 1: filter (replaces WHERE EXISTS amount > 0).
filtered = df.where(df.amount > 0)
# Layer 2: running total window.
run_w = Window.partitionBy("category").orderBy("id")
with_run = filtered.withColumn("run_amt", sf.sum(filtered.amount).over(run_w))
# Layer 3: per-category max window (replaces correlated subquery
# for cat_max).
cat_w = Window.partitionBy("category")
with_max = with_run.withColumn("cat_max", sf.max(with_run.amount).over(cat_w))
# Layer 4: UDF.
double = sf.udf(lambda x: x * 2 if x is not None else None, IntegerType())
layered = with_max.withColumn("doubled_amt", double(with_max.amount))
rows = (
layered.filter(layered.amount > 0)
.select(
layered.id,
layered.category,
layered.amount,
layered.run_amt,
layered.cat_max,
layered.doubled_amt,
)
.collect()
)
result = sorted(
(r.id, r.category, r.amount, r.run_amt, r.cat_max, r.doubled_amt) for r in rows
)
self.assertEqual(
result,
[
(1, "A", 100, 100, 200, 200),
(2, "A", 200, 300, 200, 400),
(3, "B", 150, 150, 250, 300),
(4, "B", 250, 400, 250, 500),
(5, "C", 50, 50, 50, 100),
],
)
# --- Tagged DataFrame column resolution --------------------------------
#
# ``df.col`` / ``df["col"]`` carries the source DataFrame's plan id. These
# tests pin how that tagged reference resolves after assorted operators.
# The behavior is shared across Spark Classic and Spark Connect (both
# ``spark.sql.analyzer.strictDataFrameColumnResolution`` modes) except for
# a few diverging cases, which are overridden in the Connect parity suites
# (``ColumnParityTests`` / ``...WithNonStrictDFColResolution``):
#
# * the shadowing trio - Classic and Connect strict raise, Connect
# lenient resolves the shadowed name via name-based fallback;
# * union - Classic resolves via attribute-id propagation, Connect
# raises in both modes.
def test_resolve_after_chained_withcolumn_shadow(self):
# Two consecutive withColumn calls each shadow `c` with a new
# attribute of the same name, so the original `c` leaves the
# projection and the tagged `df.c` cannot resolve.
# Connect lenient diverges: name-based fallback resolves the
# shadowed name (overridden in the lenient parity suite).
df = self.spark.sql("SELECT 1 AS c")
with self.assertRaises(AnalysisException):
df.withColumn("c", sf.col("c").cast("string")).withColumn(
"c", sf.col("c").cast("int")
).select(df.c).collect()
def test_resolve_after_select_alias_shadow(self):
# Same shadowing shape as withColumn but via select + alias.
# Connect lenient diverges: name-based fallback resolves the
# shadowed name (overridden in the lenient parity suite).
df = self.spark.sql("SELECT 1 AS c")
with self.assertRaises(AnalysisException):
df.select(df.c.cast("string").alias("c")).select(df.c).collect()
def test_resolve_after_withcolumnrenamed(self):
# withColumnRenamed drops the original `c` attribute and projects it
# as `c2`; the tagged `df.c` matches neither the original attribute
# nor a current column named `c`, so all modes raise.
df = self.spark.sql("SELECT 1 AS c")
with self.assertRaises(AnalysisException):
df.withColumnRenamed("c", "c2").select(df.c).collect()
def test_resolve_after_drop(self):
# drop("c") removes the column entirely; the tagged `df.c` cannot
# resolve under any mode.
df = self.spark.sql("SELECT 1 AS c, 2 AS d")
with self.assertRaises(AnalysisException):
df.drop("c").select(df.c).collect()
def test_resolve_through_filter(self):
# filter is a pass-through operator: the child Project's attributes
# flow through unchanged, so the tagged reference resolves.
df = self.spark.sql("SELECT 1 AS c UNION ALL SELECT 2 AS c")
rows = df.filter(df.c > 0).select(df.c).collect()
self.assertEqual(sorted(r.c for r in rows), [1, 2])
def test_resolve_through_sort(self):
# sort is also a pass-through operator.
df = self.spark.sql("SELECT 2 AS c UNION ALL SELECT 1 AS c")
rows = df.sort(df.c).select(df.c).collect()
self.assertEqual([r.c for r in rows], [1, 2])
def test_resolve_through_distinct(self):
# distinct preserves attribute identity for column resolution.
df = self.spark.sql("SELECT 1 AS c UNION ALL SELECT 1 AS c")
rows = df.distinct().select(df.c).collect()
self.assertEqual([r.c for r in rows], [1])
def test_resolve_after_groupby_count(self):
# groupBy("c").count() preserves the grouping key's attribute id, so
# the tagged reference resolves.
df = self.spark.sql("SELECT 1 AS c UNION ALL SELECT 1 AS c UNION ALL SELECT 2 AS c")
rows = df.groupBy("c").count().select(df.c).collect()
self.assertEqual(sorted(r.c for r in rows), [1, 2])
def test_resolve_after_agg_alias_shadow(self):
# An aggregate output aliased `c` collides by name with the source
# `c`, but the tagged `df.c` still references the aggregated-away
# source attribute, so it cannot resolve.
# Connect lenient diverges: name-based fallback resolves the
# aliased name (overridden in the lenient parity suite).
df = self.spark.sql("SELECT 1 AS c")
with self.assertRaises(AnalysisException):
df.groupBy().agg(sf.sum("c").alias("c")).select(df.c).collect()
def test_resolve_after_pivot(self):
# pivot preserves the grouping key's attribute id, so the tagged
# reference resolves.
df = self.spark.sql(
"SELECT 1 AS c, 'a' AS k, 10 AS v UNION ALL SELECT 2 AS c, 'b' AS k, 20 AS v"
)
rows = df.groupBy("c").pivot("k").sum("v").select(df.c).collect()
self.assertEqual(sorted(r.c for r in rows), [1, 2])
def test_resolve_after_union(self):
# Union's output keeps the left child's attribute ids
# (Union.mergeChildOutputs), so Classic resolves the tagged
# left-side reference directly against that output and succeeds.
# Connect resolves by walking the plan tree for the plan id but
# treats Union as a leaf (ColumnResolutionHelper), so the id below
# the Union is never found and it raises in both modes (overridden
# there).
df1 = self.spark.sql("SELECT 1 AS c")
df2 = self.spark.sql("SELECT 2 AS c")
rows = df1.union(df2).select(df1.c).collect()
self.assertEqual(sorted(r.c for r in rows), [1, 2])
def test_resolve_after_intersect(self):
# Intersect's output also keeps the left child's attribute ids
# (Intersect.mergeChildOutputs). Unlike Union, it is not treated as
# a leaf during plan-id resolution, so Connect's tree walk descends
# into the left child, finds the tagged node and resolves it; all
# modes succeed.
df1 = self.spark.sql("SELECT 1 AS c UNION ALL SELECT 2 AS c")
df2 = self.spark.sql("SELECT 2 AS c UNION ALL SELECT 3 AS c")
rows = df1.intersect(df2).select(df1.c).collect()
self.assertEqual([r.c for r in rows], [2])
def test_resolve_after_zip(self):
# zip merges two column-projected DataFrames side by side. Classic
# resolves the tagged left/right reference by attribute id, which
# ResolveZip preserves in the merged Project, so it succeeds. Connect
# resolves by plan id, but ResolveZip collapses the two sides into one
# plan and drops the per-DataFrame plan-id tags, so the tagged
# reference is never found and it raises (overridden in the parity suite).
df = self.spark.createDataFrame([(1, 10), (2, 20), (3, 30)], ["a", "b"])
left = df.select((df.a + 1).alias("x"))
right = df.select((df.b * 2).alias("y"))
zipped = left.zip(right)
self.assertEqual(zipped.columns, ["x", "y"])
self.assertEqual(sorted(r.x for r in zipped.select(left.x).collect()), [2, 3, 4])
self.assertEqual(sorted(r.y for r in zipped.select(right.y).collect()), [20, 40, 60])
self.assertEqual(
sorted((r.x, r.y) for r in zipped.select(left.x, right.y).collect()),
[(2, 20), (3, 40), (4, 60)],
)
def test_resolve_after_zip_reordered(self):
# The originating DataFrame controls which side each column reads from, in
# any order. Classic resolves by attribute id; Connect raises.
df = self.spark.createDataFrame([(1, 10), (2, 20)], ["a", "b"])
left = df.select(df.a.alias("x"))
right = df.select(df.b.alias("y"))
zipped = left.zip(right)
self.assertEqual(
sorted((r.y, r.x) for r in zipped.select(right.y, left.x).collect()),
[(10, 1), (20, 2)],
)
def test_resolve_after_zip_duplicate_names(self):
# Both sides expose a column named "v" from different sources, so the
# merged schema has two "v"s. A bare "v" is ambiguous, but the tagged
# left/right reference disambiguates by attribute id on Classic.
# Connect raises (no plan-id node survives the merge).
df = self.spark.createDataFrame([(1, 2), (3, 4)], ["a", "b"])
left = df.select(df.a.alias("v"))
right = df.select(df.b.alias("v"))
zipped = left.zip(right)
self.assertEqual(zipped.columns, ["v", "v"])
self.assertEqual(sorted(r.v for r in zipped.select(left.v).collect()), [1, 3])
self.assertEqual(sorted(r.v for r in zipped.select(right.v).collect()), [2, 4])
def test_resolve_after_zip_shared_producer(self):
# Both sides project the same producer under the same name "v";
# ResolveZip dedups the producer but keeps one output column per side,
# each still selectable by its originating DataFrame on Classic.
df = self.spark.createDataFrame([(1, 2), (3, 4)], ["a", "b"])
left = df.select(df.a.alias("v"))
right = df.select(df.a.alias("v"))
zipped = left.zip(right)
self.assertEqual(zipped.columns, ["v", "v"])
self.assertEqual(sorted(r.v for r in zipped.select(left.v).collect()), [1, 3])
self.assertEqual(sorted(r.v for r in zipped.select(right.v).collect()), [1, 3])
def test_resolve_after_zip_in_expression(self):
# A tagged reference from each side can be combined in one expression.
df = self.spark.createDataFrame([(1, 10), (2, 20), (3, 30)], ["a", "b"])
left = df.select(df.a.alias("x"))
right = df.select(df.b.alias("y"))
zipped = left.zip(right)
self.assertEqual(
sorted(r.s for r in zipped.select((left.x + right.y).alias("s")).collect()),
[11, 22, 33],
)
def test_resolve_after_zip_in_filter(self):
# A tagged reference resolves in a filter over the zip result too.
df = self.spark.createDataFrame([(1, 10), (2, 20), (3, 30)], ["a", "b"])
left = df.select(df.a.alias("x"))
right = df.select(df.b.alias("y"))
zipped = left.zip(right)
result = zipped.filter(left.x >= 2).select(left.x, right.y)
self.assertEqual(
sorted((r.x, r.y) for r in result.collect()),
[(2, 20), (3, 30)],
)
def test_resolve_after_zip_chained(self):
# Each side is its own chain of projections off the shared base; the
# tagged reference still resolves on Classic.
df = self.spark.createDataFrame([(1, 2, 3), (4, 5, 6)], ["a", "b", "c"])
left0 = df.select("a", "b")
left = left0.select((left0.a + 1).alias("x"))
right0 = df.select("b", "c")
right = right0.select((right0.c * 2).alias("y"))
zipped = left.zip(right)
self.assertEqual(
sorted((r.x, r.y) for r in zipped.select(left.x, right.y).collect()),
[(2, 6), (5, 12)],
)
def test_resolve_after_zip_base_side(self):
# A base-side reference: df.zip(right).select(df.a). Classic resolves
# by attribute id like the projected-side cases. On Connect the result
# depends on the base plan's root node: createDataFrame analyzes to a
# Project over a LocalRelation, and that Project - the node carrying
# the DataFrame's plan-id tag - is dissolved into the merged chain by
# ResolveZip, so the tagged reference raises (overridden in the parity
# suite). See test_resolve_after_zip_bare_base_side for a base whose
# tagged root survives the merge.
df = self.spark.createDataFrame([(1, 10), (2, 20), (3, 30)], ["a", "b"])
right = df.select((df.b * 2).alias("y"))
zipped = df.zip(right)
self.assertEqual(zipped.columns, ["a", "b", "y"])
self.assertEqual(sorted(r.a for r in zipped.select(df.a).collect()), [1, 2, 3])
self.assertEqual(
sorted((r.b, r.a) for r in zipped.select(df.b, df.a).collect()),
[(10, 1), (20, 2), (30, 3)],
)
def test_resolve_after_zip_bare_base_side(self):
# When the base DataFrame's plan root is the base relation itself
# (range analyzes to a bare Range node), ResolveZip reuses that node
# unchanged as the merged plan's base, so its plan-id tag survives and
# the tagged reference resolves on Connect in all modes as well - the
# one zip shape with no Classic/Connect divergence for df.col.
df = self.spark.range(3)
right = df.select((df.id * 2).alias("y"))
zipped = df.zip(right)
self.assertEqual(zipped.columns, ["id", "y"])
self.assertEqual(sorted(r.id for r in zipped.select(df.id).collect()), [0, 1, 2])
def test_resolve_self_join_alias(self):
# Both self-join sides originate from the same plan-id-tagged
# ancestor, yielding two equal-depth candidates with the same
# attribute id. Disambiguation cannot tiebreak and all modes raise
# an ambiguous-reference error.
df = self.spark.sql("SELECT 1 AS c UNION ALL SELECT 2 AS c")
a, b = df.alias("a"), df.alias("b")
with self.assertRaises(AnalysisException):
a.join(b, a.c == b.c).select(df.c).collect()
def test_resolve_after_subquery_view(self):
# Persisting the DataFrame as a temp view and reading it back via
# table() produces a new plan; the tagged reference still resolves in
# all modes.
view = f"v_{uuid.uuid4().hex}"
df = self.spark.sql("SELECT 1 AS c")
df.createOrReplaceTempView(view)
try:
rows = self.spark.table(view).select(df.c).collect()
self.assertEqual([r.c for r in rows], [1])
finally:
self.spark.sql(f"DROP VIEW IF EXISTS {view}")
def test_resolve_cross_dataframe_illegal_reference(self):
# Referencing a column from a DataFrame whose plan id is not an
# ancestor of the target plan (`df1.select(df2.id)`) fails in all
# modes; the strict / lenient switch does not gate this throw.
df1 = self.spark.range(3)
df2 = self.spark.range(5)
with self.assertRaises(AnalysisException):
df1.select(df2.id).collect()
def test_resolve_df_star(self):
# `df["*"]` is an UnresolvedDataFrameStar carrying df's plan id; the
# analyzer expands it to the matched node's output in all modes.
df = self.spark.sql(
"SELECT 'Books' AS c, 100 AS v UNION ALL SELECT 'Electronics' AS c, 200 AS v"
)
rows = df.select(df["*"]).collect()
self.assertEqual(sorted((r.c, r.v) for r in rows), [("Books", 100), ("Electronics", 200)])
def test_resolve_self_join_withcolumnrenamed(self):
# Documented ColumnResolutionHelper case: df1 = range(10) + col `a`;
# df2 = df1 renamed `a` -> `b`; df1.join(df2, df1.a == df2.b). The
# node with df1's plan id is found on both Join sides; the right
# candidate is filtered out because its `a` is not in the renaming
# Project's output, so disambiguation succeeds in all modes.
df1 = self.spark.range(10).withColumn("a", sf.col("id"))
df2 = df1.withColumnRenamed("a", "b")
rows = df1.join(df2, df1.a == df2.b).select(df1.a, df2.b).collect()
self.assertEqual(len(rows), 10)
def test_resolve_sort_missing_attr_recovery(self):
# Documented ColumnResolutionHelper case: df.select(df.v).sort(df.id)
# where df.id is not in the select's output. The analyzer descends
# through the Project, resolves df.id via plan id at the source, and
# adds it back to the upstream projection. Works in all modes.
df = self.spark.range(10).withColumn("v", sf.col("id") + 1)
rows = df.select(df.v).sort(df.id).collect()
self.assertEqual(len(rows), 10)
class ColumnTests(ColumnTestsMixin, ReusedSQLTestCase):
pass
if __name__ == "__main__":
from pyspark.testing import main
main()