Skip to main content

nodedb_sql/resolver/
columns.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Column and table resolution against the catalog.
4
5use std::collections::HashMap;
6
7use nodedb_types::DatabaseId;
8
9use crate::error::{Result, SqlError};
10use crate::functions::registry::FunctionRegistry;
11use crate::parser::normalize::table_name_from_factor;
12use crate::temporal::TemporalScope;
13use crate::types::{CollectionInfo, ColumnInfo, EngineType, SqlCatalog};
14
15pub use super::column_naming::{BareColumn, ColumnNaming};
16
17/// Synthetic temporal columns an audit read injects into every version row.
18/// They are not declared columns, so a bitemporal relation resolves them by
19/// name.
20const BITEMPORAL_AUDIT_COLUMNS: [&str; 3] = ["_ts_system", "_ts_valid_from", "_ts_valid_until"];
21
22/// Resolved table reference: name, alias, and catalog info.
23#[derive(Debug, Clone)]
24pub struct ResolvedTable {
25    pub name: String,
26    pub alias: Option<String>,
27    pub info: CollectionInfo,
28}
29
30impl ResolvedTable {
31    /// The name to use for qualified column references.
32    pub fn ref_name(&self) -> &str {
33        self.alias.as_deref().unwrap_or(&self.name)
34    }
35
36    /// The qualifier a join's merged row gives this relation's columns.
37    ///
38    /// A collection or catalog relation's columns are keyed
39    /// `ref_name.column`. An `ARRAY_*` table function's rows keep bare
40    /// column names, so it has none.
41    pub fn row_qualifier(&self) -> Option<&str> {
42        match self.info.engine {
43            EngineType::Array => None,
44            EngineType::DocumentSchemaless
45            | EngineType::DocumentStrict
46            | EngineType::KeyValue
47            | EngineType::Columnar
48            | EngineType::Timeseries
49            | EngineType::Spatial
50            | EngineType::ContinuousAggregate => Some(self.ref_name()),
51        }
52    }
53}
54
55/// Context built during FROM clause resolution.
56#[derive(Debug, Default, Clone)]
57pub struct TableScope {
58    /// Tables by reference name (alias or table name).
59    pub tables: HashMap<String, ResolvedTable>,
60    /// Insertion order for unambiguous column resolution.
61    order: Vec<String>,
62    /// Names resolvable here that belong to no relation: SELECT output
63    /// aliases visible to ORDER BY, GROUP BY, and HAVING, plus the output
64    /// names substituted for aggregate calls.
65    output_names: Vec<String>,
66    /// The enclosing query's scope, for a correlated subquery. A qualifier
67    /// naming no relation here resolves there instead. Boxed rather than
68    /// borrowed: a lifetime on `TableScope` would ripple through every
69    /// planner signature that stores or returns one.
70    outer: Option<Box<TableScope>>,
71    /// Relations a bare column name never resolves against, reachable only
72    /// through their qualifier. A MERGE source and the `excluded` relation of
73    /// `ON CONFLICT DO UPDATE` are both qualified-only, so a bare name in a
74    /// WHEN or SET clause names the target column.
75    qualified_only: Vec<String>,
76    /// Whether the SELECT this scope backs produces the statement's result
77    /// rows. Only such a SELECT list may hold a Control-Plane-computed item:
78    /// the Control Plane evaluates the statement's output rows and nothing
79    /// deeper. A subquery, CTE, UNION branch, derived table, INSERT source,
80    /// or MERGE source keeps the default. A scope nested inside this one
81    /// never inherits the flag.
82    statement_output: bool,
83    /// Whether a sequence accessor resolves here although rows are in scope.
84    /// Set for a SELECT-list item over a relation: the Control Plane
85    /// evaluates that item once per output row. Every other row-scope clause
86    /// keeps the default and refuses the call. A scope nested inside this one
87    /// never inherits the flag.
88    cp_functions: bool,
89    /// How a column reference to one of this scope's relations names its
90    /// field. A scope nested inside this one never inherits it.
91    column_naming: ColumnNaming,
92}
93
94impl TableScope {
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Add a resolved table. Returns error if name conflicts.
100    pub fn add(&mut self, table: ResolvedTable) -> Result<()> {
101        let key = table.ref_name().to_string();
102        if self.tables.contains_key(&key) {
103            return Err(SqlError::Parse {
104                detail: format!("duplicate table reference: {key}"),
105            });
106        }
107        self.order.push(key.clone());
108        self.tables.insert(key, table);
109        Ok(())
110    }
111
112    /// Add a relation that only a qualified reference reaches.
113    ///
114    /// A bare column name skips it, so a name both this relation and a
115    /// bare-resolvable one declare is not ambiguous.
116    pub fn add_qualified_only(&mut self, table: ResolvedTable) -> Result<()> {
117        let key = table.ref_name().to_string();
118        self.add(table)?;
119        self.qualified_only.push(key);
120        Ok(())
121    }
122
123    fn column_exists(&self, table: &ResolvedTable, column: &str) -> bool {
124        if table.info.open_schema {
125            return true;
126        }
127        if table.info.bitemporal && BITEMPORAL_AUDIT_COLUMNS.contains(&column) {
128            return true;
129        }
130        let rules = crate::engine_rules::resolve_engine_rules(table.info.engine);
131        if rules.implicit_columns().contains(&column) {
132            return true;
133        }
134        // A key-only collection on an engine with a free value-column name
135        // has not fixed that name yet, so any name resolves.
136        if rules.value_column_name_is_free()
137            && !table.info.columns.iter().any(|c| !c.is_primary_key)
138        {
139            return true;
140        }
141        table.info.columns.iter().any(|c| c.name == column)
142    }
143
144    fn validate_column(&self, table: &ResolvedTable, column: &str) -> Result<()> {
145        if self.column_exists(table, column) {
146            Ok(())
147        } else {
148            Err(SqlError::UnknownColumn {
149                table: table.name.clone(),
150                column: column.into(),
151            })
152        }
153    }
154
155    /// Get the single table in scope (for single-table queries).
156    pub fn single_table(&self) -> Option<&ResolvedTable> {
157        if self.tables.len() == 1 {
158            self.tables.values().next()
159        } else {
160            Option::None
161        }
162    }
163
164    /// Whether an expression here is evaluated once per row of some relation.
165    ///
166    /// A scope with no relation of its own and no enclosing query stands
167    /// behind a FROM-less `SELECT`, which produces exactly one row and
168    /// evaluates its projection at plan time.
169    pub fn is_row_scope(&self) -> bool {
170        !self.tables.is_empty() || self.outer.is_some()
171    }
172
173    /// A copy of this scope nested inside `outer`, for planning a correlated
174    /// subquery body. The body's own clauses refuse a sequence accessor even
175    /// when the enclosing SELECT list allows one.
176    pub fn nested_in(mut self, outer: TableScope) -> Self {
177        self.outer = Some(Box::new(outer));
178        self.statement_output = false;
179        self.cp_functions = false;
180        self.column_naming = ColumnNaming::AsWritten;
181        self
182    }
183
184    /// This scope marked as backing the SELECT that produces the statement's
185    /// result rows.
186    pub fn as_statement_output(mut self) -> Self {
187        self.statement_output = true;
188        self
189    }
190
191    /// Whether the SELECT this scope backs produces the statement's result
192    /// rows.
193    pub fn is_statement_output(&self) -> bool {
194        self.statement_output
195    }
196
197    /// This scope with sequence accessors allowed: the expression it resolves
198    /// is a SELECT-list item the Control Plane evaluates per output row.
199    pub fn allowing_cp_functions(mut self) -> Self {
200        self.cp_functions = true;
201        self
202    }
203
204    /// Whether a sequence accessor resolves here despite rows being in scope.
205    pub fn allows_cp_functions(&self) -> bool {
206        self.cp_functions
207    }
208
209    /// A copy of this scope widened with output column names.
210    ///
211    /// An ORDER BY, GROUP BY, or HAVING identifier resolves against input
212    /// columns first and output names second, so this only widens: a name
213    /// that already resolves to a column keeps resolving to it.
214    pub fn with_output_names(&self, names: impl IntoIterator<Item = String>) -> Self {
215        let mut out = self.clone();
216        out.output_names.extend(names);
217        out
218    }
219
220    /// A single-relation scope, for the DML planners that resolve one
221    /// collection and build no FROM clause.
222    pub fn single(table: ResolvedTable) -> Result<Self> {
223        let mut scope = Self::new();
224        scope.add(table)?;
225        Ok(scope)
226    }
227
228    /// Reject a column reference that names nothing in scope.
229    pub fn check_name(&self, table_ref: Option<&str>, column: &str) -> Result<()> {
230        let col = column.to_lowercase();
231
232        if let Some(tref) = table_ref {
233            let tref_lower = tref.to_lowercase();
234            return match self.tables.get(&tref_lower) {
235                Some(table) => self.validate_column(table, &col),
236                // A qualifier naming no relation here belongs to the outer
237                // query of a correlated subquery. With no outer scope it is
238                // an unknown relation, not an unknown column.
239                None => match &self.outer {
240                    Some(outer) => outer.check_name(Some(&tref_lower), &col),
241                    None => Err(SqlError::UnknownTable { name: tref_lower }),
242                },
243            };
244        }
245
246        self.resolve_bare(&col).map(|_| ())
247    }
248
249    /// Where an unqualified column reference resolves.
250    ///
251    /// A SELECT output name wins. Otherwise the relations a bare name reaches
252    /// are tried:
253    /// - One relation declares the column: it resolves there, even beside an
254    ///   open-schema relation.
255    /// - Two or more declare it: refused as ambiguous.
256    /// - None declares it and exactly one accepts any name (an open schema,
257    ///   an implicit column): it resolves there.
258    /// - None declares it and two or more accept any name: refused as
259    ///   ambiguous. Picking one would read NULL for a field the other holds.
260    /// - None accepts it: the enclosing query's scope is tried, else the
261    ///   column is unknown.
262    pub fn resolve_bare(&self, column: &str) -> Result<BareColumn<'_>> {
263        let col = column.to_lowercase();
264        if self.output_names.iter().any(|n| n == &col) {
265            return Ok(BareColumn::Output);
266        }
267        let bare: Vec<&ResolvedTable> = self
268            .order
269            .iter()
270            .filter(|key| !self.qualified_only.contains(*key))
271            .map(|key| &self.tables[key])
272            .collect();
273        let declared: Vec<&ResolvedTable> = bare
274            .iter()
275            .copied()
276            .filter(|table| table.info.columns.iter().any(|c| c.name == col))
277            .collect();
278        match declared.as_slice() {
279            &[table] => return Ok(BareColumn::Relation(table)),
280            [] => {}
281            _ => return Err(SqlError::AmbiguousColumn { column: col }),
282        }
283        let accepting: Vec<&ResolvedTable> = bare
284            .iter()
285            .copied()
286            .filter(|table| self.column_exists(table, &col))
287            .collect();
288        match accepting.as_slice() {
289            &[table] => Ok(BareColumn::Relation(table)),
290            [] => match &self.outer {
291                Some(outer) => outer.resolve_bare(&col).map(|_| BareColumn::Outer),
292                None => Err(SqlError::UnknownColumn {
293                    table: self
294                        .order
295                        .first()
296                        .cloned()
297                        .unwrap_or_else(|| "<unknown>".into()),
298                    column: col,
299                }),
300            },
301            _ => Err(SqlError::AmbiguousColumn { column: col }),
302        }
303    }
304
305    /// This scope with column references named for `naming`'s rows.
306    pub fn with_column_naming(mut self, naming: ColumnNaming) -> Self {
307        self.column_naming = naming;
308        self
309    }
310
311    /// How this scope's column references name their fields.
312    pub fn column_naming(&self) -> ColumnNaming {
313        self.column_naming
314    }
315
316    /// Reject a `USING (column)` key that `right_ref`'s relation or every
317    /// other relation lacks. Both sides of the join must carry it.
318    pub fn check_using_column(&self, right_ref: Option<&str>, column: &str) -> Result<()> {
319        let col = column.to_lowercase();
320        let right_key = right_ref.map(str::to_lowercase);
321        let right = right_key.as_deref().and_then(|key| self.tables.get(key));
322        let carriers = self
323            .order
324            .iter()
325            .filter(|key| !self.qualified_only.contains(*key))
326            .filter(|key| self.column_exists(&self.tables[*key], &col));
327        let has_both = match (right, right_key.as_deref()) {
328            (Some(right), Some(key)) => {
329                self.column_exists(right, &col) && carriers.into_iter().any(|k| k != key)
330            }
331            _ => carriers.count() >= 2,
332        };
333        if has_both {
334            Ok(())
335        } else {
336            Err(SqlError::UnknownColumn {
337                table: right_key.unwrap_or_else(|| "<unknown>".into()),
338                column: col,
339            })
340        }
341    }
342
343    /// The resolved tables, in the order the FROM clause introduced them.
344    pub fn tables_in_order(&self) -> impl Iterator<Item = &ResolvedTable> {
345        self.order.iter().map(|key| &self.tables[key])
346    }
347
348    /// The relation registered under `ref_name`, alias or table name.
349    pub fn table_by_ref(&self, ref_name: &str) -> Option<&ResolvedTable> {
350        self.tables.get(&ref_name.to_lowercase())
351    }
352
353    /// The declared column a reference names, when the relation declares it.
354    pub fn declared_column(&self, table_ref: Option<&str>, column: &str) -> Option<&ColumnInfo> {
355        let col = column.to_lowercase();
356        match table_ref {
357            Some(tref) => self
358                .table_by_ref(tref)?
359                .info
360                .columns
361                .iter()
362                .find(|c| c.name == col),
363            None => self
364                .tables_in_order()
365                .find_map(|t| t.info.columns.iter().find(|c| c.name == col)),
366        }
367    }
368
369    /// Resolve tables from a FROM clause.
370    ///
371    /// A derived-subquery factor is planned here so its relation carries the
372    /// search cells its plan produces; the planner context (`functions`,
373    /// `temporal`) is required for that and for no other arm.
374    pub fn resolve_from(
375        catalog: &dyn SqlCatalog,
376        functions: &FunctionRegistry,
377        temporal: TemporalScope,
378        from: &[sqlparser::ast::TableWithJoins],
379    ) -> Result<Self> {
380        let mut scope = Self::new();
381        let from_names = crate::resolver::derived::from_relation_names(from);
382        let factors = from.iter().flat_map(|twj| {
383            std::iter::once(&twj.relation).chain(twj.joins.iter().map(|j| &j.relation))
384        });
385        for factor in factors {
386            scope
387                .resolve_table_factor(catalog, functions, temporal, factor)
388                .map_err(|e| {
389                    crate::resolver::derived::refuse_correlated_derived(e, factor, &from_names)
390                })?;
391        }
392        Ok(scope)
393    }
394
395    fn resolve_table_factor(
396        &mut self,
397        catalog: &dyn SqlCatalog,
398        functions: &FunctionRegistry,
399        temporal: TemporalScope,
400        factor: &sqlparser::ast::TableFactor,
401    ) -> Result<()> {
402        // ARRAY_*(...) table-valued function: synthesize a ResolvedTable
403        // from the array's dim+attr schema so equi-join keys against the
404        // TVF's output rows resolve.
405        if let Some(resolved) = crate::resolver::array_tvf::resolve_array_tvf(catalog, factor)? {
406            self.add(resolved)?;
407            return Ok(());
408        }
409        // Derived subquery, LATERAL or not: register the alias as the relation
410        // its projection list exposes — a column reference on the alias
411        // resolves without a catalog lookup. A non-LATERAL subquery is planned
412        // so its relation carries the search cells the plan produces; a
413        // correlated LATERAL one cannot be planned at scope time, and the
414        // lateral planner routes its shapes without those cells.
415        if let sqlparser::ast::TableFactor::Derived {
416            lateral,
417            subquery,
418            alias: Some(alias),
419            ..
420        } = factor
421        {
422            let alias_str = crate::reserved::check_ast_identifier(&alias.name)?;
423            let declared: Vec<String> = alias
424                .columns
425                .iter()
426                .map(|column| crate::reserved::check_ast_identifier(&column.name))
427                .collect::<Result<_>>()?;
428            let plan = if *lateral {
429                None
430            } else {
431                Some(crate::planner::select::plan_query(
432                    subquery, catalog, functions, temporal,
433                )?)
434            };
435            let info = crate::resolver::derived::infer_subquery_relation(
436                catalog,
437                &alias_str,
438                subquery,
439                plan.as_ref(),
440                functions,
441                temporal,
442            )?;
443            self.add(ResolvedTable {
444                name: alias_str.clone(),
445                alias: Some(alias_str),
446                info: crate::resolver::derived::rename_output_columns(info, &declared),
447            })?;
448            return Ok(());
449        }
450        if let Some((name, alias)) = table_name_from_factor(factor)? {
451            let info = catalog
452                .resolve_relation(DatabaseId::DEFAULT, &name)?
453                .ok_or_else(|| SqlError::UnknownTable { name: name.clone() })?;
454            self.add(ResolvedTable { name, alias, info })?;
455        }
456        Ok(())
457    }
458}
459
460/// Scope builders shared by the planner unit tests.
461#[cfg(test)]
462pub(crate) mod test_support {
463    use super::{ResolvedTable, TableScope};
464    use crate::types::{CollectionInfo, EngineType};
465
466    /// A one-relation scope over `collection` that accepts any column name.
467    pub(crate) fn open_scope(collection: &str) -> TableScope {
468        let info = CollectionInfo {
469            name: collection.into(),
470            engine: EngineType::DocumentSchemaless,
471            columns: Vec::new(),
472            primary_key: None,
473            has_auto_tier: false,
474            indexes: Vec::new(),
475            bitemporal: false,
476            primary: nodedb_types::PrimaryEngine::Document,
477            vector_primary: None,
478            partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
479            open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
480        };
481        TableScope::single(ResolvedTable {
482            name: info.name.clone(),
483            alias: None,
484            info,
485        })
486        .expect("single-relation scope")
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::test_support::open_scope;
493    use super::*;
494    use crate::types::{CollectionInfo, ColumnInfo, EngineType, SqlDataType};
495    use nodedb_types::PrimaryEngine;
496
497    fn strict_collection(name: &str, columns: Vec<&str>) -> CollectionInfo {
498        CollectionInfo {
499            name: name.into(),
500            engine: EngineType::DocumentStrict,
501            columns: columns
502                .into_iter()
503                .map(|c| ColumnInfo {
504                    name: c.into(),
505                    data_type: SqlDataType::String,
506                    nullable: true,
507                    is_primary_key: false,
508                    default: None,
509                    raw_type: None,
510                    int_width: None,
511                    float_width: None,
512                })
513                .collect(),
514            primary_key: None,
515            has_auto_tier: false,
516            indexes: Vec::new(),
517            bitemporal: false,
518            primary: PrimaryEngine::Document,
519            vector_primary: None,
520            partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
521            open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
522        }
523    }
524
525    fn scope_with(info: CollectionInfo) -> TableScope {
526        let mut scope = TableScope::new();
527        scope
528            .add(ResolvedTable {
529                name: info.name.clone(),
530                alias: None,
531                info,
532            })
533            .expect("add failed");
534        scope
535    }
536
537    fn open_collection(name: &str) -> CollectionInfo {
538        CollectionInfo {
539            engine: EngineType::DocumentSchemaless,
540            open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
541            ..strict_collection(name, Vec::new())
542        }
543    }
544
545    fn array_relation(name: &str, columns: Vec<&str>) -> CollectionInfo {
546        CollectionInfo {
547            engine: EngineType::Array,
548            open_schema: false,
549            ..strict_collection(name, columns)
550        }
551    }
552
553    /// A join scope over `(alias, info)` relations, in FROM order.
554    fn join_scope(relations: Vec<(&str, CollectionInfo)>) -> TableScope {
555        let mut scope = TableScope::new();
556        for (alias, info) in relations {
557            scope
558                .add(ResolvedTable {
559                    name: info.name.clone(),
560                    alias: Some(alias.into()),
561                    info,
562                })
563                .expect("add failed");
564        }
565        scope.with_column_naming(ColumnNaming::JoinRow)
566    }
567
568    fn owner<'a>(scope: &'a TableScope, column: &str) -> Option<&'a str> {
569        match scope.resolve_bare(column).expect("resolves") {
570            BareColumn::Relation(table) => Some(table.ref_name()),
571            BareColumn::Output | BareColumn::Outer => None,
572        }
573    }
574
575    #[test]
576    fn column_declared_by_one_relation_resolves_there() {
577        let scope = join_scope(vec![
578            ("a", strict_collection("articles", vec!["id", "title"])),
579            ("o", strict_collection("orders", vec!["id", "amount"])),
580        ]);
581        assert_eq!(owner(&scope, "title"), Some("a"));
582        assert_eq!(owner(&scope, "amount"), Some("o"));
583    }
584
585    #[test]
586    fn declared_relation_wins_over_a_schemaless_one() {
587        let scope = join_scope(vec![
588            ("e", open_collection("events")),
589            ("a", strict_collection("articles", vec!["id", "title"])),
590        ]);
591        assert_eq!(owner(&scope, "title"), Some("a"));
592    }
593
594    #[test]
595    fn column_declared_by_two_relations_is_ambiguous() {
596        let scope = join_scope(vec![
597            ("a", strict_collection("articles", vec!["id", "title"])),
598            ("b", strict_collection("blogs", vec!["id", "title"])),
599        ]);
600        let err = scope.resolve_bare("title").expect_err("ambiguous");
601        assert!(
602            matches!(err, SqlError::AmbiguousColumn { ref column } if column == "title"),
603            "{err:?}"
604        );
605    }
606
607    #[test]
608    fn undeclared_column_resolves_to_the_only_schemaless_relation() {
609        let scope = join_scope(vec![
610            ("a", strict_collection("articles", vec!["id", "title"])),
611            ("e", open_collection("events")),
612        ]);
613        assert_eq!(owner(&scope, "payload"), Some("e"));
614    }
615
616    #[test]
617    fn undeclared_column_over_two_schemaless_relations_is_ambiguous() {
618        let scope = join_scope(vec![
619            ("e", open_collection("events")),
620            ("d", open_collection("devices")),
621        ]);
622        let err = scope.resolve_bare("region").expect_err("ambiguous");
623        assert!(
624            matches!(err, SqlError::AmbiguousColumn { ref column } if column == "region"),
625            "{err:?}"
626        );
627    }
628
629    #[test]
630    fn column_no_relation_accepts_is_unknown() {
631        let scope = join_scope(vec![
632            ("a", strict_collection("articles", vec!["id", "title"])),
633            ("o", strict_collection("orders", vec!["id", "amount"])),
634        ]);
635        let err = scope.resolve_bare("ghost").expect_err("unknown");
636        assert!(matches!(err, SqlError::UnknownColumn { .. }), "{err:?}");
637    }
638
639    #[test]
640    fn output_name_resolves_before_relations() {
641        let scope = join_scope(vec![
642            ("e", open_collection("events")),
643            ("d", open_collection("devices")),
644        ])
645        .with_output_names(["total".to_string()]);
646        assert!(matches!(
647            scope.resolve_bare("total").expect("resolves"),
648            BareColumn::Output
649        ));
650        assert_eq!(
651            scope.bare_column_qualifier("total").expect("resolves"),
652            None
653        );
654    }
655
656    #[test]
657    fn join_row_naming_qualifies_a_bare_column_with_its_owner() {
658        let scope = join_scope(vec![
659            ("a", strict_collection("articles", vec!["id", "title"])),
660            ("e", open_collection("events")),
661        ]);
662        assert_eq!(
663            scope.bare_column_qualifier("title").expect("resolves"),
664            Some("a".to_string())
665        );
666        assert_eq!(
667            scope.bare_column_qualifier("payload").expect("resolves"),
668            Some("e".to_string())
669        );
670    }
671
672    #[test]
673    fn as_written_naming_checks_but_keeps_a_bare_column_bare() {
674        let scope = join_scope(vec![
675            ("a", strict_collection("articles", vec!["id", "title"])),
676            ("o", strict_collection("orders", vec!["id", "amount"])),
677        ])
678        .with_column_naming(ColumnNaming::AsWritten);
679        assert_eq!(
680            scope.bare_column_qualifier("title").expect("resolves"),
681            None
682        );
683        assert!(
684            scope.bare_column_qualifier("id").is_err(),
685            "still ambiguous"
686        );
687    }
688
689    #[test]
690    fn array_relation_columns_carry_no_qualifier() {
691        let scope = join_scope(vec![
692            ("a", strict_collection("articles", vec!["id", "title"])),
693            ("g", array_relation("genome", vec!["chrom", "qual"])),
694        ]);
695        assert_eq!(scope.bare_column_qualifier("qual").expect("resolves"), None);
696        assert_eq!(scope.qualified_column_qualifier("g"), None);
697        assert_eq!(scope.qualified_column_qualifier("a"), Some("a".to_string()));
698    }
699
700    #[test]
701    fn output_row_naming_drops_relation_qualifiers() {
702        let scope = join_scope(vec![
703            ("a", strict_collection("articles", vec!["id", "title"])),
704            ("o", strict_collection("orders", vec!["id", "amount"])),
705        ])
706        .with_column_naming(ColumnNaming::OutputRow);
707        assert_eq!(scope.qualified_column_qualifier("a"), None);
708        assert_eq!(
709            scope.bare_column_qualifier("title").expect("resolves"),
710            None
711        );
712        assert_eq!(
713            scope.qualified_column_qualifier("outer_ref"),
714            Some("outer_ref".to_string()),
715            "a qualifier naming no relation here is kept"
716        );
717    }
718
719    #[test]
720    fn nested_scope_does_not_inherit_join_row_naming() {
721        let outer = join_scope(vec![
722            ("a", strict_collection("articles", vec!["id", "title"])),
723            ("o", strict_collection("orders", vec!["id", "amount"])),
724        ]);
725        let inner = scope_with(strict_collection("tags", vec!["tag"])).nested_in(outer);
726        assert_eq!(inner.column_naming(), ColumnNaming::AsWritten);
727        assert!(matches!(
728            inner.resolve_bare("amount").expect("resolves"),
729            BareColumn::Outer
730        ));
731    }
732
733    #[test]
734    fn using_column_must_exist_on_both_sides() {
735        let scope = join_scope(vec![
736            ("a", strict_collection("articles", vec!["id", "title"])),
737            ("o", strict_collection("orders", vec!["id", "amount"])),
738        ]);
739        scope
740            .check_using_column(Some("o"), "id")
741            .expect("both sides carry id");
742        assert!(scope.check_using_column(Some("o"), "title").is_err());
743        assert!(scope.check_using_column(Some("o"), "amount").is_err());
744    }
745
746    #[test]
747    fn using_column_over_two_schemaless_relations_is_not_ambiguous() {
748        let scope = join_scope(vec![
749            ("e", open_collection("events")),
750            ("d", open_collection("devices")),
751        ]);
752        scope
753            .check_using_column(Some("d"), "device_id")
754            .expect("USING names the column on both sides");
755    }
756
757    /// A double-quoted identifier resolves as a column name.
758    /// `"userId"` is parsed by the SQL layer as `Expr::Identifier` with
759    /// `quote_style = Some('"')` and `value = "userId"`. At the `TableScope`
760    /// level the column name arrives lowercase, because strict schema columns
761    /// are stored lowercase.
762    ///
763    /// This test covers the resolution path, not just `convert_expr`.
764    #[test]
765    fn quoted_identifier_resolves_as_column() {
766        let scope = scope_with(strict_collection("users", vec!["userid", "email"]));
767        scope.check_name(None, "userid").expect("must resolve");
768    }
769
770    /// An unrecognized column in a strict collection yields
771    /// `SqlError::UnknownColumn`, never `SqlError::Unsupported`.
772    ///
773    /// A double-quoted identifier like `"ghost_col"` maps to
774    /// `SqlExpr::Column { name: "ghost_col" }`. Resolving it against a strict
775    /// schema must name the missing column.
776    #[test]
777    fn unknown_column_in_strict_collection_yields_unknown_column_error() {
778        let scope = scope_with(strict_collection("users", vec!["id", "email"]));
779        let err = scope
780            .check_name(None, "ghost_col")
781            .expect_err("must reject an unknown column");
782        assert!(
783            matches!(err, SqlError::UnknownColumn { ref column, .. } if column == "ghost_col"),
784            "expected UnknownColumn(ghost_col), got {err:?}"
785        );
786        // Unsupported is the wrong error variant here.
787        assert!(
788            !matches!(err, SqlError::Unsupported { .. }),
789            "must not surface Unsupported for a missing column"
790        );
791    }
792
793    /// Schemaless collections accept any column, including ones that look
794    /// like they could be misidentified double-quoted identifiers.
795    #[test]
796    fn any_column_accepted_in_schemaless_collection() {
797        let scope = open_scope("events");
798        scope
799            .check_name(None, "ghost_col")
800            .expect("a schemaless relation must accept any column");
801    }
802
803    /// Qualified column reference: `"t"."col"` → table `t`, column `col`.
804    #[test]
805    fn qualified_column_resolves_correctly() {
806        let scope = scope_with(strict_collection("t", vec!["col", "other"]));
807        scope
808            .check_name(Some("t"), "col")
809            .expect("a qualified column must resolve");
810    }
811
812    /// Qualified reference to an unknown column in a strict collection must
813    /// yield `SqlError::UnknownColumn`, not `Unsupported`.
814    #[test]
815    fn qualified_unknown_column_in_strict_collection() {
816        let scope = scope_with(strict_collection("t", vec!["id"]));
817        let err = scope
818            .check_name(Some("t"), "missing")
819            .expect_err("must reject an unknown column");
820        assert!(
821            matches!(err, SqlError::UnknownColumn { .. }),
822            "expected UnknownColumn, got {err:?}"
823        );
824    }
825}