Skip to main content

spark_connect/
session.rs

1//! SparkSession implementation mirroring `pyspark.sql.SparkSession`.
2//!
3//! Provides the entry point for DataFrame operations and SQL queries.
4
5use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
6use std::sync::{Arc, Mutex};
7
8use spark_connect_core::channel::ChannelBuilder;
9use spark_connect_core::client::SparkConnectClient;
10use spark_connect_core::error::{Result, SparkError};
11use spark_connect_core::runtime::block_on;
12use spark_connect_proto as proto;
13
14use crate::dataframe::DataFrame;
15use crate::plan::LogicalPlan;
16use crate::profiler::ProfilerCollector;
17use crate::row::{Row, Value};
18use crate::types::DataType;
19
20/// A progress handler invoked with each `ExecutionProgress` message received on
21/// the response stream. Mirrors `SparkSession.registerProgressHandler`.
22pub type ProgressHandler =
23    Arc<dyn Fn(&proto::execute_plan_response::ExecutionProgress) + Send + Sync>;
24
25/// Metrics captured from the last executed query. Mirrors the data behind
26/// `pyspark.sql.DataFrame.executionInfo` / `client.core.ExecutionInfo`.
27#[derive(Debug, Clone, Default)]
28pub struct ExecutionInfo {
29    /// Plan metrics (typically present on the final response of an execution).
30    pub metrics: Option<proto::execute_plan_response::Metrics>,
31    /// Observed metrics collected during execution (also where `observe(...)`
32    /// and UDF-profiler results surface).
33    pub observed_metrics: Vec<proto::execute_plan_response::ObservedMetrics>,
34}
35
36/// A Spark Connect session for interacting with a remote Spark cluster.
37///
38/// Mirrors `pyspark.sql.SparkSession`.
39pub struct SparkSession {
40    /// Shared gRPC client
41    client: Arc<SparkConnectClient>,
42    /// Plan ID counter for unique IDs in nested plans
43    plan_id_counter: Arc<AtomicI64>,
44    /// Operation tags attached to every ExecutePlan request from this session.
45    /// Mirrors `pyspark.sql.connect.session.SparkSession`'s addTag/removeTag/getTags.
46    tags: Arc<Mutex<Vec<String>>>,
47    /// Metrics/observed-metrics captured from the most recent execution on this
48    /// session. Backs `DataFrame::execution_info` and the profiler accessor.
49    last_execution: Arc<Mutex<Option<ExecutionInfo>>>,
50    /// Registered progress handlers, keyed by id (for removal). Invoked on each
51    /// `ExecutionProgress` message. Mirrors `registerProgressHandler`.
52    progress_handlers: Arc<Mutex<Vec<(u64, ProgressHandler)>>>,
53    /// Monotonic id source for `register_progress_handler`.
54    progress_handler_id: Arc<AtomicU64>,
55    /// Profiler collector for accumulating profile results across executions.
56    profiler: Arc<ProfilerCollector>,
57    /// Whether `stop()` has been called on this session (shared across clones).
58    /// Backs `is_stopped`.
59    stopped: Arc<AtomicBool>,
60}
61
62impl SparkSession {
63    /// Create a new SparkSession with a client.
64    fn new(client: SparkConnectClient) -> Self {
65        SparkSession {
66            client: Arc::new(client),
67            plan_id_counter: Arc::new(AtomicI64::new(1)),
68            tags: Arc::new(Mutex::new(Vec::new())),
69            last_execution: Arc::new(Mutex::new(None)),
70            progress_handlers: Arc::new(Mutex::new(Vec::new())),
71            progress_handler_id: Arc::new(AtomicU64::new(0)),
72            profiler: Arc::new(ProfilerCollector::new()),
73            stopped: Arc::new(AtomicBool::new(false)),
74        }
75    }
76
77    /// Add a tag to be attached to all subsequent operations from this session.
78    ///
79    /// Mirrors `SparkSession.addTag`. Tags cannot be empty or contain a comma.
80    pub fn add_tag(&self, tag: &str) -> Result<()> {
81        if tag.is_empty() {
82            return Err(SparkError::value(
83                "INVALID_TAG",
84                &[("detail", "Tag cannot be empty")],
85            ));
86        }
87        if tag.contains(',') {
88            return Err(SparkError::value(
89                "INVALID_TAG",
90                &[("detail", "Tag cannot contain ','")],
91            ));
92        }
93        let mut tags = self.tags.lock().unwrap();
94        if !tags.iter().any(|t| t == tag) {
95            tags.push(tag.to_string());
96        }
97        Ok(())
98    }
99
100    /// Remove a previously added tag. Mirrors `SparkSession.removeTag`.
101    pub fn remove_tag(&self, tag: &str) {
102        self.tags.lock().unwrap().retain(|t| t != tag);
103    }
104
105    /// Get the tags currently set on this session. Mirrors `SparkSession.getTags`.
106    pub fn get_tags(&self) -> Vec<String> {
107        self.tags.lock().unwrap().clone()
108    }
109
110    /// Clear all tags set on this session. Mirrors `SparkSession.clearTags`.
111    pub fn clear_tags(&self) {
112        self.tags.lock().unwrap().clear();
113    }
114
115    /// Register a handler invoked for every `ExecutionProgress` message received
116    /// during query execution. Returns an id usable with
117    /// [`SparkSession::remove_progress_handler`]. Mirrors `registerProgressHandler`.
118    pub fn register_progress_handler(
119        &self,
120        handler: impl Fn(&proto::execute_plan_response::ExecutionProgress) + Send + Sync + 'static,
121    ) -> u64 {
122        let id = self.progress_handler_id.fetch_add(1, Ordering::SeqCst);
123        self.progress_handlers
124            .lock()
125            .unwrap()
126            .push((id, Arc::new(handler)));
127        id
128    }
129
130    /// Remove a progress handler by the id returned from
131    /// [`SparkSession::register_progress_handler`]. Mirrors `removeProgressHandler`.
132    pub fn remove_progress_handler(&self, id: u64) {
133        self.progress_handlers
134            .lock()
135            .unwrap()
136            .retain(|(hid, _)| *hid != id);
137    }
138
139    /// Remove all progress handlers. Mirrors `clearProgressHandlers`.
140    pub fn clear_progress_handlers(&self) {
141        self.progress_handlers.lock().unwrap().clear();
142    }
143
144    /// Invoke all registered progress handlers with a progress message. Called by
145    /// the execute loop; not part of the public API.
146    pub(crate) fn notify_progress(
147        &self,
148        progress: &proto::execute_plan_response::ExecutionProgress,
149    ) {
150        // Clone the handler `Arc`s out of the lock so a handler can't deadlock by
151        // touching the session's handler list.
152        let handlers: Vec<ProgressHandler> = {
153            self.progress_handlers
154                .lock()
155                .unwrap()
156                .iter()
157                .map(|(_, h)| Arc::clone(h))
158                .collect()
159        };
160        for h in handlers {
161            h(progress);
162        }
163    }
164
165    /// Store the metrics captured from the most recent execution. Called by the
166    /// execute loop; not part of the public API.
167    pub(crate) fn record_execution(&self, info: ExecutionInfo) {
168        *self.last_execution.lock().unwrap() = Some(info);
169    }
170
171    /// The metrics captured from the most recent execution on this session, if any.
172    /// Backs `DataFrame::execution_info`.
173    pub fn last_execution_info(&self) -> Option<ExecutionInfo> {
174        self.last_execution.lock().unwrap().clone()
175    }
176
177    /// Raw observed metrics from the most recent execution only.
178    ///
179    /// This is a low-level snapshot of the last execution's observed metrics. For
180    /// the profiler surface that mirrors `SparkSession.profile` (results accumulated
181    /// across executions, with show/dump/clear), use [`SparkSession::profiler`].
182    pub fn profile(&self) -> Vec<proto::execute_plan_response::ObservedMetrics> {
183        self.last_execution_info()
184            .map(|i| i.observed_metrics)
185            .unwrap_or_default()
186    }
187
188    /// Internal accessor used by the execute-request builders to attach tags.
189    pub(crate) fn tags(&self) -> Vec<String> {
190        self.tags.lock().unwrap().clone()
191    }
192
193    /// Start a brand-new session over the same connection. Mirrors
194    /// `SparkSession.newSession` — a fresh server-side session (new session id),
195    /// with its own tags and plan-id counter.
196    pub fn new_session(&self) -> SparkSession {
197        SparkSession {
198            client: Arc::new(self.client.with_new_session_id()),
199            plan_id_counter: Arc::new(AtomicI64::new(1)),
200            tags: Arc::new(Mutex::new(Vec::new())),
201            last_execution: Arc::new(Mutex::new(None)),
202            progress_handlers: Arc::new(Mutex::new(Vec::new())),
203            progress_handler_id: Arc::new(AtomicU64::new(0)),
204            profiler: Arc::new(ProfilerCollector::new()),
205            stopped: Arc::new(AtomicBool::new(false)),
206        }
207    }
208
209    /// Alias of [`SparkSession::new_session`]; mirrors the reference
210    /// `SparkSession.cloneSession`, which creates a new session on the same client.
211    pub fn clone_session(&self) -> SparkSession {
212        self.new_session()
213    }
214
215    /// Get the next plan ID (atomic post-increment).
216    pub(crate) fn next_plan_id(&self) -> i64 {
217        self.plan_id_counter.fetch_add(1, Ordering::SeqCst)
218    }
219
220    /// Get the underlying client.
221    pub(crate) fn client(&self) -> &Arc<SparkConnectClient> {
222        &self.client
223    }
224
225    /// Create a builder for a new SparkSession.
226    pub fn builder() -> SparkSessionBuilder {
227        SparkSessionBuilder::new()
228    }
229
230    /// Create a DataFrame representing a range of integers.
231    ///
232    /// Mirrors `pyspark.sql.SparkSession.range(start, end=None, step=1, numPartitions=None)`.
233    pub fn range(&self, end: i64) -> Result<DataFrame> {
234        self.range_full(0, end, 1, None)
235    }
236
237    /// Create a DataFrame representing a range with full parameters.
238    pub fn range_full(
239        &self,
240        start: i64,
241        end: i64,
242        step: i64,
243        num_partitions: Option<i32>,
244    ) -> Result<DataFrame> {
245        let plan = LogicalPlan::Range {
246            start,
247            end,
248            step,
249            num_partitions,
250        };
251        Ok(DataFrame::new(self.clone(), plan))
252    }
253
254    /// Execute a SQL query and return a DataFrame.
255    ///
256    /// Mirrors `pyspark.sql.SparkSession.sql(sqlQuery)`.
257    pub fn sql(&self, query: &str) -> Result<DataFrame> {
258        self.sql_with_args(query, Vec::new(), std::collections::HashMap::new())
259    }
260
261    /// SQL with parameters: positional (`pos_args`) and/or named (`named_args`) expression
262    /// bindings, mirroring `SparkSession.sql(query, args=...)`.
263    pub fn sql_with_args(
264        &self,
265        query: &str,
266        pos_args: Vec<crate::expression::Expression>,
267        named_args: std::collections::HashMap<String, crate::expression::Expression>,
268    ) -> Result<DataFrame> {
269        let plan = LogicalPlan::Sql {
270            query: query.to_string(),
271            pos_args,
272            named_args,
273        };
274        Ok(DataFrame::new(self.clone(), plan))
275    }
276
277    /// Create a DataFrame from a collection of Rows and a schema.
278    ///
279    /// Mirrors `pyspark.sql.SparkSession.createDataFrame(rows, schema)`.
280    pub fn create_dataframe(&self, rows: Vec<Row>, schema: DataType) -> Result<DataFrame> {
281        // Empty data -> send the schema only (no Arrow `data`); the server builds an empty
282        // relation from the schema. A zero-row Arrow payload would be empty bytes, which the
283        // server cannot parse ("Unexpected end of input. Missing schema.").
284        let data = if rows.is_empty() {
285            None
286        } else {
287            Some(rows_to_arrow_ipc(&rows, &schema)?)
288        };
289        let plan = LogicalPlan::LocalRelation { schema, data };
290        Ok(DataFrame::new(self.clone(), plan))
291    }
292
293    /// Create a DataFrameReader for reading data from various sources.
294    ///
295    /// Mirrors `pyspark.sql.SparkSession.read`.
296    pub fn read(&self) -> crate::readwriter::DataFrameReader {
297        crate::readwriter::DataFrameReader::new(self.clone())
298    }
299
300    /// Create a DataStreamReader for reading streaming data from various sources.
301    ///
302    /// Mirrors `pyspark.sql.SparkSession.readStream`.
303    pub fn read_stream(&self) -> crate::streaming::DataStreamReader {
304        crate::streaming::DataStreamReader::new(self.clone())
305    }
306
307    /// Get the streaming query manager.
308    ///
309    /// Mirrors `pyspark.sql.SparkSession.streams`.
310    pub fn streams(&self) -> crate::streaming::StreamingQueryManager {
311        crate::streaming::StreamingQueryManager::new(self.clone())
312    }
313
314    /// Get the catalog for this session.
315    ///
316    /// Mirrors `pyspark.sql.SparkSession.catalog`.
317    pub fn catalog(&self) -> crate::catalog::Catalog {
318        crate::catalog::Catalog::new(self.clone())
319    }
320
321    /// Register a Java UDF/UDAF by class name, mirroring the reference
322    /// `client.register_java(name, javaClassName, return_type, aggregate)` used by
323    /// `UDFRegistration.registerJavaFunction` / `registerJavaUDAF`: builds a
324    /// `CommonInlineUserDefinedFunction` carrying a `JavaUDF` and sends it as the
325    /// `RegisterFunction` command. `return_type_ddl` is only used for non-aggregate
326    /// functions (matching the reference, which omits the output type when aggregate).
327    pub fn register_java_function(
328        &self,
329        name: &str,
330        java_class_name: &str,
331        return_type_ddl: Option<&str>,
332        aggregate: bool,
333    ) -> Result<()> {
334        let mut java_udf = proto::JavaUdf::default();
335        java_udf.class_name = java_class_name.to_string();
336        if let Some(ddl) = return_type_ddl {
337            java_udf.output_type = Some(crate::types::DataType::from_ddl(ddl)?.to_proto());
338        } else {
339            java_udf.aggregate = aggregate;
340        }
341        let mut fun = proto::CommonInlineUserDefinedFunction::default();
342        fun.function_name = name.to_string();
343        fun.deterministic = true;
344        fun.function =
345            Some(proto::common_inline_user_defined_function::Function::JavaUdf(java_udf));
346        crate::dataframe::execute_command_collect(
347            self,
348            proto::command::CommandType::RegisterFunction(fun),
349        )?;
350        Ok(())
351    }
352
353    /// Get the runtime configuration for this session.
354    ///
355    /// Mirrors `pyspark.sql.SparkSession.conf`.
356    pub fn conf(&self) -> crate::conf::RuntimeConf {
357        crate::conf::RuntimeConf::new(Arc::clone(&self.client))
358    }
359
360    /// Get table-valued functions for this session.
361    ///
362    /// Mirrors `pyspark.sql.SparkSession.tvf`.
363    pub fn tvf(&self) -> crate::tvf::TableValuedFunction {
364        crate::tvf::TableValuedFunction::new(self.clone())
365    }
366
367    /// The session ID of this session.
368    ///
369    /// Mirrors `pyspark.sql.SparkSession.session_id` / the connect client session id.
370    pub fn session_id(&self) -> &str {
371        self.client.session_id()
372    }
373
374    /// Return the Spark version of the connected server.
375    ///
376    /// Mirrors `pyspark.sql.SparkSession.version`.
377    pub fn version(&self) -> Result<String> {
378        let mut request = proto::AnalyzePlanRequest::default();
379        request.session_id = self.client.session_id().to_string();
380        request.user_context = Some(proto::UserContext::default());
381        request.analyze = Some(proto::analyze_plan_request::Analyze::SparkVersion(
382            proto::analyze_plan_request::SparkVersion::default(),
383        ));
384        let resp = block_on(self.client.analyze_plan(request))?;
385        match resp.result {
386            Some(proto::analyze_plan_response::Result::SparkVersion(v)) => Ok(v.version),
387            _ => Err(SparkError::connect_msg(
388                "AnalyzePlan response did not contain a spark version",
389            )),
390        }
391    }
392
393    /// Return the DataFrame for the given table/view.
394    ///
395    /// Mirrors `pyspark.sql.SparkSession.table(tableName)`.
396    pub fn table(&self, table_name: &str) -> Result<DataFrame> {
397        Ok(self.read().table(table_name))
398    }
399
400    /// Return an empty DataFrame with no rows and an empty schema.
401    ///
402    /// Mirrors `pyspark.sql.SparkSession.createDataFrame([], StructType([]))` /
403    /// `SparkSession.emptyDataFrame`.
404    pub fn empty_data_frame(&self) -> Result<DataFrame> {
405        let plan = LogicalPlan::LocalRelation {
406            schema: DataType::Struct { fields: vec![] },
407            data: None,
408        };
409        Ok(DataFrame::new(self.clone(), plan))
410    }
411
412    /// Interrupt all operations of this session.
413    ///
414    /// Mirrors `pyspark.sql.SparkSession.interruptAll()`. Returns interrupted operation ids.
415    pub fn interrupt_all(&self) -> Result<Vec<String>> {
416        block_on(self.client.interrupt_all())
417    }
418
419    /// Interrupt all operations of this session with the given tag.
420    ///
421    /// Mirrors `pyspark.sql.SparkSession.interruptTag(tag)`.
422    pub fn interrupt_tag(&self, tag: &str) -> Result<Vec<String>> {
423        block_on(self.client.interrupt_tag(tag))
424    }
425
426    /// Interrupt the operation with the given operation id.
427    ///
428    /// Mirrors `pyspark.sql.SparkSession.interruptOperation(opId)`.
429    pub fn interrupt_operation(&self, operation_id: &str) -> Result<Vec<String>> {
430        block_on(self.client.interrupt_operation(operation_id))
431    }
432
433    /// Add local files as artifacts to the session (e.g. `.py`, `.jar`, `.zip`).
434    ///
435    /// Mirrors `pyspark.sql.SparkSession.addArtifacts(*path)`.
436    pub fn add_artifacts(&self, paths: &[&str]) -> Result<()> {
437        block_on(self.client.add_artifacts(paths, false, false, true))
438    }
439
440    /// Add a single local file as an artifact to the session.
441    ///
442    /// Mirrors `pyspark.sql.SparkSession.addArtifact(path)`.
443    pub fn add_artifact(&self, path: &str) -> Result<()> {
444        block_on(self.client.add_artifacts(&[path], false, false, true))
445    }
446
447    /// Copy a local file to the driver's filesystem at `dest_path`.
448    ///
449    /// Mirrors `pyspark.sql.SparkSession.copyFromLocalToFs`: uploads the file as a
450    /// `forward_to_fs/<dest_path>` artifact, which the server writes to `dest_path`.
451    pub fn copy_from_local_to_fs(&self, local_path: &str, dest_path: &str) -> Result<()> {
452        let name = format!("forward_to_fs/{}", dest_path);
453        block_on(self.client.add_named_artifact(&name, local_path))
454    }
455
456    /// Register a user-defined function on the session so it can be referenced by
457    /// name in SQL / expressions.
458    ///
459    /// Mirrors the server-side effect of `pyspark.sql.SparkSession.udf.register` /
460    /// `udtf.register`: the UDF is cloudpickled on the client (see
461    /// [`crate::udf`]) and sent as a `RegisterFunction` command.
462    pub fn register_function(
463        &self,
464        udf: crate::udf::CommonInlineUserDefinedFunctionExpression,
465    ) -> Result<()> {
466        crate::dataframe::execute_command(
467            self,
468            proto::command::CommandType::RegisterFunction(udf.to_proto()),
469        )
470    }
471
472    /// Build and register a ResourceProfile with the server.
473    ///
474    /// Sends a `CreateResourceProfileCommand` to the server with the specified executor
475    /// and task resource requests, and returns the server-assigned profile id.
476    /// The profile can then be used with `DataFrame.withResources(profile_id)`.
477    ///
478    /// Mirrors `pyspark.sql.SparkSession._build_resource_profile` (internal).
479    pub fn build_resource_profile(
480        &self,
481        profile: &crate::resource::ResourceProfile,
482    ) -> Result<i32> {
483        let mut cmd = proto::CreateResourceProfileCommand::default();
484        cmd.profile = Some(profile.proto().clone());
485
486        let responses = crate::dataframe::execute_command_collect(
487            self,
488            proto::command::CommandType::CreateResourceProfileCommand(cmd),
489        )?;
490
491        for resp in responses {
492            if let Some(
493                proto::execute_plan_response::ResponseType::CreateResourceProfileCommandResult(res),
494            ) = resp.response_type
495            {
496                return Ok(res.profile_id);
497            }
498        }
499
500        Err(SparkError::connect_msg(
501            "build_resource_profile: server returned no CreateResourceProfileCommandResult",
502        ))
503    }
504
505    /// Register a user-defined data source on the session so it can be referenced
506    /// in SQL queries.
507    ///
508    /// Mirrors the server-side effect of `pyspark.sql.SparkSession.dataSource.register`.
509    /// The data source is cloudpickled on the Python client and sent as a
510    /// `RegisterDataSource` command. Since Rust cannot cloudpickle Python classes, the
511    /// command bytes must be prepared on the client (typically by a Python wrapper).
512    pub fn register_data_source(
513        &self,
514        data_source: crate::datasource::CommonInlineUserDefinedDataSourceExpression,
515    ) -> Result<()> {
516        crate::dataframe::execute_command(
517            self,
518            proto::command::CommandType::RegisterDataSource(data_source.to_proto()),
519        )
520    }
521
522    /// Get the profiler collector for this session.
523    ///
524    /// Mirrors the client-visible surface of `SparkSession.profile`. Profile data is
525    /// accumulated across query executions and can be shown, dumped, or cleared via the
526    /// returned collector. Profile data is populated by the server only when UDF profiling
527    /// is enabled via `spark.python.profile*` or `spark.sql.pyspark.udf.profiler` configuration.
528    pub fn profiler(&self) -> Arc<ProfilerCollector> {
529        Arc::clone(&self.profiler)
530    }
531
532    /// Stop this Spark session.
533    pub fn stop(&self) -> Result<()> {
534        block_on(self.client.release_session())?;
535        self.stopped.store(true, Ordering::SeqCst);
536        Ok(())
537    }
538
539    /// Whether `stop()` has been called on this session. Mirrors
540    /// `pyspark.sql.connect.session.SparkSession.is_stopped`.
541    pub fn is_stopped(&self) -> bool {
542        self.stopped.load(Ordering::SeqCst)
543    }
544}
545
546impl Clone for SparkSession {
547    fn clone(&self) -> Self {
548        SparkSession {
549            client: Arc::clone(&self.client),
550            plan_id_counter: Arc::clone(&self.plan_id_counter),
551            tags: Arc::clone(&self.tags),
552            last_execution: Arc::clone(&self.last_execution),
553            progress_handlers: Arc::clone(&self.progress_handlers),
554            progress_handler_id: Arc::clone(&self.progress_handler_id),
555            profiler: Arc::clone(&self.profiler),
556            stopped: Arc::clone(&self.stopped),
557        }
558    }
559}
560
561/// Builder for creating a SparkSession.
562pub struct SparkSessionBuilder {
563    remote_url: Option<String>,
564}
565
566impl SparkSessionBuilder {
567    /// Create a new builder.
568    pub fn new() -> Self {
569        SparkSessionBuilder { remote_url: None }
570    }
571
572    /// Set the remote Spark Connect server URL.
573    pub fn remote(mut self, url: &str) -> Self {
574        self.remote_url = Some(url.to_string());
575        self
576    }
577
578    /// Build and return a SparkSession.
579    pub fn get_or_create(self) -> Result<SparkSession> {
580        let url = self.remote_url.ok_or_else(|| {
581            SparkError::value(
582                "NO_REMOTE_URL",
583                &[("detail", "Must call .remote(url) before .get_or_create()")],
584            )
585        })?;
586
587        let builder = ChannelBuilder::parse(&url)?;
588        let client = block_on(SparkConnectClient::connect(&builder))?;
589        Ok(SparkSession::new(client))
590    }
591}
592
593impl Default for SparkSessionBuilder {
594    fn default() -> Self {
595        Self::new()
596    }
597}
598
599/// Convert Arrow RecordBatch data to IPC bytes.
600/// Used by createDataFrame to package local data.
601fn rows_to_arrow_ipc(rows: &[Row], schema: &DataType) -> Result<Vec<u8>> {
602    use arrow::datatypes::Schema as ArrowSchema;
603    use arrow::ipc::writer::StreamWriter;
604    use arrow::record_batch::RecordBatch;
605
606    if rows.is_empty() {
607        return Ok(vec![]);
608    }
609
610    // Convert our schema to Arrow schema
611    let arrow_schema_vec = schema_to_arrow_fields(schema)?;
612    let arrow_schema = ArrowSchema::new(arrow_schema_vec);
613
614    // Build column arrays from rows
615    let mut columns = vec![];
616    let fields = match schema {
617        DataType::Struct { fields } => fields,
618        _ => return Err(SparkError::connect_msg("Schema is not a struct type")),
619    };
620
621    // Coerce each value to its declared field type so the built arrays match the
622    // Arrow schema (e.g. a Python int decodes as Long but "a int" wants Int32).
623    let coerced: Vec<Row> = rows
624        .iter()
625        .map(|r| {
626            let vals: Vec<Value> = fields
627                .iter()
628                .enumerate()
629                .map(|(i, f)| coerce_value(r.get(i), &f.data_type))
630                .collect();
631            Row::new(r.fields().to_vec(), vals)
632        })
633        .collect();
634
635    for field_idx in 0..fields.len() {
636        let array = build_arrow_array(&coerced, field_idx, Some(&fields[field_idx].data_type))?;
637        columns.push(array);
638    }
639
640    // Create RecordBatch
641    let batch = RecordBatch::try_new(Arc::new(arrow_schema), columns)
642        .map_err(|e| SparkError::connect_msg(format!("Failed to create Arrow batch: {}", e)))?;
643
644    // Write to IPC stream
645    let mut buffer = Vec::new();
646    {
647        let mut writer = StreamWriter::try_new(&mut buffer, &batch.schema())
648            .map_err(|e| SparkError::connect_msg(format!("Failed to create IPC writer: {}", e)))?;
649        writer
650            .write(&batch)
651            .map_err(|e| SparkError::connect_msg(format!("Failed to write batch to IPC: {}", e)))?;
652        writer
653            .finish()
654            .map_err(|e| SparkError::connect_msg(format!("Failed to finish IPC writer: {}", e)))?;
655    }
656
657    Ok(buffer)
658}
659
660/// Convert DataType fields to Arrow fields.
661fn schema_to_arrow_fields(schema: &DataType) -> Result<Vec<arrow::datatypes::Field>> {
662    use arrow::datatypes::Field;
663
664    match schema {
665        DataType::Struct { fields } => {
666            let mut arrow_fields = vec![];
667            for field in fields {
668                arrow_fields.push(Field::new(
669                    &field.name,
670                    datatype_to_arrow(&field.data_type)?,
671                    field.nullable,
672                ));
673            }
674            Ok(arrow_fields)
675        }
676        _ => Err(SparkError::connect_msg("Schema is not a struct")),
677    }
678}
679
680/// Map a Spark `DataType` to its Arrow `DataType`, recursively (array/map/struct included).
681fn datatype_to_arrow(dt: &DataType) -> Result<arrow::datatypes::DataType> {
682    use arrow::datatypes::{DataType as A, Field, Fields, TimeUnit};
683    Ok(match dt {
684        DataType::Null => A::Null,
685        DataType::Boolean => A::Boolean,
686        DataType::Byte => A::Int8,
687        DataType::Short => A::Int16,
688        DataType::Integer => A::Int32,
689        DataType::Long => A::Int64,
690        DataType::Float => A::Float32,
691        DataType::Double => A::Float64,
692        // CHAR/VARCHAR are string-backed on the wire.
693        DataType::String { .. } | DataType::Char { .. } | DataType::Varchar { .. } => A::Utf8,
694        DataType::Binary => A::Binary,
695        DataType::Date => A::Date32,
696        // TIMESTAMP (LTZ) and TIMESTAMP_NTZ are both micros; NTZ carries no zone.
697        DataType::Timestamp => A::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
698        DataType::TimestampNtz => A::Timestamp(TimeUnit::Microsecond, None),
699        DataType::Time { .. } => A::Time64(TimeUnit::Microsecond),
700        DataType::Decimal { precision, scale } => A::Decimal128(*precision as u8, *scale as i8),
701        DataType::Array {
702            element_type,
703            contains_null,
704        } => A::List(Arc::new(Field::new(
705            "element",
706            datatype_to_arrow(element_type)?,
707            *contains_null,
708        ))),
709        DataType::Map {
710            key_type,
711            value_type,
712            value_contains_null,
713        } => {
714            let entry_fields: Fields = vec![
715                Field::new("key", datatype_to_arrow(key_type)?, false),
716                Field::new(
717                    "value",
718                    datatype_to_arrow(value_type)?,
719                    *value_contains_null,
720                ),
721            ]
722            .into();
723            A::Map(
724                Arc::new(Field::new("entries", A::Struct(entry_fields), false)),
725                false,
726            )
727        }
728        DataType::Struct { fields } => {
729            let f: Fields = fields
730                .iter()
731                .map(|field| {
732                    Ok(Field::new(
733                        &field.name,
734                        datatype_to_arrow(&field.data_type)?,
735                        field.nullable,
736                    ))
737                })
738                .collect::<Result<Vec<_>>>()?
739                .into();
740            A::Struct(f)
741        }
742        other => {
743            return Err(SparkError::connect_msg(format!(
744                "Unsupported type for Arrow createDataFrame conversion: {other:?}"
745            )))
746        }
747    })
748}
749
750/// Coerce a value to a declared field type (numeric widening/narrowing from the
751/// Python-inferred type). Non-numeric or already-matching values pass through, so an
752/// explicit `createDataFrame` schema produces arrays that match the Arrow schema.
753fn coerce_value(v: Option<&Value>, target: &DataType) -> Value {
754    let v = match v {
755        Some(v) => v,
756        None => return Value::Null,
757    };
758    match (target, v) {
759        (_, Value::Null) => Value::Null,
760        (DataType::Byte, Value::Long(n)) => Value::Byte(*n as i8),
761        (DataType::Byte, Value::Integer(n)) => Value::Byte(*n as i8),
762        (DataType::Short, Value::Long(n)) => Value::Short(*n as i16),
763        (DataType::Short, Value::Integer(n)) => Value::Short(*n as i16),
764        (DataType::Integer, Value::Long(n)) => Value::Integer(*n as i32),
765        (DataType::Long, Value::Integer(n)) => Value::Long(*n as i64),
766        // Float target from any narrower numeric (a Python int decodes as Long/Integer
767        // but "a float" wants Float32); without these, an Int64Array is built against a
768        // Float32 field and RecordBatch::try_new rejects the mismatch.
769        (DataType::Float, Value::Double(f)) => Value::Float(*f as f32),
770        (DataType::Float, Value::Long(n)) => Value::Float(*n as f32),
771        (DataType::Float, Value::Integer(n)) => Value::Float(*n as f32),
772        (DataType::Double, Value::Long(n)) => Value::Double(*n as f64),
773        (DataType::Double, Value::Integer(n)) => Value::Double(*n as f64),
774        (DataType::Double, Value::Float(f)) => Value::Double(*f as f64),
775        // Nested types: coerce children to their declared element/field/value types so a
776        // nested `Integer` field built from a Python int (Long) matches the Arrow schema.
777        (DataType::Array { element_type, .. }, Value::List(items)) => Value::List(
778            items
779                .iter()
780                .map(|it| coerce_value(Some(it), element_type))
781                .collect(),
782        ),
783        (DataType::Struct { fields }, Value::Struct(sf)) => Value::Struct(
784            fields
785                .iter()
786                .enumerate()
787                .map(|(i, f)| {
788                    let val = sf
789                        .iter()
790                        .find(|(n, _)| n == &f.name)
791                        .map(|(_, v)| v)
792                        .or_else(|| sf.get(i).map(|(_, v)| v));
793                    (f.name.clone(), coerce_value(val, &f.data_type))
794                })
795                .collect(),
796        ),
797        (DataType::Map { value_type, .. }, Value::Map(m)) => Value::Map(
798            m.iter()
799                .map(|(k, v)| (k.clone(), coerce_value(Some(v), value_type)))
800                .collect(),
801        ),
802        _ => v.clone(),
803    }
804}
805
806/// Build an Arrow array for a specific field across all rows.
807/// Build the Arrow array for one column: gather the column's values and build them
808/// against the declared type (schema-driven, so nested array/map/struct recurse).
809fn build_arrow_array(
810    rows: &[Row],
811    field_idx: usize,
812    target: Option<&DataType>,
813) -> Result<Arc<dyn arrow::array::Array>> {
814    use arrow::array::NullArray;
815    if rows.is_empty() {
816        return Ok(Arc::new(NullArray::new(0)));
817    }
818    let column: Vec<Value> = rows
819        .iter()
820        .map(|r| r.get(field_idx).cloned().unwrap_or(Value::Null))
821        .collect();
822    match target {
823        Some(dt) => values_to_arrow(&column, dt),
824        // No declared type (shouldn't happen for createDataFrame): fall back to the
825        // value-driven scalar builder for backward compatibility.
826        None => build_arrow_array_scalar(rows, field_idx),
827    }
828}
829
830/// Recursively build an Arrow array from a column of `Value`s against a declared
831/// `DataType`, covering scalars and the nested array/map/struct types.
832fn values_to_arrow(values: &[Value], dt: &DataType) -> Result<Arc<dyn arrow::array::Array>> {
833    use arrow::array::*;
834    use arrow::buffer::{NullBuffer, OffsetBuffer};
835    use arrow::datatypes::{Field, Fields};
836
837    macro_rules! prim {
838        ($arr:ty, $pat:path) => {{
839            let vals: Result<Vec<_>> = values
840                .iter()
841                .map(|v| match v {
842                    Value::Null => Ok(None),
843                    $pat(x) => Ok(Some(*x)),
844                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
845                })
846                .collect();
847            Ok(Arc::new(<$arr>::from(vals?)) as Arc<dyn Array>)
848        }};
849    }
850
851    // Numeric encoders coerce across integer/float widths, so a value the schema-unaware
852    // Python->Value conversion produced as a wider/narrower numeric (e.g. a nested-struct
853    // int arriving as Long) still matches its declared field type.
854    macro_rules! int_prim {
855        ($arr:ty, $t:ty) => {{
856            let vals: Result<Vec<_>> = values
857                .iter()
858                .map(|v| match v {
859                    Value::Null => Ok(None),
860                    Value::Byte(x) => Ok(Some(*x as $t)),
861                    Value::Short(x) => Ok(Some(*x as $t)),
862                    Value::Integer(x) => Ok(Some(*x as $t)),
863                    Value::Long(x) => Ok(Some(*x as $t)),
864                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
865                })
866                .collect();
867            Ok(Arc::new(<$arr>::from(vals?)) as Arc<dyn Array>)
868        }};
869    }
870    macro_rules! float_prim {
871        ($arr:ty, $t:ty) => {{
872            let vals: Result<Vec<_>> = values
873                .iter()
874                .map(|v| match v {
875                    Value::Null => Ok(None),
876                    Value::Float(x) => Ok(Some(*x as $t)),
877                    Value::Double(x) => Ok(Some(*x as $t)),
878                    Value::Integer(x) => Ok(Some(*x as $t)),
879                    Value::Long(x) => Ok(Some(*x as $t)),
880                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
881                })
882                .collect();
883            Ok(Arc::new(<$arr>::from(vals?)) as Arc<dyn Array>)
884        }};
885    }
886
887    match dt {
888        DataType::Null => Ok(Arc::new(NullArray::new(values.len()))),
889        DataType::Boolean => prim!(BooleanArray, Value::Bool),
890        DataType::Byte => int_prim!(Int8Array, i8),
891        DataType::Short => int_prim!(Int16Array, i16),
892        DataType::Integer => int_prim!(Int32Array, i32),
893        DataType::Long => int_prim!(Int64Array, i64),
894        DataType::Float => float_prim!(Float32Array, f32),
895        DataType::Double => float_prim!(Float64Array, f64),
896        DataType::String { .. } | DataType::Char { .. } | DataType::Varchar { .. } => {
897            let vals: Result<Vec<Option<&str>>> = values
898                .iter()
899                .map(|v| match v {
900                    Value::Null => Ok(None),
901                    Value::String(s) => Ok(Some(s.as_str())),
902                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
903                })
904                .collect();
905            Ok(Arc::new(StringArray::from(vals?)))
906        }
907        DataType::Binary => {
908            let vals: Result<Vec<Option<&[u8]>>> = values
909                .iter()
910                .map(|v| match v {
911                    Value::Null => Ok(None),
912                    Value::Binary(b) => Ok(Some(b.as_slice())),
913                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
914                })
915                .collect();
916            Ok(Arc::new(BinaryArray::from(vals?)))
917        }
918        DataType::Date => prim!(Date32Array, Value::Date),
919        DataType::Timestamp | DataType::TimestampNtz => {
920            let vals: Result<Vec<Option<i64>>> = values
921                .iter()
922                .map(|v| match v {
923                    Value::Null => Ok(None),
924                    Value::Timestamp(t) => Ok(Some(*t)),
925                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
926                })
927                .collect();
928            let arr = TimestampMicrosecondArray::from(vals?);
929            let arr = if matches!(dt, DataType::TimestampNtz) {
930                arr
931            } else {
932                arr.with_timezone("UTC")
933            };
934            Ok(Arc::new(arr))
935        }
936        DataType::Decimal { precision, scale } => {
937            let col_scale = *scale;
938            let vals: Result<Vec<Option<i128>>> = values
939                .iter()
940                .map(|v| match v {
941                    Value::Null => Ok(None),
942                    Value::Decimal { value, .. } => {
943                        decimal_str_to_unscaled(value, col_scale).map(Some)
944                    }
945                    _ => Err(SparkError::connect_msg("Type mismatch in row data")),
946                })
947                .collect();
948            // Use the DECLARED precision/scale so the array matches the Arrow schema field
949            // (Decimal128(precision, scale)); hardcoding 38 mismatched a DecimalType(10, 2).
950            let arr = Decimal128Array::from(vals?)
951                .with_precision_and_scale(*precision as u8, col_scale as i8)
952                .map_err(|e| SparkError::connect_msg(format!("decimal build: {e}")))?;
953            Ok(Arc::new(arr))
954        }
955        DataType::Array {
956            element_type,
957            contains_null,
958        } => {
959            let mut child: Vec<Value> = Vec::new();
960            let mut offsets: Vec<i32> = vec![0];
961            let mut valid = arrow::array::builder::BooleanBufferBuilder::new(values.len());
962            for v in values {
963                match v {
964                    Value::Null => {
965                        valid.append(false);
966                        offsets.push(*offsets.last().unwrap());
967                    }
968                    Value::List(items) => {
969                        valid.append(true);
970                        child.extend(items.iter().cloned());
971                        offsets.push(child.len() as i32);
972                    }
973                    _ => return Err(SparkError::connect_msg("Type mismatch: expected array")),
974                }
975            }
976            let child_arr = values_to_arrow(&child, element_type)?;
977            let field = Arc::new(Field::new(
978                "element",
979                datatype_to_arrow(element_type)?,
980                *contains_null,
981            ));
982            Ok(Arc::new(ListArray::new(
983                field,
984                OffsetBuffer::new(offsets.into()),
985                child_arr,
986                Some(NullBuffer::new(valid.finish())),
987            )))
988        }
989        DataType::Struct { fields } => {
990            let mut cols: Vec<Vec<Value>> = vec![Vec::with_capacity(values.len()); fields.len()];
991            let mut valid = arrow::array::builder::BooleanBufferBuilder::new(values.len());
992            for v in values {
993                match v {
994                    Value::Null => {
995                        valid.append(false);
996                        for c in cols.iter_mut() {
997                            c.push(Value::Null);
998                        }
999                    }
1000                    Value::Struct(sf) => {
1001                        valid.append(true);
1002                        for (i, f) in fields.iter().enumerate() {
1003                            // Prefer match by field name, else positional.
1004                            let val = sf
1005                                .iter()
1006                                .find(|(n, _)| n == &f.name)
1007                                .map(|(_, val)| val.clone())
1008                                .or_else(|| sf.get(i).map(|(_, val)| val.clone()))
1009                                .unwrap_or(Value::Null);
1010                            cols[i].push(val);
1011                        }
1012                    }
1013                    // A Python dict supplied for a struct field (e.g. array<struct> data)
1014                    // arrives as a Map; match its entries to the struct fields by name.
1015                    Value::Map(m) => {
1016                        valid.append(true);
1017                        for (i, f) in fields.iter().enumerate() {
1018                            cols[i].push(m.get(&f.name).cloned().unwrap_or(Value::Null));
1019                        }
1020                    }
1021                    _ => return Err(SparkError::connect_msg("Type mismatch: expected struct")),
1022                }
1023            }
1024            let arrays: Vec<Arc<dyn Array>> = fields
1025                .iter()
1026                .enumerate()
1027                .map(|(i, f)| values_to_arrow(&cols[i], &f.data_type))
1028                .collect::<Result<_>>()?;
1029            let afields: Fields = fields
1030                .iter()
1031                .map(|f| {
1032                    Ok(Field::new(
1033                        &f.name,
1034                        datatype_to_arrow(&f.data_type)?,
1035                        f.nullable,
1036                    ))
1037                })
1038                .collect::<Result<Vec<_>>>()?
1039                .into();
1040            Ok(Arc::new(StructArray::new(
1041                afields,
1042                arrays,
1043                Some(NullBuffer::new(valid.finish())),
1044            )))
1045        }
1046        DataType::Map {
1047            key_type,
1048            value_type,
1049            value_contains_null,
1050        } => {
1051            let mut keys: Vec<Value> = Vec::new();
1052            let mut vals: Vec<Value> = Vec::new();
1053            let mut offsets: Vec<i32> = vec![0];
1054            let mut valid = arrow::array::builder::BooleanBufferBuilder::new(values.len());
1055            for v in values {
1056                match v {
1057                    Value::Null => {
1058                        valid.append(false);
1059                        offsets.push(*offsets.last().unwrap());
1060                    }
1061                    Value::Map(m) => {
1062                        valid.append(true);
1063                        for (k, val) in m {
1064                            keys.push(Value::String(k.clone()));
1065                            vals.push(val.clone());
1066                        }
1067                        offsets.push(keys.len() as i32);
1068                    }
1069                    _ => return Err(SparkError::connect_msg("Type mismatch: expected map")),
1070                }
1071            }
1072            let key_arr = values_to_arrow(&keys, key_type)?;
1073            let val_arr = values_to_arrow(&vals, value_type)?;
1074            let entry_fields: Fields = vec![
1075                Field::new("key", datatype_to_arrow(key_type)?, false),
1076                Field::new(
1077                    "value",
1078                    datatype_to_arrow(value_type)?,
1079                    *value_contains_null,
1080                ),
1081            ]
1082            .into();
1083            let entries = StructArray::new(entry_fields.clone(), vec![key_arr, val_arr], None);
1084            let entries_field = Arc::new(Field::new(
1085                "entries",
1086                arrow::datatypes::DataType::Struct(entry_fields),
1087                false,
1088            ));
1089            Ok(Arc::new(MapArray::new(
1090                entries_field,
1091                OffsetBuffer::new(offsets.into()),
1092                entries,
1093                Some(NullBuffer::new(valid.finish())),
1094                false,
1095            )))
1096        }
1097        other => Err(SparkError::connect_msg(format!(
1098            "Unsupported type for Arrow createDataFrame conversion: {other:?}"
1099        ))),
1100    }
1101}
1102
1103/// Legacy value-driven scalar array builder (used only when no declared type is available).
1104fn build_arrow_array_scalar(
1105    rows: &[Row],
1106    field_idx: usize,
1107) -> Result<Arc<dyn arrow::array::Array>> {
1108    use arrow::array::*;
1109
1110    if rows.is_empty() {
1111        return Ok(Arc::new(NullArray::new(0)));
1112    }
1113
1114    // Peek at the first row to determine the type
1115    let first_val = rows[0]
1116        .get(field_idx)
1117        .ok_or_else(|| SparkError::connect_msg("Invalid row index"))?;
1118
1119    match first_val {
1120        Value::Null => Ok(Arc::new(NullArray::new(rows.len()))),
1121        Value::Bool(_) => {
1122            let values: Result<Vec<Option<bool>>> = rows
1123                .iter()
1124                .map(|r| match r.get(field_idx) {
1125                    None | Some(Value::Null) => Ok(None),
1126                    Some(Value::Bool(b)) => Ok(Some(*b)),
1127                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1128                })
1129                .collect();
1130            Ok(Arc::new(BooleanArray::from(values?)))
1131        }
1132        Value::Byte(_) => {
1133            let values: Result<Vec<_>> = rows
1134                .iter()
1135                .map(|r| match r.get(field_idx) {
1136                    None | Some(Value::Null) => Ok(None),
1137                    Some(Value::Byte(b)) => Ok(Some(*b)),
1138                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1139                })
1140                .collect();
1141            Ok(Arc::new(Int8Array::from(values?)))
1142        }
1143        Value::Short(_) => {
1144            let values: Result<Vec<_>> = rows
1145                .iter()
1146                .map(|r| match r.get(field_idx) {
1147                    None | Some(Value::Null) => Ok(None),
1148                    Some(Value::Short(s)) => Ok(Some(*s)),
1149                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1150                })
1151                .collect();
1152            Ok(Arc::new(Int16Array::from(values?)))
1153        }
1154        Value::Integer(_) => {
1155            let values: Result<Vec<_>> = rows
1156                .iter()
1157                .map(|r| match r.get(field_idx) {
1158                    None | Some(Value::Null) => Ok(None),
1159                    Some(Value::Integer(i)) => Ok(Some(*i)),
1160                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1161                })
1162                .collect();
1163            Ok(Arc::new(Int32Array::from(values?)))
1164        }
1165        Value::Long(_) => {
1166            let values: Result<Vec<_>> = rows
1167                .iter()
1168                .map(|r| match r.get(field_idx) {
1169                    None | Some(Value::Null) => Ok(None),
1170                    Some(Value::Long(l)) => Ok(Some(*l)),
1171                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1172                })
1173                .collect();
1174            Ok(Arc::new(Int64Array::from(values?)))
1175        }
1176        Value::Float(_) => {
1177            let values: Result<Vec<_>> = rows
1178                .iter()
1179                .map(|r| match r.get(field_idx) {
1180                    None | Some(Value::Null) => Ok(None),
1181                    Some(Value::Float(f)) => Ok(Some(*f)),
1182                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1183                })
1184                .collect();
1185            Ok(Arc::new(Float32Array::from(values?)))
1186        }
1187        Value::Double(_) => {
1188            let values: Result<Vec<_>> = rows
1189                .iter()
1190                .map(|r| match r.get(field_idx) {
1191                    None | Some(Value::Null) => Ok(None),
1192                    Some(Value::Double(d)) => Ok(Some(*d)),
1193                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1194                })
1195                .collect();
1196            Ok(Arc::new(Float64Array::from(values?)))
1197        }
1198        Value::String(_) => {
1199            let values: Result<Vec<_>> = rows
1200                .iter()
1201                .map(|r| match r.get(field_idx) {
1202                    None | Some(Value::Null) => Ok(None),
1203                    Some(Value::String(s)) => Ok(Some(s.as_str())),
1204                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1205                })
1206                .collect();
1207            Ok(Arc::new(StringArray::from(values?)))
1208        }
1209        Value::Binary(_) => {
1210            let values: Result<Vec<_>> = rows
1211                .iter()
1212                .map(|r| match r.get(field_idx) {
1213                    None | Some(Value::Null) => Ok(None),
1214                    Some(Value::Binary(b)) => Ok(Some(b.as_slice())),
1215                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1216                })
1217                .collect();
1218            Ok(Arc::new(BinaryArray::from(values?)))
1219        }
1220        Value::Date(_) => {
1221            let values: Result<Vec<Option<i32>>> = rows
1222                .iter()
1223                .map(|r| match r.get(field_idx) {
1224                    None | Some(Value::Null) => Ok(None),
1225                    Some(Value::Date(d)) => Ok(Some(*d)),
1226                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1227                })
1228                .collect();
1229            Ok(Arc::new(Date32Array::from(values?)))
1230        }
1231        Value::Timestamp(_) => {
1232            let values: Result<Vec<Option<i64>>> = rows
1233                .iter()
1234                .map(|r| match r.get(field_idx) {
1235                    None | Some(Value::Null) => Ok(None),
1236                    Some(Value::Timestamp(t)) => Ok(Some(*t)),
1237                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1238                })
1239                .collect();
1240            // Scalar fallback (no declared type): default to UTC (LTZ).
1241            let arr = TimestampMicrosecondArray::from(values?).with_timezone("UTC");
1242            Ok(Arc::new(arr))
1243        }
1244        Value::Decimal { scale, .. } => {
1245            // Column-wide precision/scale come from the first value (Spark decimals in a
1246            // column share one precision/scale). Each value's string is parsed to an
1247            // unscaled i128 at that scale.
1248            let col_scale = scale.unwrap_or(0);
1249            let values: Result<Vec<Option<i128>>> = rows
1250                .iter()
1251                .map(|r| match r.get(field_idx) {
1252                    None | Some(Value::Null) => Ok(None),
1253                    Some(Value::Decimal { value, .. }) => {
1254                        decimal_str_to_unscaled(value, col_scale).map(Some)
1255                    }
1256                    Some(_) => Err(SparkError::connect_msg("Type mismatch in row data")),
1257                })
1258                .collect();
1259            let arr = Decimal128Array::from(values?)
1260                .with_precision_and_scale(38, col_scale as i8)
1261                .map_err(|e| SparkError::connect_msg(format!("decimal build: {e}")))?;
1262            Ok(Arc::new(arr))
1263        }
1264        _ => Err(SparkError::connect_msg("Unsupported value type")),
1265    }
1266}
1267
1268/// Parse a decimal string (e.g. "-1.50") into an unscaled `i128` at the given scale
1269/// (e.g. scale 2 → -150). Pads/truncates the fractional part to `scale` digits.
1270fn decimal_str_to_unscaled(s: &str, scale: i32) -> Result<i128> {
1271    let s = s.trim();
1272    let (neg, s) = match s.strip_prefix('-') {
1273        Some(rest) => (true, rest),
1274        None => (false, s.strip_prefix('+').unwrap_or(s)),
1275    };
1276    let (int_part, frac_part) = match s.split_once('.') {
1277        Some((i, f)) => (i, f),
1278        None => (s, ""),
1279    };
1280    let scale = scale.max(0) as usize;
1281    let mut digits = String::with_capacity(int_part.len() + scale);
1282    digits.push_str(int_part);
1283    // Pad or truncate the fractional digits to exactly `scale`.
1284    let frac: String = frac_part
1285        .chars()
1286        .chain(std::iter::repeat('0'))
1287        .take(scale)
1288        .collect();
1289    digits.push_str(&frac);
1290    let digits = digits.trim_start_matches('0');
1291    let mag: i128 = if digits.is_empty() {
1292        0
1293    } else {
1294        digits
1295            .parse::<i128>()
1296            .map_err(|e| SparkError::connect_msg(format!("invalid decimal '{s}': {e}")))?
1297    };
1298    Ok(if neg { -mag } else { mag })
1299}
1300
1301#[cfg(test)]
1302mod tests {
1303    use super::*;
1304
1305    fn session() -> SparkSession {
1306        SparkSession::builder()
1307            .remote("sc://localhost:15002")
1308            .get_or_create()
1309            .expect("failed to build session")
1310    }
1311
1312    #[test]
1313    fn session_is_not_stopped_initially() {
1314        let spark = session();
1315        assert!(!spark.is_stopped());
1316    }
1317
1318    #[test]
1319    fn session_id_is_set() {
1320        let spark = session();
1321        let session_id = spark.session_id();
1322        assert!(!session_id.is_empty());
1323    }
1324
1325    #[test]
1326    fn session_builder_without_remote_fails() {
1327        let result = SparkSessionBuilder::new().get_or_create();
1328        assert!(result.is_err());
1329    }
1330
1331    #[test]
1332    fn session_tags_add_and_remove() {
1333        let spark = session();
1334        spark.add_tag("test_tag").unwrap();
1335        let tags = spark.get_tags();
1336        assert!(tags.contains(&"test_tag".to_string()));
1337
1338        spark.remove_tag("test_tag");
1339        let tags = spark.get_tags();
1340        assert!(!tags.contains(&"test_tag".to_string()));
1341    }
1342
1343    #[test]
1344    fn session_tags_cannot_be_empty() {
1345        let spark = session();
1346        let result = spark.add_tag("");
1347        assert!(result.is_err());
1348    }
1349
1350    #[test]
1351    fn session_tags_cannot_contain_comma() {
1352        let spark = session();
1353        let result = spark.add_tag("tag,with,comma");
1354        assert!(result.is_err());
1355    }
1356
1357    #[test]
1358    fn session_tags_clear() {
1359        let spark = session();
1360        spark.add_tag("tag1").unwrap();
1361        spark.add_tag("tag2").unwrap();
1362        spark.clear_tags();
1363        assert!(spark.get_tags().is_empty());
1364    }
1365
1366    #[test]
1367    fn session_tags_no_duplicates() {
1368        let spark = session();
1369        spark.add_tag("tag").unwrap();
1370        spark.add_tag("tag").unwrap();
1371        let tags = spark.get_tags();
1372        assert_eq!(tags.len(), 1);
1373    }
1374
1375    #[test]
1376    fn session_clone_shares_state() {
1377        let spark1 = session();
1378        spark1.add_tag("test").unwrap();
1379        let spark2 = spark1.clone();
1380        // Both should have the same tag
1381        let tags = spark2.get_tags();
1382        assert!(tags.contains(&"test".to_string()));
1383    }
1384}