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    /// INSERT rows produced by a SELECT query.
181    InsertSelect {
182        /// Target table name.
183        table: String,
184        /// Column names (always populated, never empty).
185        columns: Vec<String>,
186        /// Query that produces one row per inserted row.
187        source: Box<LogicalPlan>,
188    },
189
190    /// UPDATE operation.
191    ///
192    /// Updates rows in a table that match an optional filter.
193    Update {
194        /// Target table name.
195        table: String,
196        /// Assignments (SET column = value).
197        assignments: Vec<TypedAssignment>,
198        /// Optional filter predicate (WHERE clause).
199        filter: Option<TypedExpr>,
200    },
201
202    /// DELETE operation.
203    ///
204    /// Deletes rows from a table that match an optional filter.
205    Delete {
206        /// Target table name.
207        table: String,
208        /// Optional filter predicate (WHERE clause).
209        filter: Option<TypedExpr>,
210    },
211
212    // === DDL Plans ===
213    /// CREATE TABLE operation.
214    ///
215    /// Creates a new table with the specified metadata.
216    CreateTable {
217        /// Table metadata (name, columns, constraints).
218        table: TableMetadata,
219        /// If true, don't error if table already exists.
220        if_not_exists: bool,
221        /// Raw WITH options to be validated during execution.
222        with_options: Vec<(String, String)>,
223    },
224
225    /// DROP TABLE operation.
226    ///
227    /// Drops an existing table.
228    DropTable {
229        /// Table name to drop.
230        name: String,
231        /// If true, don't error if table doesn't exist.
232        if_exists: bool,
233    },
234
235    /// CREATE INDEX operation.
236    ///
237    /// Creates a new index on a table column.
238    CreateIndex {
239        /// Index metadata (name, table, column, method, options).
240        index: IndexMetadata,
241        /// If true, don't error if index already exists.
242        if_not_exists: bool,
243    },
244
245    /// DROP INDEX operation.
246    ///
247    /// Drops an existing index.
248    DropIndex {
249        /// Index name to drop.
250        name: String,
251        /// If true, don't error if index doesn't exist.
252        if_exists: bool,
253    },
254}
255
256impl LogicalPlan {
257    pub fn operation_name(&self) -> &'static str {
258        match self {
259            LogicalPlan::Pragma { .. } => "PRAGMA",
260            LogicalPlan::Scan { .. }
261            | LogicalPlan::Filter { .. }
262            | LogicalPlan::Project { .. }
263            | LogicalPlan::Join { .. }
264            | LogicalPlan::Aggregate { .. }
265            | LogicalPlan::Sort { .. }
266            | LogicalPlan::Limit { .. } => "SELECT",
267            LogicalPlan::Insert { .. } => "INSERT",
268            LogicalPlan::InsertSelect { .. } => "INSERT",
269            LogicalPlan::Update { .. } => "UPDATE",
270            LogicalPlan::Delete { .. } => "DELETE",
271            LogicalPlan::CreateTable { .. } => "CREATE TABLE",
272            LogicalPlan::DropTable { .. } => "DROP TABLE",
273            LogicalPlan::CreateIndex { .. } => "CREATE INDEX",
274            LogicalPlan::DropIndex { .. } => "DROP INDEX",
275        }
276    }
277
278    /// Creates a new Scan plan.
279    pub fn scan(table: String, projection: Projection) -> Self {
280        LogicalPlan::Scan { table, projection }
281    }
282
283    /// Creates a new Filter plan.
284    pub fn filter(input: LogicalPlan, predicate: TypedExpr) -> Self {
285        LogicalPlan::Filter {
286            input: Box::new(input),
287            predicate,
288        }
289    }
290
291    /// Creates a new Project plan.
292    pub fn project(input: LogicalPlan, projection: Projection) -> Self {
293        LogicalPlan::Project {
294            input: Box::new(input),
295            projection,
296        }
297    }
298
299    /// Creates a new Join plan.
300    pub fn join(
301        left: LogicalPlan,
302        right: LogicalPlan,
303        join_type: JoinType,
304        condition: Option<TypedExpr>,
305        using: Option<Vec<String>>,
306    ) -> Self {
307        LogicalPlan::Join {
308            left: Box::new(left),
309            right: Box::new(right),
310            join_type,
311            condition,
312            using,
313        }
314    }
315
316    /// Creates a new Aggregate plan.
317    pub fn aggregate(
318        input: LogicalPlan,
319        group_keys: Vec<TypedExpr>,
320        aggregates: Vec<AggregateExpr>,
321        having: Option<TypedExpr>,
322        projection: Projection,
323    ) -> Self {
324        LogicalPlan::Aggregate {
325            input: Box::new(input),
326            group_keys,
327            aggregates,
328            having,
329            projection,
330        }
331    }
332
333    /// Creates a new Sort plan.
334    pub fn sort(input: LogicalPlan, order_by: Vec<SortExpr>) -> Self {
335        LogicalPlan::Sort {
336            input: Box::new(input),
337            order_by,
338        }
339    }
340
341    /// Creates a new Limit plan.
342    pub fn limit(input: LogicalPlan, limit: Option<u64>, offset: Option<u64>) -> Self {
343        LogicalPlan::Limit {
344            input: Box::new(input),
345            limit,
346            offset,
347        }
348    }
349
350    /// Creates a new Insert plan.
351    pub fn insert(table: String, columns: Vec<String>, values: Vec<Vec<TypedExpr>>) -> Self {
352        LogicalPlan::Insert {
353            table,
354            columns,
355            values,
356        }
357    }
358
359    /// Creates a new Update plan.
360    pub fn update(
361        table: String,
362        assignments: Vec<TypedAssignment>,
363        filter: Option<TypedExpr>,
364    ) -> Self {
365        LogicalPlan::Update {
366            table,
367            assignments,
368            filter,
369        }
370    }
371
372    /// Creates a new Delete plan.
373    pub fn delete(table: String, filter: Option<TypedExpr>) -> Self {
374        LogicalPlan::Delete { table, filter }
375    }
376
377    /// Creates a new CreateTable plan.
378    pub fn create_table(
379        table: TableMetadata,
380        if_not_exists: bool,
381        with_options: Vec<(String, String)>,
382    ) -> Self {
383        LogicalPlan::CreateTable {
384            table,
385            if_not_exists,
386            with_options,
387        }
388    }
389
390    /// Creates a new DropTable plan.
391    pub fn drop_table(name: String, if_exists: bool) -> Self {
392        LogicalPlan::DropTable { name, if_exists }
393    }
394
395    /// Creates a new CreateIndex plan.
396    pub fn create_index(index: IndexMetadata, if_not_exists: bool) -> Self {
397        LogicalPlan::CreateIndex {
398            index,
399            if_not_exists,
400        }
401    }
402
403    /// Creates a new DropIndex plan.
404    pub fn drop_index(name: String, if_exists: bool) -> Self {
405        LogicalPlan::DropIndex { name, if_exists }
406    }
407
408    /// Returns the name of this plan variant.
409    pub fn name(&self) -> &'static str {
410        match self {
411            LogicalPlan::Pragma { .. } => "Pragma",
412            LogicalPlan::Scan { .. } => "Scan",
413            LogicalPlan::Filter { .. } => "Filter",
414            LogicalPlan::Project { .. } => "Project",
415            LogicalPlan::Join { .. } => "Join",
416            LogicalPlan::Aggregate { .. } => "Aggregate",
417            LogicalPlan::Sort { .. } => "Sort",
418            LogicalPlan::Limit { .. } => "Limit",
419            LogicalPlan::Insert { .. } => "Insert",
420            LogicalPlan::InsertSelect { .. } => "InsertSelect",
421            LogicalPlan::Update { .. } => "Update",
422            LogicalPlan::Delete { .. } => "Delete",
423            LogicalPlan::CreateTable { .. } => "CreateTable",
424            LogicalPlan::DropTable { .. } => "DropTable",
425            LogicalPlan::CreateIndex { .. } => "CreateIndex",
426            LogicalPlan::DropIndex { .. } => "DropIndex",
427        }
428    }
429
430    /// Returns true if this is a query plan (Scan, Filter, Sort, Limit).
431    pub fn is_query(&self) -> bool {
432        matches!(
433            self,
434            LogicalPlan::Scan { .. }
435                | LogicalPlan::Filter { .. }
436                | LogicalPlan::Project { .. }
437                | LogicalPlan::Join { .. }
438                | LogicalPlan::Aggregate { .. }
439                | LogicalPlan::Sort { .. }
440                | LogicalPlan::Limit { .. }
441        )
442    }
443
444    /// Returns true if this is a DML plan (Insert, Update, Delete).
445    pub fn is_dml(&self) -> bool {
446        matches!(
447            self,
448            LogicalPlan::Insert { .. }
449                | LogicalPlan::InsertSelect { .. }
450                | LogicalPlan::Update { .. }
451                | LogicalPlan::Delete { .. }
452        )
453    }
454
455    /// Returns true if this is a DDL plan (CreateTable, DropTable, CreateIndex, DropIndex).
456    pub fn is_ddl(&self) -> bool {
457        matches!(
458            self,
459            LogicalPlan::CreateTable { .. }
460                | LogicalPlan::DropTable { .. }
461                | LogicalPlan::CreateIndex { .. }
462                | LogicalPlan::DropIndex { .. }
463                | LogicalPlan::Pragma { .. }
464        )
465    }
466
467    /// Returns the input plan if this is a transformation (Filter, Aggregate, Sort, Limit).
468    pub fn input(&self) -> Option<&LogicalPlan> {
469        match self {
470            LogicalPlan::Filter { input, .. }
471            | LogicalPlan::Project { input, .. }
472            | LogicalPlan::Aggregate { input, .. }
473            | LogicalPlan::Sort { input, .. }
474            | LogicalPlan::Limit { input, .. } => Some(input),
475            LogicalPlan::Join { .. } => None,
476            _ => None,
477        }
478    }
479
480    /// Returns the table name if this plan operates on a single table.
481    pub fn table_name(&self) -> Option<&str> {
482        match self {
483            LogicalPlan::Scan { table, .. }
484            | LogicalPlan::Insert { table, .. }
485            | LogicalPlan::InsertSelect { table, .. }
486            | LogicalPlan::Update { table, .. }
487            | LogicalPlan::Delete { table, .. } => Some(table),
488            LogicalPlan::CreateTable { table, .. } => Some(&table.name),
489            LogicalPlan::DropTable { name, .. } => Some(name),
490            LogicalPlan::CreateIndex { index, .. } => Some(&index.table),
491            LogicalPlan::DropIndex { .. } => None,
492            LogicalPlan::Pragma { .. } => None,
493            LogicalPlan::Filter { input, .. }
494            | LogicalPlan::Project { input, .. }
495            | LogicalPlan::Aggregate { input, .. }
496            | LogicalPlan::Sort { input, .. }
497            | LogicalPlan::Limit { input, .. } => input.table_name(),
498            LogicalPlan::Join { .. } => None,
499        }
500    }
501
502    /// Returns whether this plan tree contains a JOIN boundary.
503    ///
504    /// The normal local planner/executor continues to support JOIN.  Consumers
505    /// with a deliberately closed execution catalog (such as distributed
506    /// reads) can use this structural fact to reject it before any transport is
507    /// opened rather than trying to infer it from a table name.
508    pub fn contains_join(&self) -> bool {
509        match self {
510            LogicalPlan::Join { .. } => true,
511            LogicalPlan::Filter { input, .. }
512            | LogicalPlan::Project { input, .. }
513            | LogicalPlan::Aggregate { input, .. }
514            | LogicalPlan::Sort { input, .. }
515            | LogicalPlan::Limit { input, .. } => input.contains_join(),
516            _ => false,
517        }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::ast::expr::Literal;
525    use crate::ast::span::Span;
526    use crate::catalog::ColumnMetadata;
527    use crate::planner::typed_expr::ProjectedColumn;
528    use crate::planner::types::ResolvedType;
529
530    fn create_test_table_metadata() -> TableMetadata {
531        TableMetadata::new(
532            "users",
533            vec![
534                ColumnMetadata::new("id", ResolvedType::Integer)
535                    .with_primary_key(true)
536                    .with_not_null(true),
537                ColumnMetadata::new("name", ResolvedType::Text).with_not_null(true),
538                ColumnMetadata::new("email", ResolvedType::Text),
539            ],
540        )
541        .with_primary_key(vec!["id".to_string()])
542    }
543
544    #[test]
545    fn test_scan_plan() {
546        let plan = LogicalPlan::scan(
547            "users".to_string(),
548            Projection::All(vec![
549                "id".to_string(),
550                "name".to_string(),
551                "email".to_string(),
552            ]),
553        );
554
555        assert_eq!(plan.name(), "Scan");
556        assert!(plan.is_query());
557        assert!(!plan.is_dml());
558        assert!(!plan.is_ddl());
559        assert_eq!(plan.table_name(), Some("users"));
560        assert!(plan.input().is_none());
561    }
562
563    #[test]
564    fn test_filter_plan() {
565        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
566        let predicate = TypedExpr::column_ref(
567            "users".to_string(),
568            "id".to_string(),
569            0,
570            ResolvedType::Integer,
571            Span::default(),
572        );
573
574        let plan = LogicalPlan::filter(scan, predicate);
575
576        assert_eq!(plan.name(), "Filter");
577        assert!(plan.is_query());
578        assert!(plan.input().is_some());
579        assert_eq!(plan.table_name(), Some("users"));
580    }
581
582    #[test]
583    fn test_sort_plan() {
584        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
585        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
586            "users".to_string(),
587            "name".to_string(),
588            1,
589            ResolvedType::Text,
590            Span::default(),
591        ));
592
593        let plan = LogicalPlan::sort(scan, vec![sort_expr]);
594
595        assert_eq!(plan.name(), "Sort");
596        assert!(plan.is_query());
597    }
598
599    #[test]
600    fn test_limit_plan() {
601        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
602        let plan = LogicalPlan::limit(scan, Some(10), Some(5));
603
604        assert_eq!(plan.name(), "Limit");
605        assert!(plan.is_query());
606
607        if let LogicalPlan::Limit { limit, offset, .. } = &plan {
608            assert_eq!(*limit, Some(10));
609            assert_eq!(*offset, Some(5));
610        } else {
611            panic!("Expected Limit plan");
612        }
613    }
614
615    #[test]
616    fn test_nested_query_plan() {
617        // SELECT * FROM users WHERE id > 5 ORDER BY name LIMIT 10
618        let scan = LogicalPlan::scan(
619            "users".to_string(),
620            Projection::All(vec!["id".to_string(), "name".to_string()]),
621        );
622
623        let predicate = TypedExpr::literal(
624            Literal::Boolean(true),
625            ResolvedType::Boolean,
626            Span::default(),
627        );
628        let filter = LogicalPlan::filter(scan, predicate);
629
630        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
631            "users".to_string(),
632            "name".to_string(),
633            1,
634            ResolvedType::Text,
635            Span::default(),
636        ));
637        let sort = LogicalPlan::sort(filter, vec![sort_expr]);
638
639        let limit = LogicalPlan::limit(sort, Some(10), None);
640
641        // Verify the plan tree
642        assert_eq!(limit.name(), "Limit");
643        assert_eq!(limit.table_name(), Some("users"));
644
645        let sort_plan = limit.input().unwrap();
646        assert_eq!(sort_plan.name(), "Sort");
647
648        let filter_plan = sort_plan.input().unwrap();
649        assert_eq!(filter_plan.name(), "Filter");
650
651        let scan_plan = filter_plan.input().unwrap();
652        assert_eq!(scan_plan.name(), "Scan");
653        assert!(scan_plan.input().is_none());
654    }
655
656    #[test]
657    fn test_insert_plan() {
658        let value1 = TypedExpr::literal(
659            Literal::Number("1".to_string()),
660            ResolvedType::Integer,
661            Span::default(),
662        );
663        let value2 = TypedExpr::literal(
664            Literal::String("Alice".to_string()),
665            ResolvedType::Text,
666            Span::default(),
667        );
668
669        let plan = LogicalPlan::insert(
670            "users".to_string(),
671            vec!["id".to_string(), "name".to_string()],
672            vec![vec![value1, value2]],
673        );
674
675        assert_eq!(plan.name(), "Insert");
676        assert!(plan.is_dml());
677        assert!(!plan.is_query());
678        assert!(!plan.is_ddl());
679        assert_eq!(plan.table_name(), Some("users"));
680
681        if let LogicalPlan::Insert {
682            table,
683            columns,
684            values,
685        } = &plan
686        {
687            assert_eq!(table, "users");
688            assert_eq!(columns, &vec!["id".to_string(), "name".to_string()]);
689            assert_eq!(values.len(), 1);
690            assert_eq!(values[0].len(), 2);
691        } else {
692            panic!("Expected Insert plan");
693        }
694    }
695
696    #[test]
697    fn test_update_plan() {
698        let assignment = TypedAssignment::new(
699            "name".to_string(),
700            1,
701            TypedExpr::literal(
702                Literal::String("Bob".to_string()),
703                ResolvedType::Text,
704                Span::default(),
705            ),
706        );
707
708        let filter = TypedExpr::literal(
709            Literal::Boolean(true),
710            ResolvedType::Boolean,
711            Span::default(),
712        );
713
714        let plan = LogicalPlan::update("users".to_string(), vec![assignment], Some(filter));
715
716        assert_eq!(plan.name(), "Update");
717        assert!(plan.is_dml());
718        assert_eq!(plan.table_name(), Some("users"));
719    }
720
721    #[test]
722    fn test_delete_plan() {
723        let filter = TypedExpr::column_ref(
724            "users".to_string(),
725            "id".to_string(),
726            0,
727            ResolvedType::Integer,
728            Span::default(),
729        );
730
731        let plan = LogicalPlan::delete("users".to_string(), Some(filter));
732
733        assert_eq!(plan.name(), "Delete");
734        assert!(plan.is_dml());
735        assert_eq!(plan.table_name(), Some("users"));
736    }
737
738    #[test]
739    fn test_create_table_plan() {
740        let table = create_test_table_metadata();
741        let plan = LogicalPlan::create_table(table, false, vec![]);
742
743        assert_eq!(plan.name(), "CreateTable");
744        assert!(plan.is_ddl());
745        assert!(!plan.is_dml());
746        assert!(!plan.is_query());
747        assert_eq!(plan.table_name(), Some("users"));
748    }
749
750    #[test]
751    fn test_drop_table_plan() {
752        let plan = LogicalPlan::drop_table("users".to_string(), true);
753
754        assert_eq!(plan.name(), "DropTable");
755        assert!(plan.is_ddl());
756        assert_eq!(plan.table_name(), Some("users"));
757
758        if let LogicalPlan::DropTable { name, if_exists } = &plan {
759            assert_eq!(name, "users");
760            assert!(*if_exists);
761        } else {
762            panic!("Expected DropTable plan");
763        }
764    }
765
766    #[test]
767    fn test_create_index_plan() {
768        let index = IndexMetadata::new(0, "idx_users_name", "users", vec!["name".into()]);
769        let plan = LogicalPlan::create_index(index, false);
770
771        assert_eq!(plan.name(), "CreateIndex");
772        assert!(plan.is_ddl());
773        assert_eq!(plan.table_name(), Some("users"));
774    }
775
776    #[test]
777    fn test_drop_index_plan() {
778        let plan = LogicalPlan::drop_index("idx_users_name".to_string(), false);
779
780        assert_eq!(plan.name(), "DropIndex");
781        assert!(plan.is_ddl());
782        // DropIndex doesn't have table_name directly
783        assert!(plan.table_name().is_none());
784    }
785
786    #[test]
787    fn test_projection_columns() {
788        let col1 = ProjectedColumn::new(TypedExpr::column_ref(
789            "users".to_string(),
790            "id".to_string(),
791            0,
792            ResolvedType::Integer,
793            Span::default(),
794        ));
795        let col2 = ProjectedColumn::with_alias(
796            TypedExpr::column_ref(
797                "users".to_string(),
798                "name".to_string(),
799                1,
800                ResolvedType::Text,
801                Span::default(),
802            ),
803            "user_name".to_string(),
804        );
805
806        let plan = LogicalPlan::scan("users".to_string(), Projection::Columns(vec![col1, col2]));
807
808        if let LogicalPlan::Scan { projection, .. } = &plan {
809            assert_eq!(projection.len(), 2);
810        } else {
811            panic!("Expected Scan plan");
812        }
813    }
814}