You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by do...@apache.org on 2026/09/11 00:05:04 UTC

(spark-connect-swift) branch main updated: [SPARK-59416] Support `col` and subscript column accessors in `DataFrame`

This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-swift.git


The following commit(s) were added to refs/heads/main by this push:
     new 830b64e  [SPARK-59416] Support `col` and subscript column accessors in `DataFrame`
830b64e is described below

commit 830b64e6f708e0906e8a6ab6b4207bb1e4db5ccb
Author: Dongjoon Hyun <do...@apache.org>
AuthorDate: Thu Sep 10 17:04:56 2026 -0700

    [SPARK-59416] Support `col` and subscript column accessors in `DataFrame`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to support `col` and subscript column accessors in `DataFrame`.
    
    ```swift
    public nonisolated func col(_ colName: String) -> Column
    public nonisolated subscript(_ colName: String) -> Column
    ```
    
    Unlike the global `col(_:)` function, the returned `Column` carries the plan ID of the `DataFrame` (assigned since SPARK-59414), so the server can tell apart the columns with the same name in multiple `DataFrame`s.
    
    ```swift
    let df = try await spark.range(3)
    let df1 = await df.filter("id > 0")
    let df2 = await df.select("id")
    let joined = await df1.join(df2, joinExprs: df1["id"] == df2["id"])
    try await joined.select(df1["id"], df2.col("id")).show()
    ```
    
    - `"*"` becomes `UnresolvedStar` with `plan_id`.
    - A name ending with `".*"`, e.g. `"s.*"`, becomes `UnresolvedStar` with `unparsed_target` only, like the global `col(_:)`. The server rejects a star with both a target and a plan ID (`CONNECT_INVALID_PLAN.UNRESOLVED_STAR_WITH_BOTH_TARGET_AND_PLAN_ID`).
    - Other names become `UnresolvedAttribute` with `plan_id`.
    
    The plan ID of the root relation is stored as an immutable property when a `DataFrame` is created, so both accessors are `nonisolated` and don't require `await`.
    
    ### Why are the changes needed?
    
    To resolve ambiguous column references like `Dataset.col` and `Dataset.apply` in Scala and `DataFrame.__getitem__` in PySpark. Spark Connect has supported this since Apache Spark [v3.4.0](https://github.com/apache/spark/releases/tag/v3.4.0) (2023-04-07) via SPARK-41812 and SPARK-41823. No version gating is needed for the supported Apache Spark 4.x servers.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No, this adds only new public APIs, `DataFrame.col` and `DataFrame.subscript`.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5
    
    Closes #548 from dongjoon-hyun/SPARK-59416.
    
    Authored-by: Dongjoon Hyun <do...@apache.org>
    Signed-off-by: Dongjoon Hyun <do...@apache.org>
---
 .../SparkConnect/DataFrame+Transformations.swift   | 48 ++++++++++++++++++++++
 Sources/SparkConnect/DataFrame.swift               |  4 ++
 .../SparkConnectTests/DataFrameInternalTests.swift | 18 ++++++++
 Tests/SparkConnectTests/DataFrameTests.swift       | 32 +++++++++++++++
 4 files changed, 102 insertions(+)

diff --git a/Sources/SparkConnect/DataFrame+Transformations.swift b/Sources/SparkConnect/DataFrame+Transformations.swift
index d240883..11114f2 100644
--- a/Sources/SparkConnect/DataFrame+Transformations.swift
+++ b/Sources/SparkConnect/DataFrame+Transformations.swift
@@ -47,6 +47,54 @@ extension DataFrame {
     return DataFrame(spark: self.spark, plan: SparkConnectClient.getProject(self.plan.root, exprs))
   }
 
+  /// Selects a column based on the column name and returns it as a ``Column`` bound to this
+  /// ``DataFrame``.
+  ///
+  /// Unlike the global ``SparkConnect/col(_:)`` function, the returned ``Column`` remembers which
+  /// ``DataFrame`` it comes from. This lets the server tell apart the columns with the same name
+  /// in multiple ``DataFrame``s, e.g. both sides of a self-join.
+  ///
+  /// ```swift
+  /// let df = try await spark.range(3)
+  /// let df1 = await df.filter("id > 0")
+  /// let df2 = await df.select("id")
+  /// // `col("id") == col("id")` fails because `id` is ambiguous.
+  /// let joined = await df1.join(df2, joinExprs: df1.col("id") == df2.col("id"))
+  /// try await joined.select(df1.col("id")).show()
+  /// ```
+  ///
+  /// `"*"` selects all columns of this ``DataFrame``. A name ending with `".*"`, e.g. `"s.*"`,
+  /// expands the fields of a struct column like ``SparkConnect/col(_:)``, but it is not bound to
+  /// this ``DataFrame`` because the server doesn't allow a star with both a target and a plan ID.
+  /// - Parameter colName: A column name.
+  /// - Returns: A ``Column`` expression.
+  public nonisolated func col(_ colName: String) -> Column {
+    var expr = Spark_Connect_Expression()
+    if colName == "*" {
+      var star = Spark_Connect_Expression.UnresolvedStar()
+      star.planID = planID
+      expr.unresolvedStar = star
+    } else if colName.hasSuffix(".*") {
+      return Column(colName)
+    } else {
+      var attribute = colName.toUnresolvedAttribute
+      attribute.planID = planID
+      expr.unresolvedAttribute = attribute
+    }
+    return Column(expr)
+  }
+
+  /// Selects a column based on the column name and returns it as a ``Column`` bound to this
+  /// ``DataFrame``. This is an alias of ``col(_:)``.
+  ///
+  /// ```swift
+  /// let joined = await df1.join(df2, joinExprs: df1["id"] == df2["id"])
+  /// ```
+  /// - Parameter colName: A column name.
+  public nonisolated subscript(_ colName: String) -> Column {
+    col(colName)
+  }
+
   /// Selects column based on the column name specified as a regex and returns it as ``Column``.
   ///
   /// Only a column name enclosed in backticks is interpreted as a regex. The regex follows the
diff --git a/Sources/SparkConnect/DataFrame.swift b/Sources/SparkConnect/DataFrame.swift
index e39e686..9e7db1d 100644
--- a/Sources/SparkConnect/DataFrame.swift
+++ b/Sources/SparkConnect/DataFrame.swift
@@ -205,6 +205,9 @@ private let planIDGenerator = Atomic<Int64>(0)
 public actor DataFrame: Sendable {
   var spark: SparkSession
   var plan: Plan
+  /// The plan ID of the root relation, which binds the ``Column``s from ``col(_:)`` to this
+  /// `DataFrame`.
+  let planID: Int64
   var _schema: StructType? = nil
   var batches: [RecordBatch] = [RecordBatch]()
 
@@ -215,6 +218,7 @@ public actor DataFrame: Sendable {
   init(spark: SparkSession, plan: Plan) {
     self.spark = spark
     self.plan = DataFrame.withPlanID(plan)
+    self.planID = self.plan.root.common.planID
   }
 
   /// Create a new `DataFrame` instance with the given SparkSession and a SQL statement.
diff --git a/Tests/SparkConnectTests/DataFrameInternalTests.swift b/Tests/SparkConnectTests/DataFrameInternalTests.swift
index df5d25a..b31400c 100644
--- a/Tests/SparkConnectTests/DataFrameInternalTests.swift
+++ b/Tests/SparkConnectTests/DataFrameInternalTests.swift
@@ -123,6 +123,24 @@ struct DataFrameInternalTests {
     await spark.stop()
   }
 
+  @Test
+  func colExpression() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let df = try await spark.range(1)
+    let planID = await df.plan.root.common.planID
+    let attribute = df["id"].expr.unresolvedAttribute
+    #expect(attribute.unparsedIdentifier == "id")
+    #expect(attribute.planID == planID)
+    #expect(df.col("id").expr == df["id"].expr)
+    let star = df["*"].expr.unresolvedStar
+    #expect(!star.hasUnparsedTarget)
+    #expect(star.planID == planID)
+    let structStar = df["s.*"].expr.unresolvedStar
+    #expect(structStar.unparsedTarget == "s.*")
+    #expect(!structStar.hasPlanID)
+    await spark.stop()
+  }
+
   @Test
   func removeCachedRemoteRelation() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/DataFrameTests.swift b/Tests/SparkConnectTests/DataFrameTests.swift
index 1a81bee..2daae54 100644
--- a/Tests/SparkConnectTests/DataFrameTests.swift
+++ b/Tests/SparkConnectTests/DataFrameTests.swift
@@ -421,6 +421,20 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func dataFrameCol() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let df = try await spark.sql("SELECT 1 a, struct(2 b, 3 c) s")
+    #expect(try await df.select(df["a"], df.col("s")).columns == ["a", "s"])
+    #expect(try await df.select(df["*"]).columns == ["a", "s"])
+    #expect(try await df.select(df["s.*"]).columns == ["b", "c"])
+    // A column of an unrelated `DataFrame` cannot be resolved.
+    try await #require(throws: Error.self) {
+      try await spark.range(1).select(df["a"]).collect()
+    }
+    await spark.stop()
+  }
+
   @Test
   func colRegexWithPrefix() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
@@ -939,6 +953,24 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func joinWithDataFrameColumn() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let df = try await spark.range(3)
+    let df1 = await df.filter("id > 0")
+    let df2 = await df.select("id")
+    // The global `col` function cannot tell which side `id` comes from.
+    try await #require(throws: Error.self) {
+      try await df1.join(df2, joinExprs: col("id") == col("id")).collect()
+    }
+    let joined = await df1.join(df2, joinExprs: df1["id"] == df2["id"])
+    #expect(
+      try await joined.select(df1["id"], df2.col("id")).orderBy(df1["id"]).collect()
+        == [Row(1, 1), Row(2, 2)])
+    #expect(try await joined.select(df2["*"]).columns == ["id"])
+    await spark.stop()
+  }
+
   @Test
   func alias() async throws {
     let spark = try await SparkSession.builder.getOrCreate()


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org