Skip to main content

vibesql_executor/
schema.rs

1use std::{
2    borrow::Borrow,
3    collections::{HashMap, HashSet},
4    fmt,
5    hash::{Hash, Hasher},
6    ops::Deref,
7};
8
9use vibesql_catalog::TableIdentifier;
10
11/// A normalized table/alias key for case-insensitive lookups.
12/// Always stored as lowercase, making case-insensitive handling impossible to get wrong.
13#[derive(Debug, Clone, Eq)]
14pub struct TableKey(String);
15
16impl TableKey {
17    /// Create a new TableKey, normalizing to lowercase.
18    #[inline]
19    pub fn new(name: impl AsRef<str>) -> Self {
20        TableKey(name.as_ref().to_lowercase())
21    }
22
23    /// Get the normalized key as a string slice.
24    #[inline]
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28
29    /// Consume the TableKey and return the inner String.
30    #[inline]
31    pub fn into_inner(self) -> String {
32        self.0
33    }
34}
35
36impl PartialEq for TableKey {
37    fn eq(&self, other: &Self) -> bool {
38        self.0 == other.0
39    }
40}
41
42impl Hash for TableKey {
43    fn hash<H: Hasher>(&self, state: &mut H) {
44        self.0.hash(state);
45    }
46}
47
48impl Deref for TableKey {
49    type Target = str;
50
51    fn deref(&self) -> &Self::Target {
52        &self.0
53    }
54}
55
56impl AsRef<str> for TableKey {
57    fn as_ref(&self) -> &str {
58        &self.0
59    }
60}
61
62impl Borrow<str> for TableKey {
63    fn borrow(&self) -> &str {
64        &self.0
65    }
66}
67
68impl fmt::Display for TableKey {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "{}", self.0)
71    }
72}
73
74impl From<String> for TableKey {
75    fn from(s: String) -> Self {
76        TableKey::new(s)
77    }
78}
79
80impl From<&str> for TableKey {
81    fn from(s: &str) -> Self {
82        TableKey::new(s)
83    }
84}
85
86impl From<TableKey> for String {
87    fn from(key: TableKey) -> Self {
88        key.0
89    }
90}
91
92impl From<&TableKey> for TableKey {
93    fn from(key: &TableKey) -> Self {
94        key.clone()
95    }
96}
97
98impl From<&String> for TableKey {
99    fn from(s: &String) -> Self {
100        TableKey::new(s)
101    }
102}
103
104/// Represents the combined schema from multiple tables (for JOINs)
105#[derive(Debug, Clone)]
106pub struct CombinedSchema {
107    /// Map from table identifier to (start_index, TableSchema)
108    /// start_index is where this table's columns begin in the combined row
109    /// TableIdentifier provides both canonical form for lookups and display form for output
110    pub table_schemas: HashMap<TableIdentifier, (usize, vibesql_catalog::TableSchema)>,
111    /// Total number of columns across all tables
112    pub total_columns: usize,
113    /// Columns that are hidden from `SELECT *` expansion due to NATURAL JOIN deduplication.
114    /// These columns are still accessible via qualified references like `table.*`.
115    /// This allows `SELECT t1.*` to return all columns from t1, while `SELECT *`
116    /// correctly deduplicates columns for NATURAL JOIN.
117    pub hidden_columns: HashSet<usize>,
118    /// Reference to outer scope schema for nested subquery column resolution (issue #4493)
119    /// Forms a linked-list chain similar to SQLite's NameContext.pNext
120    /// Enables resolution of columns from multiple nesting levels
121    pub outer_schema: Option<Box<CombinedSchema>>,
122    /// Table aliases/names that appear more than once in the FROM clause (issue #4507)
123    /// Used to detect ambiguous qualified column references like "A.f1" when table "A" appears
124    /// twice Stores normalized (lowercase) table identifiers for case-insensitive matching
125    pub duplicate_aliases: HashSet<TableIdentifier>,
126    /// Column names that have been joined via NATURAL JOIN or USING clause (issue #4517)
127    /// These columns exist in multiple tables but should NOT be considered ambiguous
128    /// because they are logically the same column after the join.
129    /// Stored as lowercase for case-insensitive matching.
130    pub joined_columns: HashSet<String>,
131    /// For USING columns in RIGHT/FULL OUTER JOINs, maps the unqualified column name
132    /// (lowercase) to a list of column indices for N-way COALESCE resolution.
133    /// When an unqualified reference is made to a USING column, we apply COALESCE
134    /// semantics: return the first non-NULL value from the list of columns.
135    /// This supports chained joins like `t1 NATURAL FULL JOIN t2 NATURAL FULL JOIN t3`
136    /// where we need COALESCE(t1.id, t2.id, t3.id).
137    /// Issue #4783, #4903: USING column semantics differ from SQLite in OUTER JOINs
138    pub using_coalesce_indices: HashMap<String, Vec<usize>>,
139    /// Column replacement map for RIGHT/FULL OUTER NATURAL JOINs in SELECT * expansion.
140    /// Maps hidden_column_index -> replacement_column_index.
141    /// When expanding SELECT *, if a column is hidden but has a replacement, output
142    /// the replacement column's value instead of skipping. This maintains the column
143    /// ordering from the left table while using values from the right table.
144    /// Example: In `t5 NATURAL RIGHT JOIN t4`, t5.id is hidden but should be replaced
145    /// by t4.id to maintain the output order (id, y, x) instead of (y, id, x).
146    pub column_replacement_map: HashMap<usize, usize>,
147    /// Alias tables that are added for parenthesized join expressions (issue #4905).
148    /// These are virtual tables that point to the same columns as existing tables.
149    /// They exist for column resolution (e.g., `j1.id` in `FROM t1 JOIN (...) AS j1 ON j1.id = t1.id`).
150    /// Stores the table identifiers of alias tables.
151    pub alias_tables: HashSet<TableIdentifier>,
152    /// Tables that are shadowed by an alias table (issue #4786).
153    /// When a parenthesized join has an alias, the underlying tables are shadowed
154    /// and should be skipped in SELECT * expansion. Instead, the alias table's columns
155    /// should be used.
156    /// Maps: aliased table name -> tables shadowed by that alias
157    pub shadowed_tables: HashMap<TableIdentifier, HashSet<TableIdentifier>>,
158}
159
160impl CombinedSchema {
161    /// Create an empty combined schema with no tables
162    ///
163    /// Used for SELECT statements without a FROM clause when the expression
164    /// evaluation needs outer context for column resolution.
165    pub fn empty() -> Self {
166        CombinedSchema {
167            table_schemas: HashMap::new(),
168            total_columns: 0,
169            hidden_columns: HashSet::new(),
170            outer_schema: None,
171            duplicate_aliases: HashSet::new(),
172            joined_columns: HashSet::new(),
173            using_coalesce_indices: HashMap::new(),
174            column_replacement_map: HashMap::new(),
175            alias_tables: HashSet::new(),
176            shadowed_tables: HashMap::new(),
177        }
178    }
179
180    /// Create a new combined schema from a single table
181    ///
182    /// Note: Table name is automatically normalized via TableIdentifier for case-insensitive
183    /// lookups
184    pub fn from_table(table_name: String, schema: vibesql_catalog::TableSchema) -> Self {
185        let total_columns = schema.columns.len();
186        let mut table_schemas = HashMap::new();
187        let table_id = TableIdentifier::unquoted(&table_name);
188        table_schemas.insert(table_id, (0, schema));
189        CombinedSchema {
190            table_schemas,
191            total_columns,
192            hidden_columns: HashSet::new(),
193            outer_schema: None,
194            duplicate_aliases: HashSet::new(),
195            joined_columns: HashSet::new(),
196            using_coalesce_indices: HashMap::new(),
197            column_replacement_map: HashMap::new(),
198            alias_tables: HashSet::new(),
199            shadowed_tables: HashMap::new(),
200        }
201    }
202
203    /// Create a new combined schema from a derived table (subquery result)
204    ///
205    /// Note: Alias is automatically normalized via TableIdentifier for case-insensitive lookups
206    pub fn from_derived_table(
207        alias: String,
208        column_names: Vec<String>,
209        column_types: Vec<vibesql_types::DataType>,
210    ) -> Self {
211        let total_columns = column_names.len();
212
213        // Build column definitions
214        let columns: Vec<vibesql_catalog::ColumnSchema> = column_names
215            .into_iter()
216            .zip(column_types)
217            .map(|(name, data_type)| vibesql_catalog::ColumnSchema {
218                name,
219                data_type,
220                nullable: true,       // Derived table columns are always nullable
221                default_value: None,  // Derived table columns have no defaults
222                generated_expr: None, // Derived table columns are not generated
223                collation: None,      // Derived table columns don't inherit collation
224                is_exact_integer_type: false, // Derived columns don't preserve exact type
225            })
226            .collect();
227
228        // A derived table (FROM subquery) has no implicit rowid, just like a
229        // view: `SELECT rowid FROM (SELECT ...)` must error with
230        // `no such column: rowid` (sqlite3 parity, #5492). Mark the synthetic
231        // schema as a view so the rowid pseudo-column is not exposed.
232        let mut schema = vibesql_catalog::TableSchema::new(alias.clone(), columns);
233        schema.set_is_view(true);
234        let mut table_schemas = HashMap::new();
235        let table_id = TableIdentifier::unquoted(&alias);
236        table_schemas.insert(table_id, (0, schema));
237        CombinedSchema {
238            table_schemas,
239            total_columns,
240            hidden_columns: HashSet::new(),
241            outer_schema: None,
242            duplicate_aliases: HashSet::new(),
243            joined_columns: HashSet::new(),
244            using_coalesce_indices: HashMap::new(),
245            column_replacement_map: HashMap::new(),
246            alias_tables: HashSet::new(),
247            shadowed_tables: HashMap::new(),
248        }
249    }
250
251    /// Add an alias for a parenthesized join expression.
252    ///
253    /// This is used for expressions like `(t1 JOIN t2) AS j1` where `j1` becomes an
254    /// alias for the combined result. The alias is added as a virtual table containing
255    /// all visible columns, allowing references like `j1.column` to work.
256    ///
257    /// The alias table is marked as "alias-only" and will not be expanded in SELECT *.
258    ///
259    /// **Important**: The alias table uses start_idx=0 and stores the original column
260    /// indices in a mapping, so that `j1.column` resolves to the correct index in the
261    /// actual row data.
262    pub fn add_join_alias(mut self, alias: &str) -> Self {
263        // Build the alias table columns to match what SELECT * would output:
264        // 1. First: USING/NATURAL JOIN columns (from joined_columns)
265        // 2. Then: Other visible columns
266        //
267        // For USING columns, use the first index from using_coalesce_indices
268        // so that j1.id resolves to the correct column position.
269
270        // Collect USING column schemas (from joined_columns)
271        let mut joined_col_entries: Vec<(usize, vibesql_catalog::ColumnSchema)> = Vec::new();
272        for joined_col in &self.joined_columns {
273            // Find the first column with this name (it will be the leftmost in the join)
274            if let Some(indices) = self.using_coalesce_indices.get(joined_col) {
275                if let Some(&first_idx) = indices.first() {
276                    // Find the column schema for this index
277                    for (table_id, (start_idx, table_schema)) in &self.table_schemas {
278                        if self.alias_tables.contains(table_id) {
279                            continue;
280                        }
281                        for (col_idx, col) in table_schema.columns.iter().enumerate() {
282                            let absolute_idx = *start_idx + col_idx;
283                            if absolute_idx == first_idx {
284                                // Use the column name (lowercase matched the joined_col)
285                                // but the index should be the first coalesce index
286                                joined_col_entries.push((first_idx, col.clone()));
287                                break;
288                            }
289                        }
290                    }
291                }
292            }
293        }
294
295        // Collect all visible non-USING columns
296        let mut other_columns: Vec<(usize, vibesql_catalog::ColumnSchema)> = Vec::new();
297        for (table_id, (start_idx, table_schema)) in &self.table_schemas {
298            if self.alias_tables.contains(table_id) {
299                continue;
300            }
301            for (col_idx, col) in table_schema.columns.iter().enumerate() {
302                let absolute_idx = *start_idx + col_idx;
303                // Skip hidden columns
304                if self.hidden_columns.contains(&absolute_idx) {
305                    continue;
306                }
307                // Skip USING columns (already handled above)
308                let is_joined = self.joined_columns.contains(&col.name.to_lowercase());
309                if is_joined {
310                    continue;
311                }
312                other_columns.push((absolute_idx, col.clone()));
313            }
314        }
315
316        // Sort other columns by index
317        other_columns.sort_by_key(|(idx, _)| *idx);
318
319        // Combine: USING columns first (sorted by their index), then other columns
320        joined_col_entries.sort_by_key(|(idx, _)| *idx);
321        let mut all_columns = joined_col_entries;
322        all_columns.extend(other_columns);
323
324        let columns: Vec<vibesql_catalog::ColumnSchema> =
325            all_columns.iter().map(|(_, col)| col.clone()).collect();
326
327        let schema = vibesql_catalog::TableSchema::new(alias.to_string(), columns);
328        let table_id = TableIdentifier::unquoted(alias);
329        // Use start_idx = 0 so column resolution returns indices 0, 1, 2, ...
330        // The order matches the all_columns order, so j1.id maps to index 0 if id is first
331        self.table_schemas.insert(table_id.clone(), (0, schema));
332        self.alias_tables.insert(table_id.clone());
333
334        // Issue #4786: Mark all existing non-alias tables as shadowed by this alias.
335        // This ensures that in SELECT *, the alias table's columns are used instead of
336        // the individual base tables' columns. This is especially important for outer
337        // joins with ON clause, where the aliased join needs to appear as a single table.
338        let shadowed: HashSet<TableIdentifier> = self
339            .table_schemas
340            .keys()
341            .filter(|t| !self.alias_tables.contains(*t) && *t != &table_id)
342            .cloned()
343            .collect();
344        self.shadowed_tables.insert(table_id, shadowed);
345
346        self
347    }
348
349    /// Combine two schemas (for JOIN operations)
350    ///
351    /// Note: Right table name is automatically normalized via TableIdentifier for case-insensitive
352    /// lookups
353    pub fn combine(
354        left: CombinedSchema,
355        right_table_name: String,
356        right_schema: vibesql_catalog::TableSchema,
357    ) -> Self {
358        let mut table_schemas = left.table_schemas;
359        let mut duplicate_aliases = left.duplicate_aliases;
360        let left_total = left.total_columns;
361        let right_columns = right_schema.columns.len();
362        let right_id = TableIdentifier::unquoted(&right_table_name);
363
364        // Track duplicate table alias/name - but NOT for self-joins
365        // A self-join is when the same table (identical schema) is joined to itself.
366        // In SQLite, self-joins like `FROM t1 JOIN t1 USING(a,b)` allow unambiguous
367        // references to `t1.column` because it's the same underlying table.
368        // Only mark as duplicate if it's a different table with the same alias.
369        if let Some((_, existing_schema)) = table_schemas.get(&right_id) {
370            // Check if it's a true self-join (same table) or alias conflict (different tables)
371            // For self-joins, the schemas are identical (same table name, same columns)
372            if existing_schema != &right_schema {
373                // Different tables with same alias - this is ambiguous
374                duplicate_aliases.insert(right_id.clone());
375            }
376            // If same schema, it's a self-join - don't mark as duplicate
377        }
378
379        // Always insert/overwrite the table
380        table_schemas.insert(right_id, (left_total, right_schema));
381        CombinedSchema {
382            table_schemas,
383            total_columns: left_total + right_columns,
384            hidden_columns: left.hidden_columns,
385            outer_schema: left.outer_schema,
386            duplicate_aliases,
387            joined_columns: left.joined_columns,
388            using_coalesce_indices: left.using_coalesce_indices,
389            column_replacement_map: left.column_replacement_map,
390            alias_tables: left.alias_tables,
391            shadowed_tables: left.shadowed_tables,
392        }
393    }
394
395    /// Merge two CombinedSchemas (for JOIN operations with nested joins)
396    ///
397    /// Unlike `combine` which adds a single table, this method merges ALL tables
398    /// from the right schema into the left schema. This is essential for nested
399    /// joins like `t1 JOIN (t2 JOIN t3 USING(a)) USING(a)` where the right side
400    /// contains multiple tables that must all remain visible.
401    ///
402    /// The right schema's tables have their start indices adjusted to account
403    /// for the left schema's total column count.
404    pub fn merge(left: CombinedSchema, right: CombinedSchema) -> Self {
405        let mut table_schemas = left.table_schemas;
406        let mut duplicate_aliases = left.duplicate_aliases;
407        let left_total = left.total_columns;
408
409        // Add all tables from right schema with adjusted start indices
410        for (table_id, (start_index, schema)) in right.table_schemas {
411            let adjusted_start = left_total + start_index;
412
413            // Check if this table already exists in the left schema
414            if let Some((_, existing_schema)) = table_schemas.get(&table_id) {
415                // Only mark as duplicate if it's a different table with the same alias
416                if existing_schema != &schema {
417                    duplicate_aliases.insert(table_id.clone());
418                }
419
420                // For self-joins (same table appearing twice), we need to keep BOTH
421                // entries so that USING/NATURAL join conditions can distinguish between
422                // the left and right instances. Use a synthetic suffix to create a
423                // unique key for the right-side instance.
424                // The synthetic key format "__selfjoin_right_<original_name>_<start_idx>"
425                // ensures uniqueness even for multi-way self-joins.
426                let synthetic_key = TableIdentifier::unquoted(&format!(
427                    "__selfjoin_right_{}_{}",
428                    table_id.canonical(),
429                    adjusted_start
430                ));
431                table_schemas.insert(synthetic_key, (adjusted_start, schema));
432            } else {
433                // No conflict - insert normally
434                table_schemas.insert(table_id, (adjusted_start, schema));
435            }
436        }
437
438        // Merge hidden columns, adjusting right side indices
439        let mut hidden_columns = left.hidden_columns;
440        for idx in right.hidden_columns {
441            hidden_columns.insert(left_total + idx);
442        }
443
444        // Merge duplicate aliases from both sides
445        duplicate_aliases.extend(right.duplicate_aliases);
446
447        // Merge joined columns from both sides
448        let mut joined_columns = left.joined_columns;
449        joined_columns.extend(right.joined_columns);
450
451        // Merge using_coalesce_indices from both sides (adjusting right side indices)
452        // For N-way coalescing, we extend existing Vec entries rather than overwriting
453        let mut using_coalesce_indices = left.using_coalesce_indices;
454        for (col_name, indices) in right.using_coalesce_indices {
455            let adjusted_indices: Vec<usize> = indices.iter().map(|idx| left_total + idx).collect();
456            using_coalesce_indices
457                .entry(col_name)
458                .or_insert_with(Vec::new)
459                .extend(adjusted_indices);
460        }
461
462        // Merge column_replacement_map from both sides (adjusting right side indices)
463        let mut column_replacement_map = left.column_replacement_map;
464        for (hidden_idx, replacement_idx) in right.column_replacement_map {
465            column_replacement_map.insert(left_total + hidden_idx, left_total + replacement_idx);
466        }
467
468        // Merge alias_tables from both sides
469        let mut alias_tables = left.alias_tables;
470        alias_tables.extend(right.alias_tables);
471
472        // Merge shadowed_tables from both sides
473        let mut shadowed_tables = left.shadowed_tables;
474        shadowed_tables.extend(right.shadowed_tables);
475
476        CombinedSchema {
477            table_schemas,
478            total_columns: left_total + right.total_columns,
479            hidden_columns,
480            outer_schema: left.outer_schema,
481            duplicate_aliases,
482            joined_columns,
483            using_coalesce_indices,
484            column_replacement_map,
485            alias_tables,
486            shadowed_tables,
487        }
488    }
489
490    /// Look up a column by name (optionally qualified with table name)
491    /// Uses case-insensitive matching for table/alias and column names
492    ///
493    /// Searches the current schema level first, then follows the outer_schema
494    /// chain to search enclosing scopes (similar to SQLite's NameContext.pNext).
495    /// This enables correlated subqueries to reference columns from outer queries.
496    pub fn get_column_index(&self, table: Option<&str>, column: &str) -> Option<usize> {
497        // Try current level first
498        let current_result = if let Some(table_name) = table {
499            // Qualified column reference (table.column)
500            // TableIdentifier normalizes to lowercase, so lookup is case-insensitive
501            let table_id = TableIdentifier::unquoted(table_name);
502            if let Some((start_index, schema)) = self.table_schemas.get(&table_id) {
503                // Special handling for alias tables (issue #4905)
504                // For alias tables, we need to find the actual column index in the
505                // underlying schema, not use start_index + idx (which would be wrong
506                // since alias tables have start_index=0 but columns are non-contiguous).
507                if self.alias_tables.contains(&table_id) {
508                    // Check if it's a USING column - use coalesce index
509                    let col_lower = column.to_lowercase();
510                    if let Some(indices) = self.using_coalesce_indices.get(&col_lower) {
511                        return indices.first().copied();
512                    }
513                    // Otherwise, find this column in the underlying (non-alias) tables
514                    return self.get_column_index(None, column);
515                }
516                schema.get_column_index(column).map(|idx| start_index + idx)
517            } else {
518                None
519            }
520        } else {
521            // Unqualified column reference - search all tables
522            // IMPORTANT: For LEFT JOINs, we must resolve to the LEFTMOST table
523            // that has the column. Since HashMap iteration order is non-deterministic,
524            // we find ALL matches and pick the one with the lowest start_index.
525            //
526            // Issue #4781: For NATURAL/USING joins, prefer NON-HIDDEN columns.
527            // In RIGHT JOIN USING(a), the left-side `a` is hidden, so we should
528            // pick the right-side `a` (which is not hidden). This ensures
529            // COALESCE semantics where the USING column picks the non-NULL value.
530            let column_lower = column.to_lowercase();
531            let is_joined_column = self.joined_columns.contains(&column_lower);
532
533            let mut best_match: Option<usize> = None;
534            let mut best_match_is_hidden = false;
535
536            for (table_id, (start_index, schema)) in &self.table_schemas {
537                // Skip alias tables for unqualified column resolution (issue #4905)
538                // Alias tables are virtual tables that should only be accessed via
539                // qualified references like `j1.column`, not unqualified references.
540                if self.alias_tables.contains(table_id) {
541                    continue;
542                }
543                if let Some(idx) = schema.get_column_index(column) {
544                    let absolute_idx = start_index + idx;
545                    let is_hidden = self.hidden_columns.contains(&absolute_idx);
546
547                    // For joined columns, prefer non-hidden columns
548                    // For regular columns, prefer leftmost (lowest index) as before
549                    let should_update = match (best_match, is_joined_column) {
550                        (None, _) => true,
551                        // For joined columns: prefer non-hidden over hidden
552                        (Some(_), true) if best_match_is_hidden && !is_hidden => true,
553                        // For all columns: prefer lower index if same hidden status
554                        (Some(current_best), _)
555                            if absolute_idx < current_best
556                                && (!is_joined_column || is_hidden == best_match_is_hidden) =>
557                        {
558                            true
559                        }
560                        _ => false,
561                    };
562
563                    if should_update {
564                        best_match = Some(absolute_idx);
565                        best_match_is_hidden = is_hidden;
566                    }
567                }
568            }
569            best_match
570        };
571
572        // If found at current level, return it
573        if current_result.is_some() {
574            return current_result;
575        }
576
577        // Not found at current level - search outer scopes via chain
578        // This enables nested correlated subqueries to reference columns
579        // from multiple enclosing scopes (issue #4493)
580        //
581        // Index convention (window1.test 61.1): the row paired with a chained
582        // schema is laid out as `current.values ++ outer.values` (see
583        // `build_merged_outer_row`), so indices resolved in the outer chain
584        // must be offset by this level's column span. This keeps the current
585        // level's 0-based indices valid both against the merged row (current
586        // values form the prefix) and against raw current-level rows (as used
587        // by the outer-correlated aggregate path over `outer_rows`, #4930 /
588        // window1.test 53.0).
589        if let Some(outer) = &self.outer_schema {
590            return outer.get_column_index(table, column).map(|idx| self.total_columns + idx);
591        }
592
593        // Not found anywhere in the chain
594        None
595    }
596
597    /// Get the declared data type for a column by its combined (absolute) index
598    ///
599    /// Returns `None` if the index does not fall inside any table at this
600    /// schema level (e.g. it resolves into an outer scope, or belongs to an
601    /// alias table). Used by columnar predicate extraction to decide whether
602    /// a predicate can be pushed down to the columnar comparators (issue
603    /// #5335: temporal columns need type-aware pushdown decisions).
604    pub fn get_column_type_by_index(&self, idx: usize) -> Option<&vibesql_types::DataType> {
605        for (table_id, (start_index, schema)) in &self.table_schemas {
606            // Alias tables share start_index 0 with non-contiguous columns;
607            // skip them so we only match real, contiguous column ranges.
608            if self.alias_tables.contains(table_id) {
609                continue;
610            }
611            if idx >= *start_index && idx < start_index + schema.columns.len() {
612                return Some(&schema.columns[idx - start_index].data_type);
613            }
614        }
615        None
616    }
617
618    /// Get the type affinity for a column by name
619    ///
620    /// Returns the SQLite type affinity for the column, which determines how
621    /// type coercion is performed in comparisons.
622    pub fn get_column_affinity(
623        &self,
624        table: Option<&str>,
625        column: &str,
626    ) -> Option<vibesql_types::TypeAffinity> {
627        if let Some(table_name) = table {
628            // Qualified column reference (table.column)
629            let table_id = TableIdentifier::unquoted(table_name);
630            if let Some((_start_index, schema)) = self.table_schemas.get(&table_id) {
631                if let Some(col_idx) = schema.get_column_index(column) {
632                    return Some(schema.columns[col_idx].data_type.sqlite_affinity());
633                }
634            }
635        } else {
636            // Unqualified column reference - search all tables
637            for (table_id, (_start_index, schema)) in &self.table_schemas {
638                // Skip alias tables for unqualified column resolution
639                if self.alias_tables.contains(table_id) {
640                    continue;
641                }
642                if let Some(col_idx) = schema.get_column_index(column) {
643                    return Some(schema.columns[col_idx].data_type.sqlite_affinity());
644                }
645            }
646        }
647        None
648    }
649
650    /// Check if an unqualified column reference is ambiguous
651    /// (i.e., exists in multiple tables in the schema)
652    ///
653    /// Returns true if the column exists in more than one table,
654    /// UNLESS the column is a "joined column" from NATURAL JOIN or USING clause.
655    /// Joined columns are deduplicated and should be accessible without qualification.
656    ///
657    /// Only relevant for unqualified column references - qualified references
658    /// (with table prefix) are never ambiguous.
659    pub fn is_column_ambiguous(&self, column: &str) -> bool {
660        // Columns joined via NATURAL JOIN or USING clause are never ambiguous (issue #4517)
661        // They logically represent a single column even though they exist in multiple tables
662        let column_lower = column.to_lowercase();
663        if self.joined_columns.contains(&column_lower) {
664            return false;
665        }
666
667        let mut match_count = 0;
668        for (table_id, (_start_index, schema)) in &self.table_schemas {
669            // Skip alias tables - they're virtual tables for column resolution only
670            // and should not cause ambiguity for unqualified column references (issue #4905)
671            if self.alias_tables.contains(table_id) {
672                continue;
673            }
674            if schema.get_column_index(column).is_some() {
675                match_count += 1;
676                if match_count > 1 {
677                    return true;
678                }
679            }
680        }
681        false
682    }
683
684    /// Check if any table in this schema has a column with the given name.
685    ///
686    /// This is a faster alternative to building a HashSet of all column names
687    /// for cases where we just need to check if a column exists.
688    /// Used for WHERE clause alias resolution (SQLite compatibility).
689    ///
690    /// # Arguments
691    /// * `column` - The column name (case-insensitive)
692    ///
693    /// # Returns
694    /// `true` if any table in the schema has a column matching this name
695    #[inline]
696    pub fn has_column(&self, column: &str) -> bool {
697        // Case-insensitive search through all tables
698        for (_start_index, schema) in self.table_schemas.values() {
699            if schema.get_column_index(column).is_some() {
700                return true;
701            }
702        }
703        false
704    }
705
706    /// Validate that a qualified column reference is not ambiguous.
707    ///
708    /// This checks if the table identifier appears more than once in the FROM clause,
709    /// which would make qualified references like "A.f1" ambiguous (issue #4507).
710    ///
711    /// # Arguments
712    /// * `table` - The table name/alias from the qualified reference
713    /// * `column` - The column name (used for error message only)
714    ///
715    /// # Returns
716    /// * `Ok(())` if the reference is unambiguous
717    /// * `Err(ExecutorError::AmbiguousColumnName)` if the table appears multiple times
718    ///
719    /// # Example
720    /// ```sql
721    /// -- This should fail validation:
722    /// SELECT A.f1 FROM test1 A, test2 A;  -- "A" appears twice
723    /// ```
724    pub fn validate_qualified_reference(
725        &self,
726        table: &str,
727        column: &str,
728    ) -> Result<(), crate::errors::ExecutorError> {
729        let table_id = TableIdentifier::unquoted(table);
730        if self.duplicate_aliases.contains(&table_id) {
731            return Err(crate::errors::ExecutorError::AmbiguousColumnName {
732                column_name: format!("{}.{}", table, column),
733            });
734        }
735        Ok(())
736    }
737
738    /// Get a table schema by name (case-insensitive lookup)
739    pub fn get_table(&self, table_name: &str) -> Option<&(usize, vibesql_catalog::TableSchema)> {
740        self.table_schemas.get(&TableIdentifier::unquoted(table_name))
741    }
742
743    /// Check if a table exists (case-insensitive lookup)
744    pub fn contains_table(&self, table_name: &str) -> bool {
745        self.table_schemas.contains_key(&TableIdentifier::unquoted(table_name))
746    }
747
748    /// Get all table names as strings (using display form)
749    pub fn table_names(&self) -> Vec<String> {
750        self.table_schemas.keys().map(|table_id| table_id.display().to_string()).collect()
751    }
752
753    /// Insert or update a table in the schema
754    pub fn insert_table(
755        &mut self,
756        name: String,
757        start_index: usize,
758        schema: vibesql_catalog::TableSchema,
759    ) {
760        let table_id = TableIdentifier::unquoted(&name);
761        self.table_schemas.insert(table_id, (start_index, schema));
762    }
763
764    /// Get the original column name from the schema for a column reference.
765    ///
766    /// SQLite preserves the schema column name (not the query identifier case)
767    /// when returning column names in results. This method looks up the column
768    /// in the schema and returns the original name.
769    ///
770    /// # Arguments
771    /// * `table` - Optional table name for qualified references (e.g., "t1" in "t1.col")
772    /// * `column` - Column name to look up (case-insensitive)
773    ///
774    /// # Returns
775    /// The original column name from the schema, or the input column name if not found.
776    pub fn get_original_column_name(&self, table: Option<&str>, column: &str) -> String {
777        if let Some(table_name) = table {
778            // Qualified column reference (table.column)
779            let table_id = TableIdentifier::unquoted(table_name);
780            if let Some((_start_index, schema)) = self.table_schemas.get(&table_id) {
781                if let Some(idx) = schema.get_column_index(column) {
782                    return schema.columns[idx].name.clone();
783                }
784            }
785        } else {
786            // Unqualified column reference - search all tables
787            // Find the match with the lowest start_index (leftmost table)
788            let mut best_match: Option<(usize, String)> = None;
789            for (start_index, schema) in self.table_schemas.values() {
790                if let Some(idx) = schema.get_column_index(column) {
791                    let name = schema.columns[idx].name.clone();
792                    match &best_match {
793                        None => best_match = Some((*start_index, name)),
794                        Some((current_start, _)) if *start_index < *current_start => {
795                            best_match = Some((*start_index, name));
796                        }
797                        _ => {}
798                    }
799                }
800            }
801            if let Some((_, name)) = best_match {
802                return name;
803            }
804        }
805        // Fallback: return the input column name if not found in schema
806        column.to_string()
807    }
808
809    /// Get the fully qualified column name with original table name prefix.
810    ///
811    /// This follows SQLite's `full_column_names=ON` behavior where column names
812    /// in results are prefixed with the original table name from the schema.
813    ///
814    /// For example, if a table was created as `CREATE TABLE test1(f1 int)` and
815    /// queried with `SELECT a.f1 FROM test1 a`, this returns `test1.f1` (using
816    /// the original table name "test1", not the alias "a").
817    ///
818    /// # Arguments
819    /// * `table` - Optional table alias/name for qualified references
820    /// * `column` - Column name to look up (case-insensitive)
821    ///
822    /// # Returns
823    /// The fully qualified column name in `table.column` format, or just the
824    /// column name if the table is not found.
825    pub fn get_full_column_name(&self, table: Option<&str>, column: &str) -> String {
826        if let Some(table_name) = table {
827            // Qualified column reference (table.column)
828            let table_id = TableIdentifier::unquoted(table_name);
829            if let Some((_start_index, schema)) = self.table_schemas.get(&table_id) {
830                if let Some(idx) = schema.get_column_index(column) {
831                    // Use the original table name from the schema
832                    return format!("{}.{}", schema.name, schema.columns[idx].name);
833                }
834            }
835        } else {
836            // Unqualified column reference - search all tables
837            // Find the match with the lowest start_index (leftmost table)
838            let mut best_match: Option<(usize, String, String)> = None;
839            for (start_index, schema) in self.table_schemas.values() {
840                if let Some(idx) = schema.get_column_index(column) {
841                    let table_name = schema.name.clone();
842                    let col_name = schema.columns[idx].name.clone();
843                    match &best_match {
844                        None => best_match = Some((*start_index, table_name, col_name)),
845                        Some((current_start, _, _)) if *start_index < *current_start => {
846                            best_match = Some((*start_index, table_name, col_name));
847                        }
848                        _ => {}
849                    }
850                }
851            }
852            if let Some((_, table_name, col_name)) = best_match {
853                return format!("{}.{}", table_name, col_name);
854            }
855        }
856        // Fallback: return the input column name if not found in schema
857        column.to_string()
858    }
859
860    /// Check if a column index is hidden from `SELECT *` expansion.
861    ///
862    /// Columns are hidden when they are duplicates in a NATURAL JOIN.
863    /// For example, in `SELECT * FROM t1 NATURAL JOIN t2` where both tables
864    /// have column `a`, the `t2.a` column is hidden so `SELECT *` only shows
865    /// one copy of `a` (from t1).
866    ///
867    /// However, `SELECT t2.*` should still include `t2.a` because qualified
868    /// wildcards expand all columns from that specific table.
869    #[inline]
870    pub fn is_column_hidden(&self, idx: usize) -> bool {
871        self.hidden_columns.contains(&idx)
872    }
873
874    /// Mark a column as hidden from `SELECT *` expansion.
875    ///
876    /// This is used by NATURAL JOIN to hide duplicate columns from the right side.
877    pub fn hide_column(&mut self, idx: usize) {
878        self.hidden_columns.insert(idx);
879    }
880
881    /// Mark a column name as a "joined column" from NATURAL JOIN or USING clause.
882    ///
883    /// Joined columns exist in multiple tables but should NOT be considered ambiguous
884    /// because they are logically the same column after the join. This allows
885    /// unqualified references to these columns without triggering an ambiguity error.
886    ///
887    /// # Arguments
888    /// * `column` - The column name (will be normalized to lowercase)
889    pub fn add_joined_column(&mut self, column: &str) {
890        self.joined_columns.insert(column.to_lowercase());
891    }
892
893    /// Add a USING column coalesce pair for RIGHT/FULL OUTER JOINs.
894    ///
895    /// For USING columns in OUTER JOINs, unqualified references should use
896    /// COALESCE semantics. This method records a column index for later coalesce evaluation.
897    /// For chained joins, each call extends the existing Vec with new indices.
898    ///
899    /// # Arguments
900    /// * `column` - The column name (will be normalized to lowercase)
901    /// * `left_idx` - Index of the left-side column (first in the chain)
902    /// * `right_idx` - Index of the right-side column (added to the chain)
903    ///
904    /// Issue #4783, #4903: USING column semantics in OUTER JOINs with N-way coalescing
905    pub fn add_using_coalesce_pair(&mut self, column: &str, left_idx: usize, right_idx: usize) {
906        let indices =
907            self.using_coalesce_indices.entry(column.to_lowercase()).or_insert_with(Vec::new);
908
909        // Issue #4909: For chained NATURAL FULL JOINs like `t3 NATURAL FULL JOIN (inner)`,
910        // the Vec may already have entries from the inner join (e.g., [t4.id, t5.id]).
911        // We must INSERT left_idx at the BEGINNING if not present, to get [t3.id, t4.id, t5.id].
912        // This ensures COALESCE picks the leftmost non-NULL value.
913        if !indices.contains(&left_idx) {
914            indices.insert(0, left_idx);
915        }
916        // Always add right_idx at the end if not already present
917        if !indices.contains(&right_idx) {
918            indices.push(right_idx);
919        }
920    }
921
922    /// Get the coalesce pair for a USING column, if any.
923    /// For backwards compatibility, returns the first two indices as a pair.
924    ///
925    /// Returns Some((left_idx, right_idx)) if this column needs COALESCE
926    /// semantics for OUTER JOIN USING, None otherwise.
927    ///
928    /// # Arguments
929    /// * `column` - The column name (will be normalized to lowercase)
930    pub fn get_using_coalesce_pair(&self, column: &str) -> Option<(usize, usize)> {
931        self.using_coalesce_indices
932            .get(&column.to_lowercase())
933            .filter(|indices| indices.len() >= 2)
934            .map(|indices| (indices[0], indices[1]))
935    }
936
937    /// Get all coalesce indices for a USING column (for N-way COALESCE).
938    ///
939    /// Returns Some(&Vec<usize>) containing all column indices that should be
940    /// coalesced for this column name.
941    pub fn get_using_coalesce_indices(&self, column: &str) -> Option<&Vec<usize>> {
942        self.using_coalesce_indices.get(&column.to_lowercase())
943    }
944
945    /// Add a column replacement for SELECT * expansion (for RIGHT/FULL OUTER JOINs).
946    ///
947    /// When a hidden column has a replacement, SELECT * will output the replacement
948    /// column's value at the hidden column's position, maintaining correct column ordering.
949    pub fn add_column_replacement(&mut self, hidden_idx: usize, replacement_idx: usize) {
950        self.column_replacement_map.insert(hidden_idx, replacement_idx);
951    }
952
953    /// Get the replacement column index for a hidden column, if any.
954    pub fn get_column_replacement(&self, hidden_idx: usize) -> Option<usize> {
955        self.column_replacement_map.get(&hidden_idx).copied()
956    }
957
958    /// Get all indices for N-way COALESCE for a left-side USING column (for SELECT *).
959    ///
960    /// In FULL OUTER JOIN with USING clause, when expanding SELECT *, we need to apply
961    /// N-way COALESCE for USING columns. This method returns all indices except the first
962    /// (which is the "left" index) for coalescing.
963    ///
964    /// Returns Some(&[indices]) if the given index is a left-side USING column, None otherwise.
965    pub fn get_using_coalesce_rest_for_left(&self, left_idx: usize) -> Option<&[usize]> {
966        for indices in self.using_coalesce_indices.values() {
967            if !indices.is_empty() && indices[0] == left_idx && indices.len() > 1 {
968                return Some(&indices[1..]);
969            }
970        }
971        None
972    }
973
974    /// Get the right-side column index for a left-side USING column (for COALESCE in SELECT *).
975    /// For backwards compatibility - returns only the second index (first "right" index).
976    ///
977    /// Returns Some(right_idx) if the given index is a left-side USING column, None otherwise.
978    pub fn get_using_coalesce_right_for_left(&self, left_idx: usize) -> Option<usize> {
979        for indices in self.using_coalesce_indices.values() {
980            if !indices.is_empty() && indices[0] == left_idx && indices.len() > 1 {
981                return Some(indices[1]);
982            }
983        }
984        None
985    }
986
987    /// Check if the given column index is a right-side of a USING coalesce chain.
988    ///
989    /// These columns should be skipped in SELECT * output because they're
990    /// represented by the first column with COALESCE applied.
991    pub fn is_using_coalesce_right_side(&self, idx: usize) -> bool {
992        // Check if this index appears in any position other than the first
993        for indices in self.using_coalesce_indices.values() {
994            if indices.len() > 1 && indices[1..].contains(&idx) {
995                return true;
996            }
997        }
998        false
999    }
1000
1001    /// Get all coalesce indices for a column that's anywhere in the chain.
1002    ///
1003    /// Unlike `get_using_coalesce_rest_for_left` which only works if the given index
1004    /// is the FIRST in the chain, this method returns all indices in the chain if
1005    /// the given index is found ANYWHERE in the chain. This is needed for N-way
1006    /// coalescing where the visible column might be in the middle of the chain.
1007    ///
1008    /// Returns Some(&Vec<usize>) if the given index is part of a coalesce chain.
1009    pub fn get_all_coalesce_indices_for_column(&self, idx: usize) -> Option<&Vec<usize>> {
1010        for indices in self.using_coalesce_indices.values() {
1011            if indices.contains(&idx) && indices.len() > 1 {
1012                return Some(indices);
1013            }
1014        }
1015        None
1016    }
1017
1018    /// Build a map from column names to their indices.
1019    ///
1020    /// This is used by window function frame calculations to resolve named column
1021    /// references in ORDER BY expressions. The map contains both the original case
1022    /// and lowercase versions of column names for case-insensitive matching.
1023    ///
1024    /// For columns that appear in multiple tables, the mapping prefers the leftmost
1025    /// table (lowest start_index) to match the behavior of unqualified column lookups.
1026    pub fn build_column_name_map(&self) -> std::collections::HashMap<String, usize> {
1027        let mut map = std::collections::HashMap::new();
1028
1029        // Collect entries sorted by start_index to ensure deterministic ordering
1030        let mut entries: Vec<_> = self
1031            .table_schemas
1032            .iter()
1033            .filter(|(table_id, _)| !self.alias_tables.contains(*table_id))
1034            .map(|(_, (start_index, schema))| (*start_index, schema))
1035            .collect();
1036        entries.sort_by_key(|(start_index, _)| *start_index);
1037
1038        for (start_index, schema) in entries {
1039            for (idx, col) in schema.columns.iter().enumerate() {
1040                let absolute_idx = start_index + idx;
1041                let name = &col.name;
1042
1043                // Insert original case if not already present
1044                if !map.contains_key(name) {
1045                    map.insert(name.clone(), absolute_idx);
1046                }
1047
1048                // Insert lowercase for case-insensitive matching
1049                let lower = name.to_lowercase();
1050                if !map.contains_key(&lower) {
1051                    map.insert(lower, absolute_idx);
1052                }
1053            }
1054        }
1055
1056        map
1057    }
1058}
1059
1060/// Builder for incrementally constructing a CombinedSchema
1061///
1062/// Builds schemas in O(n) time instead of O(n²) by tracking
1063/// the column offset as tables are added.
1064#[derive(Debug)]
1065pub struct SchemaBuilder {
1066    table_schemas: HashMap<TableIdentifier, (usize, vibesql_catalog::TableSchema)>,
1067    column_offset: usize,
1068    hidden_columns: HashSet<usize>,
1069    duplicate_aliases: HashSet<TableIdentifier>,
1070    joined_columns: HashSet<String>,
1071    using_coalesce_indices: HashMap<String, Vec<usize>>,
1072    column_replacement_map: HashMap<usize, usize>,
1073    alias_tables: HashSet<TableIdentifier>,
1074    shadowed_tables: HashMap<TableIdentifier, HashSet<TableIdentifier>>,
1075}
1076
1077impl SchemaBuilder {
1078    /// Create a new empty schema builder
1079    pub fn new() -> Self {
1080        SchemaBuilder {
1081            table_schemas: HashMap::new(),
1082            column_offset: 0,
1083            hidden_columns: HashSet::new(),
1084            duplicate_aliases: HashSet::new(),
1085            joined_columns: HashSet::new(),
1086            using_coalesce_indices: HashMap::new(),
1087            column_replacement_map: HashMap::new(),
1088            alias_tables: HashSet::new(),
1089            shadowed_tables: HashMap::new(),
1090        }
1091    }
1092
1093    /// Create a schema builder initialized with an existing CombinedSchema
1094    ///
1095    /// Note: Table names are already normalized via TableIdentifier
1096    pub fn from_schema(schema: CombinedSchema) -> Self {
1097        let column_offset = schema.total_columns;
1098        SchemaBuilder {
1099            table_schemas: schema.table_schemas,
1100            column_offset,
1101            hidden_columns: schema.hidden_columns,
1102            duplicate_aliases: schema.duplicate_aliases,
1103            joined_columns: schema.joined_columns,
1104            using_coalesce_indices: schema.using_coalesce_indices,
1105            column_replacement_map: schema.column_replacement_map,
1106            alias_tables: schema.alias_tables,
1107            shadowed_tables: schema.shadowed_tables,
1108        }
1109    }
1110
1111    /// Add a table to the schema
1112    ///
1113    /// This is an O(1) operation - columns are not copied, just indexed
1114    /// Note: Table names are automatically normalized via TableIdentifier for case-insensitive
1115    /// lookups
1116    pub fn add_table(&mut self, name: String, schema: vibesql_catalog::TableSchema) -> &mut Self {
1117        let num_columns = schema.columns.len();
1118        let table_id = TableIdentifier::unquoted(&name);
1119
1120        // Track duplicate table alias/name - but NOT for self-joins
1121        // (same logic as CombinedSchema::combine())
1122        if let Some((_, existing_schema)) = self.table_schemas.get(&table_id) {
1123            // Only mark as duplicate if it's a different table with the same alias
1124            if existing_schema != &schema {
1125                self.duplicate_aliases.insert(table_id.clone());
1126            }
1127        }
1128
1129        self.table_schemas.insert(table_id, (self.column_offset, schema));
1130        self.column_offset += num_columns;
1131        self
1132    }
1133
1134    /// Build the final CombinedSchema
1135    ///
1136    /// This consumes the builder and produces the schema in O(1) time
1137    pub fn build(self) -> CombinedSchema {
1138        CombinedSchema {
1139            table_schemas: self.table_schemas,
1140            total_columns: self.column_offset,
1141            hidden_columns: self.hidden_columns,
1142            outer_schema: None,
1143            duplicate_aliases: self.duplicate_aliases,
1144            joined_columns: self.joined_columns,
1145            using_coalesce_indices: self.using_coalesce_indices,
1146            column_replacement_map: self.column_replacement_map,
1147            alias_tables: self.alias_tables,
1148            shadowed_tables: self.shadowed_tables,
1149        }
1150    }
1151
1152    /// Add a column replacement for SELECT * expansion (for RIGHT/FULL OUTER JOINs)
1153    pub fn add_column_replacement(&mut self, hidden_idx: usize, replacement_idx: usize) {
1154        self.column_replacement_map.insert(hidden_idx, replacement_idx);
1155    }
1156}
1157
1158impl Default for SchemaBuilder {
1159    fn default() -> Self {
1160        Self::new()
1161    }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use vibesql_catalog::ColumnSchema;
1167    use vibesql_types::DataType;
1168
1169    use super::*;
1170
1171    /// Helper to create a simple table schema with the given columns
1172    fn table_schema_with_columns(
1173        table_name: &str,
1174        columns: Vec<(&str, DataType)>,
1175    ) -> vibesql_catalog::TableSchema {
1176        let cols: Vec<ColumnSchema> = columns
1177            .into_iter()
1178            .map(|(name, data_type)| ColumnSchema::new(name.to_string(), data_type, true))
1179            .collect();
1180        vibesql_catalog::TableSchema::new(table_name.to_string(), cols)
1181    }
1182
1183    /// Helper to create a table schema with a single column
1184    fn table_schema_with_column(
1185        table_name: &str,
1186        column_name: &str,
1187    ) -> vibesql_catalog::TableSchema {
1188        table_schema_with_columns(table_name, vec![(column_name, DataType::Integer)])
1189    }
1190
1191    // ==========================================================================
1192    // CombinedSchema::from_table - Case-Insensitive Table Name Tests
1193    // ==========================================================================
1194
1195    #[test]
1196    fn test_from_table_uppercase_insertion_case_insensitive_lookup() {
1197        // Insert with uppercase table name
1198        let schema = CombinedSchema::from_table(
1199            "ITEM".to_string(),
1200            table_schema_with_column("ITEM", "price"),
1201        );
1202
1203        // All case variations should find the column
1204        assert!(schema.get_column_index(Some("ITEM"), "price").is_some(), "ITEM should find price");
1205        assert!(schema.get_column_index(Some("item"), "price").is_some(), "item should find price");
1206        assert!(schema.get_column_index(Some("Item"), "price").is_some(), "Item should find price");
1207        assert!(schema.get_column_index(Some("iTEM"), "price").is_some(), "iTEM should find price");
1208    }
1209
1210    #[test]
1211    fn test_from_table_lowercase_insertion_case_insensitive_lookup() {
1212        // Insert with lowercase table name
1213        let schema = CombinedSchema::from_table(
1214            "item".to_string(),
1215            table_schema_with_column("item", "price"),
1216        );
1217
1218        // All case variations should find the column
1219        assert!(schema.get_column_index(Some("ITEM"), "price").is_some());
1220        assert!(schema.get_column_index(Some("item"), "price").is_some());
1221        assert!(schema.get_column_index(Some("Item"), "price").is_some());
1222    }
1223
1224    #[test]
1225    fn test_from_table_mixedcase_insertion_case_insensitive_lookup() {
1226        // Insert with mixed case table name
1227        let schema = CombinedSchema::from_table(
1228            "MyTable".to_string(),
1229            table_schema_with_column("MyTable", "id"),
1230        );
1231
1232        // All case variations should find the column
1233        assert!(schema.get_column_index(Some("MYTABLE"), "id").is_some());
1234        assert!(schema.get_column_index(Some("mytable"), "id").is_some());
1235        assert!(schema.get_column_index(Some("MyTable"), "id").is_some());
1236        assert!(schema.get_column_index(Some("myTable"), "id").is_some());
1237    }
1238
1239    // ==========================================================================
1240    // CombinedSchema::from_derived_table - Case-Insensitive Alias Tests
1241    // ==========================================================================
1242
1243    #[test]
1244    fn test_from_derived_table_case_insensitive_alias() {
1245        // Derived table with uppercase alias
1246        let schema = CombinedSchema::from_derived_table(
1247            "SUBQ".to_string(),
1248            vec!["col1".to_string(), "col2".to_string()],
1249            vec![DataType::Integer, DataType::Varchar { max_length: None }],
1250        );
1251
1252        // All alias case variations should work
1253        assert!(schema.get_column_index(Some("SUBQ"), "col1").is_some());
1254        assert!(schema.get_column_index(Some("subq"), "col1").is_some());
1255        assert!(schema.get_column_index(Some("Subq"), "col1").is_some());
1256    }
1257
1258    // ==========================================================================
1259    // CombinedSchema::combine - Multi-Table Case-Insensitive Tests
1260    // ==========================================================================
1261
1262    #[test]
1263    fn test_combine_case_insensitive_both_tables() {
1264        // Create left schema with uppercase
1265        let left = CombinedSchema::from_table(
1266            "ORDERS".to_string(),
1267            table_schema_with_columns(
1268                "ORDERS",
1269                vec![("order_id", DataType::Integer), ("customer_id", DataType::Integer)],
1270            ),
1271        );
1272
1273        // Combine with right table using different case
1274        let combined = CombinedSchema::combine(
1275            left,
1276            "Items".to_string(),
1277            table_schema_with_columns(
1278                "Items",
1279                vec![("item_id", DataType::Integer), ("price", DataType::DoublePrecision)],
1280            ),
1281        );
1282
1283        // Verify left table columns accessible with any case
1284        assert!(combined.get_column_index(Some("orders"), "order_id").is_some());
1285        assert!(combined.get_column_index(Some("ORDERS"), "order_id").is_some());
1286        assert!(combined.get_column_index(Some("Orders"), "customer_id").is_some());
1287
1288        // Verify right table columns accessible with any case
1289        assert!(combined.get_column_index(Some("items"), "item_id").is_some());
1290        assert!(combined.get_column_index(Some("ITEMS"), "item_id").is_some());
1291        assert!(combined.get_column_index(Some("Items"), "price").is_some());
1292
1293        // Verify correct indices (left table starts at 0, right at 2)
1294        assert_eq!(combined.get_column_index(Some("orders"), "order_id"), Some(0));
1295        assert_eq!(combined.get_column_index(Some("orders"), "customer_id"), Some(1));
1296        assert_eq!(combined.get_column_index(Some("items"), "item_id"), Some(2));
1297        assert_eq!(combined.get_column_index(Some("items"), "price"), Some(3));
1298    }
1299
1300    #[test]
1301    fn test_combine_multiple_joins_case_insensitive() {
1302        // Simulate a 3-way join: orders JOIN customers JOIN items
1303        let orders = CombinedSchema::from_table(
1304            "O".to_string(), // short alias
1305            table_schema_with_column("O", "order_id"),
1306        );
1307
1308        let with_customers = CombinedSchema::combine(
1309            orders,
1310            "C".to_string(),
1311            table_schema_with_column("C", "customer_id"),
1312        );
1313
1314        let with_items = CombinedSchema::combine(
1315            with_customers,
1316            "I".to_string(),
1317            table_schema_with_column("I", "item_id"),
1318        );
1319
1320        // All aliases should be case-insensitive
1321        assert!(with_items.get_column_index(Some("o"), "order_id").is_some());
1322        assert!(with_items.get_column_index(Some("O"), "order_id").is_some());
1323        assert!(with_items.get_column_index(Some("c"), "customer_id").is_some());
1324        assert!(with_items.get_column_index(Some("C"), "customer_id").is_some());
1325        assert!(with_items.get_column_index(Some("i"), "item_id").is_some());
1326        assert!(with_items.get_column_index(Some("I"), "item_id").is_some());
1327    }
1328
1329    // ==========================================================================
1330    // CombinedSchema::get_column_index - Unqualified Column Lookup
1331    // ==========================================================================
1332
1333    #[test]
1334    fn test_unqualified_column_lookup_no_ambiguity() {
1335        let schema = CombinedSchema::from_table(
1336            "USERS".to_string(),
1337            table_schema_with_columns(
1338                "USERS",
1339                vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: None })],
1340            ),
1341        );
1342
1343        // Unqualified lookup should work
1344        assert!(schema.get_column_index(None, "id").is_some());
1345        assert!(schema.get_column_index(None, "name").is_some());
1346        assert!(schema.get_column_index(None, "missing").is_none());
1347    }
1348
1349    #[test]
1350    fn test_column_case_sensitive_with_fallback() {
1351        // Column created with mixed case (simulating a delimited identifier like "UserName")
1352        let schema = CombinedSchema::from_table(
1353            "users".to_string(),
1354            table_schema_with_column("users", "UserName"),
1355        );
1356
1357        // Exact case match works
1358        assert!(schema.get_column_index(Some("users"), "UserName").is_some());
1359        // Case-insensitive fallback also works for backward compatibility
1360        assert!(schema.get_column_index(Some("users"), "username").is_some());
1361        assert!(schema.get_column_index(Some("users"), "USERNAME").is_some());
1362    }
1363
1364    /// Test case for issue #4111: TPC-DS Q6 scenario
1365    /// Schema created with lowercase column names (from data loader)
1366    /// Query uses uppercase identifiers (from parser normalization)
1367    #[test]
1368    fn test_tpcds_q6_case_insensitive_column_lookup_issue_4111() {
1369        // Simulate TPC-DS item table with lowercase columns (as created by data loader)
1370        let schema = CombinedSchema::from_table(
1371            "J".to_string(), // Uppercase alias from parser
1372            table_schema_with_columns(
1373                "item",
1374                vec![
1375                    ("i_item_sk", DataType::Integer),
1376                    ("i_current_price", DataType::DoublePrecision), // lowercase!
1377                    ("i_category", DataType::Varchar { max_length: None }),
1378                ],
1379            ),
1380        );
1381
1382        // Query uses uppercase column names (from parser normalization)
1383        // This is the exact pattern that fails in TPC-DS Q6:
1384        // SELECT AVG(j.i_current_price) FROM item j WHERE j.i_category = i.i_category
1385        assert!(
1386            schema.get_column_index(Some("J"), "I_CURRENT_PRICE").is_some(),
1387            "J.I_CURRENT_PRICE should find i_current_price via case-insensitive lookup"
1388        );
1389        assert!(
1390            schema.get_column_index(Some("J"), "I_CATEGORY").is_some(),
1391            "J.I_CATEGORY should find i_category via case-insensitive lookup"
1392        );
1393        assert!(
1394            schema.get_column_index(Some("j"), "I_CURRENT_PRICE").is_some(),
1395            "j.I_CURRENT_PRICE should find i_current_price"
1396        );
1397        assert!(
1398            schema.get_column_index(Some("J"), "i_current_price").is_some(),
1399            "J.i_current_price should find via exact match"
1400        );
1401    }
1402
1403    #[test]
1404    fn test_column_distinct_cases_exact_match() {
1405        // When there are multiple columns with different cases (via delimited identifiers),
1406        // exact match takes precedence
1407        let cols: Vec<vibesql_catalog::ColumnSchema> = vec![
1408            vibesql_catalog::ColumnSchema::new("value".to_string(), DataType::Integer, true),
1409            vibesql_catalog::ColumnSchema::new("VALUE".to_string(), DataType::Integer, true),
1410            vibesql_catalog::ColumnSchema::new("Value".to_string(), DataType::Integer, true),
1411        ];
1412        let table_schema = vibesql_catalog::TableSchema::new("data".to_string(), cols);
1413        let schema = CombinedSchema::from_table("data".to_string(), table_schema);
1414
1415        // Each case variation should find its specific column
1416        assert_eq!(schema.get_column_index(Some("data"), "value"), Some(0));
1417        assert_eq!(schema.get_column_index(Some("data"), "VALUE"), Some(1));
1418        assert_eq!(schema.get_column_index(Some("data"), "Value"), Some(2));
1419    }
1420
1421    // ==========================================================================
1422    // SchemaBuilder - Case-Insensitive Tests
1423    // ==========================================================================
1424
1425    #[test]
1426    fn test_schema_builder_add_table_case_insensitive() {
1427        let mut builder = SchemaBuilder::new();
1428
1429        // Add tables with different case
1430        builder.add_table("ORDERS".to_string(), table_schema_with_column("ORDERS", "order_id"));
1431        builder.add_table("Items".to_string(), table_schema_with_column("Items", "item_id"));
1432
1433        let schema = builder.build();
1434
1435        // All case variations should work
1436        assert!(schema.get_column_index(Some("orders"), "order_id").is_some());
1437        assert!(schema.get_column_index(Some("ORDERS"), "order_id").is_some());
1438        assert!(schema.get_column_index(Some("items"), "item_id").is_some());
1439        assert!(schema.get_column_index(Some("ITEMS"), "item_id").is_some());
1440    }
1441
1442    #[test]
1443    fn test_schema_builder_from_schema_preserves_case_insensitivity() {
1444        // Create initial schema with uppercase table name
1445        let initial = CombinedSchema::from_table(
1446            "PRODUCTS".to_string(),
1447            table_schema_with_columns(
1448                "PRODUCTS",
1449                vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: None })],
1450            ),
1451        );
1452
1453        // Verify initial schema works
1454        assert!(initial.get_column_index(Some("products"), "id").is_some());
1455
1456        // Create builder from schema and add another table
1457        let mut builder = SchemaBuilder::from_schema(initial);
1458        builder
1459            .add_table("Categories".to_string(), table_schema_with_column("Categories", "cat_id"));
1460
1461        let final_schema = builder.build();
1462
1463        // Original table should still be case-insensitive
1464        assert!(final_schema.get_column_index(Some("products"), "id").is_some());
1465        assert!(final_schema.get_column_index(Some("PRODUCTS"), "id").is_some());
1466        assert!(final_schema.get_column_index(Some("Products"), "name").is_some());
1467
1468        // New table should also be case-insensitive
1469        assert!(final_schema.get_column_index(Some("categories"), "cat_id").is_some());
1470        assert!(final_schema.get_column_index(Some("CATEGORIES"), "cat_id").is_some());
1471    }
1472
1473    #[test]
1474    fn test_schema_builder_from_schema_multiple_tables() {
1475        // Create combined schema with multiple tables
1476        let orders = CombinedSchema::from_table(
1477            "Orders".to_string(),
1478            table_schema_with_column("Orders", "order_id"),
1479        );
1480        let combined = CombinedSchema::combine(
1481            orders,
1482            "Items".to_string(),
1483            table_schema_with_column("Items", "item_id"),
1484        );
1485
1486        // Create builder from combined schema
1487        let mut builder = SchemaBuilder::from_schema(combined);
1488        builder
1489            .add_table("CUSTOMERS".to_string(), table_schema_with_column("CUSTOMERS", "cust_id"));
1490
1491        let final_schema = builder.build();
1492
1493        // All tables should be case-insensitive
1494        assert!(final_schema.get_column_index(Some("orders"), "order_id").is_some());
1495        assert!(final_schema.get_column_index(Some("ORDERS"), "order_id").is_some());
1496        assert!(final_schema.get_column_index(Some("items"), "item_id").is_some());
1497        assert!(final_schema.get_column_index(Some("ITEMS"), "item_id").is_some());
1498        assert!(final_schema.get_column_index(Some("customers"), "cust_id").is_some());
1499        assert!(final_schema.get_column_index(Some("CUSTOMERS"), "cust_id").is_some());
1500
1501        // Verify column offsets are correct
1502        assert_eq!(final_schema.get_column_index(Some("orders"), "order_id"), Some(0));
1503        assert_eq!(final_schema.get_column_index(Some("items"), "item_id"), Some(1));
1504        assert_eq!(final_schema.get_column_index(Some("customers"), "cust_id"), Some(2));
1505    }
1506
1507    // ==========================================================================
1508    // Regression Tests for Issue #3633
1509    // ==========================================================================
1510
1511    #[test]
1512    fn test_issue_3633_correlated_subquery_alias_case() {
1513        // This test verifies the fix for issue #3633 where correlated subqueries
1514        // with uppercase aliases (like "J") couldn't find columns because the
1515        // parser uses uppercase but the schema stored lowercase.
1516
1517        // Simulate the scenario: outer query has table with alias "J"
1518        let schema = CombinedSchema::from_table(
1519            "J".to_string(), // Parser often uppercases aliases
1520            table_schema_with_columns(
1521                "items",
1522                vec![("price", DataType::DoublePrecision), ("quantity", DataType::Integer)],
1523            ),
1524        );
1525
1526        // The correlated subquery should be able to reference J.price
1527        // regardless of case used by the parser/resolver
1528        assert!(
1529            schema.get_column_index(Some("J"), "price").is_some(),
1530            "Uppercase J should find price (parser case)"
1531        );
1532        assert!(
1533            schema.get_column_index(Some("j"), "price").is_some(),
1534            "Lowercase j should find price (normalized case)"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_issue_3633_multi_table_join_with_aliases() {
1540        // Simulates: SELECT * FROM orders O JOIN items I ON O.id = I.order_id
1541        let orders = CombinedSchema::from_table(
1542            "O".to_string(),
1543            table_schema_with_columns(
1544                "orders",
1545                vec![("id", DataType::Integer), ("date", DataType::Date)],
1546            ),
1547        );
1548
1549        let combined = CombinedSchema::combine(
1550            orders,
1551            "I".to_string(),
1552            table_schema_with_columns(
1553                "items",
1554                vec![("order_id", DataType::Integer), ("amount", DataType::DoublePrecision)],
1555            ),
1556        );
1557
1558        // Both O and I aliases should work case-insensitively
1559        // This is critical for correlated subqueries that reference outer aliases
1560        assert_eq!(combined.get_column_index(Some("O"), "id"), Some(0));
1561        assert_eq!(combined.get_column_index(Some("o"), "id"), Some(0));
1562        assert_eq!(combined.get_column_index(Some("O"), "date"), Some(1));
1563        assert_eq!(combined.get_column_index(Some("I"), "order_id"), Some(2));
1564        assert_eq!(combined.get_column_index(Some("i"), "order_id"), Some(2));
1565        assert_eq!(combined.get_column_index(Some("I"), "amount"), Some(3));
1566    }
1567
1568    // ==========================================================================
1569    // Edge Cases
1570    // ==========================================================================
1571
1572    #[test]
1573    fn test_nonexistent_table_returns_none() {
1574        let schema = CombinedSchema::from_table(
1575            "users".to_string(),
1576            table_schema_with_column("users", "id"),
1577        );
1578
1579        assert!(schema.get_column_index(Some("nonexistent"), "id").is_none());
1580        assert!(schema.get_column_index(Some("NONEXISTENT"), "id").is_none());
1581    }
1582
1583    #[test]
1584    fn test_nonexistent_column_returns_none() {
1585        let schema = CombinedSchema::from_table(
1586            "users".to_string(),
1587            table_schema_with_column("users", "id"),
1588        );
1589
1590        assert!(schema.get_column_index(Some("users"), "nonexistent").is_none());
1591        assert!(schema.get_column_index(Some("USERS"), "nonexistent").is_none());
1592    }
1593
1594    #[test]
1595    fn test_empty_table_name() {
1596        let schema = CombinedSchema::from_table("".to_string(), table_schema_with_column("", "id"));
1597
1598        // Empty string table should still work
1599        assert!(schema.get_column_index(Some(""), "id").is_some());
1600    }
1601
1602    #[test]
1603    fn test_total_columns_tracking() {
1604        let mut builder = SchemaBuilder::new();
1605        builder.add_table(
1606            "t1".to_string(),
1607            table_schema_with_columns(
1608                "t1",
1609                vec![("a", DataType::Integer), ("b", DataType::Integer)],
1610            ),
1611        );
1612        builder.add_table(
1613            "t2".to_string(),
1614            table_schema_with_columns("t2", vec![("c", DataType::Integer)]),
1615        );
1616
1617        let schema = builder.build();
1618        assert_eq!(schema.total_columns, 3);
1619    }
1620
1621    // ==========================================================================
1622    // Ambiguous Column Detection Tests (Issue #4391)
1623    // ==========================================================================
1624
1625    #[test]
1626    fn test_is_column_ambiguous_single_table() {
1627        // Single table - no column can be ambiguous
1628        let schema = CombinedSchema::from_table(
1629            "test1".to_string(),
1630            table_schema_with_columns(
1631                "test1",
1632                vec![("f1", DataType::Integer), ("f2", DataType::Integer)],
1633            ),
1634        );
1635
1636        assert!(!schema.is_column_ambiguous("f1"));
1637        assert!(!schema.is_column_ambiguous("f2"));
1638        assert!(!schema.is_column_ambiguous("nonexistent"));
1639    }
1640
1641    #[test]
1642    fn test_is_column_ambiguous_two_tables_no_overlap() {
1643        // Two tables with different columns - no ambiguity
1644        let test1 = CombinedSchema::from_table(
1645            "test1".to_string(),
1646            table_schema_with_columns(
1647                "test1",
1648                vec![("f1", DataType::Integer), ("f2", DataType::Integer)],
1649            ),
1650        );
1651        let schema = CombinedSchema::combine(
1652            test1,
1653            "test2".to_string(),
1654            table_schema_with_columns(
1655                "test2",
1656                vec![("f3", DataType::Integer), ("f4", DataType::Integer)],
1657            ),
1658        );
1659
1660        assert!(!schema.is_column_ambiguous("f1"));
1661        assert!(!schema.is_column_ambiguous("f2"));
1662        assert!(!schema.is_column_ambiguous("f3"));
1663        assert!(!schema.is_column_ambiguous("f4"));
1664    }
1665
1666    #[test]
1667    fn test_is_column_ambiguous_two_tables_with_overlap() {
1668        // Two tables with same column names - should be ambiguous
1669        // This is the exact scenario from issue #4391:
1670        // CREATE TABLE test1(f1, f2);
1671        // CREATE TABLE test2(f1, f2);
1672        // SELECT f1 FROM test1, test2;
1673        let test1 = CombinedSchema::from_table(
1674            "test1".to_string(),
1675            table_schema_with_columns(
1676                "test1",
1677                vec![("f1", DataType::Integer), ("f2", DataType::Integer)],
1678            ),
1679        );
1680        let schema = CombinedSchema::combine(
1681            test1,
1682            "test2".to_string(),
1683            table_schema_with_columns(
1684                "test2",
1685                vec![("f1", DataType::Integer), ("f2", DataType::Integer)],
1686            ),
1687        );
1688
1689        // Both f1 and f2 exist in both tables - should be ambiguous
1690        assert!(schema.is_column_ambiguous("f1"), "f1 should be ambiguous");
1691        assert!(schema.is_column_ambiguous("f2"), "f2 should be ambiguous");
1692
1693        // Nonexistent columns are not ambiguous (they just don't exist)
1694        assert!(!schema.is_column_ambiguous("f3"));
1695    }
1696
1697    #[test]
1698    fn test_is_column_ambiguous_case_insensitive() {
1699        // Column names should be matched case-insensitively
1700        let test1 = CombinedSchema::from_table(
1701            "test1".to_string(),
1702            table_schema_with_columns("test1", vec![("F1", DataType::Integer)]),
1703        );
1704        let schema = CombinedSchema::combine(
1705            test1,
1706            "test2".to_string(),
1707            table_schema_with_columns("test2", vec![("f1", DataType::Integer)]),
1708        );
1709
1710        // F1 and f1 should be considered the same column, so it's ambiguous
1711        assert!(schema.is_column_ambiguous("f1"));
1712        assert!(schema.is_column_ambiguous("F1"));
1713        assert!(schema.is_column_ambiguous("F1")); // Mixed case
1714    }
1715
1716    #[test]
1717    fn test_is_column_ambiguous_partial_overlap() {
1718        // Two tables where only some columns overlap
1719        let test1 = CombinedSchema::from_table(
1720            "test1".to_string(),
1721            table_schema_with_columns(
1722                "test1",
1723                vec![("id", DataType::Integer), ("name", DataType::Varchar { max_length: None })],
1724            ),
1725        );
1726        let schema = CombinedSchema::combine(
1727            test1,
1728            "test2".to_string(),
1729            table_schema_with_columns(
1730                "test2",
1731                vec![
1732                    ("id", DataType::Integer),    // Same as test1
1733                    ("value", DataType::Integer), // Different from test1
1734                ],
1735            ),
1736        );
1737
1738        // id is in both tables - ambiguous
1739        assert!(schema.is_column_ambiguous("id"));
1740
1741        // name only in test1, value only in test2 - not ambiguous
1742        assert!(!schema.is_column_ambiguous("name"));
1743        assert!(!schema.is_column_ambiguous("value"));
1744    }
1745
1746    #[test]
1747    fn test_is_column_ambiguous_three_tables() {
1748        // Three tables where a column appears in multiple (but not all)
1749        let t1 = CombinedSchema::from_table(
1750            "t1".to_string(),
1751            table_schema_with_columns(
1752                "t1",
1753                vec![("a", DataType::Integer), ("b", DataType::Integer)],
1754            ),
1755        );
1756        let t1_t2 = CombinedSchema::combine(
1757            t1,
1758            "t2".to_string(),
1759            table_schema_with_columns(
1760                "t2",
1761                vec![("b", DataType::Integer), ("c", DataType::Integer)],
1762            ),
1763        );
1764        let schema = CombinedSchema::combine(
1765            t1_t2,
1766            "t3".to_string(),
1767            table_schema_with_columns(
1768                "t3",
1769                vec![("c", DataType::Integer), ("d", DataType::Integer)],
1770            ),
1771        );
1772
1773        // a only in t1 - not ambiguous
1774        assert!(!schema.is_column_ambiguous("a"));
1775
1776        // b in t1 and t2 - ambiguous
1777        assert!(schema.is_column_ambiguous("b"));
1778
1779        // c in t2 and t3 - ambiguous
1780        assert!(schema.is_column_ambiguous("c"));
1781
1782        // d only in t3 - not ambiguous
1783        assert!(!schema.is_column_ambiguous("d"));
1784    }
1785
1786    // ==========================================================================
1787    // Joined Column Tests (Issue #4517 - NATURAL JOIN columns not ambiguous)
1788    // ==========================================================================
1789
1790    #[test]
1791    fn test_joined_column_not_ambiguous_natural_join() {
1792        // Test that columns marked as "joined" via NATURAL JOIN are not ambiguous
1793        // This simulates: SELECT b FROM t1 NATURAL JOIN t2 WHERE t1.b = t2.b AND t1.c = t2.c
1794        let t1 = CombinedSchema::from_table(
1795            "t1".to_string(),
1796            table_schema_with_columns(
1797                "t1",
1798                vec![("a", DataType::Integer), ("b", DataType::Integer), ("c", DataType::Integer)],
1799            ),
1800        );
1801        let mut schema = CombinedSchema::combine(
1802            t1,
1803            "t2".to_string(),
1804            table_schema_with_columns(
1805                "t2",
1806                vec![("b", DataType::Integer), ("c", DataType::Integer), ("d", DataType::Integer)],
1807            ),
1808        );
1809
1810        // Before marking as joined, b and c should be ambiguous
1811        assert!(
1812            schema.is_column_ambiguous("b"),
1813            "b should be ambiguous before NATURAL JOIN processing"
1814        );
1815        assert!(
1816            schema.is_column_ambiguous("c"),
1817            "c should be ambiguous before NATURAL JOIN processing"
1818        );
1819
1820        // Mark b and c as joined columns (as would happen in NATURAL JOIN processing)
1821        schema.add_joined_column("b");
1822        schema.add_joined_column("c");
1823
1824        // After marking as joined, b and c should NOT be ambiguous
1825        assert!(!schema.is_column_ambiguous("b"), "b should NOT be ambiguous after NATURAL JOIN");
1826        assert!(!schema.is_column_ambiguous("c"), "c should NOT be ambiguous after NATURAL JOIN");
1827
1828        // a only in t1, d only in t2 - never ambiguous
1829        assert!(!schema.is_column_ambiguous("a"));
1830        assert!(!schema.is_column_ambiguous("d"));
1831    }
1832
1833    #[test]
1834    fn test_joined_column_case_insensitive() {
1835        // Test that joined column matching is case-insensitive
1836        let t1 = CombinedSchema::from_table(
1837            "t1".to_string(),
1838            table_schema_with_columns("t1", vec![("Name", DataType::Integer)]),
1839        );
1840        let mut schema = CombinedSchema::combine(
1841            t1,
1842            "t2".to_string(),
1843            table_schema_with_columns("t2", vec![("NAME", DataType::Integer)]),
1844        );
1845
1846        // Before marking as joined, should be ambiguous
1847        assert!(schema.is_column_ambiguous("name"));
1848        assert!(schema.is_column_ambiguous("NAME"));
1849        assert!(schema.is_column_ambiguous("Name"));
1850
1851        // Mark as joined with lowercase
1852        schema.add_joined_column("name");
1853
1854        // All case variants should now be non-ambiguous
1855        assert!(!schema.is_column_ambiguous("name"));
1856        assert!(!schema.is_column_ambiguous("NAME"));
1857        assert!(!schema.is_column_ambiguous("Name"));
1858    }
1859
1860    #[test]
1861    fn test_joined_column_with_using_clause() {
1862        // Test USING clause behavior (similar to NATURAL JOIN but explicit columns)
1863        // This simulates: SELECT id FROM t1 JOIN t2 USING(id)
1864        let t1 = CombinedSchema::from_table(
1865            "t1".to_string(),
1866            table_schema_with_columns(
1867                "t1",
1868                vec![("id", DataType::Integer), ("value1", DataType::Integer)],
1869            ),
1870        );
1871        let mut schema = CombinedSchema::combine(
1872            t1,
1873            "t2".to_string(),
1874            table_schema_with_columns(
1875                "t2",
1876                vec![("id", DataType::Integer), ("value2", DataType::Integer)],
1877            ),
1878        );
1879
1880        // Mark id as joined (as would happen in USING clause processing)
1881        schema.add_joined_column("id");
1882
1883        // id should NOT be ambiguous (it's a USING column)
1884        assert!(!schema.is_column_ambiguous("id"));
1885
1886        // value1 and value2 are unique to their tables - not ambiguous
1887        assert!(!schema.is_column_ambiguous("value1"));
1888        assert!(!schema.is_column_ambiguous("value2"));
1889    }
1890}