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 3a3a0f3  [SPARK-58648] Support StructType in schema, createDataFrame, 
and to APIs
3a3a0f3 is described below

commit 3a3a0f39bc577bfb84e353dd2d02cbcbfaa17fb2
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Fri Aug 7 12:07:33 2026 -0700

    [SPARK-58648] Support StructType in schema, createDataFrame, and to APIs
    
    ### What changes were proposed in this pull request?
    
    This PR adds `StructType` overloads to the four schema-taking APIs which 
previously accepted only DDL strings.
    
    - `DataFrameReader.schema(_ schema: StructType)`
    - `DataStreamReader.schema(_ schema: StructType)`
    - `SparkSession.createDataFrame(_ data: [[Sendable?]], _ schema: 
StructType)`
    - `DataFrame.to(_ schema: StructType)`
    
    Following the Scala client implementation, the reader and `createDataFrame` 
overloads convert the schema via the newly added public 
`StructType.toDDL`/`StructField.toDDL` (Catalyst `toDDL` format with 
backtick-quoting and `NOT NULL`) and reuse the existing DDL-string paths, while 
`DataFrame.to` converts the model directly to the protobuf `DataType` (like 
`DataTypeProtoConverter.toConnectProtoType`) and requires no server round-trip.
    
    ```swift
    let schema = StructType(fields: [
      StructField(name: "id", dataType: .integer, nullable: false),
      StructField(name: "name", dataType: .string),
    ])
    let df = try await spark.createDataFrame([[1, "Alice"], [2, nil]], schema)
    ```
    
    ### Why are the changes needed?
    
    For feature parity with the Scala/PySpark clients where all schema-taking 
APIs accept a `StructType` object, so users can build and pass schemas 
programmatically instead of assembling DDL strings by hand.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This adds new APIs (`StructType` overloads and `toDDL`) without 
changing existing behavior.
    
    ### 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 Fable 5
    
    Closes #488 from dongjoon-hyun/SPARK-58648.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../SparkConnect/DataFrame+Transformations.swift   |   9 ++
 Sources/SparkConnect/DataFrameReader.swift         |  10 ++
 Sources/SparkConnect/DataStreamReader.swift        |  10 ++
 Sources/SparkConnect/DataType.swift                | 126 +++++++++++++++++++++
 Sources/SparkConnect/SparkSession.swift            |  11 ++
 Sources/SparkConnect/StructType.swift              |  54 +++++++++
 Sources/SparkConnect/TypeAliases.swift             |   1 +
 Tests/SparkConnectTests/CreateDataFrameTests.swift |  19 ++++
 Tests/SparkConnectTests/DataFrameReaderTests.swift |  13 +++
 Tests/SparkConnectTests/DataFrameTests.swift       |  25 ++++
 Tests/SparkConnectTests/DataStreamTests.swift      |  14 +++
 Tests/SparkConnectTests/StructTypeTests.swift      |  25 ++++
 12 files changed, 317 insertions(+)

diff --git a/Sources/SparkConnect/DataFrame+Transformations.swift 
b/Sources/SparkConnect/DataFrame+Transformations.swift
index ccb83c9..492f9b5 100644
--- a/Sources/SparkConnect/DataFrame+Transformations.swift
+++ b/Sources/SparkConnect/DataFrame+Transformations.swift
@@ -69,6 +69,15 @@ extension DataFrame {
       spark: self.spark, plan: SparkConnectClient.getToSchema(self.plan.root, 
dataType))
   }
 
+  /// Returns a new DataFrame where each row is reconciled to match the 
specified schema.
+  /// - Parameter schema: The given ``StructType`` schema.
+  /// - Returns: A ``DataFrame`` with the given schema.
+  public func to(_ schema: StructType) -> DataFrame {
+    return DataFrame(
+      spark: self.spark,
+      plan: SparkConnectClient.getToSchema(self.plan.root, 
DataType.struct(schema).toProtoDataType))
+  }
+
   /// Returns the content of the Dataset as a Dataset of JSON strings.
   /// - Returns: A ``DataFrame`` with a single string column whose content is 
