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}