Skip to main content

spark_connect/
dataframe.rs

1//! DataFrame implementation mirroring `pyspark.sql.DataFrame`.
2//!
3//! Provides transformations and actions for working with distributed data.
4
5use std::collections::HashMap;
6
7use spark_connect_core::client::ReattachableResponseStream;
8use spark_connect_core::error::{Result, SparkError};
9use spark_connect_core::runtime::{block_on, get_runtime};
10use spark_connect_proto as proto;
11
12use crate::column::Column;
13use crate::expression::Expression;
14use crate::plan::{AggregateGroupType, JoinType, LogicalPlan, SetOpType};
15use crate::row::{Row, Value};
16use crate::session::{ExecutionInfo, SparkSession};
17use crate::types::DataType;
18use crate::udf::CommonInlineUserDefinedFunctionExpression;
19
20/// A Spark DataFrame, lazily evaluated.
21///
22/// Mirrors `pyspark.sql.DataFrame`.
23#[derive(Clone)]
24pub struct DataFrame {
25    pub(crate) session: SparkSession,
26    pub(crate) plan: LogicalPlan,
27}
28
29/// Iterator over rows from a DataFrame, yielded lazily as the server streams results.
30///
31/// Returned by `DataFrame::to_local_iterator()`, this iterator consumes the ExecutePlan
32/// response stream incrementally and yields Row objects without buffering the entire result.
33pub struct LocalRowIterator {
34    /// Rows decoded from the current Arrow batch, handed out one at a time.
35    current_rows: std::vec::IntoIter<Row>,
36    /// Where subsequent batches come from: pulled on demand, or fed by a
37    /// background prefetch task.
38    source: RowSource,
39    /// Set once the batch source is exhausted or has errored.
40    done: bool,
41}
42
43/// The source of successive Arrow batches for a [`LocalRowIterator`].
44enum RowSource {
45    /// Each batch is pulled from the response stream only when the previous
46    /// one is exhausted (`prefetchPartitions=False`).
47    OnDemand {
48        session: SparkSession,
49        stream: ReattachableResponseStream,
50        execution_info: ExecutionInfo,
51        execution_recorded: bool,
52    },
53    /// Batches are fetched by a background task that keeps one batch buffered
54    /// ahead, so the next server fetch overlaps with the caller consuming the
55    /// current batch's rows (`prefetchPartitions=True`).
56    Prefetch {
57        rx: tokio::sync::mpsc::Receiver<Result<Vec<Row>>>,
58    },
59}
60
61impl LocalRowIterator {
62    /// Create a new `LocalRowIterator` over an already-issued ExecutePlan stream.
63    pub(crate) fn new(
64        session: SparkSession,
65        stream: ReattachableResponseStream,
66        prefetch_partitions: bool,
67    ) -> Self {
68        let source = if prefetch_partitions {
69            RowSource::Prefetch {
70                rx: spawn_prefetch(session, stream),
71            }
72        } else {
73            RowSource::OnDemand {
74                session,
75                stream,
76                execution_info: ExecutionInfo::default(),
77                execution_recorded: false,
78            }
79        };
80        LocalRowIterator {
81            current_rows: vec![].into_iter(),
82            source,
83            done: false,
84        }
85    }
86
87    /// Fetch the next batch of rows, or `None` once the source is exhausted.
88    ///
89    /// A batch may legitimately decode to zero rows (e.g. a metrics-only
90    /// response was skipped); the caller loops until it gets a row or `None`.
91    fn fetch_next_batch(&mut self) -> Option<Result<Vec<Row>>> {
92        match &mut self.source {
93            RowSource::OnDemand {
94                session,
95                stream,
96                execution_info,
97                execution_recorded,
98            } => loop {
99                match block_on(stream.message()) {
100                    Ok(Some(mut resp)) => {
101                        capture_execution(&mut resp, execution_info, session);
102                        if let Some(proto::execute_plan_response::ResponseType::ArrowBatch(batch)) =
103                            resp.response_type
104                        {
105                            return Some(decode_arrow_batch(&batch));
106                        }
107                        // Metrics/progress response: keep pulling for a batch.
108                    }
109                    Ok(None) => {
110                        if !*execution_recorded {
111                            session.record_execution(execution_info.clone());
112                            *execution_recorded = true;
113                        }
114                        return None;
115                    }
116                    Err(e) => {
117                        if !*execution_recorded {
118                            session.record_execution(execution_info.clone());
119                            *execution_recorded = true;
120                        }
121                        return Some(Err(e));
122                    }
123                }
124            },
125            RowSource::Prefetch { rx } => block_on(rx.recv()),
126        }
127    }
128}
129
130impl Iterator for LocalRowIterator {
131    type Item = Result<Row>;
132
133    fn next(&mut self) -> Option<Self::Item> {
134        loop {
135            if let Some(row) = self.current_rows.next() {
136                return Some(Ok(row));
137            }
138            if self.done {
139                return None;
140            }
141            match self.fetch_next_batch() {
142                Some(Ok(rows)) => self.current_rows = rows.into_iter(),
143                Some(Err(e)) => {
144                    self.done = true;
145                    return Some(Err(e));
146                }
147                None => {
148                    self.done = true;
149                    return None;
150                }
151            }
152        }
153    }
154}
155
156/// Spawn a background task that drains the response stream and forwards decoded
157/// batches over a bounded (capacity-1) channel, keeping one batch buffered ahead
158/// of the consumer. Execution metrics are recorded on the session once the
159/// stream ends. Backs `to_local_iterator(prefetch_partitions = true)`.
160fn spawn_prefetch(
161    session: SparkSession,
162    mut stream: ReattachableResponseStream,
163) -> tokio::sync::mpsc::Receiver<Result<Vec<Row>>> {
164    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Vec<Row>>>(1);
165    get_runtime().spawn(async move {
166        let mut execution_info = ExecutionInfo::default();
167        loop {
168            match stream.message().await {
169                Ok(Some(mut resp)) => {
170                    capture_execution(&mut resp, &mut execution_info, &session);
171                    if let Some(proto::execute_plan_response::ResponseType::ArrowBatch(batch)) =
172                        resp.response_type
173                    {
174                        match decode_arrow_batch(&batch) {
175                            Ok(rows) => {
176                                // A send error means the consumer dropped the
177                                // iterator; stop fetching.
178                                if tx.send(Ok(rows)).await.is_err() {
179                                    break;
180                                }
181                            }
182                            Err(e) => {
183                                let _ = tx.send(Err(e)).await;
184                                break;
185                            }
186                        }
187                    }
188                }
189                Ok(None) => break,
190                Err(e) => {
191                    let _ = tx.send(Err(e)).await;
192                    break;
193                }
194            }
195        }
196        session.record_execution(execution_info);
197    });
198    rx
199}
200
201impl DataFrame {
202    /// Create a new DataFrame.
203    pub(crate) fn new(session: SparkSession, plan: LogicalPlan) -> Self {
204        DataFrame { session, plan }
205    }
206
207    /// Get the underlying logical plan.
208    pub(crate) fn plan(&self) -> &LogicalPlan {
209        &self.plan
210    }
211
212    /// Select specific columns.
213    pub fn select<C: Into<Column>>(&self, columns: impl IntoIterator<Item = C>) -> DataFrame {
214        let columns: Vec<Column> = columns.into_iter().map(Into::into).collect();
215        let plan = LogicalPlan::Project {
216            input: Box::new(self.plan.clone()),
217            columns,
218        };
219        DataFrame::new(self.session.clone(), plan)
220    }
221
222    /// Filter rows by a condition.
223    pub fn filter(&self, condition: Column) -> DataFrame {
224        let plan = LogicalPlan::Filter {
225            input: Box::new(self.plan.clone()),
226            condition,
227        };
228        DataFrame::new(self.session.clone(), plan)
229    }
230
231    /// Alias for filter().
232    pub fn where_(&self, condition: Column) -> DataFrame {
233        self.filter(condition)
234    }
235
236    /// Add or replace a column.
237    pub fn with_column(&self, name: &str, col: Column) -> DataFrame {
238        let plan = LogicalPlan::WithColumns {
239            input: Box::new(self.plan.clone()),
240            column_names: vec![name.to_string()],
241            columns: vec![col],
242        };
243        DataFrame::new(self.session.clone(), plan)
244    }
245
246    /// Add or replace multiple columns.
247    pub fn with_columns(&self, columns: Vec<(String, Column)>) -> DataFrame {
248        let (names, cols) = columns.into_iter().unzip();
249        let plan = LogicalPlan::WithColumns {
250            input: Box::new(self.plan.clone()),
251            column_names: names,
252            columns: cols,
253        };
254        DataFrame::new(self.session.clone(), plan)
255    }
256
257    /// Rename a column.
258    pub fn with_column_renamed(&self, existing: &str, new: &str) -> DataFrame {
259        let mut renames = HashMap::new();
260        renames.insert(existing.to_string(), new.to_string());
261        let plan = LogicalPlan::WithColumnsRenamed {
262            input: Box::new(self.plan.clone()),
263            renames,
264        };
265        DataFrame::new(self.session.clone(), plan)
266    }
267
268    /// Rename multiple columns.
269    pub fn with_columns_renamed(&self, renames: Vec<(String, String)>) -> DataFrame {
270        let mut rename_map = HashMap::new();
271        for (old, new) in renames {
272            rename_map.insert(old, new);
273        }
274        let plan = LogicalPlan::WithColumnsRenamed {
275            input: Box::new(self.plan.clone()),
276            renames: rename_map,
277        };
278        DataFrame::new(self.session.clone(), plan)
279    }
280
281    /// Drop columns.
282    pub fn drop(&self, columns: Vec<&str>) -> DataFrame {
283        let col_names = columns.iter().map(|s| s.to_string()).collect();
284        let plan = LogicalPlan::Drop {
285            input: Box::new(self.plan.clone()),
286            columns: col_names,
287        };
288        DataFrame::new(self.session.clone(), plan)
289    }
290
291    /// Limit the number of rows.
292    pub fn limit(&self, n: i32) -> DataFrame {
293        let plan = LogicalPlan::Limit {
294            input: Box::new(self.plan.clone()),
295            limit: n,
296        };
297        DataFrame::new(self.session.clone(), plan)
298    }
299
300    /// Skip the first n rows.
301    pub fn offset(&self, n: i32) -> DataFrame {
302        let plan = LogicalPlan::Offset {
303            input: Box::new(self.plan.clone()),
304            offset: n,
305        };
306        DataFrame::new(self.session.clone(), plan)
307    }
308
309    /// Get the last n rows.
310    pub fn tail(&self, n: i32) -> DataFrame {
311        let plan = LogicalPlan::Tail {
312            input: Box::new(self.plan.clone()),
313            limit: n,
314        };
315        DataFrame::new(self.session.clone(), plan)
316    }
317
318    /// Remove duplicate rows.
319    pub fn distinct(&self) -> DataFrame {
320        let plan = LogicalPlan::Deduplicate {
321            input: Box::new(self.plan.clone()),
322            all_columns_as_keys: true,
323            column_names: vec![],
324            within_watermark: false,
325        };
326        DataFrame::new(self.session.clone(), plan)
327    }
328
329    /// Remove duplicate rows, optionally on specific columns.
330    pub fn drop_duplicates(&self, column_names: Option<Vec<&str>>) -> DataFrame {
331        let all_cols = column_names.is_none();
332        let cols = column_names
333            .map(|c| c.iter().map(|s| s.to_string()).collect())
334            .unwrap_or_default();
335
336        let plan = LogicalPlan::Deduplicate {
337            input: Box::new(self.plan.clone()),
338            all_columns_as_keys: all_cols,
339            column_names: cols,
340            within_watermark: false,
341        };
342        DataFrame::new(self.session.clone(), plan)
343    }
344
345    /// Sort rows.
346    pub fn sort(&self, columns: Vec<Expression>) -> DataFrame {
347        let plan = LogicalPlan::Sort {
348            input: Box::new(self.plan.clone()),
349            order: columns,
350            is_global: true,
351        };
352        DataFrame::new(self.session.clone(), plan)
353    }
354
355    /// Alias for sort().
356    pub fn order_by(&self, columns: Vec<Expression>) -> DataFrame {
357        self.sort(columns)
358    }
359
360    /// Join with another DataFrame.
361    pub fn join(&self, right: &DataFrame, on: Option<Column>, join_type: JoinType) -> DataFrame {
362        let plan = LogicalPlan::Join {
363            left: Box::new(self.plan.clone()),
364            right: Box::new(right.plan.clone()),
365            join_type,
366            on,
367            using_columns: vec![],
368        };
369        DataFrame::new(self.session.clone(), plan)
370    }
371
372    /// Join with another DataFrame using column names (a name-based/"using" join).
373    pub fn join_using<S: Into<String>>(
374        &self,
375        right: &DataFrame,
376        using_columns: impl IntoIterator<Item = S>,
377        join_type: JoinType,
378    ) -> DataFrame {
379        let plan = LogicalPlan::Join {
380            left: Box::new(self.plan.clone()),
381            right: Box::new(right.plan.clone()),
382            join_type,
383            on: None,
384            using_columns: using_columns.into_iter().map(Into::into).collect(),
385        };
386        DataFrame::new(self.session.clone(), plan)
387    }
388
389    /// Nearest-by join: for each left row, the `num_results` nearest right rows
390    /// ranked by `ranking_expression`. Mirrors `DataFrame.nearestByJoin`.
391    ///
392    /// `mode` ∈ {"approx","exact"}, `direction` ∈ {"distance","similarity"},
393    /// `join_type` ∈ {"inner","leftouter"}.
394    pub fn nearest_by_join(
395        &self,
396        other: &DataFrame,
397        ranking_expression: Column,
398        num_results: i32,
399        mode: &str,
400        direction: &str,
401        join_type: &str,
402    ) -> DataFrame {
403        let plan = LogicalPlan::NearestByJoin {
404            left: Box::new(self.plan.clone()),
405            right: Box::new(other.plan.clone()),
406            ranking_expression: ranking_expression.expression().clone(),
407            num_results,
408            join_type: join_type.to_string(),
409            mode: mode.to_string(),
410            direction: direction.to_string(),
411        };
412        DataFrame::new(self.session.clone(), plan)
413    }
414
415    /// Cross join.
416    pub fn cross_join(&self, right: &DataFrame) -> DataFrame {
417        self.join(right, None, JoinType::Cross)
418    }
419
420    /// Lateral join with another DataFrame (a `LATERAL` correlated subquery join).
421    ///
422    /// Mirrors `pyspark.sql.DataFrame.lateralJoin`.
423    pub fn lateral_join(
424        &self,
425        right: &DataFrame,
426        on: Option<Column>,
427        join_type: JoinType,
428    ) -> DataFrame {
429        let plan = LogicalPlan::LateralJoin {
430            left: Box::new(self.plan.clone()),
431            right: Box::new(right.plan.clone()),
432            join_type,
433            on,
434        };
435        DataFrame::new(self.session.clone(), plan)
436    }
437
438    /// Union with another DataFrame.
439    pub fn union(&self, other: &DataFrame) -> DataFrame {
440        let plan = LogicalPlan::SetOperation {
441            left: Box::new(self.plan.clone()),
442            right: Box::new(other.plan.clone()),
443            set_op_type: SetOpType::Union,
444            is_all: true,
445            by_name: false,
446            allow_missing_columns: false,
447        };
448        DataFrame::new(self.session.clone(), plan)
449    }
450
451    /// Union by name.
452    pub fn union_by_name(&self, other: &DataFrame) -> DataFrame {
453        self.union_by_name_opt(other, false)
454    }
455
456    /// `unionByName` with the `allowMissingColumns` option (columns present in only
457    /// one side are filled with null rather than rejected). Mirrors
458    /// `DataFrame.unionByName(other, allowMissingColumns=False)`.
459    pub fn union_by_name_opt(&self, other: &DataFrame, allow_missing_columns: bool) -> DataFrame {
460        let plan = LogicalPlan::SetOperation {
461            left: Box::new(self.plan.clone()),
462            right: Box::new(other.plan.clone()),
463            set_op_type: SetOpType::Union,
464            is_all: true,
465            by_name: true,
466            allow_missing_columns,
467        };
468        DataFrame::new(self.session.clone(), plan)
469    }
470
471    /// Intersect with another DataFrame.
472    pub fn intersect(&self, other: &DataFrame) -> DataFrame {
473        let plan = LogicalPlan::SetOperation {
474            left: Box::new(self.plan.clone()),
475            right: Box::new(other.plan.clone()),
476            set_op_type: SetOpType::Intersect,
477            is_all: false,
478            by_name: false,
479            allow_missing_columns: false,
480        };
481        DataFrame::new(self.session.clone(), plan)
482    }
483
484    /// Subtract (except) another DataFrame.
485    pub fn subtract(&self, other: &DataFrame) -> DataFrame {
486        let plan = LogicalPlan::SetOperation {
487            left: Box::new(self.plan.clone()),
488            right: Box::new(other.plan.clone()),
489            set_op_type: SetOpType::Except,
490            is_all: false,
491            by_name: false,
492            allow_missing_columns: false,
493        };
494        DataFrame::new(self.session.clone(), plan)
495    }
496
497    /// Repartition.
498    pub fn repartition(&self, num_partitions: i32) -> DataFrame {
499        let plan = LogicalPlan::Repartition {
500            input: Box::new(self.plan.clone()),
501            num_partitions,
502            shuffle: true,
503        };
504        DataFrame::new(self.session.clone(), plan)
505    }
506
507    /// Coalesce.
508    pub fn coalesce(&self, num_partitions: i32) -> DataFrame {
509        let plan = LogicalPlan::Repartition {
510            input: Box::new(self.plan.clone()),
511            num_partitions,
512            shuffle: false,
513        };
514        DataFrame::new(self.session.clone(), plan)
515    }
516
517    /// Add a hint.
518    pub fn hint<S: Into<String>>(
519        &self,
520        name: &str,
521        parameters: impl IntoIterator<Item = S>,
522    ) -> DataFrame {
523        let plan = LogicalPlan::Hint {
524            input: Box::new(self.plan.clone()),
525            name: name.to_string(),
526            parameters: parameters.into_iter().map(Into::into).collect(),
527        };
528        DataFrame::new(self.session.clone(), plan)
529    }
530
531    /// Marks a DataFrame as eligible for broadcast join (smaller table).
532    /// Mirrors `pyspark.sql.functions.broadcast`.
533    pub fn broadcast(&self) -> DataFrame {
534        self.hint("broadcast", Vec::<String>::new())
535    }
536
537    /// Convert to DataFrame with new column names.
538    pub fn to_df(&self, column_names: Vec<&str>) -> DataFrame {
539        let names = column_names.iter().map(|s| s.to_string()).collect();
540        let plan = LogicalPlan::ToDF {
541            input: Box::new(self.plan.clone()),
542            column_names: names,
543        };
544        DataFrame::new(self.session.clone(), plan)
545    }
546
547    /// Alias this DataFrame.
548    pub fn alias(&self, alias: &str) -> DataFrame {
549        let plan = LogicalPlan::SubqueryAlias {
550            input: Box::new(self.plan.clone()),
551            alias: alias.to_string(),
552        };
553        DataFrame::new(self.session.clone(), plan)
554    }
555
556    /// Map over each partition with a pandas UDF (`DataFrame.mapInPandas`).
557    ///
558    /// The `func` (built by the Python side, cloudpickled with eval type
559    /// `SQL_MAP_PANDAS_ITER_UDF`) is applied to iterators of pandas DataFrames.
560    pub fn map_in_pandas(
561        &self,
562        func: CommonInlineUserDefinedFunctionExpression,
563        is_barrier: bool,
564    ) -> DataFrame {
565        self.map_partitions(func, is_barrier)
566    }
567
568    /// Map over each partition with an Arrow UDF (`DataFrame.mapInArrow`).
569    pub fn map_in_arrow(
570        &self,
571        func: CommonInlineUserDefinedFunctionExpression,
572        is_barrier: bool,
573    ) -> DataFrame {
574        self.map_partitions(func, is_barrier)
575    }
576
577    /// Build a `MapPartitions` relation from an already-constructed UDF.
578    fn map_partitions(
579        &self,
580        func: CommonInlineUserDefinedFunctionExpression,
581        is_barrier: bool,
582    ) -> DataFrame {
583        let plan = LogicalPlan::MapPartitions {
584            input: Box::new(self.plan.clone()),
585            func,
586            is_barrier,
587        };
588        DataFrame::new(self.session.clone(), plan)
589    }
590
591    /// Apply a function to each row for its side effects (`DataFrame.foreach`).
592    ///
593    /// Backed by an Arrow map partition; results are forced and discarded.
594    pub fn foreach(&self, func: CommonInlineUserDefinedFunctionExpression) -> Result<()> {
595        let _ = self.map_partitions(func, false).collect()?;
596        Ok(())
597    }
598
599    /// Apply a function to each partition for its side effects
600    /// (`DataFrame.foreachPartition`).
601    pub fn foreach_partition(&self, func: CommonInlineUserDefinedFunctionExpression) -> Result<()> {
602        let _ = self.map_partitions(func, false).collect()?;
603        Ok(())
604    }
605
606    /// Sample rows.
607    pub fn sample(&self, fraction: f64, seed: Option<i64>) -> DataFrame {
608        self.sample_opt(fraction, false, seed)
609    }
610
611    /// `sample` with the `withReplacement` option. Mirrors
612    /// `DataFrame.sample(withReplacement, fraction, seed)`.
613    pub fn sample_opt(
614        &self,
615        fraction: f64,
616        with_replacement: bool,
617        seed: Option<i64>,
618    ) -> DataFrame {
619        let plan = LogicalPlan::Sample {
620            input: Box::new(self.plan.clone()),
621            lower_bound: 0.0,
622            upper_bound: fraction,
623            with_replacement,
624            seed,
625        };
626        DataFrame::new(self.session.clone(), plan)
627    }
628
629    /// Group by columns for aggregation.
630    pub fn group_by<C: Into<Column>>(
631        &self,
632        group_cols: impl IntoIterator<Item = C>,
633    ) -> crate::group::GroupedData {
634        let group_cols: Vec<Column> = group_cols.into_iter().map(Into::into).collect();
635        crate::group::GroupedData::new(self.clone(), group_cols, AggregateGroupType::GroupBy)
636    }
637
638    /// Collect all rows into memory.
639    pub fn collect(&self) -> Result<Vec<Row>> {
640        let request = self.build_execute_request()?;
641        let mut stream = block_on(self.session.client().execute_plan_reattachable(request))?;
642
643        let mut rows = vec![];
644        let mut info = ExecutionInfo::default();
645        loop {
646            let resp = block_on(stream.message())?;
647            let Some(mut resp) = resp else {
648                break;
649            };
650            capture_execution(&mut resp, &mut info, &self.session);
651            if let Some(proto::execute_plan_response::ResponseType::ArrowBatch(batch)) =
652                resp.response_type
653            {
654                let batch_rows = decode_arrow_batch(&batch)?;
655                rows.extend(batch_rows);
656            }
657        }
658        self.session.record_execution(info);
659
660        Ok(rows)
661    }
662
663    /// Return an iterator that lazily streams rows from the server.
664    ///
665    /// Mirrors `pyspark.sql.DataFrame.toLocalIterator(prefetchPartitions=False)`.
666    /// Unlike `collect()`, which buffers all results in memory, this returns an iterator
667    /// that yields Row objects as the server streams them, consuming minimal memory.
668    ///
669    /// # Arguments
670    /// * `prefetch_partitions` - If true, a background task fetches the next batch
671    ///   from the server while the caller consumes the current one (one batch buffered
672    ///   ahead), overlapping network I/O with row processing. If false, each batch is
673    ///   fetched on demand only once the previous batch is exhausted.
674    pub fn to_local_iterator(&self, prefetch_partitions: bool) -> Result<LocalRowIterator> {
675        let request = self.build_execute_request()?;
676        let stream = block_on(self.session.client().execute_plan_reattachable(request))?;
677        Ok(LocalRowIterator::new(
678            self.session.clone(),
679            stream,
680            prefetch_partitions,
681        ))
682    }
683
684    /// Execution metrics collected during the most recent action on this
685    /// DataFrame's session. Mirrors `pyspark.sql.DataFrame.executionInfo`.
686    ///
687    /// The metrics reflect the session's most recent execution; call this right
688    /// after an action (e.g. `collect`/`count`/`show`).
689    pub fn execution_info(&self) -> Result<ExecutionInfo> {
690        self.session.last_execution_info().ok_or_else(|| {
691            SparkError::connect_msg("no execution info available; run an action first")
692        })
693    }
694
695    /// Collect all data as Arrow RecordBatches.
696    ///
697    /// Streams execution results from the server and decodes Arrow IPC batches,
698    /// returning the raw `RecordBatch`es without converting to Rows. This is the
699    /// foundation for `to_datafusion()` and `to_polars()` conversions.
700    pub fn collect_record_batches(&self) -> Result<Vec<arrow::record_batch::RecordBatch>> {
701        let request = self.build_execute_request()?;
702        let mut stream = block_on(self.session.client().execute_plan_reattachable(request))?;
703
704        let mut batches = vec![];
705        let mut info = ExecutionInfo::default();
706        loop {
707            let resp = block_on(stream.message())?;
708            let Some(mut resp) = resp else {
709                break;
710            };
711            capture_execution(&mut resp, &mut info, &self.session);
712            if let Some(proto::execute_plan_response::ResponseType::ArrowBatch(batch)) =
713                resp.response_type
714            {
715                let record_batches = decode_arrow_record_batches(&batch)?;
716                batches.extend(record_batches);
717            }
718        }
719        self.session.record_execution(info);
720
721        Ok(batches)
722    }
723
724    /// Get the count of rows.
725    ///
726    /// Mirrors `pyspark.sql.DataFrame.count()` = `groupBy().count().collect()[0][0]`:
727    /// a global count aggregate is pushed to the server, which returns a single row,
728    /// rather than streaming every row back to the client just to count them.
729    pub fn count(&self) -> Result<i64> {
730        let count_expr = crate::functions::count(Column::new(Expression::Literal(
731            crate::expression::LiteralExpression::int(1),
732        )))
733        .expression()
734        .clone();
735
736        let plan = LogicalPlan::Aggregate {
737            input: Box::new(self.plan.clone()),
738            group_type: AggregateGroupType::GroupBy,
739            grouping_expressions: vec![],
740            aggregate_expressions: vec![count_expr],
741            pivot_col: None,
742            pivot_values: vec![],
743            grouping_sets: vec![],
744        };
745        let agg_df = DataFrame::new(self.session.clone(), plan);
746
747        let rows = agg_df.collect()?;
748        match rows.into_iter().next() {
749            Some(row) => row.get(0).and_then(|v| v.as_i64()).ok_or_else(|| {
750                SparkError::connect_msg("count() aggregate returned a non-integer value")
751            }),
752            None => Ok(0),
753        }
754    }
755
756    /// Show the first n rows.
757    pub fn show(&self, n: usize) -> Result<()> {
758        let limited = self.limit(n as i32).collect()?;
759        for row in limited {
760            println!("{}", row);
761        }
762        Ok(())
763    }
764
765    /// Get the schema of this DataFrame.
766    pub fn schema(&self) -> Result<DataType> {
767        let request = self.build_analyze_request()?;
768        let response = block_on(self.session.client().analyze_plan(request))?;
769
770        if let Some(proto::analyze_plan_response::Result::Schema(schema)) = response.result {
771            Ok(DataType::from_proto(&schema.schema.ok_or_else(|| {
772                SparkError::connect_msg("Schema is missing")
773            })?)?)
774        } else {
775            Err(SparkError::connect_msg(
776                "Schema analyze failed: no schema in response",
777            ))
778        }
779    }
780
781    /// Get the first row.
782    pub fn first(&self) -> Result<Option<Row>> {
783        self.limit(1).collect().map(|rows| rows.into_iter().next())
784    }
785
786    /// Alias for first().
787    pub fn head(&self) -> Result<Option<Row>> {
788        self.first()
789    }
790
791    /// Get the first n rows.
792    pub fn take(&self, n: usize) -> Result<Vec<Row>> {
793        self.limit(n as i32).collect()
794    }
795
796    /// Check if the DataFrame is empty.
797    pub fn is_empty(&self) -> Result<bool> {
798        self.limit(1).count().map(|c| c == 0)
799    }
800
801    /// Get column names.
802    pub fn columns(&self) -> Result<Vec<String>> {
803        let schema = self.schema()?;
804        match schema {
805            DataType::Struct { fields } => Ok(fields.iter().map(|f| f.name.clone()).collect()),
806            _ => Err(SparkError::connect_msg("Schema is not a struct type")),
807        }
808    }
809
810    /// Build an ExecutePlanRequest from the logical plan.
811    fn build_execute_request(&self) -> Result<proto::ExecutePlanRequest> {
812        // Build proto from plan with plan_id assignment
813        let mut relation = self.plan.to_proto();
814        assign_plan_ids(&mut relation, &self.session)?;
815
816        let mut request = proto::ExecutePlanRequest::default();
817        request.session_id = self.session.client().session_id().to_string();
818        request.user_context = Some(proto::UserContext::default());
819        request.tags = self.session.tags();
820
821        let mut plan = proto::Plan::default();
822        plan.op_type = Some(proto::plan::OpType::Root(relation));
823        request.plan = Some(plan);
824
825        Ok(request)
826    }
827
828    /// Build an AnalyzePlanRequest from the logical plan.
829    fn build_analyze_request(&self) -> Result<proto::AnalyzePlanRequest> {
830        let mut relation = self.plan.to_proto();
831        assign_plan_ids(&mut relation, &self.session)?;
832
833        let mut plan = proto::Plan::default();
834        plan.op_type = Some(proto::plan::OpType::Root(relation));
835
836        let mut schema = proto::analyze_plan_request::Schema::default();
837        schema.plan = Some(plan);
838
839        let mut request = proto::AnalyzePlanRequest::default();
840        request.session_id = self.session.client().session_id().to_string();
841        request.user_context = Some(proto::UserContext::default());
842        request.analyze = Some(proto::analyze_plan_request::Analyze::Schema(schema));
843
844        Ok(request)
845    }
846
847    /// Create a DataFrameWriter for writing this DataFrame to various destinations.
848    ///
849    /// Mirrors `pyspark.sql.DataFrame.write`.
850    pub fn write(&self) -> crate::readwriter::DataFrameWriter {
851        crate::readwriter::DataFrameWriter::new(self.session.clone(), self.plan.clone())
852    }
853
854    /// Create a [`DataFrameWriterV2`](crate::readwriter::DataFrameWriterV2) for
855    /// the v2 write API.
856    ///
857    /// Mirrors `pyspark.sql.DataFrame.writeTo`.
858    pub fn write_to(&self, table_name: &str) -> crate::readwriter::DataFrameWriterV2 {
859        crate::readwriter::DataFrameWriterV2::new(
860            self.session.clone(),
861            self.plan.clone(),
862            table_name,
863        )
864    }
865
866    /// Merge a set of updates, insertions, and deletions into a target table.
867    ///
868    /// Mirrors `pyspark.sql.DataFrame.mergeInto`: returns a [`crate::merge::MergeIntoWriter`]
869    /// on which `when_matched` / `when_not_matched` / `when_not_matched_by_source` clauses
870    /// are added before calling `merge()`.
871    pub fn merge_into(&self, table: &str, condition: Column) -> crate::merge::MergeIntoWriter {
872        crate::merge::MergeIntoWriter::new(
873            self.session.clone(),
874            self.plan.clone(),
875            table.to_string(),
876            condition,
877        )
878    }
879
880    /// Create a DataStreamWriter for writing this streaming DataFrame to various sinks.
881    ///
882    /// Mirrors `pyspark.sql.DataFrame.writeStream`.
883    pub fn write_stream(&self) -> crate::streaming::DataStreamWriter {
884        crate::streaming::DataStreamWriter::new(self.session.clone(), self.plan.clone())
885    }
886
887    /// The default `MEMORY_AND_DISK_DESER` storage level used by `cache()`.
888    fn memory_and_disk_deser() -> proto::StorageLevel {
889        proto::StorageLevel {
890            use_disk: true,
891            use_memory: true,
892            use_off_heap: false,
893            deserialized: true,
894            replication: 1,
895        }
896    }
897
898    /// Build this DataFrame's plan as a proto `Relation` with plan ids assigned
899    /// (the shape the `Persist`/`Unpersist`/`GetStorageLevel` analyze ops take).
900    fn analyze_relation(&self) -> Result<proto::Relation> {
901        let mut relation = self.plan.to_proto();
902        assign_plan_ids(&mut relation, &self.session)?;
903        Ok(relation)
904    }
905
906    fn analyze_request(
907        &self,
908        analyze: proto::analyze_plan_request::Analyze,
909    ) -> proto::AnalyzePlanRequest {
910        proto::AnalyzePlanRequest {
911            session_id: self.session.client().session_id().to_string(),
912            user_context: Some(proto::UserContext::default()),
913            analyze: Some(analyze),
914            ..Default::default()
915        }
916    }
917
918    /// Cache this DataFrame with the default `MEMORY_AND_DISK_DESER` storage level.
919    ///
920    /// Mirrors `pyspark.sql.DataFrame.cache()`.
921    pub fn cache(&self) -> Result<DataFrame> {
922        self.persist(Self::memory_and_disk_deser())
923    }
924
925    /// Persist this DataFrame with the given storage level.
926    ///
927    /// Mirrors `pyspark.sql.DataFrame.persist(storageLevel)`.
928    pub fn persist(&self, storage_level: proto::StorageLevel) -> Result<DataFrame> {
929        let persist = proto::analyze_plan_request::Persist {
930            relation: Some(self.analyze_relation()?),
931            storage_level: Some(storage_level),
932        };
933        let request = self.analyze_request(proto::analyze_plan_request::Analyze::Persist(persist));
934        block_on(self.session.client().analyze_plan(request))?;
935        Ok(self.clone())
936    }
937
938    /// Remove this DataFrame from cache. Mirrors `DataFrame.unpersist(blocking)`.
939    pub fn unpersist(&self, blocking: bool) -> Result<DataFrame> {
940        let unpersist = proto::analyze_plan_request::Unpersist {
941            relation: Some(self.analyze_relation()?),
942            blocking: Some(blocking),
943        };
944        let request =
945            self.analyze_request(proto::analyze_plan_request::Analyze::Unpersist(unpersist));
946        block_on(self.session.client().analyze_plan(request))?;
947        Ok(self.clone())
948    }
949
950    /// Checkpoint this DataFrame to disk.
951    pub fn checkpoint(&self) -> Result<DataFrame> {
952        self.checkpoint_impl(false, true)
953    }
954
955    /// Create a local checkpoint of this DataFrame.
956    pub fn local_checkpoint(&self) -> Result<DataFrame> {
957        self.checkpoint_impl(true, true)
958    }
959
960    /// Execute a `CheckpointCommand` and return a DataFrame referencing the resulting
961    /// cached remote relation. Mirrors `DataFrame.checkpoint`/`localCheckpoint`, which
962    /// materialize server-side and return a handle to the checkpointed data.
963    fn checkpoint_impl(&self, local: bool, eager: bool) -> Result<DataFrame> {
964        let mut cmd = proto::CheckpointCommand::default();
965        cmd.relation = Some(self.plan.to_proto());
966        cmd.local = local;
967        cmd.eager = eager;
968        let responses = execute_command_collect(
969            &self.session,
970            proto::command::CommandType::CheckpointCommand(cmd),
971        )?;
972        for resp in &responses {
973            if let Some(proto::execute_plan_response::ResponseType::CheckpointCommandResult(res)) =
974                &resp.response_type
975            {
976                if let Some(rel) = &res.relation {
977                    return Ok(DataFrame::new(
978                        self.session.clone(),
979                        LogicalPlan::CachedRemoteRelation {
980                            relation_id: rel.relation_id.clone(),
981                        },
982                    ));
983                }
984            }
985        }
986        Err(SparkError::connect_msg(
987            "checkpoint: server returned no CheckpointCommandResult",
988        ))
989    }
990
991    /// Create a temporary view for this DataFrame.
992    pub fn create_temp_view(&self, name: &str) -> Result<()> {
993        self.create_view(name, false, false)
994    }
995
996    /// Create or replace a temporary view for this DataFrame.
997    pub fn create_or_replace_temp_view(&self, name: &str) -> Result<()> {
998        self.create_view(name, true, false)
999    }
1000
1001    /// Create a global temporary view for this DataFrame.
1002    pub fn create_global_temp_view(&self, name: &str) -> Result<()> {
1003        self.create_view(name, false, true)
1004    }
1005
1006    /// Create or replace a global temporary view for this DataFrame.
1007    pub fn create_or_replace_global_temp_view(&self, name: &str) -> Result<()> {
1008        self.create_view(name, true, true)
1009    }
1010
1011    /// Build + execute a real `CreateDataFrameViewCommand` (was previously a
1012    /// silent no-op that passed the query relation through and never created a view).
1013    fn create_view(&self, name: &str, replace: bool, global: bool) -> Result<()> {
1014        let mut input = self.plan.to_proto();
1015        assign_plan_ids(&mut input, &self.session)?;
1016        let mut cmd = proto::CreateDataFrameViewCommand::default();
1017        cmd.input = Some(input);
1018        cmd.name = name.to_string();
1019        cmd.is_global = global;
1020        cmd.replace = replace;
1021        execute_command(
1022            &self.session,
1023            proto::command::CommandType::CreateDataframeView(cmd),
1024        )
1025    }
1026
1027    /// Print the execution plan to the console. Mirrors `pyspark.sql.DataFrame.explain`
1028    /// (was previously a no-op that ran the query relation instead of an AnalyzePlan).
1029    pub fn explain(&self) -> Result<()> {
1030        self.explain_mode("simple")
1031    }
1032
1033    /// Print the execution plan in a specific mode. Mirrors the `mode` argument of
1034    /// `pyspark.sql.DataFrame.explain`: one of "simple", "extended", "codegen",
1035    /// "cost", "formatted" (case-insensitive).
1036    pub fn explain_mode(&self, mode: &str) -> Result<()> {
1037        use proto::analyze_plan_request::explain::ExplainMode;
1038        let explain_mode = match mode.to_lowercase().as_str() {
1039            "simple" => ExplainMode::Simple,
1040            "extended" => ExplainMode::Extended,
1041            "codegen" => ExplainMode::Codegen,
1042            "cost" => ExplainMode::Cost,
1043            "formatted" => ExplainMode::Formatted,
1044            other => {
1045                return Err(SparkError::value(
1046                    "UNSUPPORTED_EXPLAIN_MODE",
1047                    &[("mode", other)],
1048                ))
1049            }
1050        };
1051        let mut relation = self.plan.to_proto();
1052        assign_plan_ids(&mut relation, &self.session)?;
1053        let mut plan = proto::Plan::default();
1054        plan.op_type = Some(proto::plan::OpType::Root(relation));
1055        let mut ex = proto::analyze_plan_request::Explain::default();
1056        ex.plan = Some(plan);
1057        ex.explain_mode = explain_mode as i32;
1058        let mut request = proto::AnalyzePlanRequest::default();
1059        request.session_id = self.session.client().session_id().to_string();
1060        request.user_context = Some(proto::UserContext::default());
1061        request.analyze = Some(proto::analyze_plan_request::Analyze::Explain(ex));
1062        let response = block_on(self.session.client().analyze_plan(request))?;
1063        if let Some(proto::analyze_plan_response::Result::Explain(e)) = response.result {
1064            println!("{}", e.explain_string);
1065        }
1066        Ok(())
1067    }
1068
1069    /// Add a watermark to this DataFrame for event-time based windows.
1070    pub fn with_watermark(&self, time_column: &str, delay_threshold: &str) -> DataFrame {
1071        let plan = LogicalPlan::WithWatermark {
1072            input: Box::new(self.plan.clone()),
1073            time_column: time_column.to_string(),
1074            delay_threshold: delay_threshold.to_string(),
1075        };
1076        DataFrame::new(self.session.clone(), plan)
1077    }
1078
1079    /// Repartition this DataFrame by range.
1080    pub fn repartition_by_range(&self, num_partitions: i32, columns: Vec<Expression>) -> DataFrame {
1081        let plan = LogicalPlan::RepartitionByRange {
1082            input: Box::new(self.plan.clone()),
1083            num_partitions: Some(num_partitions),
1084            partition_exprs: columns,
1085        };
1086        DataFrame::new(self.session.clone(), plan)
1087    }
1088
1089    /// Repartition into `num_partitions` by hashing the given column expressions.
1090    /// Mirrors `df.repartition(numPartitions, *cols)`.
1091    pub fn repartition_by_expressions(
1092        &self,
1093        num_partitions: i32,
1094        columns: Vec<Expression>,
1095    ) -> DataFrame {
1096        let plan = LogicalPlan::RepartitionByExpression {
1097            input: Box::new(self.plan.clone()),
1098            num_partitions,
1099            expressions: columns,
1100        };
1101        DataFrame::new(self.session.clone(), plan)
1102    }
1103
1104    /// Alias for to_df().
1105    pub fn to_schema(&self, column_names: Vec<&str>) -> DataFrame {
1106        self.to_df(column_names)
1107    }
1108
1109    /// Melt (unpivot) this DataFrame.
1110    pub fn melt(
1111        &self,
1112        id_vars: Vec<&str>,
1113        value_vars: Option<Vec<&str>>,
1114        var_name: &str,
1115        value_name: &str,
1116    ) -> DataFrame {
1117        use crate::column::col;
1118        let ids: Vec<Column> = id_vars.iter().map(|name| col(name)).collect();
1119        let vals: Option<Vec<Column>> =
1120            value_vars.map(|v| v.iter().map(|name| col(name)).collect());
1121
1122        let plan = LogicalPlan::Unpivot {
1123            input: Box::new(self.plan.clone()),
1124            ids,
1125            values: vals,
1126            variable_column_name: var_name.to_string(),
1127            value_column_name: value_name.to_string(),
1128        };
1129        DataFrame::new(self.session.clone(), plan)
1130    }
1131
1132    /// Get the input files for this DataFrame. Mirrors `pyspark.sql.DataFrame.inputFiles`.
1133    pub fn input_files(&self) -> Result<Vec<String>> {
1134        let mut relation = self.plan.to_proto();
1135        assign_plan_ids(&mut relation, &self.session)?;
1136        let mut plan = proto::Plan::default();
1137        plan.op_type = Some(proto::plan::OpType::Root(relation));
1138        let mut inp = proto::analyze_plan_request::InputFiles::default();
1139        inp.plan = Some(plan);
1140        let mut request = proto::AnalyzePlanRequest::default();
1141        request.session_id = self.session.client().session_id().to_string();
1142        request.user_context = Some(proto::UserContext::default());
1143        request.analyze = Some(proto::analyze_plan_request::Analyze::InputFiles(inp));
1144        let response = block_on(self.session.client().analyze_plan(request))?;
1145        match response.result {
1146            Some(proto::analyze_plan_response::Result::InputFiles(f)) => Ok(f.files),
1147            _ => Ok(vec![]),
1148        }
1149    }
1150
1151    /// Observe metrics on this DataFrame.
1152    pub fn observe(&self, name: &str, exprs: Vec<Expression>) -> DataFrame {
1153        let plan = LogicalPlan::Observe {
1154            input: Box::new(self.plan.clone()),
1155            name: name.to_string(),
1156            exprs,
1157        };
1158        DataFrame::new(self.session.clone(), plan)
1159    }
1160
1161    /// Get stat functions.
1162    pub fn stat(&self) -> crate::group::StatFunctions {
1163        crate::group::StatFunctions::new(self.clone())
1164    }
1165
1166    /// Returns a [`crate::group::NaFunctions`] for handling missing values.
1167    ///
1168    /// Mirrors `pyspark.sql.DataFrame.na`.
1169    pub fn na(&self) -> crate::group::NaFunctions {
1170        crate::group::NaFunctions::new(self.clone())
1171    }
1172
1173    /// Perform aggregation without grouping.
1174    pub fn agg(&self, expressions: Vec<Expression>) -> DataFrame {
1175        let plan = LogicalPlan::Aggregate {
1176            input: Box::new(self.plan.clone()),
1177            group_type: AggregateGroupType::GroupBy,
1178            grouping_expressions: vec![],
1179            aggregate_expressions: expressions,
1180            pivot_col: None,
1181            pivot_values: vec![],
1182            grouping_sets: vec![],
1183        };
1184        DataFrame::new(self.session.clone(), plan)
1185    }
1186
1187    /// Select with SQL expressions, mirroring `DataFrame.selectExpr`.
1188    ///
1189    /// Each string is parsed as a SQL expression (e.g. `"id + 1 AS x"`), not treated
1190    /// as a bare column name - so it must go through `functions::expr` (an
1191    /// `ExpressionString` the server parses), not `col` (an unresolved attribute,
1192    /// which made `selectExpr("id + 1 AS x")` fail to resolve).
1193    pub fn select_expr(&self, exprs: Vec<&str>) -> DataFrame {
1194        let cols: Vec<Column> = exprs.iter().map(|e| crate::functions::expr(e)).collect();
1195        self.select(cols)
1196    }
1197
1198    /// Fill NA values with an integer.
1199    pub fn fillna(&self, value: i64, subset: Option<Vec<&str>>) -> DataFrame {
1200        self.fillna_value(crate::row::Value::Long(value), subset)
1201    }
1202
1203    /// Fill NA values with a double (e.g. a fractional fill into a double column).
1204    pub fn fillna_double(&self, value: f64, subset: Option<Vec<&str>>) -> DataFrame {
1205        self.fillna_value(crate::row::Value::Double(value), subset)
1206    }
1207
1208    /// Fill NA values with a string.
1209    pub fn fillna_string(&self, value: &str, subset: Option<Vec<&str>>) -> DataFrame {
1210        self.fillna_value(crate::row::Value::String(value.to_string()), subset)
1211    }
1212
1213    /// Fill NA values with a boolean.
1214    pub fn fillna_bool(&self, value: bool, subset: Option<Vec<&str>>) -> DataFrame {
1215        self.fillna_value(crate::row::Value::Bool(value), subset)
1216    }
1217
1218    /// Fill NA values with a typed [`crate::row::Value`] (Long/Double/String/Bool/...).
1219    pub fn fillna_value(&self, value: crate::row::Value, subset: Option<Vec<&str>>) -> DataFrame {
1220        let columns = subset
1221            .map(|v| v.iter().map(|s| s.to_string()).collect())
1222            .unwrap_or_default();
1223        let plan = LogicalPlan::NAFill {
1224            input: Box::new(self.plan.clone()),
1225            fill_value: value,
1226            columns,
1227        };
1228        DataFrame::new(self.session.clone(), plan)
1229    }
1230
1231    /// Fill NA values per column from `(column, value)` pairs.
1232    /// Mirrors `df.fillna({col: value, ...})`.
1233    pub fn fillna_map(&self, pairs: Vec<(String, crate::row::Value)>) -> DataFrame {
1234        let (cols, values): (Vec<String>, Vec<crate::row::Value>) = pairs.into_iter().unzip();
1235        let plan = LogicalPlan::NAFillColumns {
1236            input: Box::new(self.plan.clone()),
1237            cols,
1238            values,
1239        };
1240        DataFrame::new(self.session.clone(), plan)
1241    }
1242
1243    /// Drop NA values.
1244    pub fn dropna(
1245        &self,
1246        how: Option<&str>,
1247        thresh: Option<i32>,
1248        subset: Option<Vec<&str>>,
1249    ) -> DataFrame {
1250        let how_str = how.unwrap_or("any").to_string();
1251        let columns = subset
1252            .map(|v| v.iter().map(|s| s.to_string()).collect())
1253            .unwrap_or_default();
1254        // `how` is carried into the plan; `min_non_nulls` is derived from `how`
1255        // and `thresh` in `NADrop::to_proto` (mirrors pyspark's translation).
1256        let plan = LogicalPlan::NADrop {
1257            input: Box::new(self.plan.clone()),
1258            how: how_str,
1259            min_non_null: thresh,
1260            columns,
1261        };
1262        DataFrame::new(self.session.clone(), plan)
1263    }
1264
1265    /// Replace values.
1266    pub fn replace(
1267        &self,
1268        to_replace: Vec<(String, String)>,
1269        subset: Option<Vec<&str>>,
1270    ) -> DataFrame {
1271        let columns = subset
1272            .map(|v| v.iter().map(|s| s.to_string()).collect())
1273            .unwrap_or_default();
1274        let plan = LogicalPlan::NAReplace {
1275            input: Box::new(self.plan.clone()),
1276            replacements: to_replace,
1277            columns,
1278        };
1279        DataFrame::new(self.session.clone(), plan)
1280    }
1281
1282    /// Describe this DataFrame (show statistics).
1283    pub fn describe(&self, columns: Vec<&str>) -> DataFrame {
1284        let col_names = columns.iter().map(|s| s.to_string()).collect();
1285        let plan = LogicalPlan::Describe {
1286            input: Box::new(self.plan.clone()),
1287            columns: col_names,
1288        };
1289        DataFrame::new(self.session.clone(), plan)
1290    }
1291
1292    /// Get summary statistics.
1293    pub fn summary(&self, percentiles: Vec<&str>) -> DataFrame {
1294        let percs = percentiles.iter().map(|s| s.to_string()).collect();
1295        let plan = LogicalPlan::Summary {
1296            input: Box::new(self.plan.clone()),
1297            percentiles: percs,
1298        };
1299        DataFrame::new(self.session.clone(), plan)
1300    }
1301
1302    /// Select columns by regex pattern.
1303    pub fn col_regex(&self, col_name: &str) -> DataFrame {
1304        let plan = LogicalPlan::ColRegex {
1305            input: Box::new(self.plan.clone()),
1306            col_name: col_name.to_string(),
1307        };
1308        DataFrame::new(self.session.clone(), plan)
1309    }
1310
1311    /// Select a metadata column by name. Mirrors `pyspark.sql.DataFrame.metadataColumn`.
1312    pub fn metadata_column(&self, name: &str) -> Column {
1313        Column::new(Expression::ColumnReference(
1314            crate::expression::ColumnReference::new(name).metadata(),
1315        ))
1316    }
1317
1318    /// Group by with rollup.
1319    pub fn rollup<C: Into<Column>>(
1320        &self,
1321        group_cols: impl IntoIterator<Item = C>,
1322    ) -> crate::group::GroupedData {
1323        let group_cols: Vec<Column> = group_cols.into_iter().map(Into::into).collect();
1324        crate::group::GroupedData::new(self.clone(), group_cols, AggregateGroupType::Rollup)
1325    }
1326
1327    /// Group by with cube.
1328    pub fn cube<C: Into<Column>>(
1329        &self,
1330        group_cols: impl IntoIterator<Item = C>,
1331    ) -> crate::group::GroupedData {
1332        let group_cols: Vec<Column> = group_cols.into_iter().map(Into::into).collect();
1333        crate::group::GroupedData::new(self.clone(), group_cols, AggregateGroupType::Cube)
1334    }
1335
1336    /// Group by with grouping sets. Each inner `Vec<Column>` is one grouping set;
1337    /// the sets are preserved on the wire (`GROUP_TYPE_GROUPING_SETS` + the
1338    /// `grouping_sets` field) rather than flattened into a single group-by.
1339    pub fn grouping_sets(&self, group_cols: Vec<Vec<Column>>) -> crate::group::GroupedData {
1340        crate::group::GroupedData::new_grouping_sets(self.clone(), group_cols)
1341    }
1342
1343    /// Sort within partitions (local sort).
1344    pub fn sort_within_partitions(&self, columns: Vec<Expression>) -> DataFrame {
1345        let plan = LogicalPlan::Sort {
1346            input: Box::new(self.plan.clone()),
1347            order: columns,
1348            is_global: false,
1349        };
1350        DataFrame::new(self.session.clone(), plan)
1351    }
1352
1353    /// Drop duplicates within a watermark.
1354    pub fn drop_duplicates_within_watermark(&self, column_names: Option<Vec<&str>>) -> DataFrame {
1355        let all_cols = column_names.is_none();
1356        let cols = column_names
1357            .map(|c| c.iter().map(|s| s.to_string()).collect())
1358            .unwrap_or_default();
1359
1360        let plan = LogicalPlan::Deduplicate {
1361            input: Box::new(self.plan.clone()),
1362            all_columns_as_keys: all_cols,
1363            column_names: cols,
1364            within_watermark: true,
1365        };
1366        DataFrame::new(self.session.clone(), plan)
1367    }
1368
1369    /// Apply a transformation function to this DataFrame.
1370    pub fn transform<F>(&self, f: F) -> DataFrame
1371    where
1372        F: Fn(&DataFrame) -> DataFrame,
1373    {
1374        f(self)
1375    }
1376
1377    /// Randomly split this DataFrame into multiple parts.
1378    pub fn random_split(&self, weights: Vec<f64>, seed: Option<i64>) -> Vec<DataFrame> {
1379        let total: f64 = weights.iter().sum();
1380        let normalized: Vec<f64> = weights.iter().map(|w| w / total).collect();
1381
1382        let mut results = vec![];
1383        let mut cumulative = 0.0;
1384
1385        for weight in normalized {
1386            let upper = cumulative + weight;
1387            let plan = LogicalPlan::Sample {
1388                input: Box::new(self.plan.clone()),
1389                lower_bound: cumulative,
1390                upper_bound: upper,
1391                with_replacement: false,
1392                seed,
1393            };
1394            results.push(DataFrame::new(self.session.clone(), plan));
1395            cumulative = upper;
1396        }
1397
1398        results
1399    }
1400
1401    /// Print the schema of this DataFrame.
1402    pub fn print_schema(&self) -> Result<()> {
1403        let schema = self.schema()?;
1404        println!("{}", schema);
1405        Ok(())
1406    }
1407
1408    /// Get the storage level of this DataFrame. Mirrors `DataFrame.storageLevel`.
1409    pub fn storage_level(&self) -> Result<proto::StorageLevel> {
1410        let get = proto::analyze_plan_request::GetStorageLevel {
1411            relation: Some(self.analyze_relation()?),
1412        };
1413        let request =
1414            self.analyze_request(proto::analyze_plan_request::Analyze::GetStorageLevel(get));
1415        let response = block_on(self.session.client().analyze_plan(request))?;
1416        match response.result {
1417            Some(proto::analyze_plan_response::Result::GetStorageLevel(g)) => {
1418                Ok(g.storage_level.unwrap_or_default())
1419            }
1420            _ => Ok(proto::StorageLevel::default()),
1421        }
1422    }
1423
1424    /// Check if this DataFrame is cached. Mirrors `DataFrame.is_cached`.
1425    ///
1426    /// Derived from the server-reported storage level (cached iff it uses memory
1427    /// or disk), rather than inspecting the local plan.
1428    pub fn is_cached(&self) -> Result<bool> {
1429        let level = self.storage_level()?;
1430        Ok(level.use_memory || level.use_disk)
1431    }
1432
1433    /// Get dtypes (column names and types).
1434    pub fn dtypes(&self) -> Result<Vec<(String, String)>> {
1435        let schema = self.schema()?;
1436        match schema {
1437            DataType::Struct { fields } => {
1438                let dtypes = fields
1439                    .iter()
1440                    .map(|f| (f.name.clone(), f.data_type.to_string()))
1441                    .collect();
1442                Ok(dtypes)
1443            }
1444            _ => Err(SparkError::connect_msg("Schema is not a struct type")),
1445        }
1446    }
1447
1448    /// Compute the server-side semantic hash of this DataFrame's logical plan,
1449    /// mirroring `DataFrame.semanticHash()` (an AnalyzePlan request).
1450    pub fn semantic_hash(&self) -> Result<i32> {
1451        let mut relation = self.plan.to_proto();
1452        assign_plan_ids(&mut relation, &self.session)?;
1453        let mut plan = proto::Plan::default();
1454        plan.op_type = Some(proto::plan::OpType::Root(relation));
1455        let mut request = proto::AnalyzePlanRequest::default();
1456        request.session_id = self.session.client().session_id().to_string();
1457        request.user_context = Some(proto::UserContext::default());
1458        request.analyze = Some(proto::analyze_plan_request::Analyze::SemanticHash(
1459            proto::analyze_plan_request::SemanticHash { plan: Some(plan) },
1460        ));
1461        let resp = block_on(self.session.client().analyze_plan(request))?;
1462        match resp.result {
1463            Some(proto::analyze_plan_response::Result::SemanticHash(h)) => Ok(h.result),
1464            _ => Err(SparkError::connect_msg(
1465                "AnalyzePlan response did not contain a semantic hash",
1466            )),
1467        }
1468    }
1469
1470    /// Whether two DataFrames have the same semantics, mirroring
1471    /// `DataFrame.sameSemantics(other)` (a server-side AnalyzePlan comparison).
1472    pub fn same_semantics(&self, other: &DataFrame) -> Result<bool> {
1473        let mut self_rel = self.plan.to_proto();
1474        assign_plan_ids(&mut self_rel, &self.session)?;
1475        let mut other_rel = other.plan.to_proto();
1476        assign_plan_ids(&mut other_rel, &other.session)?;
1477        let mut target_plan = proto::Plan::default();
1478        target_plan.op_type = Some(proto::plan::OpType::Root(self_rel));
1479        let mut other_plan = proto::Plan::default();
1480        other_plan.op_type = Some(proto::plan::OpType::Root(other_rel));
1481        let mut request = proto::AnalyzePlanRequest::default();
1482        request.session_id = self.session.client().session_id().to_string();
1483        request.user_context = Some(proto::UserContext::default());
1484        request.analyze = Some(proto::analyze_plan_request::Analyze::SameSemantics(
1485            proto::analyze_plan_request::SameSemantics {
1486                target_plan: Some(target_plan),
1487                other_plan: Some(other_plan),
1488            },
1489        ));
1490        let resp = block_on(self.session.client().analyze_plan(request))?;
1491        match resp.result {
1492            Some(proto::analyze_plan_response::Result::SameSemantics(r)) => Ok(r.result),
1493            _ => Err(SparkError::connect_msg(
1494                "AnalyzePlan response did not contain a sameSemantics result",
1495            )),
1496        }
1497    }
1498
1499    /// Convert each row to a JSON object string, mirroring `DataFrame.toJSON()`.
1500    ///
1501    /// Reference pyspark produces `{"col":val,...}` per row by applying the server's
1502    /// `to_json(struct(*))`, not a client-side row rendering (which previously emitted
1503    /// Rust list syntax like `[1, a]`). Build that projection and collect the strings.
1504    pub fn to_json(&self) -> Result<Vec<String>> {
1505        let cols: Vec<Column> = self
1506            .columns()?
1507            .iter()
1508            .map(|c| crate::column::col(c))
1509            .collect();
1510        let json_col = crate::functions::to_json(crate::functions::r#struct(cols));
1511        let rows = self.select(vec![json_col]).collect()?;
1512        Ok(rows
1513            .iter()
1514            .map(|r| {
1515                r.get(0)
1516                    .and_then(|v| v.as_str())
1517                    .unwrap_or_default()
1518                    .to_string()
1519            })
1520            .collect())
1521    }
1522
1523    /// Union all rows (alias for union with all=true).
1524    pub fn union_all(&self, other: &DataFrame) -> DataFrame {
1525        let plan = LogicalPlan::SetOperation {
1526            left: Box::new(self.plan.clone()),
1527            right: Box::new(other.plan.clone()),
1528            set_op_type: SetOpType::Union,
1529            is_all: true,
1530            by_name: false,
1531            allow_missing_columns: false,
1532        };
1533        DataFrame::new(self.session.clone(), plan)
1534    }
1535
1536    /// Except all rows.
1537    pub fn except_all(&self, other: &DataFrame) -> DataFrame {
1538        let plan = LogicalPlan::SetOperation {
1539            left: Box::new(self.plan.clone()),
1540            right: Box::new(other.plan.clone()),
1541            set_op_type: SetOpType::Except,
1542            is_all: true,
1543            by_name: false,
1544            allow_missing_columns: false,
1545        };
1546        DataFrame::new(self.session.clone(), plan)
1547    }
1548
1549    /// Intersect all rows.
1550    pub fn intersect_all(&self, other: &DataFrame) -> DataFrame {
1551        let plan = LogicalPlan::SetOperation {
1552            left: Box::new(self.plan.clone()),
1553            right: Box::new(other.plan.clone()),
1554            set_op_type: SetOpType::Intersect,
1555            is_all: true,
1556            by_name: false,
1557            allow_missing_columns: false,
1558        };
1559        DataFrame::new(self.session.clone(), plan)
1560    }
1561
1562    /// Unpivot columns (like melt).
1563    pub fn unpivot<C: Into<Column>, D: Into<Column>>(
1564        &self,
1565        ids: impl IntoIterator<Item = C>,
1566        values: Option<impl IntoIterator<Item = D>>,
1567        variable_column_name: &str,
1568        value_column_name: &str,
1569    ) -> DataFrame {
1570        let ids: Vec<Column> = ids.into_iter().map(Into::into).collect();
1571        let values: Option<Vec<Column>> = values.map(|v| v.into_iter().map(Into::into).collect());
1572        let plan = LogicalPlan::Unpivot {
1573            input: Box::new(self.plan.clone()),
1574            ids,
1575            values,
1576            variable_column_name: variable_column_name.to_string(),
1577            value_column_name: value_column_name.to_string(),
1578        };
1579        DataFrame::new(self.session.clone(), plan)
1580    }
1581
1582    /// Set metadata on an existing column.
1583    ///
1584    /// Mirrors `pyspark.sql.connect.dataframe.DataFrame.withMetadata`: the column is
1585    /// re-selected with the given metadata attached (serialized to a JSON map).
1586    pub fn with_metadata(&self, column_name: &str, metadata: HashMap<String, String>) -> DataFrame {
1587        let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
1588        let plan = LogicalPlan::WithColumnMetadata {
1589            input: Box::new(self.plan.clone()),
1590            column_name: column_name.to_string(),
1591            metadata_json,
1592        };
1593        DataFrame::new(self.session.clone(), plan)
1594    }
1595
1596    /// Get the Spark session.
1597    pub fn spark_session(&self) -> SparkSession {
1598        self.session.clone()
1599    }
1600
1601    /// Check if this DataFrame is local (collected).
1602    pub fn is_local(&self) -> bool {
1603        matches!(self.plan, LogicalPlan::LocalRelation { .. })
1604    }
1605
1606    /// Check if this DataFrame is streaming.
1607    pub fn is_streaming(&self) -> bool {
1608        // Check if the plan has streaming-related operations
1609        matches!(
1610            self.plan,
1611            LogicalPlan::Read {
1612                is_streaming: true,
1613                ..
1614            }
1615        )
1616    }
1617
1618    /// Collect the DataFrame and serialize it to Arrow IPC (file format) bytes.
1619    ///
1620    /// The returned buffer is a self-describing Arrow IPC stream that can be read
1621    /// back with `arrow::ipc::reader::FileReader` (or handed to pyarrow, polars,
1622    /// etc.). An empty result yields a valid IPC file with an empty schema.
1623    pub fn to_arrow(&self) -> Result<Vec<u8>> {
1624        record_batches_to_ipc(&self.collect_record_batches()?)
1625    }
1626
1627    /// Convert to a DataFusion DataFrame.
1628    ///
1629    /// Requires the `datafusion` feature to be enabled.
1630    ///
1631    /// # Arguments
1632    ///
1633    /// * `ctx` - A DataFusion `SessionContext` to use for creating the DataFrame
1634    ///
1635    /// # Example
1636    ///
1637    /// ```ignore
1638    /// use datafusion::prelude::SessionContext;
1639    /// let datafusion_ctx = SessionContext::new();
1640    /// let df = spark_df.to_datafusion(&datafusion_ctx)?;
1641    /// ```
1642    #[cfg(feature = "datafusion")]
1643    pub fn to_datafusion(
1644        &self,
1645        ctx: &datafusion::prelude::SessionContext,
1646    ) -> Result<datafusion::dataframe::DataFrame> {
1647        record_batches_to_datafusion(ctx, self.collect_record_batches()?)
1648    }
1649
1650    /// Convert a collected DataFrame into a [`polars::frame::DataFrame`].
1651    ///
1652    /// Requires the `polars` cargo feature.
1653    ///
1654    /// ```ignore
1655    /// let pdf = spark_df.to_polars()?;
1656    /// ```
1657    ///
1658    /// The result is bridged through Arrow IPC bytes rather than sharing
1659    /// arrow-rs types, so polars' vendored arrow does not have to match this
1660    /// crate's arrow-rs version.
1661    #[cfg(feature = "polars")]
1662    pub fn to_polars(&self) -> Result<polars::frame::DataFrame> {
1663        record_batches_to_polars(&self.collect_record_batches()?)
1664    }
1665
1666    /// Repartition into `num_partitions` using the given column's value directly as
1667    /// the shuffle partition id. Mirrors `DataFrame.repartitionById(numPartitions,
1668    /// partitionIdCol)`: the column is wrapped in a `DirectShufflePartitionID`
1669    /// expression and used as the sole repartition expression.
1670    pub fn repartition_by_id(&self, num_partitions: i32, partition_id_col: Column) -> DataFrame {
1671        let direct =
1672            Expression::DirectShufflePartitionId(Box::new(partition_id_col.expression().clone()));
1673        self.repartition_by_expressions(num_partitions, vec![direct])
1674    }
1675
1676    /// Append a monotonically increasing index column. Mirrors
1677    /// `DataFrame.zipWithIndex(indexColName="index")`:
1678    /// `self.select(col("*"), distributed_sequence_id().alias(indexColName))`.
1679    pub fn zip_with_index(&self, index_col_name: &str) -> DataFrame {
1680        let star = Column::new(Expression::UnresolvedStar(None));
1681        let seq = Column::new(Expression::UnresolvedFunction(
1682            crate::expression::UnresolvedFunction::new("distributed_sequence_id", vec![]),
1683        ))
1684        .alias(index_col_name);
1685        self.select(vec![star, seq])
1686    }
1687
1688    /// Reconcile this DataFrame to a new schema: reorder/select columns by name
1689    /// and cast them to the target types.
1690    ///
1691    /// Mirrors `pyspark.sql.connect.dataframe.DataFrame.to` (a `ToSchema` relation).
1692    pub fn to(&self, schema: DataType) -> DataFrame {
1693        let plan = LogicalPlan::ToSchema {
1694            input: Box::new(self.plan.clone()),
1695            schema,
1696        };
1697        DataFrame::new(self.session.clone(), plan)
1698    }
1699
1700    /// Check if the DataFrame exists (is not empty).
1701    pub fn exists(&self) -> Result<bool> {
1702        self.limit(1).count().map(|c| c > 0)
1703    }
1704
1705    /// Get a scalar value from a single-row, single-column result.
1706    pub fn scalar(&self) -> Result<Option<Value>> {
1707        let rows = self.limit(1).collect()?;
1708        if rows.is_empty() {
1709            return Ok(None);
1710        }
1711        let row = &rows[0];
1712        Ok(row.get(0).cloned())
1713    }
1714
1715    /// Transpose the DataFrame: swap rows and columns (server-side `Transpose`
1716    /// relation). Mirrors `pyspark.sql.connect.dataframe.DataFrame.transpose()`
1717    /// with no index column (the server uses the first column as the header).
1718    pub fn transpose(&self) -> Result<DataFrame> {
1719        let plan = LogicalPlan::Transpose {
1720            input: Box::new(self.plan.clone()),
1721            index_columns: vec![],
1722        };
1723        Ok(DataFrame::new(self.session.clone(), plan))
1724    }
1725
1726    /// Transpose using an explicit index column as the transposed header.
1727    /// Mirrors `DataFrame.transpose(indexColumn)`.
1728    pub fn transpose_with_index(&self, index_column: Column) -> Result<DataFrame> {
1729        let plan = LogicalPlan::Transpose {
1730            input: Box::new(self.plan.clone()),
1731            index_columns: vec![index_column.expression().clone()],
1732        };
1733        Ok(DataFrame::new(self.session.clone(), plan))
1734    }
1735
1736    /// Zip this DataFrame with another DataFrame by row number.
1737    pub fn zip(&self, other: &DataFrame) -> Result<DataFrame> {
1738        let plan = LogicalPlan::Zip {
1739            left: Box::new(self.plan.clone()),
1740            right: Box::new(other.plan.clone()),
1741        };
1742        Ok(DataFrame {
1743            plan,
1744            session: self.session.clone(),
1745        })
1746    }
1747
1748    /// Register this DataFrame as a temporary table (deprecated - use createTempView).
1749    pub fn register_temp_table(&self, name: &str) -> Result<()> {
1750        self.create_temp_view(name)?;
1751        Ok(())
1752    }
1753
1754    /// Convert to a table reference (alias for alias).
1755    pub fn as_table(&self, alias: &str) -> DataFrame {
1756        self.alias(alias)
1757    }
1758}
1759
1760/// Build the proto `Relation` for a logical plan with plan-ids assigned.
1761///
1762/// Shared by write operations (which embed a fully-formed input relation inside
1763/// a `Command`) so they go through the same plan-id assignment as `collect()`.
1764pub(crate) fn build_input_relation(
1765    plan: &LogicalPlan,
1766    session: &SparkSession,
1767) -> Result<proto::Relation> {
1768    let mut relation = plan.to_proto();
1769    assign_plan_ids(&mut relation, session)?;
1770    Ok(relation)
1771}
1772
1773/// Execute a `Command` against the server and drain the response stream.
1774///
1775/// Writes (and other side-effecting operations) are modeled as commands rather
1776/// than relations, so they are submitted through an `ExecutePlanRequest` whose
1777/// plan carries a `Command` and produce no rows to collect.
1778pub(crate) fn execute_command(
1779    session: &SparkSession,
1780    command_type: proto::command::CommandType,
1781) -> Result<()> {
1782    execute_command_collect(session, command_type).map(|_| ())
1783}
1784
1785/// Like `execute_command`, but returns the collected responses so callers can read
1786/// a command result (e.g. `CheckpointCommandResult`, `WriteStreamOperationStartResult`).
1787pub(crate) fn execute_command_collect(
1788    session: &SparkSession,
1789    command_type: proto::command::CommandType,
1790) -> Result<Vec<proto::ExecutePlanResponse>> {
1791    let mut command = proto::Command::default();
1792    command.command_type = Some(command_type);
1793
1794    let mut plan = proto::Plan::default();
1795    plan.op_type = Some(proto::plan::OpType::Command(command));
1796
1797    let mut request = proto::ExecutePlanRequest::default();
1798    request.session_id = session.client().session_id().to_string();
1799    request.user_context = Some(proto::UserContext::default());
1800    request.tags = session.tags();
1801    request.plan = Some(plan);
1802
1803    let mut stream = block_on(session.client().execute_plan_reattachable(request))?;
1804    // Drain the response stream so the command runs to completion server-side,
1805    // capturing any metrics/progress emitted along the way.
1806    let mut info = ExecutionInfo::default();
1807    let mut responses = Vec::new();
1808    while let Some(mut resp) = block_on(stream.message())? {
1809        capture_execution(&mut resp, &mut info, session);
1810        responses.push(resp);
1811    }
1812    session.record_execution(info);
1813    Ok(responses)
1814}
1815
1816/// Pull execution metrics/observed-metrics off a response and fire progress
1817/// handlers. `metrics`/`observed_metrics` are top-level fields (not part of the
1818/// `response_type` oneof), so this must run before `response_type` is consumed.
1819fn capture_execution(
1820    resp: &mut proto::ExecutePlanResponse,
1821    info: &mut ExecutionInfo,
1822    session: &SparkSession,
1823) {
1824    if let Some(metrics) = resp.metrics.take() {
1825        info.metrics = Some(metrics);
1826    }
1827    if !resp.observed_metrics.is_empty() {
1828        let metrics = std::mem::take(&mut resp.observed_metrics);
1829        // Feed observed metrics to the profiler collector
1830        session.profiler().accumulate_observed_metrics(&metrics);
1831        info.observed_metrics.extend(metrics);
1832    }
1833    if let Some(proto::execute_plan_response::ResponseType::ExecutionProgress(progress)) =
1834        &resp.response_type
1835    {
1836        session.notify_progress(progress);
1837    }
1838}
1839
1840/// Assign unique plan_ids to all relations in a tree (post-order traversal).
1841pub(crate) fn assign_plan_ids(
1842    relation: &mut proto::Relation,
1843    session: &SparkSession,
1844) -> Result<()> {
1845    if let Some(rel_type) = &mut relation.rel_type {
1846        use proto::relation::RelType;
1847        match rel_type {
1848            RelType::Range(_) => {}
1849            RelType::Sql(_) => {}
1850            RelType::LocalRelation(_) => {}
1851            RelType::CachedRemoteRelation(_) => {}
1852            RelType::Project(proj) => {
1853                if let Some(input) = &mut proj.input {
1854                    assign_plan_ids(input, session)?;
1855                }
1856            }
1857            RelType::Filter(filter) => {
1858                if let Some(input) = &mut filter.input {
1859                    assign_plan_ids(input, session)?;
1860                }
1861            }
1862            RelType::Join(join) => {
1863                if let Some(left) = &mut join.left {
1864                    assign_plan_ids(left, session)?;
1865                }
1866                if let Some(right) = &mut join.right {
1867                    assign_plan_ids(right, session)?;
1868                }
1869            }
1870            RelType::SetOp(set_op) => {
1871                if let Some(left) = &mut set_op.left_input {
1872                    assign_plan_ids(left, session)?;
1873                }
1874                if let Some(right) = &mut set_op.right_input {
1875                    assign_plan_ids(right, session)?;
1876                }
1877            }
1878            RelType::Aggregate(agg) => {
1879                if let Some(input) = &mut agg.input {
1880                    assign_plan_ids(input, session)?;
1881                }
1882            }
1883            RelType::Sort(sort) => {
1884                if let Some(input) = &mut sort.input {
1885                    assign_plan_ids(input, session)?;
1886                }
1887            }
1888            RelType::Limit(limit) => {
1889                if let Some(input) = &mut limit.input {
1890                    assign_plan_ids(input, session)?;
1891                }
1892            }
1893            RelType::Offset(offset) => {
1894                if let Some(input) = &mut offset.input {
1895                    assign_plan_ids(input, session)?;
1896                }
1897            }
1898            RelType::Tail(tail) => {
1899                if let Some(input) = &mut tail.input {
1900                    assign_plan_ids(input, session)?;
1901                }
1902            }
1903            RelType::Deduplicate(dedup) => {
1904                if let Some(input) = &mut dedup.input {
1905                    assign_plan_ids(input, session)?;
1906                }
1907            }
1908            RelType::Repartition(repartition) => {
1909                if let Some(input) = &mut repartition.input {
1910                    assign_plan_ids(input, session)?;
1911                }
1912            }
1913            RelType::RepartitionByExpression(repart_expr) => {
1914                if let Some(input) = &mut repart_expr.input {
1915                    assign_plan_ids(input, session)?;
1916                }
1917            }
1918            RelType::WithColumns(with_cols) => {
1919                if let Some(input) = &mut with_cols.input {
1920                    assign_plan_ids(input, session)?;
1921                }
1922            }
1923            RelType::WithColumnsRenamed(with_renamed) => {
1924                if let Some(input) = &mut with_renamed.input {
1925                    assign_plan_ids(input, session)?;
1926                }
1927            }
1928            RelType::Drop(drop) => {
1929                if let Some(input) = &mut drop.input {
1930                    assign_plan_ids(input, session)?;
1931                }
1932            }
1933            RelType::ToDf(to_df) => {
1934                if let Some(input) = &mut to_df.input {
1935                    assign_plan_ids(input, session)?;
1936                }
1937            }
1938            RelType::ToSchema(to_schema) => {
1939                if let Some(input) = &mut to_schema.input {
1940                    assign_plan_ids(input, session)?;
1941                }
1942            }
1943            RelType::Hint(hint) => {
1944                if let Some(input) = &mut hint.input {
1945                    assign_plan_ids(input, session)?;
1946                }
1947            }
1948            RelType::Unpivot(unpivot) => {
1949                if let Some(input) = &mut unpivot.input {
1950                    assign_plan_ids(input, session)?;
1951                }
1952            }
1953            RelType::Sample(sample) => {
1954                if let Some(input) = &mut sample.input {
1955                    assign_plan_ids(input, session)?;
1956                }
1957            }
1958            RelType::FillNa(fill_na) => {
1959                if let Some(input) = &mut fill_na.input {
1960                    assign_plan_ids(input, session)?;
1961                }
1962            }
1963            RelType::DropNa(drop_na) => {
1964                if let Some(input) = &mut drop_na.input {
1965                    assign_plan_ids(input, session)?;
1966                }
1967            }
1968            RelType::Replace(replace) => {
1969                if let Some(input) = &mut replace.input {
1970                    assign_plan_ids(input, session)?;
1971                }
1972            }
1973            RelType::Describe(describe) => {
1974                if let Some(input) = &mut describe.input {
1975                    assign_plan_ids(input, session)?;
1976                }
1977            }
1978            RelType::Summary(summary) => {
1979                if let Some(input) = &mut summary.input {
1980                    assign_plan_ids(input, session)?;
1981                }
1982            }
1983            RelType::SubqueryAlias(sq_alias) => {
1984                if let Some(input) = &mut sq_alias.input {
1985                    assign_plan_ids(input, session)?;
1986                }
1987            }
1988            RelType::CachedLocalRelation(_cached) => {
1989                // CachedLocalRelation doesn't have nested plans, it's just a reference with a hash
1990            }
1991            RelType::WithWatermark(watermark) => {
1992                if let Some(input) = &mut watermark.input {
1993                    assign_plan_ids(input, session)?;
1994                }
1995            }
1996            RelType::Crosstab(stat) => {
1997                if let Some(input) = &mut stat.input {
1998                    assign_plan_ids(input, session)?;
1999                }
2000            }
2001            RelType::FreqItems(stat) => {
2002                if let Some(input) = &mut stat.input {
2003                    assign_plan_ids(input, session)?;
2004                }
2005            }
2006            RelType::ApproxQuantile(stat) => {
2007                if let Some(input) = &mut stat.input {
2008                    assign_plan_ids(input, session)?;
2009                }
2010            }
2011            RelType::Corr(stat) => {
2012                if let Some(input) = &mut stat.input {
2013                    assign_plan_ids(input, session)?;
2014                }
2015            }
2016            RelType::Cov(stat) => {
2017                if let Some(input) = &mut stat.input {
2018                    assign_plan_ids(input, session)?;
2019                }
2020            }
2021            RelType::SampleBy(stat) => {
2022                if let Some(input) = &mut stat.input {
2023                    assign_plan_ids(input, session)?;
2024                }
2025            }
2026            RelType::CollectMetrics(metrics) => {
2027                if let Some(input) = &mut metrics.input {
2028                    assign_plan_ids(input, session)?;
2029                }
2030            }
2031            _ => {
2032                // Handle any other relation types that we haven't explicitly handled
2033            }
2034        }
2035    }
2036
2037    // Assign plan_id to this relation
2038    if relation.common.is_none() {
2039        relation.common = Some(proto::RelationCommon::default());
2040    }
2041    if let Some(common) = &mut relation.common {
2042        common.plan_id = Some(session.next_plan_id());
2043    }
2044
2045    Ok(())
2046}
2047
2048/// Decode an Arrow batch into rows.
2049fn decode_arrow_batch(batch: &proto::execute_plan_response::ArrowBatch) -> Result<Vec<Row>> {
2050    use arrow::ipc::reader::StreamReader;
2051    use std::io::Cursor;
2052
2053    if batch.data.is_empty() {
2054        return Ok(vec![]);
2055    }
2056
2057    let cursor = Cursor::new(&batch.data);
2058    let mut reader = StreamReader::try_new(cursor, None).map_err(|e| {
2059        SparkError::connect_msg(format!("Failed to create Arrow stream reader: {}", e))
2060    })?;
2061
2062    let mut rows = vec![];
2063
2064    while let Some(record_batch) = reader
2065        .next()
2066        .transpose()
2067        .map_err(|e| SparkError::connect_msg(format!("Failed to decode Arrow batch: {}", e)))?
2068    {
2069        let schema = record_batch.schema();
2070        let num_rows = record_batch.num_rows();
2071        let num_cols = record_batch.num_columns();
2072
2073        for row_idx in 0..num_rows {
2074            let mut field_names = vec![];
2075            let mut values = vec![];
2076
2077            for col_idx in 0..num_cols {
2078                let field_name = schema.field(col_idx).name().clone();
2079                let column = record_batch.column(col_idx);
2080
2081                let value = arrow_value_at(column.as_ref(), row_idx)?;
2082                field_names.push(field_name);
2083                values.push(value);
2084            }
2085
2086            rows.push(Row::new(field_names, values));
2087        }
2088    }
2089
2090    Ok(rows)
2091}
2092
2093/// Decode Arrow IPC stream into RecordBatches without converting to Rows.
2094///
2095/// Used by `collect_record_batches()` to provide raw Arrow data for conversions
2096/// to DataFusion and Polars.
2097fn decode_arrow_record_batches(
2098    batch: &proto::execute_plan_response::ArrowBatch,
2099) -> Result<Vec<arrow::record_batch::RecordBatch>> {
2100    use arrow::ipc::reader::StreamReader;
2101    use std::io::Cursor;
2102
2103    if batch.data.is_empty() {
2104        return Ok(vec![]);
2105    }
2106
2107    let cursor = Cursor::new(&batch.data);
2108    let mut reader = StreamReader::try_new(cursor, None).map_err(|e| {
2109        SparkError::connect_msg(format!("Failed to create Arrow stream reader: {}", e))
2110    })?;
2111
2112    let mut batches = vec![];
2113
2114    while let Some(record_batch) = reader
2115        .next()
2116        .transpose()
2117        .map_err(|e| SparkError::connect_msg(format!("Failed to decode Arrow batch: {}", e)))?
2118    {
2119        batches.push(record_batch);
2120    }
2121
2122    Ok(batches)
2123}
2124
2125/// Extract a value at a specific index from an Arrow array.
2126/// Format an unscaled `i128` and scale as a decimal string (e.g. 150, scale 2 -> "1.50").
2127fn i128_to_decimal_string(unscaled: i128, scale: i32) -> String {
2128    if scale <= 0 {
2129        return unscaled.to_string();
2130    }
2131    let scale = scale as usize;
2132    let neg = unscaled < 0;
2133    let mut digits = unscaled.unsigned_abs().to_string();
2134    if digits.len() <= scale {
2135        digits = format!("{}{}", "0".repeat(scale - digits.len() + 1), digits);
2136    }
2137    let point = digits.len() - scale;
2138    let s = format!("{}.{}", &digits[..point], &digits[point..]);
2139    if neg {
2140        format!("-{s}")
2141    } else {
2142        s
2143    }
2144}
2145
2146/// Serialize record batches to Arrow IPC (file format) bytes. Shared by
2147/// [`DataFrame::to_arrow`] and [`DataFrame::to_polars`]; an empty input yields a
2148/// valid empty-schema IPC file.
2149fn record_batches_to_ipc(batches: &[arrow::record_batch::RecordBatch]) -> Result<Vec<u8>> {
2150    use arrow::ipc::writer::FileWriter;
2151    let schema = match batches.first() {
2152        Some(b) => b.schema(),
2153        None => std::sync::Arc::new(arrow::datatypes::Schema::empty()),
2154    };
2155    let mut buf: Vec<u8> = Vec::new();
2156    {
2157        let mut writer = FileWriter::try_new(&mut buf, schema.as_ref())
2158            .map_err(|e| SparkError::connect_msg(format!("Arrow IPC writer init failed: {e}")))?;
2159        for batch in batches {
2160            writer
2161                .write(batch)
2162                .map_err(|e| SparkError::connect_msg(format!("Arrow IPC write failed: {e}")))?;
2163        }
2164        writer
2165            .finish()
2166            .map_err(|e| SparkError::connect_msg(format!("Arrow IPC finish failed: {e}")))?;
2167    }
2168    Ok(buf)
2169}
2170
2171/// Build a DataFusion DataFrame from record batches (the conversion behind
2172/// [`DataFrame::to_datafusion`], factored out so it is unit-testable without a
2173/// live server).
2174#[cfg(feature = "datafusion")]
2175fn record_batches_to_datafusion(
2176    ctx: &datafusion::prelude::SessionContext,
2177    batches: Vec<arrow::record_batch::RecordBatch>,
2178) -> Result<datafusion::dataframe::DataFrame> {
2179    if batches.is_empty() {
2180        return Err(SparkError::connect_msg(
2181            "Cannot create DataFusion DataFrame from empty result",
2182        ));
2183    }
2184    ctx.read_batches(batches)
2185        .map_err(|e| SparkError::connect_msg(format!("Failed to create DataFusion DataFrame: {e}")))
2186}
2187
2188/// Build a Polars DataFrame from record batches by bridging through Arrow IPC
2189/// bytes (so polars' vendored arrow need not match this crate's arrow-rs). The
2190/// conversion behind [`DataFrame::to_polars`], factored out for unit testing.
2191#[cfg(feature = "polars")]
2192fn record_batches_to_polars(
2193    batches: &[arrow::record_batch::RecordBatch],
2194) -> Result<polars::frame::DataFrame> {
2195    use polars::prelude::{IpcReader, SerReader};
2196    use std::io::Cursor;
2197    if batches.is_empty() {
2198        return Ok(polars::frame::DataFrame::empty());
2199    }
2200    let buf = record_batches_to_ipc(batches)?;
2201    IpcReader::new(Cursor::new(buf))
2202        .finish()
2203        .map_err(|e| SparkError::connect_msg(format!("Failed to create Polars DataFrame: {e}")))
2204}
2205
2206/// Render a decoded map key ([`Value`]) as its natural scalar string, since
2207/// `Value::Map` keys are `String`. Numeric/boolean keys use their value (`1`,
2208/// `true`), decimals their preserved digits; non-scalar keys (rare) fall back to
2209/// Debug. This must never use the enum's Debug form for scalars - a `map<int,…>`
2210/// key has to be "1", not "Integer(1)".
2211fn map_key_to_string(v: Value) -> String {
2212    match v {
2213        Value::String(s) => s,
2214        Value::Bool(b) => b.to_string(),
2215        Value::Byte(x) => x.to_string(),
2216        Value::Short(x) => x.to_string(),
2217        Value::Integer(x) => x.to_string(),
2218        Value::Long(x) => x.to_string(),
2219        Value::Float(x) => x.to_string(),
2220        Value::Double(x) => x.to_string(),
2221        Value::Date(d) => d.to_string(),
2222        Value::Timestamp(t) => t.to_string(),
2223        Value::Decimal { value, .. } => value,
2224        other => format!("{other:?}"),
2225    }
2226}
2227
2228pub(crate) fn arrow_value_at(array: &dyn arrow::array::Array, index: usize) -> Result<Value> {
2229    use arrow::array::*;
2230
2231    if array.is_null(index) {
2232        return Ok(Value::Null);
2233    }
2234
2235    // A NullType column arrives as an all-null NullArray; every element is Null.
2236    if array.as_any().downcast_ref::<NullArray>().is_some() {
2237        return Ok(Value::Null);
2238    }
2239
2240    // Try each array type
2241    if let Some(arr) = array.as_any().downcast_ref::<BooleanArray>() {
2242        return Ok(Value::Bool(arr.value(index)));
2243    }
2244    if let Some(arr) = array.as_any().downcast_ref::<Int8Array>() {
2245        return Ok(Value::Byte(arr.value(index)));
2246    }
2247    if let Some(arr) = array.as_any().downcast_ref::<Int16Array>() {
2248        return Ok(Value::Short(arr.value(index)));
2249    }
2250    if let Some(arr) = array.as_any().downcast_ref::<Int32Array>() {
2251        return Ok(Value::Integer(arr.value(index)));
2252    }
2253    if let Some(arr) = array.as_any().downcast_ref::<Int64Array>() {
2254        return Ok(Value::Long(arr.value(index)));
2255    }
2256    if let Some(arr) = array.as_any().downcast_ref::<Float32Array>() {
2257        return Ok(Value::Float(arr.value(index)));
2258    }
2259    if let Some(arr) = array.as_any().downcast_ref::<Float64Array>() {
2260        return Ok(Value::Double(arr.value(index)));
2261    }
2262    if let Some(arr) = array.as_any().downcast_ref::<StringArray>() {
2263        return Ok(Value::String(arr.value(index).to_string()));
2264    }
2265    if let Some(arr) = array.as_any().downcast_ref::<BinaryArray>() {
2266        return Ok(Value::Binary(arr.value(index).to_vec()));
2267    }
2268    if let Some(arr) = array.as_any().downcast_ref::<Date32Array>() {
2269        return Ok(Value::Date(arr.value(index)));
2270    }
2271    if let Some(arr) = array.as_any().downcast_ref::<TimestampMicrosecondArray>() {
2272        return Ok(Value::Timestamp(arr.value(index)));
2273    }
2274    // Unsigned integers.
2275    if let Some(arr) = array.as_any().downcast_ref::<UInt8Array>() {
2276        return Ok(Value::Short(arr.value(index) as i16));
2277    }
2278    if let Some(arr) = array.as_any().downcast_ref::<UInt16Array>() {
2279        return Ok(Value::Integer(arr.value(index) as i32));
2280    }
2281    if let Some(arr) = array.as_any().downcast_ref::<UInt32Array>() {
2282        return Ok(Value::Long(arr.value(index) as i64));
2283    }
2284    if let Some(arr) = array.as_any().downcast_ref::<UInt64Array>() {
2285        let val = arr.value(index);
2286        let i64_val = i64::try_from(val).map_err(|_| {
2287            SparkError::connect_msg(format!("UInt64 value {} exceeds i64 range", val))
2288        })?;
2289        return Ok(Value::Long(i64_val));
2290    }
2291    // Decimal128 -> Value::Decimal (exact, not lossy f64), preserving precision/scale.
2292    if let Some(arr) = array.as_any().downcast_ref::<Decimal128Array>() {
2293        let scale = arr.scale() as i32;
2294        return Ok(Value::Decimal {
2295            value: i128_to_decimal_string(arr.value(index), scale),
2296            precision: Some(arr.precision() as i32),
2297            scale: Some(scale),
2298        });
2299    }
2300    // Large / view string & binary variants.
2301    if let Some(arr) = array.as_any().downcast_ref::<LargeStringArray>() {
2302        return Ok(Value::String(arr.value(index).to_string()));
2303    }
2304    if let Some(arr) = array.as_any().downcast_ref::<LargeBinaryArray>() {
2305        return Ok(Value::Binary(arr.value(index).to_vec()));
2306    }
2307    if let Some(arr) = array.as_any().downcast_ref::<StringViewArray>() {
2308        return Ok(Value::String(arr.value(index).to_string()));
2309    }
2310    if let Some(arr) = array.as_any().downcast_ref::<BinaryViewArray>() {
2311        return Ok(Value::Binary(arr.value(index).to_vec()));
2312    }
2313    // Other timestamp units, normalized to microseconds.
2314    if let Some(arr) = array.as_any().downcast_ref::<TimestampSecondArray>() {
2315        return Ok(Value::Timestamp(arr.value(index) * 1_000_000));
2316    }
2317    if let Some(arr) = array.as_any().downcast_ref::<TimestampMillisecondArray>() {
2318        return Ok(Value::Timestamp(arr.value(index) * 1_000));
2319    }
2320    if let Some(arr) = array.as_any().downcast_ref::<TimestampNanosecondArray>() {
2321        return Ok(Value::Timestamp(arr.value(index) / 1_000));
2322    }
2323    if let Some(arr) = array.as_any().downcast_ref::<Date64Array>() {
2324        return Ok(Value::Date((arr.value(index) / 86_400_000) as i32));
2325    }
2326    // Nested: list, struct, map (recurse).
2327    if let Some(arr) = array.as_any().downcast_ref::<ListArray>() {
2328        let child = arr.value(index);
2329        let mut items = Vec::with_capacity(child.len());
2330        for i in 0..child.len() {
2331            items.push(arrow_value_at(child.as_ref(), i)?);
2332        }
2333        return Ok(Value::List(items));
2334    }
2335    if let Some(arr) = array.as_any().downcast_ref::<StructArray>() {
2336        // A VARIANT column arrives as struct<value: binary, metadata: binary> where the
2337        // `metadata` field carries arrow metadata {"variant": "true"}. Recognize it and
2338        // return a Value::Variant (raw bytes) so it materializes as a VariantVal (matching
2339        // pyspark) rather than a plain {value, metadata} struct/dict.
2340        let is_variant = arr.fields().iter().any(|f| {
2341            f.metadata()
2342                .get("variant")
2343                .map(|v| v == "true")
2344                .unwrap_or(false)
2345        });
2346        if is_variant {
2347            let bin_field = |name: &str| -> Result<Vec<u8>> {
2348                match arr.column_by_name(name) {
2349                    Some(col) => match arrow_value_at(col.as_ref(), index)? {
2350                        Value::Binary(b) => Ok(b),
2351                        Value::Null => Ok(vec![]),
2352                        _ => Err(SparkError::connect_msg("variant field is not binary")),
2353                    },
2354                    None => Ok(vec![]),
2355                }
2356            };
2357            return Ok(Value::Variant {
2358                value: bin_field("value")?,
2359                metadata: bin_field("metadata")?,
2360            });
2361        }
2362        let mut fields = Vec::new();
2363        for (f, col) in arr.fields().iter().zip(arr.columns()) {
2364            fields.push((f.name().clone(), arrow_value_at(col.as_ref(), index)?));
2365        }
2366        return Ok(Value::Struct(fields));
2367    }
2368    if let Some(arr) = array.as_any().downcast_ref::<MapArray>() {
2369        let entries = arr.value(index);
2370        let keys = entries.column(0);
2371        let vals = entries.column(1);
2372        let mut map = std::collections::BTreeMap::new();
2373        for i in 0..entries.len() {
2374            // A map key stringifies to its natural scalar form (e.g. `1`, `true`,
2375            // `1.5`), not the Rust enum's Debug output - a `map<int,string>` key must
2376            // be "1", never "Integer(1)".
2377            let k = map_key_to_string(arrow_value_at(keys.as_ref(), i)?);
2378            map.insert(k, arrow_value_at(vals.as_ref(), i)?);
2379        }
2380        return Ok(Value::Map(map));
2381    }
2382    // Decimal256 -> exact string-preserving Decimal (Arrow formats with the scale).
2383    if let Some(arr) = array.as_any().downcast_ref::<Decimal256Array>() {
2384        return Ok(Value::Decimal {
2385            value: arr.value_as_string(index),
2386            precision: Some(arr.precision() as i32),
2387            scale: Some(arr.scale() as i32),
2388        });
2389    }
2390    if let Some(arr) = array.as_any().downcast_ref::<FixedSizeBinaryArray>() {
2391        return Ok(Value::Binary(arr.value(index).to_vec()));
2392    }
2393    // TimeType (no dedicated Value): render as an ISO time string, normalized to micros.
2394    if let Some(arr) = array.as_any().downcast_ref::<Time64MicrosecondArray>() {
2395        return Ok(Value::String(micros_to_time_string(arr.value(index))));
2396    }
2397    if let Some(arr) = array.as_any().downcast_ref::<Time64NanosecondArray>() {
2398        return Ok(Value::String(micros_to_time_string(
2399            arr.value(index) / 1_000,
2400        )));
2401    }
2402    if let Some(arr) = array.as_any().downcast_ref::<Time32MillisecondArray>() {
2403        return Ok(Value::String(micros_to_time_string(
2404            arr.value(index) as i64 * 1_000,
2405        )));
2406    }
2407    if let Some(arr) = array.as_any().downcast_ref::<Time32SecondArray>() {
2408        return Ok(Value::String(micros_to_time_string(
2409            arr.value(index) as i64 * 1_000_000,
2410        )));
2411    }
2412    // Interval types (no dedicated Value): render a compact string.
2413    if let Some(arr) = array.as_any().downcast_ref::<IntervalYearMonthArray>() {
2414        let months = arr.value(index);
2415        return Ok(Value::String(format!(
2416            "{}-{}",
2417            months / 12,
2418            (months % 12).abs()
2419        )));
2420    }
2421    if let Some(arr) = array.as_any().downcast_ref::<IntervalDayTimeArray>() {
2422        let v = arr.value(index);
2423        return Ok(Value::String(format!(
2424            "{} days {} ms",
2425            v.days, v.milliseconds
2426        )));
2427    }
2428    if let Some(arr) = array.as_any().downcast_ref::<IntervalMonthDayNanoArray>() {
2429        let v = arr.value(index);
2430        return Ok(Value::String(format!(
2431            "{} months {} days {} ns",
2432            v.months, v.days, v.nanoseconds
2433        )));
2434    }
2435
2436    Err(SparkError::connect_msg(format!(
2437        "Unsupported Arrow type {:?} - cannot convert to Value",
2438        array.data_type()
2439    )))
2440}
2441
2442/// Render microseconds-since-midnight as an ISO time string `HH:MM:SS[.ffffff]`.
2443fn micros_to_time_string(micros: i64) -> String {
2444    let total_secs = micros.div_euclid(1_000_000);
2445    let us = micros.rem_euclid(1_000_000);
2446    let (h, m, s) = (total_secs / 3600, (total_secs % 3600) / 60, total_secs % 60);
2447    if us == 0 {
2448        format!("{h:02}:{m:02}:{s:02}")
2449    } else {
2450        format!("{h:02}:{m:02}:{s:02}.{us:06}")
2451    }
2452}
2453
2454#[cfg(test)]
2455mod cache_tests {
2456    use super::*;
2457    use prost::Message;
2458
2459    #[test]
2460    fn cache_default_is_memory_and_disk_deser() {
2461        let sl = DataFrame::memory_and_disk_deser();
2462        assert!(sl.use_memory && sl.use_disk && sl.deserialized);
2463        assert!(!sl.use_off_heap);
2464        assert_eq!(sl.replication, 1);
2465    }
2466
2467    #[test]
2468    fn persist_request_carries_storage_level_over_the_wire() {
2469        // The reviewer's bug class: an argument dropped before it reaches the proto.
2470        // Assert the storage level survives encode/decode inside the Persist analyze op.
2471        let persist = proto::analyze_plan_request::Persist {
2472            relation: None,
2473            storage_level: Some(DataFrame::memory_and_disk_deser()),
2474        };
2475        let decoded =
2476            proto::analyze_plan_request::Persist::decode(persist.encode_to_vec().as_slice())
2477                .unwrap();
2478        let sl = decoded
2479            .storage_level
2480            .expect("storage_level must be present");
2481        assert!(sl.use_memory && sl.use_disk && sl.deserialized && sl.replication == 1);
2482    }
2483
2484    #[test]
2485    fn get_storage_level_response_maps_to_is_cached() {
2486        // cached iff use_memory || use_disk (mirrors DataFrame.is_cached derivation)
2487        let cached = proto::StorageLevel {
2488            use_memory: true,
2489            ..Default::default()
2490        };
2491        let uncached = proto::StorageLevel::default();
2492        assert!(cached.use_memory || cached.use_disk);
2493        assert!(!(uncached.use_memory || uncached.use_disk));
2494    }
2495
2496    #[test]
2497    fn to_local_iterator_builds_same_plan_as_collect() {
2498        // The LocalRowIterator and collect() use the same underlying ExecutePlan.
2499        // The difference is purely in client-side consumption: streaming vs buffering.
2500        // We verify that to_local_iterator() creates the same iterator type.
2501        let _iter: LocalRowIterator;
2502        // This test just verifies the type exists and is constructible.
2503        // A true integration test would create an actual stream.
2504    }
2505}
2506
2507/// Deterministic tests for the collected-data conversions (`to_arrow`,
2508/// `to_datafusion`, `to_polars`). These exercise the conversion logic on
2509/// synthetic RecordBatches, so they need no live server and run in CI's
2510/// `--features datafusion,polars` job. The full server->collect->convert path is
2511/// covered separately by the server-gated e2e_integration tests.
2512#[cfg(test)]
2513mod conversion_tests {
2514    use super::*;
2515    use arrow::array::{Int64Array, StringArray};
2516    use arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
2517    use arrow::record_batch::RecordBatch;
2518    use std::sync::Arc;
2519
2520    fn sample_batch() -> RecordBatch {
2521        let schema = Arc::new(Schema::new(vec![
2522            Field::new("id", ArrowDataType::Int64, false),
2523            Field::new("name", ArrowDataType::Utf8, false),
2524        ]));
2525        RecordBatch::try_new(
2526            schema,
2527            vec![
2528                Arc::new(Int64Array::from(vec![1, 2, 3])),
2529                Arc::new(StringArray::from(vec!["a", "b", "c"])),
2530            ],
2531        )
2532        .unwrap()
2533    }
2534
2535    #[test]
2536    fn to_arrow_ipc_round_trips() {
2537        use arrow::ipc::reader::FileReader;
2538        use std::io::Cursor;
2539
2540        let ipc = record_batches_to_ipc(&[sample_batch()]).expect("ipc encode");
2541        let reader = FileReader::try_new(Cursor::new(ipc), None).expect("ipc decode");
2542        let batches: Vec<_> = reader.map(|b| b.unwrap()).collect();
2543        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
2544        assert_eq!(total, 3, "round-trip must preserve all rows");
2545        assert_eq!(batches[0].num_columns(), 2);
2546        let ids = batches[0]
2547            .column(0)
2548            .as_any()
2549            .downcast_ref::<Int64Array>()
2550            .unwrap();
2551        assert_eq!(ids.values(), &[1, 2, 3]);
2552    }
2553
2554    #[test]
2555    fn to_arrow_ipc_empty_is_valid() {
2556        // An empty result must still be a valid, readable IPC file.
2557        use arrow::ipc::reader::FileReader;
2558        use std::io::Cursor;
2559        let ipc = record_batches_to_ipc(&[]).expect("empty ipc");
2560        let reader = FileReader::try_new(Cursor::new(ipc), None).expect("empty ipc decode");
2561        assert_eq!(reader.map(|b| b.unwrap().num_rows()).sum::<usize>(), 0);
2562    }
2563
2564    #[cfg(feature = "datafusion")]
2565    #[test]
2566    fn to_datafusion_preserves_rows_and_columns() {
2567        use datafusion::prelude::SessionContext;
2568        use spark_connect_core::runtime::block_on;
2569
2570        let ctx = SessionContext::new();
2571        let df = record_batches_to_datafusion(&ctx, vec![sample_batch()]).expect("to datafusion");
2572        // Collect (async) and assert shape survives the conversion - uses only the
2573        // stable arrow RecordBatch API so it is not tied to a datafusion version.
2574        let collected = block_on(df.collect()).expect("collect datafusion");
2575        assert_eq!(collected.iter().map(|b| b.num_rows()).sum::<usize>(), 3);
2576        assert_eq!(collected[0].num_columns(), 2);
2577    }
2578
2579    #[cfg(feature = "datafusion")]
2580    #[test]
2581    fn to_datafusion_empty_errors() {
2582        use datafusion::prelude::SessionContext;
2583        let ctx = SessionContext::new();
2584        assert!(record_batches_to_datafusion(&ctx, vec![]).is_err());
2585    }
2586
2587    #[cfg(feature = "polars")]
2588    #[test]
2589    fn to_polars_preserves_shape() {
2590        // height()/width() are stable across polars versions; asserting shape proves
2591        // the Arrow-IPC bridge carried all rows and columns through.
2592        let pdf = record_batches_to_polars(&[sample_batch()]).expect("to polars");
2593        assert_eq!(
2594            pdf.height(),
2595            3,
2596            "row count must survive the Arrow-IPC bridge"
2597        );
2598        assert_eq!(
2599            pdf.width(),
2600            2,
2601            "column count must survive the Arrow-IPC bridge"
2602        );
2603    }
2604
2605    #[cfg(feature = "polars")]
2606    #[test]
2607    fn to_polars_empty_is_empty() {
2608        let pdf = record_batches_to_polars(&[]).expect("empty polars");
2609        assert_eq!(pdf.height(), 0);
2610    }
2611}
2612
2613#[cfg(test)]
2614mod plan_construction_tests {
2615    use super::*;
2616    use crate::session::SparkSession;
2617
2618    fn session() -> SparkSession {
2619        SparkSession::builder()
2620            .remote("sc://localhost:15002")
2621            .get_or_create()
2622            .expect("failed to build session")
2623    }
2624
2625    #[test]
2626    fn with_watermark_plan() {
2627        let spark = session();
2628        let df = spark.range(3).unwrap();
2629        let result = df.with_watermark("timestamp", "1 minute");
2630        match &result.plan {
2631            LogicalPlan::WithWatermark {
2632                time_column,
2633                delay_threshold,
2634                ..
2635            } => {
2636                assert_eq!(time_column, "timestamp");
2637                assert_eq!(delay_threshold, "1 minute");
2638            }
2639            _ => panic!("expected WithWatermark plan"),
2640        }
2641    }
2642
2643    #[test]
2644    fn with_metadata_plan() {
2645        let spark = session();
2646        let df = spark.range(3).unwrap();
2647        let mut metadata = std::collections::HashMap::new();
2648        metadata.insert("key".to_string(), "value".to_string());
2649        let result = df.with_metadata("col", metadata);
2650        match &result.plan {
2651            LogicalPlan::WithColumnMetadata {
2652                column_name,
2653                metadata_json,
2654                ..
2655            } => {
2656                assert_eq!(column_name, "col");
2657                assert!(!metadata_json.is_empty());
2658            }
2659            _ => panic!("expected WithColumnMetadata plan"),
2660        }
2661    }
2662
2663    #[test]
2664    fn random_split_plan() {
2665        let spark = session();
2666        let df = spark.range(10).unwrap();
2667        let dfs = df.random_split(vec![0.7, 0.3], None);
2668        assert_eq!(dfs.len(), 2);
2669        // Each split is a different DataFrame with its own plan
2670        for split_df in &dfs {
2671            match &split_df.plan {
2672                LogicalPlan::Sample {
2673                    with_replacement: false,
2674                    ..
2675                } => {
2676                    // Plan is correct
2677                }
2678                _ => panic!("expected Sample plan"),
2679            }
2680        }
2681    }
2682
2683    #[test]
2684    fn replace_plan() {
2685        let spark = session();
2686        let df = spark.range(3).unwrap();
2687        let replacements = vec![("old".to_string(), "new".to_string())];
2688        let result = df.replace(replacements, Some(vec!["col"]));
2689        match &result.plan {
2690            LogicalPlan::NAReplace { replacements, .. } => {
2691                assert_eq!(replacements.len(), 1);
2692            }
2693            _ => panic!("expected NAReplace plan"),
2694        }
2695    }
2696
2697    /// Exercise every plan-building builder AND its `plan.to_proto()` arm offline
2698    /// (the gRPC channel connects lazily, so no server is contacted). This pins the
2699    /// large builder set in dataframe.rs and the matching to_proto arms in plan.rs.
2700    #[test]
2701    fn builders_construct_and_serialize() {
2702        use crate::functions::col;
2703        use crate::types::{DataType, StructField};
2704
2705        let spark = session();
2706        let df = spark.range(5).unwrap();
2707        let df2 = spark.range(5).unwrap();
2708        let e = || col("id").expression().clone();
2709        let ser = |d: &DataFrame| {
2710            build_input_relation(d.plan(), &spark).expect("plan serializes to a relation");
2711        };
2712
2713        ser(&df.select(vec![col("id")]));
2714        ser(&df.filter(col("id")));
2715        ser(&df.where_(col("id")));
2716        ser(&df.with_column("x", col("id")));
2717        ser(&df.with_column_renamed("id", "y"));
2718        ser(&df.drop(vec!["id"]));
2719        ser(&df.limit(3));
2720        ser(&df.offset(1));
2721        ser(&df.distinct());
2722        ser(&df.drop_duplicates(Some(vec!["id"])));
2723        ser(&df.sort(vec![e()]));
2724        ser(&df.order_by(vec![e()]));
2725        ser(&df.sort_within_partitions(vec![e()]));
2726        ser(&df.cross_join(&df2));
2727        ser(&df.union(&df2));
2728        ser(&df.union_all(&df2));
2729        ser(&df.union_by_name(&df2));
2730        ser(&df.intersect(&df2));
2731        ser(&df.intersect_all(&df2));
2732        ser(&df.subtract(&df2));
2733        ser(&df.except_all(&df2));
2734        ser(&df.repartition(4));
2735        ser(&df.coalesce(2));
2736        ser(&df.repartition_by_range(3, vec![e()]));
2737        ser(&df.hint("broadcast", Vec::<String>::new()));
2738        ser(&df.to_df(vec!["a"]));
2739        ser(&df.alias("t"));
2740        ser(&df.sample(0.5, Some(1)));
2741        ser(&df.select_expr(vec!["id + 1"]));
2742        ser(&df.col_regex("id"));
2743        ser(&df.describe(vec!["id"]));
2744        ser(&df.summary(vec!["count"]));
2745        ser(&df.as_table("t2"));
2746        ser(&df.to(DataType::Struct {
2747            fields: vec![StructField {
2748                name: "id".to_string(),
2749                data_type: DataType::Long,
2750                nullable: true,
2751                metadata: std::collections::BTreeMap::new(),
2752            }],
2753        }));
2754        ser(&df.unpivot(vec![col("id")], None::<Vec<Column>>, "var", "val"));
2755        ser(&df.melt(vec!["id"], None, "var", "val"));
2756        ser(&df.group_by(vec![col("id")]).agg(vec![e()]));
2757        ser(&df.rollup(vec![col("id")]).agg(vec![e()]));
2758        ser(&df.cube(vec![col("id")]).agg(vec![e()]));
2759        ser(&df.grouping_sets(vec![vec![col("id")]]).agg(vec![e()]));
2760        ser(&df.with_watermark("id", "1 minute"));
2761        let mut md = std::collections::HashMap::new();
2762        md.insert("k".to_string(), "v".to_string());
2763        ser(&df.with_metadata("id", md));
2764        ser(&df.replace(vec![("a".to_string(), "b".to_string())], None));
2765        ser(&df.stat().crosstab("id", "id"));
2766        ser(&df.stat().freq_items(vec!["id"], 0.5));
2767    }
2768
2769    /// Streaming reader terminal builders (serialize their plans) + the writer builder
2770    /// chain across every Trigger variant (setters only; no server-side start()).
2771    #[test]
2772    fn streaming_reader_and_writer_builders() {
2773        use crate::streaming::Trigger;
2774        let spark = session();
2775        let ser = |d: &DataFrame| {
2776            build_input_relation(d.plan(), &spark).expect("stream plan serializes");
2777        };
2778        ser(&spark
2779            .read_stream()
2780            .format("rate")
2781            .option("rowsPerSecond", "5")
2782            .load(None));
2783        ser(&spark.read_stream().schema("value long").json("/tmp/in"));
2784        ser(&spark.read_stream().parquet("/tmp/in"));
2785        ser(&spark.read_stream().csv("/tmp/in"));
2786        ser(&spark.read_stream().orc("/tmp/in"));
2787        ser(&spark.read_stream().text("/tmp/in"));
2788        ser(&spark.read_stream().format("rate").table("t"));
2789
2790        let base = spark.range(3).unwrap();
2791        for trig in [
2792            Trigger::ProcessingTime("1 second".to_string()),
2793            Trigger::Once,
2794            Trigger::AvailableNow,
2795            Trigger::Continuous("1 second".to_string()),
2796        ] {
2797            let _w = base
2798                .write_stream()
2799                .output_mode("append")
2800                .format("console")
2801                .option("k", "v")
2802                .partition_by(vec!["id"])
2803                .cluster_by(vec!["id"])
2804                .query_name("q")
2805                .trigger(trig);
2806        }
2807    }
2808
2809    /// Every Column operator/method builds an expression; serialize each proto to pin
2810    /// the column.rs bodies and the expression.rs to_proto arms.
2811    #[test]
2812    fn column_operations_and_expressions() {
2813        use crate::functions::col;
2814        let a = || col("a");
2815        let b = || col("b");
2816        let exprs = vec![
2817            a().add(b()),
2818            a().sub(b()),
2819            a().mul(b()),
2820            a().div(b()),
2821            a().modulo(b()),
2822            a().and(b()),
2823            a().or(b()),
2824            a().not(),
2825            a().neg(),
2826            a().eq(b()),
2827            a().ne(b()),
2828            a().gt(b()),
2829            a().lt(b()),
2830            a().ge(b()),
2831            a().le(b()),
2832            a().bitwise_and(b()),
2833            a().bitwise_or(b()),
2834            a().bitwise_xor(b()),
2835            a().eq_null_safe(b()),
2836            a().is_null(),
2837            a().is_not_null(),
2838            a().is_nan(),
2839            a().like("x%"),
2840            a().rlike("x.*"),
2841            a().ilike("x%"),
2842            a().contains(b()),
2843            a().startswith(b()),
2844            a().endswith(b()),
2845            a().substr(b(), b()),
2846            a().between(b(), b()),
2847            a().isin(vec![b()]),
2848            a().get_field("f"),
2849            a().get_item(b()),
2850            a().with_field("f", b()),
2851            a().drop_fields(vec!["f"]),
2852            a().asc(),
2853            a().asc_nulls_first(),
2854            a().asc_nulls_last(),
2855            a().desc(),
2856            a().desc_nulls_first(),
2857            a().desc_nulls_last(),
2858            a().alias("x"),
2859            a().name("y"),
2860            a().cast_str("int"),
2861            a().try_cast_str("int"),
2862            a().astype(crate::types::DataType::Integer),
2863            a().when(b(), b()).otherwise(b()),
2864        ];
2865        for e in &exprs {
2866            let _ = e.to_proto();
2867        }
2868    }
2869
2870    /// Construct the exotic plan variants (Zip, Transpose, NearestByJoin,
2871    /// MapPartitions, GroupMap, CoGroupMap, CommonInlineUdtf) and serialize each so
2872    /// their to_proto arms in plan.rs are pinned.
2873    #[test]
2874    fn exotic_plan_variants_serialize() {
2875        use crate::functions::col;
2876        use crate::types::DataType;
2877        use crate::udf::{CommonInlineUserDefinedFunctionExpression, PythonUDFPayload};
2878
2879        let spark = session();
2880        let ser = |d: &DataFrame| {
2881            build_input_relation(d.plan(), &spark).expect("exotic plan serializes");
2882        };
2883        let df = spark.range(5).unwrap();
2884        let df2 = spark.range(5).unwrap();
2885
2886        ser(&df.zip(&df2).unwrap());
2887        ser(&df.transpose().unwrap());
2888        ser(&df.transpose_with_index(col("id")).unwrap());
2889        ser(&df.nearest_by_join(&df2, col("id"), 5, "inner", "asc", "inner"));
2890
2891        let udf = || {
2892            CommonInlineUserDefinedFunctionExpression::new(
2893                "f".to_string(),
2894                true,
2895                vec![],
2896                PythonUDFPayload::new(DataType::Integer, 200, vec![1, 2, 3], "3.11".to_string()),
2897            )
2898        };
2899        ser(&df.map_in_pandas(udf(), false));
2900        ser(&df.map_in_arrow(udf(), false));
2901        ser(&df.group_by(vec![col("id")]).apply_in_pandas(udf()));
2902        ser(&df.group_by(vec![col("id")]).apply_in_arrow(udf()));
2903        let g1 = df.group_by(vec![col("id")]);
2904        let g2 = df2.group_by(vec![col("id")]);
2905        ser(&g1.cogroup(&g2).apply_in_pandas(udf()));
2906
2907        let udtf_df = spark.tvf().udtf(
2908            "myudtf",
2909            vec![],
2910            Some(DataType::Integer),
2911            300,
2912            vec![1, 2],
2913            "3.11".to_string(),
2914            true,
2915        );
2916        ser(&udtf_df);
2917    }
2918}
2919
2920/// Deterministic coverage of the private Arrow-array -> `Value` converter
2921/// (`arrow_value_at`) and its formatting helpers. Builds a one-element array of
2922/// each Arrow type and asserts the decoded `Value` variant, so the large per-type
2923/// match runs without a live server (the server->collect->decode path is covered
2924/// separately by the server-gated e2e_integration tests).
2925#[cfg(test)]
2926mod arrow_value_tests {
2927    use super::*;
2928    use arrow::array::*;
2929    use arrow::datatypes::{
2930        i256, DataType as ArrowDataType, Field, Int32Type, IntervalDayTime, IntervalMonthDayNano,
2931    };
2932    use std::sync::Arc;
2933
2934    #[test]
2935    fn primitives_and_signed_ints() {
2936        assert!(matches!(
2937            arrow_value_at(&BooleanArray::from(vec![true]), 0).unwrap(),
2938            Value::Bool(true)
2939        ));
2940        assert!(matches!(
2941            arrow_value_at(&Int8Array::from(vec![1i8]), 0).unwrap(),
2942            Value::Byte(1)
2943        ));
2944        assert!(matches!(
2945            arrow_value_at(&Int16Array::from(vec![1i16]), 0).unwrap(),
2946            Value::Short(1)
2947        ));
2948        assert!(matches!(
2949            arrow_value_at(&Int32Array::from(vec![1i32]), 0).unwrap(),
2950            Value::Integer(1)
2951        ));
2952        assert!(matches!(
2953            arrow_value_at(&Int64Array::from(vec![1i64]), 0).unwrap(),
2954            Value::Long(1)
2955        ));
2956        assert!(matches!(
2957            arrow_value_at(&Float32Array::from(vec![1.0f32]), 0).unwrap(),
2958            Value::Float(_)
2959        ));
2960        assert!(matches!(
2961            arrow_value_at(&Float64Array::from(vec![1.0f64]), 0).unwrap(),
2962            Value::Double(_)
2963        ));
2964        assert!(matches!(
2965            arrow_value_at(&StringArray::from(vec!["x"]), 0).unwrap(),
2966            Value::String(_)
2967        ));
2968        assert!(matches!(
2969            arrow_value_at(&BinaryArray::from_iter_values([b"x".as_ref()]), 0).unwrap(),
2970            Value::Binary(_)
2971        ));
2972        assert!(matches!(
2973            arrow_value_at(&Date32Array::from(vec![1i32]), 0).unwrap(),
2974            Value::Date(1)
2975        ));
2976        assert!(matches!(
2977            arrow_value_at(&TimestampMicrosecondArray::from(vec![1i64]), 0).unwrap(),
2978            Value::Timestamp(1)
2979        ));
2980    }
2981
2982    #[test]
2983    fn unsigned_ints() {
2984        assert!(matches!(
2985            arrow_value_at(&UInt8Array::from(vec![1u8]), 0).unwrap(),
2986            Value::Short(1)
2987        ));
2988        assert!(matches!(
2989            arrow_value_at(&UInt16Array::from(vec![1u16]), 0).unwrap(),
2990            Value::Integer(1)
2991        ));
2992        assert!(matches!(
2993            arrow_value_at(&UInt32Array::from(vec![1u32]), 0).unwrap(),
2994            Value::Long(1)
2995        ));
2996        assert!(matches!(
2997            arrow_value_at(&UInt64Array::from(vec![1u64]), 0).unwrap(),
2998            Value::Long(1)
2999        ));
3000    }
3001
3002    #[test]
3003    fn decimals_128_and_256() {
3004        let d128 = Decimal128Array::from(vec![12345i128])
3005            .with_precision_and_scale(10, 2)
3006            .unwrap();
3007        assert!(matches!(
3008            arrow_value_at(&d128, 0).unwrap(),
3009            Value::Decimal { .. }
3010        ));
3011        let d256 = Decimal256Array::from(vec![i256::from_i128(12345)])
3012            .with_precision_and_scale(10, 2)
3013            .unwrap();
3014        assert!(matches!(
3015            arrow_value_at(&d256, 0).unwrap(),
3016            Value::Decimal { .. }
3017        ));
3018    }
3019
3020    #[test]
3021    fn large_and_view_bytes() {
3022        assert!(matches!(
3023            arrow_value_at(&LargeStringArray::from_iter_values(["x"]), 0).unwrap(),
3024            Value::String(_)
3025        ));
3026        assert!(matches!(
3027            arrow_value_at(&LargeBinaryArray::from_iter_values([b"x".as_ref()]), 0).unwrap(),
3028            Value::Binary(_)
3029        ));
3030        assert!(matches!(
3031            arrow_value_at(&StringViewArray::from_iter_values(["x"]), 0).unwrap(),
3032            Value::String(_)
3033        ));
3034        assert!(matches!(
3035            arrow_value_at(&BinaryViewArray::from_iter_values([b"x".as_ref()]), 0).unwrap(),
3036            Value::Binary(_)
3037        ));
3038    }
3039
3040    #[test]
3041    fn timestamps_and_date64() {
3042        assert!(matches!(
3043            arrow_value_at(&TimestampSecondArray::from(vec![1i64]), 0).unwrap(),
3044            Value::Timestamp(_)
3045        ));
3046        assert!(matches!(
3047            arrow_value_at(&TimestampMillisecondArray::from(vec![1i64]), 0).unwrap(),
3048            Value::Timestamp(_)
3049        ));
3050        assert!(matches!(
3051            arrow_value_at(&TimestampNanosecondArray::from(vec![1000i64]), 0).unwrap(),
3052            Value::Timestamp(_)
3053        ));
3054        assert!(matches!(
3055            arrow_value_at(&Date64Array::from(vec![86_400_000i64]), 0).unwrap(),
3056            Value::Date(_)
3057        ));
3058    }
3059
3060    #[test]
3061    fn time_types_render_as_string() {
3062        assert!(matches!(
3063            arrow_value_at(&Time64MicrosecondArray::from(vec![1i64]), 0).unwrap(),
3064            Value::String(_)
3065        ));
3066        assert!(matches!(
3067            arrow_value_at(&Time64NanosecondArray::from(vec![1000i64]), 0).unwrap(),
3068            Value::String(_)
3069        ));
3070        assert!(matches!(
3071            arrow_value_at(&Time32MillisecondArray::from(vec![1i32]), 0).unwrap(),
3072            Value::String(_)
3073        ));
3074        assert!(matches!(
3075            arrow_value_at(&Time32SecondArray::from(vec![1i32]), 0).unwrap(),
3076            Value::String(_)
3077        ));
3078    }
3079
3080    #[test]
3081    fn interval_types_render_as_string() {
3082        assert!(matches!(
3083            arrow_value_at(&IntervalYearMonthArray::from(vec![13i32]), 0).unwrap(),
3084            Value::String(_)
3085        ));
3086        let dt = IntervalDayTimeArray::from(vec![IntervalDayTime::new(1, 100)]);
3087        assert!(matches!(arrow_value_at(&dt, 0).unwrap(), Value::String(_)));
3088        let mdn = IntervalMonthDayNanoArray::from(vec![IntervalMonthDayNano::new(1, 2, 3)]);
3089        assert!(matches!(arrow_value_at(&mdn, 0).unwrap(), Value::String(_)));
3090    }
3091
3092    #[test]
3093    fn fixed_size_binary() {
3094        let arr = FixedSizeBinaryArray::try_from_iter(vec![vec![1u8, 2u8]].into_iter()).unwrap();
3095        assert!(matches!(arrow_value_at(&arr, 0).unwrap(), Value::Binary(_)));
3096    }
3097
3098    #[test]
3099    fn nested_list_struct_map() {
3100        let list =
3101            ListArray::from_iter_primitive::<Int32Type, _, _>(vec![Some(vec![Some(1), Some(2)])]);
3102        assert!(matches!(arrow_value_at(&list, 0).unwrap(), Value::List(_)));
3103
3104        let field = Arc::new(Field::new("a", ArrowDataType::Int32, false));
3105        let col: ArrayRef = Arc::new(Int32Array::from(vec![1]));
3106        let s = StructArray::from(vec![(field, col)]);
3107        assert!(matches!(arrow_value_at(&s, 0).unwrap(), Value::Struct(_)));
3108
3109        let mut b = MapBuilder::new(None, StringBuilder::new(), Int32Builder::new());
3110        b.keys().append_value("k");
3111        b.values().append_value(1);
3112        b.append(true).unwrap();
3113        let m = b.finish();
3114        assert!(matches!(arrow_value_at(&m, 0).unwrap(), Value::Map(_)));
3115    }
3116
3117    #[test]
3118    fn null_element_and_unsupported_type() {
3119        let with_null = Int32Array::from(vec![None as Option<i32>]);
3120        assert!(matches!(
3121            arrow_value_at(&with_null, 0).unwrap(),
3122            Value::Null
3123        ));
3124        // Duration has no dedicated Value arm -> the final unsupported-type Err.
3125        let dur = DurationSecondArray::from(vec![1i64]);
3126        assert!(arrow_value_at(&dur, 0).is_err());
3127    }
3128
3129    #[test]
3130    fn map_key_to_string_covers_scalar_arms() {
3131        assert_eq!(map_key_to_string(Value::String("x".to_string())), "x");
3132        assert_eq!(map_key_to_string(Value::Bool(true)), "true");
3133        assert_eq!(map_key_to_string(Value::Byte(1)), "1");
3134        assert_eq!(map_key_to_string(Value::Short(2)), "2");
3135        assert_eq!(map_key_to_string(Value::Integer(3)), "3");
3136        assert_eq!(map_key_to_string(Value::Long(4)), "4");
3137        assert_eq!(map_key_to_string(Value::Float(1.5)), "1.5");
3138        assert_eq!(map_key_to_string(Value::Double(2.5)), "2.5");
3139        assert_eq!(map_key_to_string(Value::Date(5)), "5");
3140        assert_eq!(map_key_to_string(Value::Timestamp(6)), "6");
3141        assert_eq!(
3142            map_key_to_string(Value::Decimal {
3143                value: "7.5".to_string(),
3144                precision: None,
3145                scale: None,
3146            }),
3147            "7.5"
3148        );
3149        // Non-scalar key falls back to the Debug form.
3150        let _ = map_key_to_string(Value::List(vec![]));
3151    }
3152
3153    #[test]
3154    fn i128_to_decimal_string_branches() {
3155        assert_eq!(i128_to_decimal_string(12345, 0), "12345");
3156        assert_eq!(i128_to_decimal_string(12345, 2), "123.45");
3157        assert_eq!(i128_to_decimal_string(5, 4), "0.0005");
3158        assert_eq!(i128_to_decimal_string(-5, 4), "-0.0005");
3159    }
3160
3161    #[test]
3162    fn micros_to_time_string_branches() {
3163        assert_eq!(micros_to_time_string(0), "00:00:00");
3164        assert!(micros_to_time_string(1).contains('.'));
3165    }
3166}