JSON.
   public func toJSON() -> DataFrame {
diff --git a/Sources/SparkConnect/DataFrameReader.swift 
b/Sources/SparkConnect/DataFrameReader.swift
index 28d07ac..245ae2e 100644
--- a/Sources/SparkConnect/DataFrameReader.swift
+++ b/Sources/SparkConnect/DataFrameReader.swift
@@ -132,6 +132,16 @@ public actor DataFrameReader: Sendable {
     return self
   }
 
+  /// Specifies the input schema. Some data sources (e.g. JSON) can infer the 
input schema
+  /// automatically from data. By specifying the schema here, the underlying 
data source can skip
+  /// the schema inference step, and thus speed up data loading.
+  /// - Parameter schema: A ``StructType`` schema.
+  /// - Returns: A ``DataFrameReader``.
+  @discardableResult
+  public func schema(_ schema: StructType) async throws -> DataFrameReader {
+    return try await self.schema(schema.toDDL)
+  }
+
   /// Loads input in as a ``DataFrame``, for data sources that don't require a 
path (e.g. external
   /// key-value stores).
   /// - Returns: A ``DataFrame``.
diff --git a/Sources/SparkConnect/DataStreamReader.swift 
b/Sources/SparkConnect/DataStreamReader.swift
index e1be7dd..931ce99 100644
--- a/Sources/SparkConnect/DataStreamReader.swift
+++ b/Sources/SparkConnect/DataStreamReader.swift
@@ -59,6 +59,16 @@ public actor DataStreamReader: Sendable {
     return self
   }
 
+  /// Specifies the input schema. Some data sources (e.g. JSON) can infer the 
input schema
+  /// automatically from data. By specifying the schema here, the underlying 
data source can skip
+  /// the schema inference step, and thus speed up data loading.
+  /// - Parameter schema: A ``StructType`` schema.
+  /// - Returns: A ``DataStreamReader``.
+  @discardableResult
+  public func schema(_ schema: StructType) async throws -> DataStreamReader {
+    return try await self.schema(schema.toDDL)
+  }
+
   /// Adds an input option for the underlying data source.
   /// - Parameters:
   ///   - key: A key string.
diff --git a/Sources/SparkConnect/DataType.swift 
b/Sources/SparkConnect/DataType.swift
index 7fe7f54..5611204 100644
--- a/Sources/SparkConnect/DataType.swift
+++ b/Sources/SparkConnect/DataType.swift
@@ -181,6 +181,132 @@ public indirect enum DataType: Sendable, Equatable {
 }
 
 extension DataType {
+  /// A type string usable in DDL like Spark SQL's `DataType.sql`, e.g. `INT` 
or
+  /// `STRUCT<x: INT NOT NULL>`. Unlike ``simpleString``, struct field names 
are quoted
+  /// if needed and their `NOT NULL` constraints are kept.
+  var sql: String {
+    switch self {
+    case .array(let elementType, _):
+      "ARRAY<\(elementType.sql)>"
+    case .map(let keyType, let valueType, _):
+      "MAP<\(keyType.sql), \(valueType.sql)>"
+    case .struct(let structType):
+      structType.sql
+    case .udt(let udt):
+      udt.sqlType?.sql ?? "UDT"
+    case .unparsed(let dataTypeString):
+      dataTypeString
+    default:
+      simpleString.uppercased()
+    }
+  }
+
+  /// Convert to the `Spark Connect` protobuf representation.
+  var toProtoDataType: ProtoDataType {
+    var proto = ProtoDataType()
+    switch self {
+    case .null:
+      proto.null = ProtoDataType.NULL()
+    case .binary:
+      proto.binary = ProtoDataType.Binary()
+    case .boolean:
+      proto.boolean = ProtoDataType.Boolean()
+    case .byte:
+      proto.byte = ProtoDataType.Byte()
+    case .short:
+      proto.short = ProtoDataType.Short()
+    case .integer:
+      proto.integer = ProtoDataType.Integer()
+    case .long:
+      proto.long = ProtoDataType.Long()
+    case .float:
+      proto.float = ProtoDataType.FloatMessage()
+    case .double:
+      proto.double = ProtoDataType.DoubleMessage()
+    case .decimal(let precision, let scale):
+      var decimal = ProtoDataType.Decimal()
+      decimal.precision = precision
+      decimal.scale = scale
+      proto.decimal = decimal
+    case .string:
+      proto.string = ProtoDataType.StringMessage()
+    case .char(let length):
+      var char = ProtoDataType.Char()
+      char.length = length
+      proto.char = char
+    case .varchar(let length):
+      var varChar = ProtoDataType.VarChar()
+      varChar.length = length
+      proto.varChar = varChar
+    case .date:
+      proto.date = ProtoDataType.Date()
+    case .timestamp:
+      proto.timestamp = ProtoDataType.Timestamp()
+    case .timestampNtz:
+      proto.timestampNtz = ProtoDataType.TimestampNTZ()
+    case .time(let precision):
+      var time = ProtoDataType.Time()
+      time.precision = precision
+      proto.time = time
+    case .calendarInterval:
+      proto.calendarInterval = ProtoDataType.CalendarInterval()
+    case .yearMonthInterval(let startField, let endField):
+      var interval = ProtoDataType.YearMonthInterval()
+      interval.startField = startField.rawValue
+      interval.endField = endField.rawValue
+      proto.yearMonthInterval = interval
+    case .dayTimeInterval(let startField, let endField):
+      var interval = ProtoDataType.DayTimeInterval()
+      interval.startField = startField.rawValue
+      interval.endField = endField.rawValue
+      proto.dayTimeInterval = interval
+    case .array(let elementType, let containsNull):
+      var array = ProtoDataType.Array()
+      array.elementType = elementType.toProtoDataType
+      array.containsNull = containsNull
+      proto.array = array
+    case .map(let keyType, let valueType, let valueContainsNull):
+      var map = ProtoDataType.Map()
+      map.keyType = keyType.toProtoDataType
+      map.valueType = valueType.toProtoDataType
+      map.valueContainsNull = valueContainsNull
+      proto.map = map
+    case .struct(let structType):
+      proto.struct = structType.toProtoStructType
+    case .variant:
+      proto.variant = ProtoDataType.Variant()
+    case .geometry(let srid):
+      var geometry = ProtoDataType.Geometry()
+      geometry.srid = srid
+      proto.geometry = geometry
+    case .geography(let srid):
+      var geography = ProtoDataType.Geography()
+      geography.srid = srid
+      proto.geography = geography
+    case .udt(let udt):
+      var udtProto = ProtoDataType.UDT()
+      udtProto.type = "udt"
+      if let jvmClass = udt.jvmClass {
+        udtProto.jvmClass = jvmClass
+      }
+      if let pythonClass = udt.pythonClass {
+        udtProto.pythonClass = pythonClass
+      }
+      if let serializedPythonClass = udt.serializedPythonClass {
+        udtProto.serializedPythonClass = serializedPythonClass
+      }
+      if let sqlType = udt.sqlType {
+        udtProto.sqlType = sqlType.toProtoDataType
+      }
+      proto.udt = udtProto
+    case .unparsed(let dataTypeString):
+      var unparsed = ProtoDataType.Unparsed()
+      unparsed.dataTypeString = dataTypeString
+      proto.unparsed = unparsed
+    }
+    return proto
+  }
+
   /// Create a public ``DataType`` from the `Spark Connect` protobuf 
representation.
   init(_ proto: ProtoDataType) throws {
     switch proto.kind {
diff --git a/Sources/SparkConnect/SparkSession.swift 
b/Sources/SparkConnect/SparkSession.swift
index 907dd91..27e1f38 100644
--- a/Sources/SparkConnect/SparkSession.swift
+++ b/Sources/SparkConnect/SparkSession.swift
@@ -175,6 +175,17 @@ public actor SparkSession {
     return await DataFrame(spark: self, plan: 
client.getLocalRelation(ipcStream, schema))
   }
 
+  /// Creates a ``DataFrame`` from the given local data with the specified 
``StructType`` schema.
+  /// This is equivalent to calling `createDataFrame(_:_:)` with the DDL 
string of the given schema.
+  /// - Parameters:
+  ///   - data: An array of rows whose values are ordered like the schema 
fields.
+  ///   - schema: A ``StructType`` schema.
+  /// - Returns: A ``DataFrame`` instance.
+  public func createDataFrame(_ data: [[Sendable?]], _ schema: StructType) 
async throws -> DataFrame
+  {
+    return try await createDataFrame(data, schema.toDDL)
+  }
+
   /// Create a ``DataFrame`` with a single `Int64` column name `id`, 
containing elements in a
   /// range from 0 to `end` (exclusive) with step value 1.
   ///
diff --git a/Sources/SparkConnect/StructType.swift 
b/Sources/SparkConnect/StructType.swift
index 3896230..5550290 100644
--- a/Sources/SparkConnect/StructType.swift
+++ b/Sources/SparkConnect/StructType.swift
@@ -30,6 +30,32 @@ public struct StructField: Sendable, Equatable {
     self.nullable = nullable
     self.metadata = metadata
   }
+
+  /// A string containing this field in DDL format like Spark SQL's 
`StructField.toDDL`,
+  /// e.g. `` `eventId` INT NOT NULL ``.
+  public var toDDL: String {
+    "\(quoteIfNeeded(name)) \(dataType.sql)\(nullable ? "" : " NOT NULL")"
+  }
+
+  /// A string usable inside a struct type string like Spark SQL's 
`StructField.sql`,
+  /// e.g. `` `eventId`: INT NOT NULL ``.
+  var sql: String {
+    "\(quoteIfNeeded(name)): \(dataType.sql)\(nullable ? "" : " NOT NULL")"
+  }
+}
+
+/// Quote the given name with backticks like Spark SQL's 
`QuotingUtils.quoteIfNeeded`
+/// unless it is a valid identifier matching `[a-zA-Z_][a-zA-Z0-9_]*`.
+private func quoteIfNeeded(_ name: String) -> String {
+  let isValidIdentifier =
+    !name.isEmpty
+    && name.utf8.enumerated().allSatisfy { (index, byte) in
+      byte == UInt8(ascii: "_")
+        || (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(byte)
+        || (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(byte)
+        || (index > 0 && (UInt8(ascii: "0")...UInt8(ascii: 
"9")).contains(byte))
+    }
+  return isValidIdentifier ? name : "`\(name.replacing("`", with: "``"))`"
 }
 
 /// A struct type holding an ordered collection of ``StructField``s, mirroring 
Spark SQL's
@@ -57,6 +83,18 @@ public struct StructType: Sendable, Equatable {
   public var simpleString: String {
     "struct<\(fields.map { "\($0.name):\($0.dataType.simpleString)" 
}.joined(separator: ","))>"
   }
+
+  /// A string containing this schema in DDL format like Spark SQL's 
`StructType.toDDL`,
+  /// e.g. `id BIGINT NOT NULL,name STRING`.
+  public var toDDL: String {
+    fields.map { $0.toDDL }.joined(separator: ",")
+  }
+
+  /// A type string usable in DDL like Spark SQL's `StructType.sql`,
+  /// e.g. `STRUCT<id: BIGINT NOT NULL, name: STRING>`.
+  var sql: String {
+    "STRUCT<\(fields.map { $0.sql }.joined(separator: ", "))>"
+  }
 }
 
 extension StructType: RandomAccessCollection {
@@ -69,6 +107,22 @@ extension StructType: RandomAccessCollection {
 }
 
 extension StructType {
+  /// Convert to the `Spark Connect` protobuf representation.
+  var toProtoStructType: ProtoStructType {
+    var proto = ProtoStructType()
+    proto.fields = fields.map { field in
+      var protoField = ProtoStructField()
+      protoField.name = field.name
+      protoField.dataType = field.dataType.toProtoDataType
+      protoField.nullable = field.nullable
+      if let metadata = field.metadata {
+        protoField.metadata = metadata
+      }
+      return protoField
+    }
+    return proto
+  }
+
   /// Create a public ``StructType`` from the `Spark Connect` protobuf 
representation.
   init(_ proto: ProtoStructType) throws {
     self.fields = try proto.fields.map {
diff --git a/Sources/SparkConnect/TypeAliases.swift 
b/Sources/SparkConnect/TypeAliases.swift
index 43c8a00..788e03e 100644
--- a/Sources/SparkConnect/TypeAliases.swift
+++ b/Sources/SparkConnect/TypeAliases.swift
@@ -52,6 +52,7 @@ typealias Parse = Spark_Connect_Parse
 typealias Plan = Spark_Connect_Plan
 typealias Project = Spark_Connect_Project
 typealias ProtoDataType = Spark_Connect_DataType
+typealias ProtoStructField = Spark_Connect_DataType.StructField
 typealias ProtoStructType = Spark_Connect_DataType.Struct
 typealias Range = Spark_Connect_Range
 typealias Read = Spark_Connect_Read
diff --git a/Tests/SparkConnectTests/CreateDataFrameTests.swift 
b/Tests/SparkConnectTests/CreateDataFrameTests.swift
index 11947b8..4858713 100644
--- a/Tests/SparkConnectTests/CreateDataFrameTests.swift
+++ b/Tests/SparkConnectTests/CreateDataFrameTests.swift
@@ -40,6 +40,25 @@ struct CreateDataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func createDataFrameWithStructType() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let schema = StructType(fields: [
+      StructField(name: "id", dataType: .integer, nullable: false),
+      StructField(name: "name", dataType: .string),
+    ])
+    let df = try await spark.createDataFrame([[1, "Alice"], [2, nil]], schema)
+    #expect(try await df.schema == schema)
+    #expect(try await df.collect() == [Row(1, "Alice"), Row(2, nil)])
+
+    // Same result as the DDL string version.
+    let df2 = try await spark.createDataFrame(
+      [[1, "Alice"], [2, nil]], "id INT NOT NULL, name STRING")
+    #expect(try await df2.schema == schema)
+    #expect(try await df2.collect() == [Row(1, "Alice"), Row(2, nil)])
+    await spark.stop()
+  }
+
   @Test
   func supportedTypes() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/DataFrameReaderTests.swift 
b/Tests/SparkConnectTests/DataFrameReaderTests.swift
index a4ce325..71210c6 100644
--- a/Tests/SparkConnectTests/DataFrameReaderTests.swift
+++ b/Tests/SparkConnectTests/DataFrameReaderTests.swift
@@ -151,6 +151,19 @@ struct DataFrameReaderTests {
     await spark.stop()
   }
 
+  @Test
+  func schemaWithStructType() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let path = "../examples/src/main/resources/people.json"
+    let structType = StructType(fields: [
+      StructField(name: "age", dataType: .short),
+      StructField(name: "name", dataType: .string),
+    ])
+    let expected = try await spark.read.schema("age SHORT, name 
STRING").json(path).schema
+    #expect(try await spark.read.schema(structType).json(path).schema == 
expected)
+    await spark.stop()
+  }
+
   @Test
   func invalidSchema() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/DataFrameTests.swift 
b/Tests/SparkConnectTests/DataFrameTests.swift
index b67adbc..98ef0ee 100644
--- a/Tests/SparkConnectTests/DataFrameTests.swift
+++ b/Tests/SparkConnectTests/DataFrameTests.swift
@@ -316,6 +316,31 @@ struct DataFrameTests {
     await spark.stop()
   }
 
+  @Test
+  func toWithStructType() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let df = try await spark.sql(
+      "SELECT 1 AS id, struct('a' AS x) AS s, array(1, 2) AS a, map('k', 1.0) 
AS m")
+    let structType = StructType(fields: [
+      StructField(name: "id", dataType: .short, nullable: false),
+      StructField(
+        name: "s", dataType: .struct(StructType(fields: [StructField(name: 
"x", dataType: .string)]))),
+      StructField(name: "a", dataType: .array(elementType: .long, 
containsNull: true)),
+      StructField(
+        name: "m",
+        dataType: .map(
+          keyType: .string, valueType: .decimal(precision: 10, scale: 2), 
valueContainsNull: true)),
+    ])
+    let ddl = "id SMALLINT NOT NULL,s STRUCT<x: STRING>,a ARRAY<BIGINT>,m 
MAP<STRING, DECIMAL(10,2)>"
+    #expect(structType.toDDL == ddl)
+
+    let schema1 = try await df.to(ddl).schema
+    let schema2 = try await df.to(structType).schema
+    #expect(schema1 == schema2)
+
+    await spark.stop()
+  }
+
   @Test
   func selectMultipleColumns() async throws {
     let spark = try await SparkSession.builder.getOrCreate()
diff --git a/Tests/SparkConnectTests/DataStreamTests.swift 
b/Tests/SparkConnectTests/DataStreamTests.swift
index 038dfdf..4ec98b2 100644
--- a/Tests/SparkConnectTests/DataStreamTests.swift
+++ b/Tests/SparkConnectTests/DataStreamTests.swift
@@ -70,4 +70,18 @@ struct DataStreamTests {
     #expect(try await df3.count() == 2025)
     await spark.stop()
   }
+
+  @Test
+  func schemaWithStructType() async throws {
+    let spark = try await SparkSession.builder.getOrCreate()
+    let input = "/tmp/input-" + UUID().uuidString
+    try await spark.range(1).write.orc(input)
+
+    let structType = StructType(fields: [StructField(name: "id", dataType: 
.long)])
+    let df1 = try await spark.readStream.schema("id LONG").orc(input)
+    let df2 = try await spark.readStream.schema(structType).orc(input)
+    #expect(try await df2.isStreaming())
+    #expect(try await df1.schema == df2.schema)
+    await spark.stop()
+  }
 }
diff --git a/Tests/SparkConnectTests/StructTypeTests.swift 
b/Tests/SparkConnectTests/StructTypeTests.swift
index db20b4d..befb419 100644
--- a/Tests/SparkConnectTests/StructTypeTests.swift
+++ b/Tests/SparkConnectTests/StructTypeTests.swift
@@ -56,6 +56,31 @@ struct StructTypeTests {
     #expect(count == 3)
   }
 
+  @Test
+  func toDDL() {
+    let nested = StructType(fields: [
+      StructField(name: "x", dataType: .integer, nullable: false),
+      StructField(name: "y z", dataType: .string),
+    ])
+    let structType = StructType(fields: [
+      StructField(name: "id", dataType: .long, nullable: false),
+      StructField(name: "1st", dataType: .decimal(precision: 10, scale: 2)),
+      StructField(name: "t", dataType: .time(precision: 6)),
+      StructField(name: "s", dataType: .struct(nested)),
+      StructField(name: "arr", dataType: .array(elementType: .struct(nested), 
containsNull: true)),
+      StructField(
+        name: "m", dataType: .map(keyType: .string, valueType: .date, 
valueContainsNull: true)),
+    ])
+    #expect(
+      structType.toDDL
+        == "id BIGINT NOT NULL,`1st` DECIMAL(10,2),t TIME(6),"
+        + "s STRUCT<x: INT NOT NULL, `y z`: STRING>,"
+        + "arr ARRAY<STRUCT<x: INT NOT NULL, `y z`: STRING>>,m MAP<STRING, 
DATE>")
+    #expect(StructType().toDDL == "")
+    #expect(StructField(name: "a`b", dataType: .string).toDDL == "`a``b` 
STRING")
+    #expect(StructField(name: "", dataType: .string).toDDL == "`` STRING")
+  }
+
   @Test
   func equatable() {
     let a = StructType(fields: [StructField(name: "id", dataType: .long)])


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to