vibesql_executor/
schema.rs

1use std::borrow::Borrow;
2use std::collections::HashMap;
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::ops::Deref;
6
7/// A normalized table/alias key for case-insensitive lookups.
8/// Always stored as lowercase, making case-insensitive handling impossible to get wrong.
9#[derive(Debug, Clone, Eq)]
10pub struct TableKey(String);
11
12impl TableKey {
13    /// Create a new TableKey, normalizing to lowercase.
14    #[inline]
15    pub fn new(name: impl AsRef<str>) -> Self {
16        TableKey(name.as_ref().to_lowercase())
17    }
18
19    /// Get the normalized key as a string slice.
20    #[inline]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24
25    /// Consume the TableKey and return the inner String.
26    #[inline]
27    pub fn into_inner(self) -> String {
28        self.0
29    }
30}
31
32impl PartialEq for TableKey {
33    fn eq(&self, other: &Self) -> bool {
34        self.0 == other.0
35    }
36}
37
38impl Hash for TableKey {
39    fn hash<H: Hasher>(&self, state: &mut H) {
40        self.0.hash(state);
41    }
42}
43
44impl Deref for TableKey {
45    type Target = str;
46
47    fn deref(&self) -> &Self::Target {
48        &self.0
49    }
50}
51
52impl AsRef<str> for TableKey {
53    fn as_ref(&self) -> &str {
54        &self.0
55    }
56}
57
58impl Borrow<str> for TableKey {
59    fn borrow(&self) -> &str {
60        &self.0
61    }
62}
63
64impl fmt::Display for TableKey {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "{}", self.0)
67    }
68}
69
70impl From<String> for TableKey {
71    fn from(s: String) -> Self {
72        TableKey::new(s)
73    }
74}
75
76impl From<&str> for TableKey {
77    fn from(s: &str) -> Self {
78        TableKey::new(s)
79    }
80}
81
82impl From<TableKey> for String {
83    fn from(key: TableKey) -> Self {
84        key.0
85    }
86}
87
88impl From<&TableKey> for TableKey {
89    fn from(key: &TableKey) -> Self {
90        key.clone()
91    }
92}
93
94impl From<&String> for TableKey {
95    fn from(s: &String) -> Self {
96        TableKey::new(s)
97    }
98}
99
100/// Represents the combined schema from multiple tables (for JOINs)
101#[derive(Debug, Clone)]
102pub struct CombinedSchema {
103    /// Map from table name (normalized via TableKey) to (start_index, TableSchema)
104    /// start_index is where this table's columns begin in the combined row
105    /// Keys are always lowercase for case-insensitive lookups
106    pub table_schemas: HashMap<TableKey, (usize, vibesql_catalog::TableSchema)>,
107    /// Total number of columns across all tables
108    pub total_columns: usize,
109}
110
111impl CombinedSchema {
112    /// Create a new combined schema from a single table
113    ///
114    /// Note: Table name is automatically normalized via TableKey for case-insensitive lookups
115    pub fn from_table(table_name: String, schema: vibesql_catalog::TableSchema) -> Self {
116        let total_columns = schema.columns.len();
117        let mut table_schemas = HashMap::new();
118        // TableKey automatically normalizes to lowercase
119        table_schemas.insert(TableKey::new(table_name), (0, schema));
120        CombinedSchema { table_schemas, total_columns }
121    }
122
123    /// Create a new combined schema from a derived table (subquery result)
124    ///
125    /// Note: Alias is automatically normalized via TableKey for case-insensitive lookups
126    pub fn from_derived_table(
127        alias: String,
128        column_names: Vec<String>,
129        column_types: Vec<vibesql_types::DataType>,
130    ) -> Self {
131        let total_columns = column_names.len();
132
133        // Build column definitions
134        let columns: Vec<vibesql_catalog::ColumnSchema> = column_names
135            .into_iter()
136            .zip(column_types)
137            .map(|(name, data_type)| vibesql_catalog::ColumnSchema {
138                name,
139                data_type,
140                nullable: true,      // Derived table columns are always nullable
141                default_value: None, // Derived table columns have no defaults
142            })
143            .collect();
144
145        let schema = vibesql_catalog::TableSchema::new(alias.clone(), columns);
146        let mut table_schemas = HashMap::new();
147        // TableKey automatically normalizes to lowercase
148        table_schemas.insert(TableKey::new(alias), (0, schema));
149        CombinedSchema { table_schemas, total_columns }
150    }
151
152    /// Combine two schemas (for JOIN operations)
153    ///
154    /// Note: Right table name is automatically normalized via TableKey for case-insensitive lookups
155    pub fn combine(
156        left: CombinedSchema,
157        right_table: impl Into<TableKey>,
158        right_schema: vibesql_catalog::TableSchema,
159    ) -> Self {
160        let mut table_schemas = left.table_schemas;
161        let left_total = left.total_columns;
162        let right_columns = right_schema.columns.len();
163        // TableKey automatically normalizes to lowercase
164        table_schemas.insert(right_table.into(), (left_total, right_schema));
165        CombinedSchema { table_schemas, total_columns: left_total + right_columns }
166    }
167
168    /// Look up a column by name (optionally qualified with table name)
169    /// Uses case-insensitive matching for table/alias and column names
170    pub fn get_column_index(&self, table: Option<&str>, column: &str) -> Option<usize> {
171        if let Some(table_name) = table {
172            // Qualified column reference (table.column)
173            // TableKey normalizes to lowercase, so lookup is case-insensitive
174            let key = TableKey::new(table_name);
175            if let Some((start_index, schema)) = self.table_schemas.get(&key) {
176                return schema.get_column_index(column).map(|idx| start_index + idx);
177            }
178            None
179        } else {
180            // Unqualified column reference - search all tables
181            // IMPORTANT: For LEFT JOINs, we must resolve to the LEFTMOST table
182            // that has the column. Since HashMap iteration order is non-deterministic,
183            // we find ALL matches and pick the one with the lowest start_index.
184            let mut best_match: Option<usize> = None;
185            for (start_index, schema) in self.table_schemas.values() {
186                if let Some(idx) = schema.get_column_index(column) {
187                    let absolute_idx = start_index + idx;
188                    match best_match {
189                        None => best_match = Some(absolute_idx),
190                        Some(current_best) if absolute_idx < current_best => {
191                            best_match = Some(absolute_idx);
192                        }
193                        _ => {}
194                    }
195                }
196            }
197            best_match
198        }
199    }
200
201    /// Get a table schema by name (case-insensitive lookup)
202    pub fn get_table(&self, table_name: &str) -> Option<&(usize, vibesql_catalog::TableSchema)> {
203        self.table_schemas.get(&TableKey::new(table_name))
204    }
205
206    /// Check if a table exists (case-insensitive lookup)
207    pub fn contains_table(&self, table_name: &str) -> bool {
208        self.table_schemas.contains_key(&TableKey::new(table_name))
209    }
210
211    /// Get all table names as strings
212    pub fn table_names(&self) -> Vec<String> {
213        self.table_schemas.keys().map(|k| k.to_string()).collect()
214    }
215
216    /// Insert or update a table in the schema
217    pub fn insert_table(
218        &mut self,
219        name: impl Into<TableKey>,
220        start_index: usize,
221        schema: vibesql_catalog::TableSchema,
222    ) {
223        self.table_schemas.insert(name.into(), (start_index, schema));
224    }
225}
226
227/// Builder for incrementally constructing a CombinedSchema
228///
229/// Builds schemas in O(n) time instead of O(n²) by tracking
230/// the column offset as tables are added.
231#[derive(Debug)]
232pub struct SchemaBuilder {
233    table_schemas: HashMap<TableKey, (usize, vibesql_catalog::TableSchema)>,
234    column_offset: usize,
235}
236
237impl SchemaBuilder {
238    /// Create a new empty schema builder
239    pub fn new() -> Self {
240        SchemaBuilder { table_schemas: HashMap::new(), column_offset: 0 }
241    }
242
243    /// Create a schema builder initialized with an existing CombinedSchema
244    ///
245    /// Note: Table names are already normalized via TableKey
246    pub fn from_schema(schema: CombinedSchema) -> Self {
247        let column_offset = schema.total_columns;
248        // TableKeys are already normalized, just pass them through
249        SchemaBuilder { table_schemas: schema.table_schemas, column_offset }
250    }
251
252    /// Add a table to the schema
253    ///
254    /// This is an O(1) operation - columns are not copied, just indexed
255    /// Note: Table names are automatically normalized via TableKey for case-insensitive lookups
256    pub fn add_table(&mut self, name: impl Into<TableKey>, schema: vibesql_catalog::TableSchema) -> &mut Self {
257        let num_columns = schema.columns.len();
258        // TableKey automatically normalizes to lowercase
259        self.table_schemas.insert(name.into(), (self.column_offset, schema));
260        self.column_offset += num_columns;
261        self
262    }
263
264    /// Build the final CombinedSchema
265    ///
266    /// This consumes the builder and produces the schema in O(1) time
267    pub fn build(self) -> CombinedSchema {
268        CombinedSchema { table_schemas: self.table_schemas, total_columns: self.column_offset }
269    }
270}
271
272impl Default for SchemaBuilder {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use vibesql_catalog::ColumnSchema;
282    use vibesql_types::DataType;
283
284    /// Helper to create a simple table schema with the given columns
285    fn table_schema_with_columns(table_name: &str, columns: Vec<(&str, DataType)>) -> vibesql_catalog::TableSchema {
286        let cols: Vec<ColumnSchema> = columns
287            .into_iter()
288            .map(|(name, data_type)| ColumnSchema::new(name.to_string(), data_type, true))
289            .collect();
290        vibesql_catalog::TableSchema::new(table_name.to_string(), cols)
291    }
292
293    /// Helper to create a table schema with a single column
294    fn table_schema_with_column(table_name: &str, column_name: &str) -> vibesql_catalog::TableSchema {
295        table_schema_with_columns(table_name, vec![(column_name, DataType::Integer)])
296    }
297
298    // ==========================================================================
299    // CombinedSchema::from_table - Case-Insensitive Table Name Tests
300    // ==========================================================================
301
302    #[test]
303    fn test_from_table_uppercase_insertion_case_insensitive_lookup() {
304        // Insert with uppercase table name
305        let schema = CombinedSchema::from_table(
306            "ITEM".to_string(),
307            table_schema_with_column("ITEM", "price"),
308        );
309
310        // All case variations should find the column
311        assert!(schema.get_column_index(Some("ITEM"), "price").is_some(), "ITEM should find price");
312        assert!(schema.get_column_index(Some("item"), "price").is_some(), "item should find price");
313        assert!(schema.get_column_index(Some("Item"), "price").is_some(), "Item should find price");
314        assert!(schema.get_column_index(Some("iTEM"), "price").is_some(), "iTEM should find price");
315    }
316
317    #[test]
318    fn test_from_table_lowercase_insertion_case_insensitive_lookup() {
319        // Insert with lowercase table name
320        let schema = CombinedSchema::from_table(
321            "item".to_string(),
322            table_schema_with_column("item", "price"),
323        );
324
325        // All case variations should find the column
326        assert!(schema.get_column_index(Some("ITEM"), "price").is_some());
327        assert!(schema.get_column_index(Some("item"), "price").is_some());
328        assert!(schema.get_column_index(Some("Item"), "price").is_some());
329    }
330
331    #[test]
332    fn test_from_table_mixedcase_insertion_case_insensitive_lookup() {
333        // Insert with mixed case table name
334        let schema = CombinedSchema::from_table(
335            "MyTable".to_string(),
336            table_schema_with_column("MyTable", "id"),
337        );
338
339        // All case variations should find the column
340        assert!(schema.get_column_index(Some("MYTABLE"), "id").is_some());
341        assert!(schema.get_column_index(Some("mytable"), "id").is_some());
342        assert!(schema.get_column_index(Some("MyTable"), "id").is_some());
343        assert!(schema.get_column_index(Some("myTable"), "id").is_some());
344    }
345
346    // ==========================================================================
347    // CombinedSchema::from_derived_table - Case-Insensitive Alias Tests
348    // ==========================================================================
349
350    #[test]
351    fn test_from_derived_table_case_insensitive_alias() {
352        // Derived table with uppercase alias
353        let schema = CombinedSchema::from_derived_table(
354            "SUBQ".to_string(),
355            vec!["col1".to_string(), "col2".to_string()],
356            vec![DataType::Integer, DataType::Varchar { max_length: None }],
357        );
358
359        // All alias case variations should work
360        assert!(schema.get_column_index(Some("SUBQ"), "col1").is_some());
361        assert!(schema.get_column_index(Some("subq"), "col1").is_some());
362        assert!(schema.get_column_index(Some("Subq"), "col1").is_some());
363    }
364
365    // ==========================================================================
366    // CombinedSchema::combine - Multi-Table Case-Insensitive Tests
367    // ==========================================================================
368
369    #[test]
370    fn test_combine_case_insensitive_both_tables() {
371        // Create left schema with uppercase
372        let left = CombinedSchema::from_table(
373            "ORDERS".to_string(),
374            table_schema_with_columns("ORDERS", vec![("order_id", DataType::Integer), ("customer_id", DataType::Integer)]),
375        );
376
377        // Combine with right table using different case
378        let combined = CombinedSchema::combine(
379            left,
380            "Items".to_string(),
381            table_schema_with_columns("Items", vec![("item_id", DataType::Integer), ("price", DataType::DoublePrecision)]),
382        );
383
384        // Verify left table columns accessible with any case
385        assert!(combined.get_column_index(Some("orders"), "order_id").is_some());
386        assert!(combined.get_column_index(Some("ORDERS"), "order_id").is_some());
387        assert!(combined.get_column_index(Some("Orders"), "customer_id").is_some());
388
389        // Verify right table columns accessible with any case
390        assert!(combined.get_column_index(Some("items"), "item_id").is_some());
391        assert!(combined.get_column_index(Some("ITEMS"), "item_id").is_some());
392        assert!(combined.get_column_index(Some("Items"), "price").is_some());
393
394        // Verify correct indices (left table starts at 0, right at 2)
395        assert_eq!(combined.get_column_index(Some("orders"), "order_id"), Some(0));
396        assert_eq!(combined.get_column_index(Some("orders"), "customer_id"), Some(1));
397        assert_eq!(combined.get_column_index(Some("items"), "item_id"), Some(2));
398        assert_eq!(combined.get_column_index(Some("items"), "price"), Some(3));
399    }
400
401    #[test]
402    fn test_combine_multiple_joins_case_insensitive() {
403        // Simulate a 3-way join: orders JOIN customers JOIN items
404        let orders = CombinedSchema::from_table(
405            "O".to_string(), // short alias
406            table_schema_with_column("O", "order_id"),
407        );
408
409        let with_customers = CombinedSchema::combine(
410            orders,
411            "C".to_string(),
412            table_schema_with_column("C", "customer_id"),
413        );
414
415        let with_items = CombinedSchema::combine(
416            with_customers,
417            "I".to_string(),
418            table_schema_with_column("I", "item_id"),
419        );
420
421        // All aliases should be case-insensitive
422        assert!(with_items.get_column_index(Some("o"), "order_id").is_some());
423        assert!(with_items.get_column_index(Some("O"), "order_id").is_some());
424        assert!(with_items.get_column_index(Some("c"), "customer_id").is_some());
425        assert!(with_items.get_column_index(Some("C"), "customer_id").is_some());
426        assert!(with_items.get_column_index(Some("i"), "item_id").is_some());
427        assert!(with_items.get_column_index(Some("I"), "item_id").is_some());
428    }
429
430    // ==========================================================================
431    // CombinedSchema::get_column_index - Unqualified Column Lookup
432    // ==========================================================================
433
434    #[test]
435    fn test_unqualified_column_lookup_no_ambiguity() {
436        let schema = CombinedSchema::from_table(
437            "USERS".to_string(),
438            table_schema_with_columns("USERS", vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: None })]),
439        );
440
441        // Unqualified lookup should work
442        assert!(schema.get_column_index(None, "id").is_some());
443        assert!(schema.get_column_index(None, "name").is_some());
444        assert!(schema.get_column_index(None, "missing").is_none());
445    }
446
447    #[test]
448    fn test_column_case_sensitive_with_fallback() {
449        // Column created with mixed case (simulating a delimited identifier like "UserName")
450        let schema = CombinedSchema::from_table(
451            "users".to_string(),
452            table_schema_with_column("users", "UserName"),
453        );
454
455        // Exact case match works
456        assert!(schema.get_column_index(Some("users"), "UserName").is_some());
457        // Case-insensitive fallback also works for backward compatibility
458        assert!(schema.get_column_index(Some("users"), "username").is_some());
459        assert!(schema.get_column_index(Some("users"), "USERNAME").is_some());
460    }
461
462    /// Test case for issue #4111: TPC-DS Q6 scenario
463    /// Schema created with lowercase column names (from data loader)
464    /// Query uses uppercase identifiers (from parser normalization)
465    #[test]
466    fn test_tpcds_q6_case_insensitive_column_lookup_issue_4111() {
467        // Simulate TPC-DS item table with lowercase columns (as created by data loader)
468        let schema = CombinedSchema::from_table(
469            "J".to_string(), // Uppercase alias from parser
470            table_schema_with_columns(
471                "item",
472                vec![
473                    ("i_item_sk", DataType::Integer),
474                    ("i_current_price", DataType::DoublePrecision), // lowercase!
475                    ("i_category", DataType::Varchar { max_length: None }),
476                ],
477            ),
478        );
479
480        // Query uses uppercase column names (from parser normalization)
481        // This is the exact pattern that fails in TPC-DS Q6:
482        // SELECT AVG(j.i_current_price) FROM item j WHERE j.i_category = i.i_category
483        assert!(
484            schema.get_column_index(Some("J"), "I_CURRENT_PRICE").is_some(),
485            "J.I_CURRENT_PRICE should find i_current_price via case-insensitive lookup"
486        );
487        assert!(
488            schema.get_column_index(Some("J"), "I_CATEGORY").is_some(),
489            "J.I_CATEGORY should find i_category via case-insensitive lookup"
490        );
491        assert!(
492            schema.get_column_index(Some("j"), "I_CURRENT_PRICE").is_some(),
493            "j.I_CURRENT_PRICE should find i_current_price"
494        );
495        assert!(
496            schema.get_column_index(Some("J"), "i_current_price").is_some(),
497            "J.i_current_price should find via exact match"
498        );
499    }
500
501    #[test]
502    fn test_column_distinct_cases_exact_match() {
503        // When there are multiple columns with different cases (via delimited identifiers),
504        // exact match takes precedence
505        let cols: Vec<vibesql_catalog::ColumnSchema> = vec![
506            vibesql_catalog::ColumnSchema::new("value".to_string(), DataType::Integer, true),
507            vibesql_catalog::ColumnSchema::new("VALUE".to_string(), DataType::Integer, true),
508            vibesql_catalog::ColumnSchema::new("Value".to_string(), DataType::Integer, true),
509        ];
510        let table_schema = vibesql_catalog::TableSchema::new("data".to_string(), cols);
511        let schema = CombinedSchema::from_table("data".to_string(), table_schema);
512
513        // Each case variation should find its specific column
514        assert_eq!(schema.get_column_index(Some("data"), "value"), Some(0));
515        assert_eq!(schema.get_column_index(Some("data"), "VALUE"), Some(1));
516        assert_eq!(schema.get_column_index(Some("data"), "Value"), Some(2));
517    }
518
519    // ==========================================================================
520    // SchemaBuilder - Case-Insensitive Tests
521    // ==========================================================================
522
523    #[test]
524    fn test_schema_builder_add_table_case_insensitive() {
525        let mut builder = SchemaBuilder::new();
526
527        // Add tables with different case
528        builder.add_table(
529            "ORDERS".to_string(),
530            table_schema_with_column("ORDERS", "order_id"),
531        );
532        builder.add_table(
533            "Items".to_string(),
534            table_schema_with_column("Items", "item_id"),
535        );
536
537        let schema = builder.build();
538
539        // All case variations should work
540        assert!(schema.get_column_index(Some("orders"), "order_id").is_some());
541        assert!(schema.get_column_index(Some("ORDERS"), "order_id").is_some());
542        assert!(schema.get_column_index(Some("items"), "item_id").is_some());
543        assert!(schema.get_column_index(Some("ITEMS"), "item_id").is_some());
544    }
545
546    #[test]
547    fn test_schema_builder_from_schema_preserves_case_insensitivity() {
548        // Create initial schema with uppercase table name
549        let initial = CombinedSchema::from_table(
550            "PRODUCTS".to_string(),
551            table_schema_with_columns("PRODUCTS", vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: None })]),
552        );
553
554        // Verify initial schema works
555        assert!(initial.get_column_index(Some("products"), "id").is_some());
556
557        // Create builder from schema and add another table
558        let mut builder = SchemaBuilder::from_schema(initial);
559        builder.add_table(
560            "Categories".to_string(),
561            table_schema_with_column("Categories", "cat_id"),
562        );
563
564        let final_schema = builder.build();
565
566        // Original table should still be case-insensitive
567        assert!(final_schema.get_column_index(Some("products"), "id").is_some());
568        assert!(final_schema.get_column_index(Some("PRODUCTS"), "id").is_some());
569        assert!(final_schema.get_column_index(Some("Products"), "name").is_some());
570
571        // New table should also be case-insensitive
572        assert!(final_schema.get_column_index(Some("categories"), "cat_id").is_some());
573        assert!(final_schema.get_column_index(Some("CATEGORIES"), "cat_id").is_some());
574    }
575
576    #[test]
577    fn test_schema_builder_from_schema_multiple_tables() {
578        // Create combined schema with multiple tables
579        let orders = CombinedSchema::from_table(
580            "Orders".to_string(),
581            table_schema_with_column("Orders", "order_id"),
582        );
583        let combined = CombinedSchema::combine(
584            orders,
585            "Items".to_string(),
586            table_schema_with_column("Items", "item_id"),
587        );
588
589        // Create builder from combined schema
590        let mut builder = SchemaBuilder::from_schema(combined);
591        builder.add_table(
592            "CUSTOMERS".to_string(),
593            table_schema_with_column("CUSTOMERS", "cust_id"),
594        );
595
596        let final_schema = builder.build();
597
598        // All tables should be case-insensitive
599        assert!(final_schema.get_column_index(Some("orders"), "order_id").is_some());
600        assert!(final_schema.get_column_index(Some("ORDERS"), "order_id").is_some());
601        assert!(final_schema.get_column_index(Some("items"), "item_id").is_some());
602        assert!(final_schema.get_column_index(Some("ITEMS"), "item_id").is_some());
603        assert!(final_schema.get_column_index(Some("customers"), "cust_id").is_some());
604        assert!(final_schema.get_column_index(Some("CUSTOMERS"), "cust_id").is_some());
605
606        // Verify column offsets are correct
607        assert_eq!(final_schema.get_column_index(Some("orders"), "order_id"), Some(0));
608        assert_eq!(final_schema.get_column_index(Some("items"), "item_id"), Some(1));
609        assert_eq!(final_schema.get_column_index(Some("customers"), "cust_id"), Some(2));
610    }
611
612    // ==========================================================================
613    // Regression Tests for Issue #3633
614    // ==========================================================================
615
616    #[test]
617    fn test_issue_3633_correlated_subquery_alias_case() {
618        // This test verifies the fix for issue #3633 where correlated subqueries
619        // with uppercase aliases (like "J") couldn't find columns because the
620        // parser uses uppercase but the schema stored lowercase.
621
622        // Simulate the scenario: outer query has table with alias "J"
623        let schema = CombinedSchema::from_table(
624            "J".to_string(), // Parser often uppercases aliases
625            table_schema_with_columns("items", vec![("price", DataType::DoublePrecision), ("quantity", DataType::Integer)]),
626        );
627
628        // The correlated subquery should be able to reference J.price
629        // regardless of case used by the parser/resolver
630        assert!(schema.get_column_index(Some("J"), "price").is_some(),
631            "Uppercase J should find price (parser case)");
632        assert!(schema.get_column_index(Some("j"), "price").is_some(),
633            "Lowercase j should find price (normalized case)");
634    }
635
636    #[test]
637    fn test_issue_3633_multi_table_join_with_aliases() {
638        // Simulates: SELECT * FROM orders O JOIN items I ON O.id = I.order_id
639        let orders = CombinedSchema::from_table(
640            "O".to_string(),
641            table_schema_with_columns("orders", vec![("id", DataType::Integer), ("date", DataType::Date)]),
642        );
643
644        let combined = CombinedSchema::combine(
645            orders,
646            "I".to_string(),
647            table_schema_with_columns("items", vec![("order_id", DataType::Integer), ("amount", DataType::DoublePrecision)]),
648        );
649
650        // Both O and I aliases should work case-insensitively
651        // This is critical for correlated subqueries that reference outer aliases
652        assert_eq!(combined.get_column_index(Some("O"), "id"), Some(0));
653        assert_eq!(combined.get_column_index(Some("o"), "id"), Some(0));
654        assert_eq!(combined.get_column_index(Some("O"), "date"), Some(1));
655        assert_eq!(combined.get_column_index(Some("I"), "order_id"), Some(2));
656        assert_eq!(combined.get_column_index(Some("i"), "order_id"), Some(2));
657        assert_eq!(combined.get_column_index(Some("I"), "amount"), Some(3));
658    }
659
660    // ==========================================================================
661    // Edge Cases
662    // ==========================================================================
663
664    #[test]
665    fn test_nonexistent_table_returns_none() {
666        let schema = CombinedSchema::from_table(
667            "users".to_string(),
668            table_schema_with_column("users", "id"),
669        );
670
671        assert!(schema.get_column_index(Some("nonexistent"), "id").is_none());
672        assert!(schema.get_column_index(Some("NONEXISTENT"), "id").is_none());
673    }
674
675    #[test]
676    fn test_nonexistent_column_returns_none() {
677        let schema = CombinedSchema::from_table(
678            "users".to_string(),
679            table_schema_with_column("users", "id"),
680        );
681
682        assert!(schema.get_column_index(Some("users"), "nonexistent").is_none());
683        assert!(schema.get_column_index(Some("USERS"), "nonexistent").is_none());
684    }
685
686    #[test]
687    fn test_empty_table_name() {
688        let schema = CombinedSchema::from_table(
689            "".to_string(),
690            table_schema_with_column("", "id"),
691        );
692
693        // Empty string table should still work
694        assert!(schema.get_column_index(Some(""), "id").is_some());
695    }
696
697    #[test]
698    fn test_total_columns_tracking() {
699        let mut builder = SchemaBuilder::new();
700        builder.add_table(
701            "t1".to_string(),
702            table_schema_with_columns("t1", vec![("a", DataType::Integer), ("b", DataType::Integer)]),
703        );
704        builder.add_table(
705            "t2".to_string(),
706            table_schema_with_columns("t2", vec![("c", DataType::Integer)]),
707        );
708
709        let schema = builder.build();
710        assert_eq!(schema.total_columns, 3);
711    }
712}