Skip to main content

alopex_sql/planner/
logical_plan.rs

1//! Logical plan representation for query execution.
2//!
3//! This module defines [`LogicalPlan`], which represents the logical structure
4//! of a query after parsing and semantic analysis. The logical plan is used
5//! by the executor to produce query results.
6//!
7//! # Plan Structure
8//!
9//! Logical plans form a tree structure where:
10//! - Leaf nodes are typically scans or DDL operations
11//! - Internal nodes represent transformations (filter, sort, limit)
12//! - DML operations (insert, update, delete) are also represented
13//!
14//! # Examples
15//!
16//! ```
17//! use alopex_sql::planner::logical_plan::LogicalPlan;
18//! use alopex_sql::planner::{Projection, TypedExpr, TypedExprKind, SortExpr};
19//! use alopex_sql::planner::types::ResolvedType;
20//! use alopex_sql::Span;
21//!
22//! // SELECT * FROM users ORDER BY name LIMIT 10
23//! let scan = LogicalPlan::Scan {
24//!     table: "users".to_string(),
25//!     projection: Projection::All(vec!["id".to_string(), "name".to_string()]),
26//! };
27//!
28//! let sort = LogicalPlan::Sort {
29//!     input: Box::new(scan),
30//!     order_by: vec![SortExpr::asc(TypedExpr::column_ref(
31//!         "users".to_string(),
32//!         "name".to_string(),
33//!         1,
34//!         ResolvedType::Text,
35//!         Span::default(),
36//!     ))],
37//! };
38//!
39//! let limit = LogicalPlan::Limit {
40//!     input: Box::new(sort),
41//!     limit: Some(10),
42//!     offset: None,
43//! };
44//! ```
45
46use crate::catalog::{IndexMetadata, TableMetadata};
47use crate::planner::aggregate_expr::AggregateExpr;
48use crate::planner::typed_expr::{Projection, SortExpr, TypedAssignment, TypedExpr};
49
50/// JOIN type for logical and physical execution.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum JoinType {
53    Inner,
54    Left,
55    Right,
56    Full,
57    Cross,
58}
59
60/// Logical query plan representation.
61///
62/// This enum represents all possible logical operations that can be performed.
63/// Plans are organized into three categories:
64///
65/// 1. **Query Plans**: Read operations (Scan, Filter, Sort, Limit)
66/// 2. **DML Plans**: Data modification (Insert, Update, Delete)
67/// 3. **DDL Plans**: Schema modification (CreateTable, DropTable, CreateIndex, DropIndex)
68#[derive(Debug, Clone)]
69pub enum LogicalPlan {
70    /// Runtime configuration or statistics operation.
71    Pragma {
72        /// PRAGMA name.
73        name: String,
74        /// Optional assignment value.
75        value: Option<crate::ast::PragmaValue>,
76    },
77
78    // === Query Plans ===
79    /// Table scan operation.
80    ///
81    /// Scans all rows from a table with the specified projection.
82    /// This is typically the leaf node of query plans.
83    Scan {
84        /// Table name to scan.
85        table: String,
86        /// Columns to project (after wildcard expansion).
87        projection: Projection,
88    },
89
90    /// Filter operation (WHERE clause).
91    ///
92    /// Filters rows from the input plan based on a predicate.
93    Filter {
94        /// Input plan to filter.
95        input: Box<LogicalPlan>,
96        /// Filter predicate (must evaluate to Boolean).
97        predicate: TypedExpr,
98    },
99
100    /// Projection boundary.
101    ///
102    /// Scan keeps the legacy single-table projection path; this node is used
103    /// when a relation-producing input such as JOIN or a derived table must be
104    /// materialized before being consumed by a parent query.
105    Project {
106        /// Input plan to project.
107        input: Box<LogicalPlan>,
108        /// Projection to apply.
109        projection: Projection,
110    },
111
112    /// JOIN operation.
113    Join {
114        /// Left input.
115        left: Box<LogicalPlan>,
116        /// Right input.
117        right: Box<LogicalPlan>,
118        /// Join type.
119        join_type: JoinType,
120        /// Optional ON condition.
121        condition: Option<TypedExpr>,
122        /// Optional USING columns.
123        using: Option<Vec<String>>,
124    },
125
126    /// Aggregate operation (GROUP BY / aggregation).
127    ///
128    /// Aggregates rows from the input plan using group keys and aggregate expressions.
129    Aggregate {
130        /// Input plan to aggregate.
131        input: Box<LogicalPlan>,
132        /// Group-by key expressions (empty for global aggregation).
133        group_keys: Vec<TypedExpr>,
134        /// Aggregate expressions to compute.
135        aggregates: Vec<AggregateExpr>,
136        /// HAVING filter applied after aggregation.
137        having: Option<TypedExpr>,
138        /// Projection to apply after aggregation.
139        projection: Projection,
140    },
141
142    /// Sort operation (ORDER BY clause).
143    ///
144    /// Sorts rows from the input plan based on sort expressions.
145    Sort {
146        /// Input plan to sort.
147        input: Box<LogicalPlan>,
148        /// Sort expressions with direction.
149        order_by: Vec<SortExpr>,
150    },
151
152    /// Limit operation (LIMIT/OFFSET clause).
153    ///
154    /// Limits the number of rows from the input plan.
155    Limit {
156        /// Input plan to limit.
157        input: Box<LogicalPlan>,
158        /// Maximum number of rows to return.
159        limit: Option<u64>,
160        /// Number of rows to skip.
161        offset: Option<u64>,
162    },
163
164    // === DML Plans ===
165    /// INSERT operation.
166    ///
167    /// Inserts one or more rows into a table.
168    /// When columns are omitted in the SQL statement, the Planner fills in
169    /// all columns from TableMetadata in definition order.
170    Insert {
171        /// Target table name.
172        table: String,
173        /// Column names (always populated, never empty).
174        /// If omitted in SQL, filled from TableMetadata.column_names().
175        columns: Vec<String>,
176        /// Values to insert (one Vec per row, each value corresponds to a column).
177        values: Vec<Vec<TypedExpr>>,
178    },
179
180    /// UPDATE operation.
181    ///
182    /// Updates rows in a table that match an optional filter.
183    Update {
184        /// Target table name.
185        table: String,
186        /// Assignments (SET column = value).
187        assignments: Vec<TypedAssignment>,
188        /// Optional filter predicate (WHERE clause).
189        filter: Option<TypedExpr>,
190    },
191
192    /// DELETE operation.
193    ///
194    /// Deletes rows from a table that match an optional filter.
195    Delete {
196        /// Target table name.
197        table: String,
198        /// Optional filter predicate (WHERE clause).
199        filter: Option<TypedExpr>,
200    },
201
202    // === DDL Plans ===
203    /// CREATE TABLE operation.
204    ///
205    /// Creates a new table with the specified metadata.
206    CreateTable {
207        /// Table metadata (name, columns, constraints).
208        table: TableMetadata,
209        /// If true, don't error if table already exists.
210        if_not_exists: bool,
211        /// Raw WITH options to be validated during execution.
212        with_options: Vec<(String, String)>,
213    },
214
215    /// DROP TABLE operation.
216    ///
217    /// Drops an existing table.
218    DropTable {
219        /// Table name to drop.
220        name: String,
221        /// If true, don't error if table doesn't exist.
222        if_exists: bool,
223    },
224
225    /// CREATE INDEX operation.
226    ///
227    /// Creates a new index on a table column.
228    CreateIndex {
229        /// Index metadata (name, table, column, method, options).
230        index: IndexMetadata,
231        /// If true, don't error if index already exists.
232        if_not_exists: bool,
233    },
234
235    /// DROP INDEX operation.
236    ///
237    /// Drops an existing index.
238    DropIndex {
239        /// Index name to drop.
240        name: String,
241        /// If true, don't error if index doesn't exist.
242        if_exists: bool,
243    },
244}
245
246impl LogicalPlan {
247    pub fn operation_name(&self) -> &'static str {
248        match self {
249            LogicalPlan::Pragma { .. } => "PRAGMA",
250            LogicalPlan::Scan { .. }
251            | LogicalPlan::Filter { .. }
252            | LogicalPlan::Project { .. }
253            | LogicalPlan::Join { .. }
254            | LogicalPlan::Aggregate { .. }
255            | LogicalPlan::Sort { .. }
256            | LogicalPlan::Limit { .. } => "SELECT",
257            LogicalPlan::Insert { .. } => "INSERT",
258            LogicalPlan::Update { .. } => "UPDATE",
259            LogicalPlan::Delete { .. } => "DELETE",
260            LogicalPlan::CreateTable { .. } => "CREATE TABLE",
261            LogicalPlan::DropTable { .. } => "DROP TABLE",
262            LogicalPlan::CreateIndex { .. } => "CREATE INDEX",
263            LogicalPlan::DropIndex { .. } => "DROP INDEX",
264        }
265    }
266
267    /// Creates a new Scan plan.
268    pub fn scan(table: String, projection: Projection) -> Self {
269        LogicalPlan::Scan { table, projection }
270    }
271
272    /// Creates a new Filter plan.
273    pub fn filter(input: LogicalPlan, predicate: TypedExpr) -> Self {
274        LogicalPlan::Filter {
275            input: Box::new(input),
276            predicate,
277        }
278    }
279
280    /// Creates a new Project plan.
281    pub fn project(input: LogicalPlan, projection: Projection) -> Self {
282        LogicalPlan::Project {
283            input: Box::new(input),
284            projection,
285        }
286    }
287
288    /// Creates a new Join plan.
289    pub fn join(
290        left: LogicalPlan,
291        right: LogicalPlan,
292        join_type: JoinType,
293        condition: Option<TypedExpr>,
294        using: Option<Vec<String>>,
295    ) -> Self {
296        LogicalPlan::Join {
297            left: Box::new(left),
298            right: Box::new(right),
299            join_type,
300            condition,
301            using,
302        }
303    }
304
305    /// Creates a new Aggregate plan.
306    pub fn aggregate(
307        input: LogicalPlan,
308        group_keys: Vec<TypedExpr>,
309        aggregates: Vec<AggregateExpr>,
310        having: Option<TypedExpr>,
311        projection: Projection,
312    ) -> Self {
313        LogicalPlan::Aggregate {
314            input: Box::new(input),
315            group_keys,
316            aggregates,
317            having,
318            projection,
319        }
320    }
321
322    /// Creates a new Sort plan.
323    pub fn sort(input: LogicalPlan, order_by: Vec<SortExpr>) -> Self {
324        LogicalPlan::Sort {
325            input: Box::new(input),
326            order_by,
327        }
328    }
329
330    /// Creates a new Limit plan.
331    pub fn limit(input: LogicalPlan, limit: Option<u64>, offset: Option<u64>) -> Self {
332        LogicalPlan::Limit {
333            input: Box::new(input),
334            limit,
335            offset,
336        }
337    }
338
339    /// Creates a new Insert plan.
340    pub fn insert(table: String, columns: Vec<String>, values: Vec<Vec<TypedExpr>>) -> Self {
341        LogicalPlan::Insert {
342            table,
343            columns,
344            values,
345        }
346    }
347
348    /// Creates a new Update plan.
349    pub fn update(
350        table: String,
351        assignments: Vec<TypedAssignment>,
352        filter: Option<TypedExpr>,
353    ) -> Self {
354        LogicalPlan::Update {
355            table,
356            assignments,
357            filter,
358        }
359    }
360
361    /// Creates a new Delete plan.
362    pub fn delete(table: String, filter: Option<TypedExpr>) -> Self {
363        LogicalPlan::Delete { table, filter }
364    }
365
366    /// Creates a new CreateTable plan.
367    pub fn create_table(
368        table: TableMetadata,
369        if_not_exists: bool,
370        with_options: Vec<(String, String)>,
371    ) -> Self {
372        LogicalPlan::CreateTable {
373            table,
374            if_not_exists,
375            with_options,
376        }
377    }
378
379    /// Creates a new DropTable plan.
380    pub fn drop_table(name: String, if_exists: bool) -> Self {
381        LogicalPlan::DropTable { name, if_exists }
382    }
383
384    /// Creates a new CreateIndex plan.
385    pub fn create_index(index: IndexMetadata, if_not_exists: bool) -> Self {
386        LogicalPlan::CreateIndex {
387            index,
388            if_not_exists,
389        }
390    }
391
392    /// Creates a new DropIndex plan.
393    pub fn drop_index(name: String, if_exists: bool) -> Self {
394        LogicalPlan::DropIndex { name, if_exists }
395    }
396
397    /// Returns the name of this plan variant.
398    pub fn name(&self) -> &'static str {
399        match self {
400            LogicalPlan::Pragma { .. } => "Pragma",
401            LogicalPlan::Scan { .. } => "Scan",
402            LogicalPlan::Filter { .. } => "Filter",
403            LogicalPlan::Project { .. } => "Project",
404            LogicalPlan::Join { .. } => "Join",
405            LogicalPlan::Aggregate { .. } => "Aggregate",
406            LogicalPlan::Sort { .. } => "Sort",
407            LogicalPlan::Limit { .. } => "Limit",
408            LogicalPlan::Insert { .. } => "Insert",
409            LogicalPlan::Update { .. } => "Update",
410            LogicalPlan::Delete { .. } => "Delete",
411            LogicalPlan::CreateTable { .. } => "CreateTable",
412            LogicalPlan::DropTable { .. } => "DropTable",
413            LogicalPlan::CreateIndex { .. } => "CreateIndex",
414            LogicalPlan::DropIndex { .. } => "DropIndex",
415        }
416    }
417
418    /// Returns true if this is a query plan (Scan, Filter, Sort, Limit).
419    pub fn is_query(&self) -> bool {
420        matches!(
421            self,
422            LogicalPlan::Scan { .. }
423                | LogicalPlan::Filter { .. }
424                | LogicalPlan::Project { .. }
425                | LogicalPlan::Join { .. }
426                | LogicalPlan::Aggregate { .. }
427                | LogicalPlan::Sort { .. }
428                | LogicalPlan::Limit { .. }
429        )
430    }
431
432    /// Returns true if this is a DML plan (Insert, Update, Delete).
433    pub fn is_dml(&self) -> bool {
434        matches!(
435            self,
436            LogicalPlan::Insert { .. } | LogicalPlan::Update { .. } | LogicalPlan::Delete { .. }
437        )
438    }
439
440    /// Returns true if this is a DDL plan (CreateTable, DropTable, CreateIndex, DropIndex).
441    pub fn is_ddl(&self) -> bool {
442        matches!(
443            self,
444            LogicalPlan::CreateTable { .. }
445                | LogicalPlan::DropTable { .. }
446                | LogicalPlan::CreateIndex { .. }
447                | LogicalPlan::DropIndex { .. }
448                | LogicalPlan::Pragma { .. }
449        )
450    }
451
452    /// Returns the input plan if this is a transformation (Filter, Aggregate, Sort, Limit).
453    pub fn input(&self) -> Option<&LogicalPlan> {
454        match self {
455            LogicalPlan::Filter { input, .. }
456            | LogicalPlan::Project { input, .. }
457            | LogicalPlan::Aggregate { input, .. }
458            | LogicalPlan::Sort { input, .. }
459            | LogicalPlan::Limit { input, .. } => Some(input),
460            LogicalPlan::Join { .. } => None,
461            _ => None,
462        }
463    }
464
465    /// Returns the table name if this plan operates on a single table.
466    pub fn table_name(&self) -> Option<&str> {
467        match self {
468            LogicalPlan::Scan { table, .. }
469            | LogicalPlan::Insert { table, .. }
470            | LogicalPlan::Update { table, .. }
471            | LogicalPlan::Delete { table, .. } => Some(table),
472            LogicalPlan::CreateTable { table, .. } => Some(&table.name),
473            LogicalPlan::DropTable { name, .. } => Some(name),
474            LogicalPlan::CreateIndex { index, .. } => Some(&index.table),
475            LogicalPlan::DropIndex { .. } => None,
476            LogicalPlan::Pragma { .. } => None,
477            LogicalPlan::Filter { input, .. }
478            | LogicalPlan::Project { input, .. }
479            | LogicalPlan::Aggregate { input, .. }
480            | LogicalPlan::Sort { input, .. }
481            | LogicalPlan::Limit { input, .. } => input.table_name(),
482            LogicalPlan::Join { .. } => None,
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::ast::expr::Literal;
491    use crate::ast::span::Span;
492    use crate::catalog::ColumnMetadata;
493    use crate::planner::typed_expr::ProjectedColumn;
494    use crate::planner::types::ResolvedType;
495
496    fn create_test_table_metadata() -> TableMetadata {
497        TableMetadata::new(
498            "users",
499            vec![
500                ColumnMetadata::new("id", ResolvedType::Integer)
501                    .with_primary_key(true)
502                    .with_not_null(true),
503                ColumnMetadata::new("name", ResolvedType::Text).with_not_null(true),
504                ColumnMetadata::new("email", ResolvedType::Text),
505            ],
506        )
507        .with_primary_key(vec!["id".to_string()])
508    }
509
510    #[test]
511    fn test_scan_plan() {
512        let plan = LogicalPlan::scan(
513            "users".to_string(),
514            Projection::All(vec![
515                "id".to_string(),
516                "name".to_string(),
517                "email".to_string(),
518            ]),
519        );
520
521        assert_eq!(plan.name(), "Scan");
522        assert!(plan.is_query());
523        assert!(!plan.is_dml());
524        assert!(!plan.is_ddl());
525        assert_eq!(plan.table_name(), Some("users"));
526        assert!(plan.input().is_none());
527    }
528
529    #[test]
530    fn test_filter_plan() {
531        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
532        let predicate = TypedExpr::column_ref(
533            "users".to_string(),
534            "id".to_string(),
535            0,
536            ResolvedType::Integer,
537            Span::default(),
538        );
539
540        let plan = LogicalPlan::filter(scan, predicate);
541
542        assert_eq!(plan.name(), "Filter");
543        assert!(plan.is_query());
544        assert!(plan.input().is_some());
545        assert_eq!(plan.table_name(), Some("users"));
546    }
547
548    #[test]
549    fn test_sort_plan() {
550        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
551        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
552            "users".to_string(),
553            "name".to_string(),
554            1,
555            ResolvedType::Text,
556            Span::default(),
557        ));
558
559        let plan = LogicalPlan::sort(scan, vec![sort_expr]);
560
561        assert_eq!(plan.name(), "Sort");
562        assert!(plan.is_query());
563    }
564
565    #[test]
566    fn test_limit_plan() {
567        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
568        let plan = LogicalPlan::limit(scan, Some(10), Some(5));
569
570        assert_eq!(plan.name(), "Limit");
571        assert!(plan.is_query());
572
573        if let LogicalPlan::Limit { limit, offset, .. } = &plan {
574            assert_eq!(*limit, Some(10));
575            assert_eq!(*offset, Some(5));
576        } else {
577            panic!("Expected Limit plan");
578        }
579    }
580
581    #[test]
582    fn test_nested_query_plan() {
583        // SELECT * FROM users WHERE id > 5 ORDER BY name LIMIT 10
584        let scan = LogicalPlan::scan(
585            "users".to_string(),
586            Projection::All(vec!["id".to_string(), "name".to_string()]),
587        );
588
589        let predicate = TypedExpr::literal(
590            Literal::Boolean(true),
591            ResolvedType::Boolean,
592            Span::default(),
593        );
594        let filter = LogicalPlan::filter(scan, predicate);
595
596        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
597            "users".to_string(),
598            "name".to_string(),
599            1,
600            ResolvedType::Text,
601            Span::default(),
602        ));
603        let sort = LogicalPlan::sort(filter, vec![sort_expr]);
604
605        let limit = LogicalPlan::limit(sort, Some(10), None);
606
607        // Verify the plan tree
608        assert_eq!(limit.name(), "Limit");
609        assert_eq!(limit.table_name(), Some("users"));
610
611        let sort_plan = limit.input().unwrap();
612        assert_eq!(sort_plan.name(), "Sort");
613
614        let filter_plan = sort_plan.input().unwrap();
615        assert_eq!(filter_plan.name(), "Filter");
616
617        let scan_plan = filter_plan.input().unwrap();
618        assert_eq!(scan_plan.name(), "Scan");
619        assert!(scan_plan.input().is_none());
620    }
621
622    #[test]
623    fn test_insert_plan() {
624        let value1 = TypedExpr::literal(
625            Literal::Number("1".to_string()),
626            ResolvedType::Integer,
627            Span::default(),
628        );
629        let value2 = TypedExpr::literal(
630            Literal::String("Alice".to_string()),
631            ResolvedType::Text,
632            Span::default(),
633        );
634
635        let plan = LogicalPlan::insert(
636            "users".to_string(),
637            vec!["id".to_string(), "name".to_string()],
638            vec![vec![value1, value2]],
639        );
640
641        assert_eq!(plan.name(), "Insert");
642        assert!(plan.is_dml());
643        assert!(!plan.is_query());
644        assert!(!plan.is_ddl());
645        assert_eq!(plan.table_name(), Some("users"));
646
647        if let LogicalPlan::Insert {
648            table,
649            columns,
650            values,
651        } = &plan
652        {
653            assert_eq!(table, "users");
654            assert_eq!(columns, &vec!["id".to_string(), "name".to_string()]);
655            assert_eq!(values.len(), 1);
656            assert_eq!(values[0].len(), 2);
657        } else {
658            panic!("Expected Insert plan");
659        }
660    }
661
662    #[test]
663    fn test_update_plan() {
664        let assignment = TypedAssignment::new(
665            "name".to_string(),
666            1,
667            TypedExpr::literal(
668                Literal::String("Bob".to_string()),
669                ResolvedType::Text,
670                Span::default(),
671            ),
672        );
673
674        let filter = TypedExpr::literal(
675            Literal::Boolean(true),
676            ResolvedType::Boolean,
677            Span::default(),
678        );
679
680        let plan = LogicalPlan::update("users".to_string(), vec![assignment], Some(filter));
681
682        assert_eq!(plan.name(), "Update");
683        assert!(plan.is_dml());
684        assert_eq!(plan.table_name(), Some("users"));
685    }
686
687    #[test]
688    fn test_delete_plan() {
689        let filter = TypedExpr::column_ref(
690            "users".to_string(),
691            "id".to_string(),
692            0,
693            ResolvedType::Integer,
694            Span::default(),
695        );
696
697        let plan = LogicalPlan::delete("users".to_string(), Some(filter));
698
699        assert_eq!(plan.name(), "Delete");
700        assert!(plan.is_dml());
701        assert_eq!(plan.table_name(), Some("users"));
702    }
703
704    #[test]
705    fn test_create_table_plan() {
706        let table = create_test_table_metadata();
707        let plan = LogicalPlan::create_table(table, false, vec![]);
708
709        assert_eq!(plan.name(), "CreateTable");
710        assert!(plan.is_ddl());
711        assert!(!plan.is_dml());
712        assert!(!plan.is_query());
713        assert_eq!(plan.table_name(), Some("users"));
714    }
715
716    #[test]
717    fn test_drop_table_plan() {
718        let plan = LogicalPlan::drop_table("users".to_string(), true);
719
720        assert_eq!(plan.name(), "DropTable");
721        assert!(plan.is_ddl());
722        assert_eq!(plan.table_name(), Some("users"));
723
724        if let LogicalPlan::DropTable { name, if_exists } = &plan {
725            assert_eq!(name, "users");
726            assert!(*if_exists);
727        } else {
728            panic!("Expected DropTable plan");
729        }
730    }
731
732    #[test]
733    fn test_create_index_plan() {
734        let index = IndexMetadata::new(0, "idx_users_name", "users", vec!["name".into()]);
735        let plan = LogicalPlan::create_index(index, false);
736
737        assert_eq!(plan.name(), "CreateIndex");
738        assert!(plan.is_ddl());
739        assert_eq!(plan.table_name(), Some("users"));
740    }
741
742    #[test]
743    fn test_drop_index_plan() {
744        let plan = LogicalPlan::drop_index("idx_users_name".to_string(), false);
745
746        assert_eq!(plan.name(), "DropIndex");
747        assert!(plan.is_ddl());
748        // DropIndex doesn't have table_name directly
749        assert!(plan.table_name().is_none());
750    }
751
752    #[test]
753    fn test_projection_columns() {
754        let col1 = ProjectedColumn::new(TypedExpr::column_ref(
755            "users".to_string(),
756            "id".to_string(),
757            0,
758            ResolvedType::Integer,
759            Span::default(),
760        ));
761        let col2 = ProjectedColumn::with_alias(
762            TypedExpr::column_ref(
763                "users".to_string(),
764                "name".to_string(),
765                1,
766                ResolvedType::Text,
767                Span::default(),
768            ),
769            "user_name".to_string(),
770        );
771
772        let plan = LogicalPlan::scan("users".to_string(), Projection::Columns(vec![col1, col2]));
773
774        if let LogicalPlan::Scan { projection, .. } = &plan {
775            assert_eq!(projection.len(), 2);
776        } else {
777            panic!("Expected Scan plan");
778        }
779    }
780}