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    /// Returns whether this plan tree contains a JOIN boundary.
487    ///
488    /// The normal local planner/executor continues to support JOIN.  Consumers
489    /// with a deliberately closed execution catalog (such as distributed
490    /// reads) can use this structural fact to reject it before any transport is
491    /// opened rather than trying to infer it from a table name.
492    pub fn contains_join(&self) -> bool {
493        match self {
494            LogicalPlan::Join { .. } => true,
495            LogicalPlan::Filter { input, .. }
496            | LogicalPlan::Project { input, .. }
497            | LogicalPlan::Aggregate { input, .. }
498            | LogicalPlan::Sort { input, .. }
499            | LogicalPlan::Limit { input, .. } => input.contains_join(),
500            _ => false,
501        }
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::ast::expr::Literal;
509    use crate::ast::span::Span;
510    use crate::catalog::ColumnMetadata;
511    use crate::planner::typed_expr::ProjectedColumn;
512    use crate::planner::types::ResolvedType;
513
514    fn create_test_table_metadata() -> TableMetadata {
515        TableMetadata::new(
516            "users",
517            vec![
518                ColumnMetadata::new("id", ResolvedType::Integer)
519                    .with_primary_key(true)
520                    .with_not_null(true),
521                ColumnMetadata::new("name", ResolvedType::Text).with_not_null(true),
522                ColumnMetadata::new("email", ResolvedType::Text),
523            ],
524        )
525        .with_primary_key(vec!["id".to_string()])
526    }
527
528    #[test]
529    fn test_scan_plan() {
530        let plan = LogicalPlan::scan(
531            "users".to_string(),
532            Projection::All(vec![
533                "id".to_string(),
534                "name".to_string(),
535                "email".to_string(),
536            ]),
537        );
538
539        assert_eq!(plan.name(), "Scan");
540        assert!(plan.is_query());
541        assert!(!plan.is_dml());
542        assert!(!plan.is_ddl());
543        assert_eq!(plan.table_name(), Some("users"));
544        assert!(plan.input().is_none());
545    }
546
547    #[test]
548    fn test_filter_plan() {
549        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
550        let predicate = TypedExpr::column_ref(
551            "users".to_string(),
552            "id".to_string(),
553            0,
554            ResolvedType::Integer,
555            Span::default(),
556        );
557
558        let plan = LogicalPlan::filter(scan, predicate);
559
560        assert_eq!(plan.name(), "Filter");
561        assert!(plan.is_query());
562        assert!(plan.input().is_some());
563        assert_eq!(plan.table_name(), Some("users"));
564    }
565
566    #[test]
567    fn test_sort_plan() {
568        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
569        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
570            "users".to_string(),
571            "name".to_string(),
572            1,
573            ResolvedType::Text,
574            Span::default(),
575        ));
576
577        let plan = LogicalPlan::sort(scan, vec![sort_expr]);
578
579        assert_eq!(plan.name(), "Sort");
580        assert!(plan.is_query());
581    }
582
583    #[test]
584    fn test_limit_plan() {
585        let scan = LogicalPlan::scan("users".to_string(), Projection::All(vec![]));
586        let plan = LogicalPlan::limit(scan, Some(10), Some(5));
587
588        assert_eq!(plan.name(), "Limit");
589        assert!(plan.is_query());
590
591        if let LogicalPlan::Limit { limit, offset, .. } = &plan {
592            assert_eq!(*limit, Some(10));
593            assert_eq!(*offset, Some(5));
594        } else {
595            panic!("Expected Limit plan");
596        }
597    }
598
599    #[test]
600    fn test_nested_query_plan() {
601        // SELECT * FROM users WHERE id > 5 ORDER BY name LIMIT 10
602        let scan = LogicalPlan::scan(
603            "users".to_string(),
604            Projection::All(vec!["id".to_string(), "name".to_string()]),
605        );
606
607        let predicate = TypedExpr::literal(
608            Literal::Boolean(true),
609            ResolvedType::Boolean,
610            Span::default(),
611        );
612        let filter = LogicalPlan::filter(scan, predicate);
613
614        let sort_expr = SortExpr::asc(TypedExpr::column_ref(
615            "users".to_string(),
616            "name".to_string(),
617            1,
618            ResolvedType::Text,
619            Span::default(),
620        ));
621        let sort = LogicalPlan::sort(filter, vec![sort_expr]);
622
623        let limit = LogicalPlan::limit(sort, Some(10), None);
624
625        // Verify the plan tree
626        assert_eq!(limit.name(), "Limit");
627        assert_eq!(limit.table_name(), Some("users"));
628
629        let sort_plan = limit.input().unwrap();
630        assert_eq!(sort_plan.name(), "Sort");
631
632        let filter_plan = sort_plan.input().unwrap();
633        assert_eq!(filter_plan.name(), "Filter");
634
635        let scan_plan = filter_plan.input().unwrap();
636        assert_eq!(scan_plan.name(), "Scan");
637        assert!(scan_plan.input().is_none());
638    }
639
640    #[test]
641    fn test_insert_plan() {
642        let value1 = TypedExpr::literal(
643            Literal::Number("1".to_string()),
644            ResolvedType::Integer,
645            Span::default(),
646        );
647        let value2 = TypedExpr::literal(
648            Literal::String("Alice".to_string()),
649            ResolvedType::Text,
650            Span::default(),
651        );
652
653        let plan = LogicalPlan::insert(
654            "users".to_string(),
655            vec!["id".to_string(), "name".to_string()],
656            vec![vec![value1, value2]],
657        );
658
659        assert_eq!(plan.name(), "Insert");
660        assert!(plan.is_dml());
661        assert!(!plan.is_query());
662        assert!(!plan.is_ddl());
663        assert_eq!(plan.table_name(), Some("users"));
664
665        if let LogicalPlan::Insert {
666            table,
667            columns,
668            values,
669        } = &plan
670        {
671            assert_eq!(table, "users");
672            assert_eq!(columns, &vec!["id".to_string(), "name".to_string()]);
673            assert_eq!(values.len(), 1);
674            assert_eq!(values[0].len(), 2);
675        } else {
676            panic!("Expected Insert plan");
677        }
678    }
679
680    #[test]
681    fn test_update_plan() {
682        let assignment = TypedAssignment::new(
683            "name".to_string(),
684            1,
685            TypedExpr::literal(
686                Literal::String("Bob".to_string()),
687                ResolvedType::Text,
688                Span::default(),
689            ),
690        );
691
692        let filter = TypedExpr::literal(
693            Literal::Boolean(true),
694            ResolvedType::Boolean,
695            Span::default(),
696        );
697
698        let plan = LogicalPlan::update("users".to_string(), vec![assignment], Some(filter));
699
700        assert_eq!(plan.name(), "Update");
701        assert!(plan.is_dml());
702        assert_eq!(plan.table_name(), Some("users"));
703    }
704
705    #[test]
706    fn test_delete_plan() {
707        let filter = TypedExpr::column_ref(
708            "users".to_string(),
709            "id".to_string(),
710            0,
711            ResolvedType::Integer,
712            Span::default(),
713        );
714
715        let plan = LogicalPlan::delete("users".to_string(), Some(filter));
716
717        assert_eq!(plan.name(), "Delete");
718        assert!(plan.is_dml());
719        assert_eq!(plan.table_name(), Some("users"));
720    }
721
722    #[test]
723    fn test_create_table_plan() {
724        let table = create_test_table_metadata();
725        let plan = LogicalPlan::create_table(table, false, vec![]);
726
727        assert_eq!(plan.name(), "CreateTable");
728        assert!(plan.is_ddl());
729        assert!(!plan.is_dml());
730        assert!(!plan.is_query());
731        assert_eq!(plan.table_name(), Some("users"));
732    }
733
734    #[test]
735    fn test_drop_table_plan() {
736        let plan = LogicalPlan::drop_table("users".to_string(), true);
737
738        assert_eq!(plan.name(), "DropTable");
739        assert!(plan.is_ddl());
740        assert_eq!(plan.table_name(), Some("users"));
741
742        if let LogicalPlan::DropTable { name, if_exists } = &plan {
743            assert_eq!(name, "users");
744            assert!(*if_exists);
745        } else {
746            panic!("Expected DropTable plan");
747        }
748    }
749
750    #[test]
751    fn test_create_index_plan() {
752        let index = IndexMetadata::new(0, "idx_users_name", "users", vec!["name".into()]);
753        let plan = LogicalPlan::create_index(index, false);
754
755        assert_eq!(plan.name(), "CreateIndex");
756        assert!(plan.is_ddl());
757        assert_eq!(plan.table_name(), Some("users"));
758    }
759
760    #[test]
761    fn test_drop_index_plan() {
762        let plan = LogicalPlan::drop_index("idx_users_name".to_string(), false);
763
764        assert_eq!(plan.name(), "DropIndex");
765        assert!(plan.is_ddl());
766        // DropIndex doesn't have table_name directly
767        assert!(plan.table_name().is_none());
768    }
769
770    #[test]
771    fn test_projection_columns() {
772        let col1 = ProjectedColumn::new(TypedExpr::column_ref(
773            "users".to_string(),
774            "id".to_string(),
775            0,
776            ResolvedType::Integer,
777            Span::default(),
778        ));
779        let col2 = ProjectedColumn::with_alias(
780            TypedExpr::column_ref(
781                "users".to_string(),
782                "name".to_string(),
783                1,
784                ResolvedType::Text,
785                Span::default(),
786            ),
787            "user_name".to_string(),
788        );
789
790        let plan = LogicalPlan::scan("users".to_string(), Projection::Columns(vec![col1, col2]));
791
792        if let LogicalPlan::Scan { projection, .. } = &plan {
793            assert_eq!(projection.len(), 2);
794        } else {
795            panic!("Expected Scan plan");
796        }
797    }
798}