Skip to main content

spark_connect/
plan.rs

1//! Logical plan nodes mirroring `pyspark.sql.connect.plan`.
2//!
3//! Each node builds a `spark.connect.Relation` (or `Command`) protobuf matching
4//! the reference PySpark client (see `tests/golden/plans.jsonl`).
5
6use spark_connect_proto as proto;
7use std::collections::HashMap;
8
9use crate::column::Column;
10use crate::expression::Expression;
11use crate::types::DataType;
12use crate::udf::CommonInlineUserDefinedFunctionExpression;
13
14/// Parameters for the `transformWithState` / `transformWithStateInPandas` operators
15/// (mirrors `spark.connect.TransformWithStateInfo`).
16#[derive(Debug, Clone)]
17pub struct TransformWithStateInfo {
18    pub time_mode: String,
19    pub event_time_column_name: Option<String>,
20    pub output_schema: Option<DataType>,
21}
22
23/// A logical plan node, mirroring `pyspark.sql.connect.plan.LogicalPlan`.
24///
25/// Each variant represents a different relation type that can be executed.
26/// Call `to_proto()` to convert to a `spark.connect.Relation` protobuf.
27#[derive(Debug, Clone)]
28pub enum LogicalPlan {
29    /// A range of integers: `range(start, end, step)`.
30    Range {
31        start: i64,
32        end: i64,
33        step: i64,
34        num_partitions: Option<i32>,
35    },
36    /// A SQL query: `sql("SELECT ...")`, with optional positional/named parameter bindings.
37    Sql {
38        query: String,
39        pos_args: Vec<Expression>,
40        named_args: HashMap<String, Expression>,
41    },
42    /// A projection (select): `df.select(cols...)`.
43    Project {
44        input: Box<LogicalPlan>,
45        columns: Vec<Column>,
46    },
47    /// A filter: `df.filter(condition)`.
48    Filter {
49        input: Box<LogicalPlan>,
50        condition: Column,
51    },
52    /// An aggregation: `df.groupBy(...).agg(...)`.
53    Aggregate {
54        input: Box<LogicalPlan>,
55        group_type: AggregateGroupType,
56        grouping_expressions: Vec<Expression>,
57        aggregate_expressions: Vec<Expression>,
58        pivot_col: Option<Expression>,
59        pivot_values: Vec<Expression>,
60        /// Explicit grouping sets (each inner Vec is one set of grouping expressions),
61        /// used when `group_type == GroupingSets`. Empty otherwise.
62        grouping_sets: Vec<Vec<Expression>>,
63    },
64    /// A join: `df.join(other, on)`.
65    Join {
66        left: Box<LogicalPlan>,
67        right: Box<LogicalPlan>,
68        join_type: JoinType,
69        on: Option<Column>,
70        using_columns: Vec<String>,
71    },
72    /// A lateral join (`LATERAL` correlated subquery join).
73    LateralJoin {
74        left: Box<LogicalPlan>,
75        right: Box<LogicalPlan>,
76        join_type: JoinType,
77        on: Option<Column>,
78    },
79    /// A set operation (union, intersect, except).
80    SetOperation {
81        left: Box<LogicalPlan>,
82        right: Box<LogicalPlan>,
83        set_op_type: SetOpType,
84        is_all: bool,
85        by_name: bool,
86        allow_missing_columns: bool,
87    },
88    /// Limit: `df.limit(n)`.
89    Limit { input: Box<LogicalPlan>, limit: i32 },
90    /// Offset: `df.offset(n)`.
91    Offset {
92        input: Box<LogicalPlan>,
93        offset: i32,
94    },
95    /// Tail: `df.tail(n)` - returns last n rows.
96    Tail { input: Box<LogicalPlan>, limit: i32 },
97    /// Deduplicate: `df.distinct()` or `df.dropDuplicates()`.
98    Deduplicate {
99        input: Box<LogicalPlan>,
100        all_columns_as_keys: bool,
101        column_names: Vec<String>,
102        within_watermark: bool,
103    },
104    /// Sort: `df.sort(cols...)`.
105    Sort {
106        input: Box<LogicalPlan>,
107        order: Vec<Expression>,
108        is_global: bool,
109    },
110    /// Sample: `df.sample(fraction)` or `df.sample(num_rows)`.
111    Sample {
112        input: Box<LogicalPlan>,
113        lower_bound: f64,
114        upper_bound: f64,
115        with_replacement: bool,
116        seed: Option<i64>,
117    },
118    /// Repartition: `df.repartition(num_partitions)`.
119    Repartition {
120        input: Box<LogicalPlan>,
121        num_partitions: i32,
122        shuffle: bool,
123    },
124    /// RepartitionByExpression: `df.repartitionByRange()` or similar.
125    RepartitionByExpression {
126        input: Box<LogicalPlan>,
127        num_partitions: i32,
128        expressions: Vec<Expression>,
129    },
130    /// WithColumns: `df.withColumn(name, col)` or `df.withColumns(...)`.
131    WithColumns {
132        input: Box<LogicalPlan>,
133        column_names: Vec<String>,
134        columns: Vec<Column>,
135    },
136    /// WithColumnMetadata: set metadata (a JSON map) on a single existing column.
137    /// Mirrors `DataFrame.withMetadata` - a `WithColumns` whose one alias re-aliases
138    /// the column to itself, carrying the metadata JSON.
139    WithColumnMetadata {
140        input: Box<LogicalPlan>,
141        column_name: String,
142        metadata_json: String,
143    },
144    /// WithColumnsRenamed: `df.withColumnRenamed(old, new)` or `df.withColumnsRenamed(...)`.
145    WithColumnsRenamed {
146        input: Box<LogicalPlan>,
147        renames: HashMap<String, String>,
148    },
149    /// Drop: `df.drop(column_names...)`.
150    Drop {
151        input: Box<LogicalPlan>,
152        columns: Vec<String>,
153    },
154    /// ToDF: `df.toDF(*column_names)`.
155    ToDF {
156        input: Box<LogicalPlan>,
157        column_names: Vec<String>,
158    },
159    /// ToSchema: set schema to match the provided type.
160    ToSchema {
161        input: Box<LogicalPlan>,
162        schema: DataType,
163    },
164    /// Hint: `df.hint(name, parameters...)`.
165    Hint {
166        input: Box<LogicalPlan>,
167        name: String,
168        parameters: Vec<String>,
169    },
170    /// Unpivot: `df.unpivot(...)`.
171    Unpivot {
172        input: Box<LogicalPlan>,
173        ids: Vec<Column>,
174        values: Option<Vec<Column>>,
175        variable_column_name: String,
176        value_column_name: String,
177    },
178    /// NAFill: `df.fillna(value, subset)`.
179    NAFill {
180        input: Box<LogicalPlan>,
181        fill_value: crate::row::Value,
182        columns: Vec<String>,
183    },
184    /// NAFill with a per-column value: `df.fillna({col: value, ...})`.
185    NAFillColumns {
186        input: Box<LogicalPlan>,
187        cols: Vec<String>,
188        values: Vec<crate::row::Value>,
189    },
190    /// NADrop: `df.dropna(how, thresh, subset)`.
191    NADrop {
192        input: Box<LogicalPlan>,
193        how: String,
194        min_non_null: Option<i32>,
195        columns: Vec<String>,
196    },
197    /// NAReplace: `df.replace(to_replace, value, subset)`.
198    NAReplace {
199        input: Box<LogicalPlan>,
200        replacements: Vec<(String, String)>,
201        columns: Vec<String>,
202    },
203    /// Describe: `df.describe(cols...)`.
204    Describe {
205        input: Box<LogicalPlan>,
206        columns: Vec<String>,
207    },
208    /// Summary: `df.summary(percentiles...)`.
209    Summary {
210        input: Box<LogicalPlan>,
211        percentiles: Vec<String>,
212    },
213    /// ColRegex: column selection by regex.
214    ColRegex {
215        input: Box<LogicalPlan>,
216        col_name: String,
217    },
218    /// SubqueryAlias: `df.as(alias)`.
219    SubqueryAlias {
220        input: Box<LogicalPlan>,
221        alias: String,
222    },
223    /// LocalRelation: data supplied locally.
224    LocalRelation {
225        schema: DataType,
226        data: Option<Vec<u8>>,
227    },
228    /// CachedRemoteRelation: a reference to a cached remote relation.
229    CachedRemoteRelation { relation_id: String },
230    /// Read: `spark.read.format(...).load(...)` or `spark.read.table(...)`.
231    Read {
232        read_type: crate::readwriter::ReadType,
233        is_streaming: bool,
234    },
235    /// RelationChanges: `spark.read.changes(table)` / `spark.readStream.changes(table)`
236    /// (CDC read for a named table).
237    RelationChanges {
238        table_name: String,
239        options: std::collections::HashMap<String, String>,
240        is_streaming: Option<bool>,
241    },
242    /// WithWatermark: `df.withWatermark(timeColumn, delayThreshold)`.
243    WithWatermark {
244        input: Box<LogicalPlan>,
245        time_column: String,
246        delay_threshold: String,
247    },
248    /// RepartitionByRange: `df.repartitionByRange(numPartitions, *cols)`.
249    RepartitionByRange {
250        input: Box<LogicalPlan>,
251        num_partitions: Option<i32>,
252        partition_exprs: Vec<Expression>,
253    },
254    /// StatCrosstab: `df.stat.crosstab(col1, col2)`.
255    StatCrosstab {
256        input: Box<LogicalPlan>,
257        col1: String,
258        col2: String,
259    },
260    /// StatFreqItems: `df.stat.freqItems(columns, support)`.
261    StatFreqItems {
262        input: Box<LogicalPlan>,
263        columns: Vec<String>,
264        support: f64,
265    },
266    /// StatApproxQuantile: `df.stat.approxQuantile(columns, probabilities, relativeError)`.
267    StatApproxQuantile {
268        input: Box<LogicalPlan>,
269        columns: Vec<String>,
270        probabilities: Vec<f64>,
271        relative_error: f64,
272    },
273    /// StatCorr: `df.stat.corr(col1, col2)`.
274    StatCorr {
275        input: Box<LogicalPlan>,
276        col1: String,
277        col2: String,
278    },
279    /// StatCov: `df.stat.cov(col1, col2)`.
280    StatCov {
281        input: Box<LogicalPlan>,
282        col1: String,
283        col2: String,
284    },
285    /// StatSampleBy: `df.stat.sampleBy(col, fractions, seed)`.
286    StatSampleBy {
287        input: Box<LogicalPlan>,
288        col: String,
289        fractions: Vec<(Expression, f64)>,
290        seed: Option<i64>,
291    },
292    /// Observe: `df.observe(name, exprs)` - collect metrics.
293    Observe {
294        input: Box<LogicalPlan>,
295        name: String,
296        exprs: Vec<Expression>,
297    },
298    /// UnresolvedTableValuedFunction: a TVF like explode, inline, range.
299    UnresolvedTableValuedFunction {
300        name: String,
301        arguments: Vec<Expression>,
302    },
303    /// Zip: `df.zip(other)` - combine two DataFrames column-wise.
304    Zip {
305        left: Box<LogicalPlan>,
306        right: Box<LogicalPlan>,
307    },
308    /// ML Transformation: wraps an MlRelation proto directly.
309    MlTransform { ml_relation: proto::Relation },
310    /// A catalog operation exposed as a relation (e.g. `listTables`, `listColumns`),
311    /// evaluated lazily so `.collect()` returns the server's rows - including zero
312    /// rows for an empty database, which must not be an error.
313    Catalog { catalog: proto::Catalog },
314    /// Transpose: swap rows and columns. `index_columns` optionally names the
315    /// column(s) used as the transposed header (empty = server default).
316    Transpose {
317        input: Box<LogicalPlan>,
318        index_columns: Vec<Expression>,
319    },
320    /// MapPartitions: `DataFrame.mapInPandas` / `mapInArrow` (also backs foreach).
321    MapPartitions {
322        input: Box<LogicalPlan>,
323        func: CommonInlineUserDefinedFunctionExpression,
324        is_barrier: bool,
325    },
326    /// GroupMap: `GroupedData.applyInPandas` / `applyInArrow`, and the stateful
327    /// variants `applyInPandasWithState` / `transformWithState[InPandas]`.
328    GroupMap {
329        input: Box<LogicalPlan>,
330        grouping_expressions: Vec<Expression>,
331        func: CommonInlineUserDefinedFunctionExpression,
332        sorting_expressions: Vec<Expression>,
333        // Stateful fields (all default to None/empty for plain applyInPandas/applyInArrow).
334        initial_input: Option<Box<LogicalPlan>>,
335        initial_grouping_expressions: Vec<Expression>,
336        is_map_groups_with_state: Option<bool>,
337        output_mode: Option<String>,
338        timeout_conf: Option<String>,
339        state_schema: Option<DataType>,
340        transform_with_state_info: Option<TransformWithStateInfo>,
341    },
342    /// CoGroupMap: `GroupedData.cogroup(...).applyInPandas` / `applyInArrow`.
343    CoGroupMap {
344        input: Box<LogicalPlan>,
345        input_grouping_expressions: Vec<Expression>,
346        other: Box<LogicalPlan>,
347        other_grouping_expressions: Vec<Expression>,
348        func: CommonInlineUserDefinedFunctionExpression,
349    },
350    /// NearestByJoin: `DataFrame.nearestByJoin`.
351    NearestByJoin {
352        left: Box<LogicalPlan>,
353        right: Box<LogicalPlan>,
354        ranking_expression: Expression,
355        num_results: i32,
356        join_type: String,
357        mode: String,
358        direction: String,
359    },
360    /// CommonInlineUserDefinedTableFunction: a Python UDTF invocation (`functions.udtf`).
361    CommonInlineUdtf {
362        function_name: String,
363        deterministic: bool,
364        arguments: Vec<Expression>,
365        return_type: Option<crate::types::DataType>,
366        eval_type: i32,
367        command: Vec<u8>,
368        python_ver: String,
369    },
370}
371
372/// Aggregation group type (GROUP BY, ROLLUP, CUBE, PIVOT).
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum AggregateGroupType {
375    GroupBy,
376    Rollup,
377    Cube,
378    Pivot,
379    GroupingSets,
380}
381
382/// Join type.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum JoinType {
385    Inner,
386    LeftOuter,
387    RightOuter,
388    FullOuter,
389    LeftSemi,
390    LeftAnti,
391    Cross,
392}
393
394/// Set operation type (UNION, INTERSECT, EXCEPT).
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum SetOpType {
397    Union,
398    Intersect,
399    Except,
400}
401
402impl LogicalPlan {
403    /// Convert this logical plan to a `spark.connect.Relation` protobuf.
404    pub fn to_proto(&self) -> proto::Relation {
405        let mut relation = proto::Relation::default();
406        relation.common = Some(proto::RelationCommon::default());
407
408        match self {
409            LogicalPlan::Range {
410                start,
411                end,
412                step,
413                num_partitions,
414            } => {
415                let mut range = proto::Range::default();
416                range.start = Some(*start);
417                range.end = *end;
418                range.step = *step;
419                if let Some(n) = num_partitions {
420                    range.num_partitions = Some(*n);
421                }
422                relation.rel_type = Some(proto::relation::RelType::Range(range));
423            }
424
425            LogicalPlan::Sql {
426                query,
427                pos_args,
428                named_args,
429            } => {
430                let mut sql = proto::Sql::default();
431                sql.query = query.clone();
432                for e in pos_args {
433                    sql.pos_arguments.push(e.to_proto());
434                }
435                for (k, v) in named_args {
436                    sql.named_arguments.insert(k.clone(), v.to_proto());
437                }
438                relation.rel_type = Some(proto::relation::RelType::Sql(sql));
439            }
440
441            LogicalPlan::Project { input, columns } => {
442                let mut project = proto::Project::default();
443                project.input = Some(Box::new(input.to_proto()));
444                for col in columns {
445                    project.expressions.push(col.to_proto());
446                }
447                relation.rel_type = Some(proto::relation::RelType::Project(Box::new(project)));
448            }
449
450            LogicalPlan::Filter { input, condition } => {
451                let mut filter = proto::Filter::default();
452                filter.input = Some(Box::new(input.to_proto()));
453                filter.condition = Some(condition.to_proto());
454                relation.rel_type = Some(proto::relation::RelType::Filter(Box::new(filter)));
455            }
456
457            LogicalPlan::Aggregate {
458                input,
459                group_type,
460                grouping_expressions,
461                aggregate_expressions,
462                pivot_col,
463                pivot_values,
464                grouping_sets,
465            } => {
466                let mut agg = proto::Aggregate::default();
467                agg.input = Some(Box::new(input.to_proto()));
468                agg.group_type = match group_type {
469                    AggregateGroupType::GroupBy => proto::aggregate::GroupType::Groupby as i32,
470                    AggregateGroupType::Rollup => proto::aggregate::GroupType::Rollup as i32,
471                    AggregateGroupType::Cube => proto::aggregate::GroupType::Cube as i32,
472                    AggregateGroupType::Pivot => proto::aggregate::GroupType::Pivot as i32,
473                    AggregateGroupType::GroupingSets => {
474                        proto::aggregate::GroupType::GroupingSets as i32
475                    }
476                };
477                for expr in grouping_expressions {
478                    agg.grouping_expressions.push(expr.to_proto());
479                }
480                // Explicit grouping sets (group_type == GroupingSets). Each set is
481                // serialized as its own `GroupingSets` message; without this the sets
482                // would be lost and the query would degrade to a plain group-by.
483                for set in grouping_sets {
484                    let mut gs = proto::aggregate::GroupingSets::default();
485                    for e in set {
486                        gs.grouping_set.push(e.to_proto());
487                    }
488                    agg.grouping_sets.push(gs);
489                }
490                for expr in aggregate_expressions {
491                    agg.aggregate_expressions.push(expr.to_proto());
492                }
493                if let Some(pcol) = pivot_col {
494                    let mut pivot = proto::aggregate::Pivot::default();
495                    pivot.col = Some(pcol.to_proto());
496                    // Serialize explicit pivot values (literals). When empty, the server
497                    // computes the distinct values itself.
498                    for v in pivot_values {
499                        if let Some(proto::expression::ExprType::Literal(lit)) =
500                            v.to_proto().expr_type
501                        {
502                            pivot.values.push(lit);
503                        }
504                    }
505                    agg.pivot = Some(pivot);
506                }
507                relation.rel_type = Some(proto::relation::RelType::Aggregate(Box::new(agg)));
508            }
509
510            LogicalPlan::Join {
511                left,
512                right,
513                join_type,
514                on,
515                using_columns,
516            } => {
517                let mut join = proto::Join::default();
518                join.left = Some(Box::new(left.to_proto()));
519                join.right = Some(Box::new(right.to_proto()));
520                join.join_type = match join_type {
521                    JoinType::Inner => proto::join::JoinType::Inner as i32,
522                    JoinType::LeftOuter => proto::join::JoinType::LeftOuter as i32,
523                    JoinType::RightOuter => proto::join::JoinType::RightOuter as i32,
524                    JoinType::FullOuter => proto::join::JoinType::FullOuter as i32,
525                    JoinType::LeftSemi => proto::join::JoinType::LeftSemi as i32,
526                    JoinType::LeftAnti => proto::join::JoinType::LeftAnti as i32,
527                    JoinType::Cross => proto::join::JoinType::Cross as i32,
528                };
529                if let Some(condition) = on {
530                    join.join_condition = Some(condition.to_proto());
531                }
532                join.using_columns.extend(using_columns.clone());
533                relation.rel_type = Some(proto::relation::RelType::Join(Box::new(join)));
534            }
535
536            LogicalPlan::LateralJoin {
537                left,
538                right,
539                join_type,
540                on,
541            } => {
542                let mut lj = proto::LateralJoin::default();
543                lj.left = Some(Box::new(left.to_proto()));
544                lj.right = Some(Box::new(right.to_proto()));
545                lj.join_type = match join_type {
546                    JoinType::Inner => proto::join::JoinType::Inner as i32,
547                    JoinType::LeftOuter => proto::join::JoinType::LeftOuter as i32,
548                    JoinType::RightOuter => proto::join::JoinType::RightOuter as i32,
549                    JoinType::FullOuter => proto::join::JoinType::FullOuter as i32,
550                    JoinType::LeftSemi => proto::join::JoinType::LeftSemi as i32,
551                    JoinType::LeftAnti => proto::join::JoinType::LeftAnti as i32,
552                    JoinType::Cross => proto::join::JoinType::Cross as i32,
553                };
554                if let Some(condition) = on {
555                    lj.join_condition = Some(condition.to_proto());
556                }
557                relation.rel_type = Some(proto::relation::RelType::LateralJoin(Box::new(lj)));
558            }
559
560            LogicalPlan::SetOperation {
561                left,
562                right,
563                set_op_type,
564                is_all,
565                by_name,
566                allow_missing_columns,
567            } => {
568                let mut set_op = proto::SetOperation::default();
569                set_op.left_input = Some(Box::new(left.to_proto()));
570                set_op.right_input = Some(Box::new(right.to_proto()));
571                set_op.set_op_type = match set_op_type {
572                    SetOpType::Union => proto::set_operation::SetOpType::Union as i32,
573                    SetOpType::Intersect => proto::set_operation::SetOpType::Intersect as i32,
574                    SetOpType::Except => proto::set_operation::SetOpType::Except as i32,
575                };
576                set_op.is_all = Some(*is_all);
577                set_op.by_name = Some(*by_name);
578                set_op.allow_missing_columns = Some(*allow_missing_columns);
579                relation.rel_type = Some(proto::relation::RelType::SetOp(Box::new(set_op)));
580            }
581
582            LogicalPlan::Limit { input, limit } => {
583                let mut lim = proto::Limit::default();
584                lim.input = Some(Box::new(input.to_proto()));
585                lim.limit = *limit;
586                relation.rel_type = Some(proto::relation::RelType::Limit(Box::new(lim)));
587            }
588
589            LogicalPlan::Offset { input, offset } => {
590                let mut off = proto::Offset::default();
591                off.input = Some(Box::new(input.to_proto()));
592                off.offset = *offset;
593                relation.rel_type = Some(proto::relation::RelType::Offset(Box::new(off)));
594            }
595
596            LogicalPlan::Tail { input, limit } => {
597                let mut tail = proto::Tail::default();
598                tail.input = Some(Box::new(input.to_proto()));
599                tail.limit = *limit;
600                relation.rel_type = Some(proto::relation::RelType::Tail(Box::new(tail)));
601            }
602
603            LogicalPlan::Deduplicate {
604                input,
605                all_columns_as_keys,
606                column_names,
607                within_watermark,
608            } => {
609                let mut dedup = proto::Deduplicate::default();
610                dedup.input = Some(Box::new(input.to_proto()));
611                dedup.all_columns_as_keys = Some(*all_columns_as_keys);
612                dedup.column_names.extend(column_names.clone());
613                dedup.within_watermark = Some(*within_watermark);
614                relation.rel_type = Some(proto::relation::RelType::Deduplicate(Box::new(dedup)));
615            }
616
617            LogicalPlan::Sort {
618                input,
619                order,
620                is_global,
621            } => {
622                // Use the generated proto enum constants rather than bare 1/2 magic
623                // numbers, so a future proto reorder can't silently change meaning.
624                use proto::expression::sort_order::{NullOrdering as PbNulls, SortDirection};
625                let mut sort = proto::Sort::default();
626                sort.input = Some(Box::new(input.to_proto()));
627                for expr in order {
628                    // Extract SortOrder from Expression if it's SortOrder type
629                    if let Expression::SortOrder(so) = expr {
630                        let mut sort_order = proto::expression::SortOrder::default();
631                        sort_order.child = Some(Box::new(so.child.to_proto()));
632                        sort_order.direction = if so.ascending {
633                            SortDirection::Ascending as i32
634                        } else {
635                            SortDirection::Descending as i32
636                        };
637                        sort_order.null_ordering = match so.null_ordering {
638                            crate::expression::NullOrdering::First => {
639                                PbNulls::SortNullsFirst as i32
640                            }
641                            crate::expression::NullOrdering::Last => PbNulls::SortNullsLast as i32,
642                        };
643                        sort.order.push(sort_order);
644                    } else {
645                        let expr_proto = expr.to_proto();
646                        if let Some(proto::expression::ExprType::SortOrder(so)) =
647                            expr_proto.expr_type
648                        {
649                            sort.order.push(*so);
650                        } else {
651                            // A bare column (not a SortOrder): default to ascending,
652                            // nulls first - matching reference pyspark's sort/orderBy.
653                            // Without this the column was dropped, leaving an empty
654                            // order and an invalid Sort plan.
655                            let mut sort_order = proto::expression::SortOrder::default();
656                            sort_order.child = Some(Box::new(expr_proto));
657                            sort_order.direction = SortDirection::Ascending as i32;
658                            sort_order.null_ordering = PbNulls::SortNullsFirst as i32;
659                            sort.order.push(sort_order);
660                        }
661                    }
662                }
663                sort.is_global = Some(*is_global);
664                relation.rel_type = Some(proto::relation::RelType::Sort(Box::new(sort)));
665            }
666
667            LogicalPlan::Sample {
668                input,
669                lower_bound,
670                upper_bound,
671                with_replacement,
672                seed,
673            } => {
674                let mut sample = proto::Sample::default();
675                sample.input = Some(Box::new(input.to_proto()));
676                sample.lower_bound = *lower_bound;
677                sample.upper_bound = *upper_bound;
678                sample.with_replacement = Some(*with_replacement);
679                if let Some(s) = seed {
680                    sample.seed = Some(*s);
681                }
682                relation.rel_type = Some(proto::relation::RelType::Sample(Box::new(sample)));
683            }
684
685            LogicalPlan::Repartition {
686                input,
687                num_partitions,
688                shuffle,
689            } => {
690                let mut repart = proto::Repartition::default();
691                repart.input = Some(Box::new(input.to_proto()));
692                repart.num_partitions = *num_partitions;
693                repart.shuffle = Some(*shuffle);
694                relation.rel_type = Some(proto::relation::RelType::Repartition(Box::new(repart)));
695            }
696
697            LogicalPlan::RepartitionByExpression {
698                input,
699                num_partitions,
700                expressions,
701            } => {
702                let mut repart = proto::RepartitionByExpression::default();
703                repart.input = Some(Box::new(input.to_proto()));
704                // num_partitions is optional in the proto: <= 0 means "unset" so the
705                // server uses the default (the `df.repartition(*cols)` column-only form,
706                // where no partition count is given). A real count is always positive.
707                repart.num_partitions = if *num_partitions > 0 {
708                    Some(*num_partitions)
709                } else {
710                    None
711                };
712                for expr in expressions {
713                    repart.partition_exprs.push(expr.to_proto());
714                }
715                relation.rel_type = Some(proto::relation::RelType::RepartitionByExpression(
716                    Box::new(repart),
717                ));
718            }
719
720            LogicalPlan::WithColumns {
721                input,
722                column_names,
723                columns,
724            } => {
725                let mut wc = proto::WithColumns::default();
726                wc.input = Some(Box::new(input.to_proto()));
727                for (name, col) in column_names.iter().zip(columns.iter()) {
728                    // Create an Alias expression for each column
729                    let mut alias = proto::expression::Alias::default();
730                    alias.expr = Some(Box::new(col.to_proto()));
731                    alias.name = vec![name.clone()];
732                    wc.aliases.push(alias);
733                }
734                relation.rel_type = Some(proto::relation::RelType::WithColumns(Box::new(wc)));
735            }
736
737            LogicalPlan::WithColumnMetadata {
738                input,
739                column_name,
740                metadata_json,
741            } => {
742                let mut wc = proto::WithColumns::default();
743                wc.input = Some(Box::new(input.to_proto()));
744                let col_expr = crate::expression::Expression::ColumnReference(
745                    crate::expression::ColumnReference::new(column_name.clone()),
746                );
747                let mut alias = proto::expression::Alias::default();
748                alias.expr = Some(Box::new(col_expr.to_proto()));
749                alias.name = vec![column_name.clone()];
750                alias.metadata = Some(metadata_json.clone());
751                wc.aliases.push(alias);
752                relation.rel_type = Some(proto::relation::RelType::WithColumns(Box::new(wc)));
753            }
754
755            LogicalPlan::WithColumnsRenamed { input, renames } => {
756                let mut wcr = proto::WithColumnsRenamed::default();
757                wcr.input = Some(Box::new(input.to_proto()));
758                for (old_name, new_name) in renames.iter() {
759                    let mut rename = proto::with_columns_renamed::Rename::default();
760                    rename.col_name = old_name.clone();
761                    rename.new_col_name = new_name.clone();
762                    wcr.renames.push(rename);
763                }
764                relation.rel_type =
765                    Some(proto::relation::RelType::WithColumnsRenamed(Box::new(wcr)));
766            }
767
768            LogicalPlan::Drop { input, columns } => {
769                let mut drop = proto::Drop::default();
770                drop.input = Some(Box::new(input.to_proto()));
771                drop.column_names.extend(columns.clone());
772                relation.rel_type = Some(proto::relation::RelType::Drop(Box::new(drop)));
773            }
774
775            LogicalPlan::ToDF {
776                input,
777                column_names,
778            } => {
779                let mut to_df = proto::ToDf::default();
780                to_df.input = Some(Box::new(input.to_proto()));
781                to_df.column_names.extend(column_names.clone());
782                relation.rel_type = Some(proto::relation::RelType::ToDf(Box::new(to_df)));
783            }
784
785            LogicalPlan::ToSchema { input, schema } => {
786                let mut to_schema = proto::ToSchema::default();
787                to_schema.input = Some(Box::new(input.to_proto()));
788                to_schema.schema = Some(schema.to_proto());
789                relation.rel_type = Some(proto::relation::RelType::ToSchema(Box::new(to_schema)));
790            }
791
792            LogicalPlan::Hint {
793                input,
794                name,
795                parameters,
796            } => {
797                let mut hint = proto::Hint::default();
798                hint.input = Some(Box::new(input.to_proto()));
799                hint.name = name.clone();
800                // Hint parameters are literal Expressions (e.g. `REPARTITION 10`).
801                // An integer-looking parameter becomes an Integer (int32) literal -
802                // matching reference pyspark's `lit(int)` - otherwise a String literal.
803                // (A Long literal is rejected as a partitionNum by e.g. the REBALANCE
804                // hint, which expects an integral int.)
805                for p in parameters {
806                    let lit = if let Ok(n) = p.parse::<i32>() {
807                        crate::expression::LiteralExpression::int(n)
808                    } else {
809                        crate::expression::LiteralExpression::string(p.clone())
810                    };
811                    hint.parameters.push(lit.to_proto());
812                }
813                relation.rel_type = Some(proto::relation::RelType::Hint(Box::new(hint)));
814            }
815
816            LogicalPlan::Unpivot {
817                input,
818                ids,
819                values,
820                variable_column_name,
821                value_column_name,
822            } => {
823                let mut unpivot = proto::Unpivot::default();
824                unpivot.input = Some(Box::new(input.to_proto()));
825                for col in ids {
826                    unpivot.ids.push(col.to_proto());
827                }
828                if let Some(v) = values {
829                    let mut vals = proto::unpivot::Values::default();
830                    for col in v {
831                        vals.values.push(col.to_proto());
832                    }
833                    unpivot.values = Some(vals);
834                }
835                unpivot.variable_column_name = variable_column_name.clone();
836                unpivot.value_column_name = value_column_name.clone();
837                relation.rel_type = Some(proto::relation::RelType::Unpivot(Box::new(unpivot)));
838            }
839
840            LogicalPlan::NAFill {
841                input,
842                fill_value,
843                columns,
844            } => {
845                let mut na_fill = proto::NaFill::default();
846                na_fill.input = Some(Box::new(input.to_proto()));
847                na_fill.cols.extend(columns.clone());
848                na_fill.values.push(value_to_proto_literal(fill_value));
849                relation.rel_type = Some(proto::relation::RelType::FillNa(Box::new(na_fill)));
850            }
851
852            LogicalPlan::NAFillColumns {
853                input,
854                cols,
855                values,
856            } => {
857                // Per-column fill: cols[i] is filled with values[i] (Spark aligns them
858                // positionally when both are non-empty).
859                let mut na_fill = proto::NaFill::default();
860                na_fill.input = Some(Box::new(input.to_proto()));
861                na_fill.cols.extend(cols.clone());
862                na_fill
863                    .values
864                    .extend(values.iter().map(value_to_proto_literal));
865                relation.rel_type = Some(proto::relation::RelType::FillNa(Box::new(na_fill)));
866            }
867
868            LogicalPlan::NADrop {
869                input,
870                how,
871                min_non_null,
872                columns,
873            } => {
874                let mut na_drop = proto::NaDrop::default();
875                na_drop.input = Some(Box::new(input.to_proto()));
876                na_drop.cols.extend(columns.clone());
877                // Mirror pyspark: an explicit `thresh` (min_non_null) wins; otherwise
878                // `how="all"` -> min_non_nulls=1 and `how="any"` -> unset (server default).
879                // Previously `how` was dropped (`how: _`), so "all" and "any" built
880                // byte-identical plans and both behaved as "any".
881                na_drop.min_non_nulls = match min_non_null {
882                    Some(m) => Some(*m),
883                    None if how == "all" => Some(1),
884                    None => None,
885                };
886                relation.rel_type = Some(proto::relation::RelType::DropNa(Box::new(na_drop)));
887            }
888
889            LogicalPlan::NAReplace {
890                input,
891                replacements,
892                columns,
893            } => {
894                let mut na_replace = proto::NaReplace::default();
895                na_replace.input = Some(Box::new(input.to_proto()));
896                for (old_val, new_val) in replacements.iter() {
897                    let mut replace = proto::na_replace::Replacement::default();
898                    // A numeric-looking value becomes a Double literal; otherwise a
899                    // String literal. Both sides are always set (never left None, which
900                    // previously made string replacements a silent no-op).
901                    replace.old_value = Some(str_to_proto_literal(old_val));
902                    replace.new_value = Some(str_to_proto_literal(new_val));
903                    na_replace.replacements.push(replace);
904                }
905                na_replace.cols.extend(columns.clone());
906                relation.rel_type = Some(proto::relation::RelType::Replace(Box::new(na_replace)));
907            }
908
909            LogicalPlan::Describe { input, columns } => {
910                let mut describe = proto::StatDescribe::default();
911                describe.input = Some(Box::new(input.to_proto()));
912                describe.cols.extend(columns.clone());
913                relation.rel_type = Some(proto::relation::RelType::Describe(Box::new(describe)));
914            }
915
916            LogicalPlan::Summary { input, percentiles } => {
917                let mut summary = proto::StatSummary::default();
918                summary.input = Some(Box::new(input.to_proto()));
919                summary.statistics.extend(percentiles.clone());
920                relation.rel_type = Some(proto::relation::RelType::Summary(Box::new(summary)));
921            }
922
923            LogicalPlan::ColRegex { input, col_name } => {
924                // ColRegex is implemented as a Project with UnresolvedRegex expressions
925                let mut project = proto::Project::default();
926                project.input = Some(Box::new(input.to_proto()));
927                let mut expr = proto::Expression::default();
928                expr.expr_type = Some(proto::expression::ExprType::UnresolvedRegex(
929                    proto::expression::UnresolvedRegex {
930                        col_name: col_name.clone(),
931                        plan_id: None,
932                    },
933                ));
934                project.expressions.push(expr);
935                relation.rel_type = Some(proto::relation::RelType::Project(Box::new(project)));
936            }
937
938            LogicalPlan::SubqueryAlias { input, alias } => {
939                let mut sq_alias = proto::SubqueryAlias::default();
940                sq_alias.input = Some(Box::new(input.to_proto()));
941                sq_alias.alias = alias.clone();
942                relation.rel_type =
943                    Some(proto::relation::RelType::SubqueryAlias(Box::new(sq_alias)));
944            }
945
946            LogicalPlan::LocalRelation { schema, data } => {
947                let mut local = proto::LocalRelation::default();
948                if let Some(d) = data {
949                    local.data = Some(d.clone().into());
950                }
951                // Carry the schema (JSON) so the server applies the user's column
952                // names/types; required when no Arrow `data` is provided (emptyDataFrame).
953                local.schema = Some(schema.json());
954                relation.rel_type = Some(proto::relation::RelType::LocalRelation(local));
955            }
956
957            LogicalPlan::CachedRemoteRelation { relation_id } => {
958                let mut cached = proto::CachedRemoteRelation::default();
959                cached.relation_id = relation_id.clone();
960                relation.rel_type = Some(proto::relation::RelType::CachedRemoteRelation(cached));
961            }
962
963            LogicalPlan::Read {
964                read_type,
965                is_streaming,
966            } => {
967                let mut read = proto::Read::default();
968                read.is_streaming = *is_streaming;
969
970                match read_type {
971                    crate::readwriter::ReadType::DataSource {
972                        format,
973                        schema,
974                        options,
975                        paths,
976                        predicates,
977                        source_name,
978                    } => {
979                        let mut data_source = proto::read::DataSource::default();
980                        if let Some(fmt) = format {
981                            data_source.format = Some(fmt.clone());
982                        }
983                        if let Some(sch) = schema {
984                            data_source.schema = Some(sch.clone());
985                        }
986                        data_source.options.extend(options.clone());
987                        data_source.paths.extend(paths.clone());
988                        data_source.predicates.extend(predicates.clone());
989                        if let Some(sn) = source_name {
990                            data_source.source_name = Some(sn.clone());
991                        }
992                        read.read_type = Some(proto::read::ReadType::DataSource(data_source));
993                    }
994                    crate::readwriter::ReadType::NamedTable {
995                        table_name,
996                        options,
997                    } => {
998                        let mut named_table = proto::read::NamedTable::default();
999                        named_table.unparsed_identifier = table_name.clone();
1000                        named_table.options.extend(options.clone());
1001                        read.read_type = Some(proto::read::ReadType::NamedTable(named_table));
1002                    }
1003                }
1004
1005                relation.rel_type = Some(proto::relation::RelType::Read(read));
1006            }
1007
1008            LogicalPlan::RelationChanges {
1009                table_name,
1010                options,
1011                is_streaming,
1012            } => {
1013                let mut changes = proto::RelationChanges::default();
1014                changes.unparsed_identifier = table_name.clone();
1015                changes.options.extend(options.clone());
1016                if let Some(s) = is_streaming {
1017                    changes.is_streaming = *s;
1018                }
1019                relation.rel_type = Some(proto::relation::RelType::RelationChanges(changes));
1020            }
1021
1022            LogicalPlan::WithWatermark {
1023                input,
1024                time_column,
1025                delay_threshold,
1026            } => {
1027                let mut watermark = proto::WithWatermark::default();
1028                watermark.input = Some(Box::new(input.to_proto()));
1029                watermark.event_time = time_column.clone();
1030                watermark.delay_threshold = delay_threshold.clone();
1031                relation.rel_type =
1032                    Some(proto::relation::RelType::WithWatermark(Box::new(watermark)));
1033            }
1034
1035            LogicalPlan::RepartitionByRange {
1036                input,
1037                num_partitions,
1038                partition_exprs,
1039            } => {
1040                let mut repart = proto::RepartitionByExpression::default();
1041                repart.input = Some(Box::new(input.to_proto()));
1042                if let Some(n) = num_partitions {
1043                    repart.num_partitions = Some(*n);
1044                }
1045                for expr in partition_exprs {
1046                    repart.partition_exprs.push(expr.to_proto());
1047                }
1048                relation.rel_type = Some(proto::relation::RelType::RepartitionByExpression(
1049                    Box::new(repart),
1050                ));
1051            }
1052
1053            LogicalPlan::StatCrosstab { input, col1, col2 } => {
1054                let mut stat = proto::StatCrosstab::default();
1055                stat.input = Some(Box::new(input.to_proto()));
1056                stat.col1 = col1.clone();
1057                stat.col2 = col2.clone();
1058                relation.rel_type = Some(proto::relation::RelType::Crosstab(Box::new(stat)));
1059            }
1060
1061            LogicalPlan::StatFreqItems {
1062                input,
1063                columns,
1064                support,
1065            } => {
1066                let mut stat = proto::StatFreqItems::default();
1067                stat.input = Some(Box::new(input.to_proto()));
1068                stat.cols.extend(columns.clone());
1069                stat.support = Some(*support);
1070                relation.rel_type = Some(proto::relation::RelType::FreqItems(Box::new(stat)));
1071            }
1072
1073            LogicalPlan::StatApproxQuantile {
1074                input,
1075                columns,
1076                probabilities,
1077                relative_error,
1078            } => {
1079                let mut stat = proto::StatApproxQuantile::default();
1080                stat.input = Some(Box::new(input.to_proto()));
1081                stat.cols.extend(columns.clone());
1082                stat.probabilities.extend(probabilities.clone());
1083                stat.relative_error = *relative_error;
1084                relation.rel_type = Some(proto::relation::RelType::ApproxQuantile(Box::new(stat)));
1085            }
1086
1087            LogicalPlan::StatCorr { input, col1, col2 } => {
1088                let mut stat = proto::StatCorr::default();
1089                stat.input = Some(Box::new(input.to_proto()));
1090                stat.col1 = col1.clone();
1091                stat.col2 = col2.clone();
1092                relation.rel_type = Some(proto::relation::RelType::Corr(Box::new(stat)));
1093            }
1094
1095            LogicalPlan::StatCov { input, col1, col2 } => {
1096                let mut stat = proto::StatCov::default();
1097                stat.input = Some(Box::new(input.to_proto()));
1098                stat.col1 = col1.clone();
1099                stat.col2 = col2.clone();
1100                relation.rel_type = Some(proto::relation::RelType::Cov(Box::new(stat)));
1101            }
1102
1103            LogicalPlan::StatSampleBy {
1104                input,
1105                col,
1106                fractions,
1107                seed,
1108            } => {
1109                let mut stat = proto::StatSampleBy::default();
1110                stat.input = Some(Box::new(input.to_proto()));
1111                // col field expects an Expression; create one from the column name
1112                let mut col_expr = proto::Expression::default();
1113                col_expr.expr_type = Some(proto::expression::ExprType::UnresolvedAttribute(
1114                    proto::expression::UnresolvedAttribute {
1115                        unparsed_identifier: col.clone(),
1116                        plan_id: None,
1117                        is_metadata_column: None,
1118                    },
1119                ));
1120                stat.col = Some(col_expr);
1121                for (expr, _frac) in fractions {
1122                    // fractions expect Expression.Literal type; create Fraction proto
1123                    let mut fraction = proto::stat_sample_by::Fraction::default();
1124                    let expr_proto = expr.to_proto();
1125                    // Extract the Literal from the Expression proto
1126                    if let Some(proto::expression::ExprType::Literal(lit)) = expr_proto.expr_type {
1127                        fraction.stratum = Some(lit);
1128                    }
1129                    stat.fractions.push(fraction);
1130                }
1131                if let Some(s) = seed {
1132                    stat.seed = Some(*s);
1133                }
1134                relation.rel_type = Some(proto::relation::RelType::SampleBy(Box::new(stat)));
1135            }
1136
1137            LogicalPlan::Observe { input, name, exprs } => {
1138                let mut collect_metrics = proto::CollectMetrics::default();
1139                collect_metrics.input = Some(Box::new(input.to_proto()));
1140                collect_metrics.name = name.clone();
1141                for expr in exprs {
1142                    collect_metrics.metrics.push(expr.to_proto());
1143                }
1144                relation.rel_type = Some(proto::relation::RelType::CollectMetrics(Box::new(
1145                    collect_metrics,
1146                )));
1147            }
1148
1149            LogicalPlan::UnresolvedTableValuedFunction { name, arguments } => {
1150                let mut tvf = proto::UnresolvedTableValuedFunction::default();
1151                tvf.function_name = name.clone();
1152                for arg in arguments {
1153                    tvf.arguments.push(arg.to_proto());
1154                }
1155                relation.rel_type =
1156                    Some(proto::relation::RelType::UnresolvedTableValuedFunction(tvf));
1157            }
1158
1159            LogicalPlan::Zip { left, right } => {
1160                let mut zip = proto::Zip::default();
1161                zip.left = Some(Box::new(left.to_proto()));
1162                zip.right = Some(Box::new(right.to_proto()));
1163                relation.rel_type = Some(proto::relation::RelType::Zip(Box::new(zip)));
1164            }
1165
1166            LogicalPlan::MlTransform { ml_relation } => {
1167                return ml_relation.clone();
1168            }
1169            LogicalPlan::Catalog { catalog } => {
1170                relation.rel_type = Some(proto::relation::RelType::Catalog(catalog.clone()));
1171                return relation;
1172            }
1173            LogicalPlan::Transpose {
1174                input,
1175                index_columns,
1176            } => {
1177                let mut transpose = proto::Transpose::default();
1178                transpose.input = Some(Box::new(input.to_proto()));
1179                transpose.index_columns = index_columns.iter().map(|e| e.to_proto()).collect();
1180                relation.rel_type = Some(proto::relation::RelType::Transpose(Box::new(transpose)));
1181                return relation;
1182            }
1183            LogicalPlan::MapPartitions {
1184                input,
1185                func,
1186                is_barrier,
1187            } => {
1188                let mut mp = proto::MapPartitions::default();
1189                mp.input = Some(Box::new(input.to_proto()));
1190                mp.func = Some(func.to_proto());
1191                mp.is_barrier = Some(*is_barrier);
1192                relation.rel_type = Some(proto::relation::RelType::MapPartitions(Box::new(mp)));
1193            }
1194            LogicalPlan::GroupMap {
1195                input,
1196                grouping_expressions,
1197                func,
1198                sorting_expressions,
1199                initial_input,
1200                initial_grouping_expressions,
1201                is_map_groups_with_state,
1202                output_mode,
1203                timeout_conf,
1204                state_schema,
1205                transform_with_state_info,
1206            } => {
1207                let mut gm = proto::GroupMap::default();
1208                gm.input = Some(Box::new(input.to_proto()));
1209                for e in grouping_expressions {
1210                    gm.grouping_expressions.push(e.to_proto());
1211                }
1212                gm.func = Some(func.to_proto());
1213                for e in sorting_expressions {
1214                    gm.sorting_expressions.push(e.to_proto());
1215                }
1216                if let Some(ii) = initial_input {
1217                    gm.initial_input = Some(Box::new(ii.to_proto()));
1218                }
1219                for e in initial_grouping_expressions {
1220                    gm.initial_grouping_expressions.push(e.to_proto());
1221                }
1222                gm.is_map_groups_with_state = *is_map_groups_with_state;
1223                gm.output_mode = output_mode.clone();
1224                gm.timeout_conf = timeout_conf.clone();
1225                gm.state_schema = state_schema.as_ref().map(|d| d.to_proto());
1226                gm.transform_with_state_info =
1227                    transform_with_state_info
1228                        .as_ref()
1229                        .map(|t| proto::TransformWithStateInfo {
1230                            time_mode: t.time_mode.clone(),
1231                            event_time_column_name: t.event_time_column_name.clone(),
1232                            output_schema: t.output_schema.as_ref().map(|d| d.to_proto()),
1233                        });
1234                relation.rel_type = Some(proto::relation::RelType::GroupMap(Box::new(gm)));
1235            }
1236            LogicalPlan::CoGroupMap {
1237                input,
1238                input_grouping_expressions,
1239                other,
1240                other_grouping_expressions,
1241                func,
1242            } => {
1243                let mut cg = proto::CoGroupMap::default();
1244                cg.input = Some(Box::new(input.to_proto()));
1245                for e in input_grouping_expressions {
1246                    cg.input_grouping_expressions.push(e.to_proto());
1247                }
1248                cg.other = Some(Box::new(other.to_proto()));
1249                for e in other_grouping_expressions {
1250                    cg.other_grouping_expressions.push(e.to_proto());
1251                }
1252                cg.func = Some(func.to_proto());
1253                relation.rel_type = Some(proto::relation::RelType::CoGroupMap(Box::new(cg)));
1254            }
1255            LogicalPlan::NearestByJoin {
1256                left,
1257                right,
1258                ranking_expression,
1259                num_results,
1260                join_type,
1261                mode,
1262                direction,
1263            } => {
1264                let mut nbj = proto::NearestByJoin::default();
1265                nbj.left = Some(Box::new(left.to_proto()));
1266                nbj.right = Some(Box::new(right.to_proto()));
1267                nbj.ranking_expression = Some(ranking_expression.to_proto());
1268                nbj.num_results = *num_results;
1269                nbj.join_type = join_type.clone();
1270                nbj.mode = mode.clone();
1271                nbj.direction = direction.clone();
1272                relation.rel_type = Some(proto::relation::RelType::NearestByJoin(Box::new(nbj)));
1273            }
1274            LogicalPlan::CommonInlineUdtf {
1275                function_name,
1276                deterministic,
1277                arguments,
1278                return_type,
1279                eval_type,
1280                command,
1281                python_ver,
1282            } => {
1283                let mut udtf = proto::PythonUdtf::default();
1284                if let Some(rt) = return_type {
1285                    udtf.return_type = Some(rt.to_proto());
1286                }
1287                udtf.eval_type = *eval_type;
1288                udtf.command = bytes::Bytes::copy_from_slice(command);
1289                udtf.python_ver = python_ver.clone();
1290                let mut f = proto::CommonInlineUserDefinedTableFunction::default();
1291                f.function_name = function_name.clone();
1292                f.deterministic = *deterministic;
1293                f.arguments = arguments.iter().map(|a| a.to_proto()).collect();
1294                f.function = Some(
1295                    proto::common_inline_user_defined_table_function::Function::PythonUdtf(udtf),
1296                );
1297                relation.rel_type =
1298                    Some(proto::relation::RelType::CommonInlineUserDefinedTableFunction(f));
1299            }
1300        }
1301
1302        relation
1303    }
1304}
1305
1306/// Create a Range plan.
1307pub fn range(start: i64, end: i64, step: i64) -> LogicalPlan {
1308    LogicalPlan::Range {
1309        start,
1310        end,
1311        step,
1312        num_partitions: None,
1313    }
1314}
1315
1316/// Create a Range plan with num_partitions.
1317pub fn range_with_partitions(start: i64, end: i64, step: i64, num_partitions: i32) -> LogicalPlan {
1318    LogicalPlan::Range {
1319        start,
1320        end,
1321        step,
1322        num_partitions: Some(num_partitions),
1323    }
1324}
1325
1326/// Create a SQL plan.
1327pub fn sql(query: impl Into<String>) -> LogicalPlan {
1328    LogicalPlan::Sql {
1329        query: query.into(),
1330        pos_args: Vec::new(),
1331        named_args: HashMap::new(),
1332    }
1333}
1334
1335/// Create a Project plan.
1336pub fn project<C: Into<Column>>(
1337    input: LogicalPlan,
1338    columns: impl IntoIterator<Item = C>,
1339) -> LogicalPlan {
1340    LogicalPlan::Project {
1341        input: Box::new(input),
1342        columns: columns.into_iter().map(Into::into).collect(),
1343    }
1344}
1345
1346/// Create a Filter plan.
1347pub fn filter(input: LogicalPlan, condition: Column) -> LogicalPlan {
1348    LogicalPlan::Filter {
1349        input: Box::new(input),
1350        condition,
1351    }
1352}
1353
1354/// Create an Aggregate plan.
1355pub fn aggregate(
1356    input: LogicalPlan,
1357    group_type: AggregateGroupType,
1358    grouping_expressions: Vec<Expression>,
1359    aggregate_expressions: Vec<Expression>,
1360) -> LogicalPlan {
1361    LogicalPlan::Aggregate {
1362        input: Box::new(input),
1363        group_type,
1364        grouping_expressions,
1365        aggregate_expressions,
1366        pivot_col: None,
1367        pivot_values: vec![],
1368        grouping_sets: vec![],
1369    }
1370}
1371
1372/// Create an Aggregate plan with pivot.
1373pub fn aggregate_with_pivot(
1374    input: LogicalPlan,
1375    group_type: AggregateGroupType,
1376    grouping_expressions: Vec<Expression>,
1377    aggregate_expressions: Vec<Expression>,
1378    pivot_col: Expression,
1379    pivot_values: Vec<Expression>,
1380) -> LogicalPlan {
1381    LogicalPlan::Aggregate {
1382        input: Box::new(input),
1383        group_type,
1384        grouping_expressions,
1385        aggregate_expressions,
1386        pivot_col: Some(pivot_col),
1387        pivot_values,
1388        grouping_sets: vec![],
1389    }
1390}
1391
1392/// Create an Aggregate plan with explicit grouping sets.
1393pub fn aggregate_with_grouping_sets(
1394    input: LogicalPlan,
1395    grouping_expressions: Vec<Expression>,
1396    aggregate_expressions: Vec<Expression>,
1397    grouping_sets: Vec<Vec<Expression>>,
1398) -> LogicalPlan {
1399    LogicalPlan::Aggregate {
1400        input: Box::new(input),
1401        group_type: AggregateGroupType::GroupingSets,
1402        grouping_expressions,
1403        aggregate_expressions,
1404        pivot_col: None,
1405        pivot_values: vec![],
1406        grouping_sets,
1407    }
1408}
1409
1410/// Create a Join plan.
1411pub fn join(
1412    left: LogicalPlan,
1413    right: LogicalPlan,
1414    join_type: JoinType,
1415    on: Option<Column>,
1416    using_columns: Vec<String>,
1417) -> LogicalPlan {
1418    LogicalPlan::Join {
1419        left: Box::new(left),
1420        right: Box::new(right),
1421        join_type,
1422        on,
1423        using_columns,
1424    }
1425}
1426
1427/// Create a SetOperation plan.
1428pub fn set_operation(
1429    left: LogicalPlan,
1430    right: LogicalPlan,
1431    set_op_type: SetOpType,
1432    is_all: bool,
1433    by_name: bool,
1434    allow_missing_columns: bool,
1435) -> LogicalPlan {
1436    LogicalPlan::SetOperation {
1437        left: Box::new(left),
1438        right: Box::new(right),
1439        set_op_type,
1440        is_all,
1441        by_name,
1442        allow_missing_columns,
1443    }
1444}
1445
1446/// Create a Limit plan.
1447pub fn limit(input: LogicalPlan, limit: i32) -> LogicalPlan {
1448    LogicalPlan::Limit {
1449        input: Box::new(input),
1450        limit,
1451    }
1452}
1453
1454/// Create an Offset plan.
1455pub fn offset(input: LogicalPlan, offset: i32) -> LogicalPlan {
1456    LogicalPlan::Offset {
1457        input: Box::new(input),
1458        offset,
1459    }
1460}
1461
1462/// Create a Tail plan.
1463pub fn tail(input: LogicalPlan, limit: i32) -> LogicalPlan {
1464    LogicalPlan::Tail {
1465        input: Box::new(input),
1466        limit,
1467    }
1468}
1469
1470/// Create a Deduplicate plan.
1471pub fn deduplicate(
1472    input: LogicalPlan,
1473    all_columns_as_keys: bool,
1474    column_names: Vec<String>,
1475    within_watermark: bool,
1476) -> LogicalPlan {
1477    LogicalPlan::Deduplicate {
1478        input: Box::new(input),
1479        all_columns_as_keys,
1480        column_names,
1481        within_watermark,
1482    }
1483}
1484
1485/// Create a Sort plan.
1486pub fn sort(input: LogicalPlan, order: Vec<Expression>, is_global: bool) -> LogicalPlan {
1487    LogicalPlan::Sort {
1488        input: Box::new(input),
1489        order,
1490        is_global,
1491    }
1492}
1493
1494/// Create a Sample plan.
1495pub fn sample(
1496    input: LogicalPlan,
1497    lower_bound: f64,
1498    upper_bound: f64,
1499    with_replacement: bool,
1500    seed: Option<i64>,
1501) -> LogicalPlan {
1502    LogicalPlan::Sample {
1503        input: Box::new(input),
1504        lower_bound,
1505        upper_bound,
1506        with_replacement,
1507        seed,
1508    }
1509}
1510
1511/// Create a Repartition plan.
1512pub fn repartition(input: LogicalPlan, num_partitions: i32, shuffle: bool) -> LogicalPlan {
1513    LogicalPlan::Repartition {
1514        input: Box::new(input),
1515        num_partitions,
1516        shuffle,
1517    }
1518}
1519
1520/// Create a RepartitionByExpression plan.
1521pub fn repartition_by_expression(
1522    input: LogicalPlan,
1523    num_partitions: i32,
1524    expressions: Vec<Expression>,
1525) -> LogicalPlan {
1526    LogicalPlan::RepartitionByExpression {
1527        input: Box::new(input),
1528        num_partitions,
1529        expressions,
1530    }
1531}
1532
1533/// Create a WithColumns plan.
1534pub fn with_columns(
1535    input: LogicalPlan,
1536    column_names: Vec<String>,
1537    columns: Vec<Column>,
1538) -> LogicalPlan {
1539    LogicalPlan::WithColumns {
1540        input: Box::new(input),
1541        column_names,
1542        columns,
1543    }
1544}
1545
1546/// Create a WithColumnsRenamed plan.
1547pub fn with_columns_renamed(input: LogicalPlan, renames: HashMap<String, String>) -> LogicalPlan {
1548    LogicalPlan::WithColumnsRenamed {
1549        input: Box::new(input),
1550        renames,
1551    }
1552}
1553
1554/// Create a Drop plan.
1555pub fn drop(input: LogicalPlan, columns: Vec<String>) -> LogicalPlan {
1556    LogicalPlan::Drop {
1557        input: Box::new(input),
1558        columns,
1559    }
1560}
1561
1562/// Create a ToDF plan.
1563pub fn to_df(input: LogicalPlan, column_names: Vec<String>) -> LogicalPlan {
1564    LogicalPlan::ToDF {
1565        input: Box::new(input),
1566        column_names,
1567    }
1568}
1569
1570/// Create a ToSchema plan.
1571pub fn to_schema(input: LogicalPlan, schema: DataType) -> LogicalPlan {
1572    LogicalPlan::ToSchema {
1573        input: Box::new(input),
1574        schema,
1575    }
1576}
1577
1578/// Create a Hint plan.
1579pub fn hint(input: LogicalPlan, name: impl Into<String>, parameters: Vec<String>) -> LogicalPlan {
1580    LogicalPlan::Hint {
1581        input: Box::new(input),
1582        name: name.into(),
1583        parameters,
1584    }
1585}
1586
1587/// Create an Unpivot plan.
1588pub fn unpivot(
1589    input: LogicalPlan,
1590    ids: Vec<Column>,
1591    values: Option<Vec<Column>>,
1592    variable_column_name: impl Into<String>,
1593    value_column_name: impl Into<String>,
1594) -> LogicalPlan {
1595    LogicalPlan::Unpivot {
1596        input: Box::new(input),
1597        ids,
1598        values,
1599        variable_column_name: variable_column_name.into(),
1600        value_column_name: value_column_name.into(),
1601    }
1602}
1603
1604/// Create a NAFill plan.
1605pub fn na_fill(
1606    input: LogicalPlan,
1607    fill_value: crate::row::Value,
1608    columns: Vec<String>,
1609) -> LogicalPlan {
1610    LogicalPlan::NAFill {
1611        input: Box::new(input),
1612        fill_value,
1613        columns,
1614    }
1615}
1616
1617/// Build a proto `Literal` from a [`crate::row::Value`] (used by NAFill).
1618fn value_to_proto_literal(v: &crate::row::Value) -> proto::expression::Literal {
1619    use crate::row::Value;
1620    use proto::expression::literal::LiteralType;
1621    let mut lit = proto::expression::Literal::default();
1622    lit.literal_type = Some(match v {
1623        Value::Bool(b) => LiteralType::Boolean(*b),
1624        Value::Byte(x) => LiteralType::Byte(*x as i32),
1625        Value::Short(x) => LiteralType::Short(*x as i32),
1626        Value::Integer(x) => LiteralType::Integer(*x),
1627        Value::Long(x) => LiteralType::Long(*x),
1628        Value::Float(x) => LiteralType::Float(*x),
1629        Value::Double(x) => LiteralType::Double(*x),
1630        Value::String(s) => LiteralType::String(s.clone()),
1631        Value::Date(d) => LiteralType::Date(*d),
1632        Value::Timestamp(t) => LiteralType::Timestamp(*t),
1633        Value::Decimal {
1634            value,
1635            precision,
1636            scale,
1637        } => {
1638            let mut decimal = proto::expression::literal::Decimal::default();
1639            decimal.value = value.clone();
1640            if let Some(p) = precision {
1641                decimal.precision = Some(*p);
1642            }
1643            if let Some(s) = scale {
1644                decimal.scale = Some(*s);
1645            }
1646            LiteralType::Decimal(decimal)
1647        }
1648        other => LiteralType::String(format!("{:?}", other)),
1649    });
1650    lit
1651}
1652
1653/// Build a proto `Literal` from a string: a numeric-looking string becomes a
1654/// Double literal, otherwise a String literal (used by NAReplace so string
1655/// replacements are not silently dropped).
1656fn str_to_proto_literal(s: &str) -> proto::expression::Literal {
1657    use proto::expression::literal::LiteralType;
1658    let mut lit = proto::expression::Literal::default();
1659    lit.literal_type = Some(match s.parse::<f64>() {
1660        Ok(v) => LiteralType::Double(v),
1661        Err(_) => LiteralType::String(s.to_string()),
1662    });
1663    lit
1664}
1665
1666/// Create a NADrop plan.
1667pub fn na_drop(
1668    input: LogicalPlan,
1669    how: impl Into<String>,
1670    min_non_null: Option<i32>,
1671    columns: Vec<String>,
1672) -> LogicalPlan {
1673    LogicalPlan::NADrop {
1674        input: Box::new(input),
1675        how: how.into(),
1676        min_non_null,
1677        columns,
1678    }
1679}
1680
1681/// Create a NAReplace plan.
1682pub fn na_replace(
1683    input: LogicalPlan,
1684    replacements: Vec<(String, String)>,
1685    columns: Vec<String>,
1686) -> LogicalPlan {
1687    LogicalPlan::NAReplace {
1688        input: Box::new(input),
1689        replacements,
1690        columns,
1691    }
1692}
1693
1694/// Create a Describe plan.
1695pub fn describe(input: LogicalPlan, columns: Vec<String>) -> LogicalPlan {
1696    LogicalPlan::Describe {
1697        input: Box::new(input),
1698        columns,
1699    }
1700}
1701
1702/// Create a Summary plan.
1703pub fn summary(input: LogicalPlan, percentiles: Vec<String>) -> LogicalPlan {
1704    LogicalPlan::Summary {
1705        input: Box::new(input),
1706        percentiles,
1707    }
1708}
1709
1710/// Create a ColRegex plan.
1711pub fn col_regex(input: LogicalPlan, col_name: impl Into<String>) -> LogicalPlan {
1712    LogicalPlan::ColRegex {
1713        input: Box::new(input),
1714        col_name: col_name.into(),
1715    }
1716}
1717
1718/// Create a SubqueryAlias plan.
1719pub fn subquery_alias(input: LogicalPlan, alias: impl Into<String>) -> LogicalPlan {
1720    LogicalPlan::SubqueryAlias {
1721        input: Box::new(input),
1722        alias: alias.into(),
1723    }
1724}
1725
1726/// Create a LocalRelation plan.
1727pub fn local_relation(schema: DataType, data: Option<Vec<u8>>) -> LogicalPlan {
1728    LogicalPlan::LocalRelation { schema, data }
1729}
1730
1731/// Create a CachedRemoteRelation plan.
1732pub fn cached_remote_relation(relation_id: impl Into<String>) -> LogicalPlan {
1733    LogicalPlan::CachedRemoteRelation {
1734        relation_id: relation_id.into(),
1735    }
1736}
1737
1738#[cfg(test)]
1739mod argfix_tests {
1740    //! Regression tests for the silent arg-drop bugs: every argument accepted by
1741    //! the DataFrame API must reach the proto. Each test would fail against the
1742    //! pre-fix code (where the argument was dropped).
1743    use super::*;
1744    use crate::column::col;
1745    use crate::expression::LiteralExpression;
1746    use proto::expression::literal::LiteralType;
1747    use spark_connect_proto as proto;
1748
1749    fn base() -> LogicalPlan {
1750        range(0, 10, 1)
1751    }
1752
1753    #[test]
1754    fn local_relation_carries_schema() {
1755        // Regression: LocalRelation dropped the explicit schema (`schema: _`), so
1756        // createDataFrame(rows, schema) / emptyDataFrame lost the user's schema.
1757        match rel_type(local_relation(DataType::Struct { fields: vec![] }, None)) {
1758            proto::relation::RelType::LocalRelation(lr) => {
1759                assert!(lr.schema.is_some(), "LocalRelation must carry the schema");
1760            }
1761            _ => panic!("expected LocalRelation"),
1762        }
1763    }
1764    fn rel_type(p: LogicalPlan) -> proto::relation::RelType {
1765        p.to_proto().rel_type.expect("rel_type")
1766    }
1767
1768    #[test]
1769    fn dropna_how_all_any_thresh_are_distinct() {
1770        let mk = |how: &str, thresh: Option<i32>| LogicalPlan::NADrop {
1771            input: Box::new(base()),
1772            how: how.to_string(),
1773            min_non_null: thresh,
1774            columns: vec![],
1775        };
1776        let get = |p: LogicalPlan| match rel_type(p) {
1777            proto::relation::RelType::DropNa(d) => d.min_non_nulls,
1778            _ => panic!("expected DropNa"),
1779        };
1780        // how="all" -> 1 ; how="any" -> unset ; explicit thresh overrides.
1781        assert_eq!(get(mk("all", None)), Some(1));
1782        assert_eq!(get(mk("any", None)), None);
1783        assert_eq!(get(mk("any", Some(3))), Some(3));
1784        // The two `how` values must NOT build identical plans.
1785        assert_ne!(get(mk("all", None)), get(mk("any", None)));
1786    }
1787
1788    #[test]
1789    fn hint_carries_parameters() {
1790        let p = LogicalPlan::Hint {
1791            input: Box::new(base()),
1792            name: "REPARTITION".to_string(),
1793            parameters: vec!["10".to_string(), "name".to_string()],
1794        };
1795        match rel_type(p) {
1796            proto::relation::RelType::Hint(h) => {
1797                assert_eq!(h.parameters.len(), 2, "parameters must be forwarded");
1798                // First param "10" -> Integer(10) literal (int32, matching reference
1799                // pyspark's lit(int); a Long is rejected by the REBALANCE hint).
1800                let lit = match h.parameters[0].expr_type.as_ref().unwrap() {
1801                    proto::expression::ExprType::Literal(l) => l.literal_type.clone().unwrap(),
1802                    _ => panic!("expected literal param"),
1803                };
1804                assert!(matches!(lit, LiteralType::Integer(10)));
1805            }
1806            _ => panic!("expected Hint"),
1807        }
1808    }
1809
1810    #[test]
1811    fn replace_sets_string_literals() {
1812        let p = LogicalPlan::NAReplace {
1813            input: Box::new(base()),
1814            replacements: vec![("foo".to_string(), "bar".to_string())],
1815            columns: vec![],
1816        };
1817        match rel_type(p) {
1818            proto::relation::RelType::Replace(r) => {
1819                let repl = &r.replacements[0];
1820                // Both sides set (previously None for non-numeric -> silent no-op).
1821                let old = repl
1822                    .old_value
1823                    .as_ref()
1824                    .unwrap()
1825                    .literal_type
1826                    .clone()
1827                    .unwrap();
1828                let new = repl
1829                    .new_value
1830                    .as_ref()
1831                    .unwrap()
1832                    .literal_type
1833                    .clone()
1834                    .unwrap();
1835                assert!(matches!(old, LiteralType::String(ref s) if s == "foo"));
1836                assert!(matches!(new, LiteralType::String(ref s) if s == "bar"));
1837            }
1838            _ => panic!("expected Replace"),
1839        }
1840    }
1841
1842    #[test]
1843    fn pivot_values_are_serialized() {
1844        let p = aggregate_with_pivot(
1845            base(),
1846            AggregateGroupType::Pivot,
1847            vec![],
1848            vec![],
1849            col("k").expression().clone(),
1850            vec![
1851                Expression::Literal(LiteralExpression::string("a")),
1852                Expression::Literal(LiteralExpression::string("b")),
1853            ],
1854        );
1855        match rel_type(p) {
1856            proto::relation::RelType::Aggregate(a) => {
1857                let pivot = a.pivot.expect("pivot set");
1858                assert_eq!(
1859                    pivot.values.len(),
1860                    2,
1861                    "explicit pivot values must be serialized"
1862                );
1863            }
1864            _ => panic!("expected Aggregate"),
1865        }
1866    }
1867
1868    #[test]
1869    fn fillna_double_literal() {
1870        let p = LogicalPlan::NAFill {
1871            input: Box::new(base()),
1872            fill_value: crate::row::Value::Double(1.5),
1873            columns: vec![],
1874        };
1875        match rel_type(p) {
1876            proto::relation::RelType::FillNa(f) => {
1877                let lit = f.values[0].literal_type.clone().unwrap();
1878                assert!(matches!(lit, LiteralType::Double(v) if (v - 1.5).abs() < 1e-9));
1879            }
1880            _ => panic!("expected FillNa"),
1881        }
1882    }
1883}