use std::collections::HashMap;
use nodedb_types::DatabaseId;
use crate::error::{Result, SqlError};
use crate::functions::registry::FunctionRegistry;
use crate::parser::normalize::table_name_from_factor;
use crate::temporal::TemporalScope;
use crate::types::{CollectionInfo, ColumnInfo, EngineType, SqlCatalog};
pub use super::column_naming::{BareColumn, ColumnNaming};
const BITEMPORAL_AUDIT_COLUMNS: [&str; 3] = ["_ts_system", "_ts_valid_from", "_ts_valid_until"];
#[derive(Debug, Clone)]
pub struct ResolvedTable {
pub name: String,
pub alias: Option<String>,
pub info: CollectionInfo,
}
impl ResolvedTable {
pub fn ref_name(&self) -> &str {
self.alias.as_deref().unwrap_or(&self.name)
}
pub fn row_qualifier(&self) -> Option<&str> {
match self.info.engine {
EngineType::Array => None,
EngineType::DocumentSchemaless
| EngineType::DocumentStrict
| EngineType::KeyValue
| EngineType::Columnar
| EngineType::Timeseries
| EngineType::Spatial
| EngineType::ContinuousAggregate => Some(self.ref_name()),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct TableScope {
pub tables: HashMap<String, ResolvedTable>,
order: Vec<String>,
output_names: Vec<String>,
outer: Option<Box<TableScope>>,
qualified_only: Vec<String>,
statement_output: bool,
cp_functions: bool,
column_naming: ColumnNaming,
}
impl TableScope {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, table: ResolvedTable) -> Result<()> {
let key = table.ref_name().to_string();
if self.tables.contains_key(&key) {
return Err(SqlError::Parse {
detail: format!("duplicate table reference: {key}"),
});
}
self.order.push(key.clone());
self.tables.insert(key, table);
Ok(())
}
pub fn add_qualified_only(&mut self, table: ResolvedTable) -> Result<()> {
let key = table.ref_name().to_string();
self.add(table)?;
self.qualified_only.push(key);
Ok(())
}
fn column_exists(&self, table: &ResolvedTable, column: &str) -> bool {
if table.info.open_schema {
return true;
}
if table.info.bitemporal && BITEMPORAL_AUDIT_COLUMNS.contains(&column) {
return true;
}
let rules = crate::engine_rules::resolve_engine_rules(table.info.engine);
if rules.implicit_columns().contains(&column) {
return true;
}
if rules.value_column_name_is_free()
&& !table.info.columns.iter().any(|c| !c.is_primary_key)
{
return true;
}
table.info.columns.iter().any(|c| c.name == column)
}
fn validate_column(&self, table: &ResolvedTable, column: &str) -> Result<()> {
if self.column_exists(table, column) {
Ok(())
} else {
Err(SqlError::UnknownColumn {
table: table.name.clone(),
column: column.into(),
})
}
}
pub fn single_table(&self) -> Option<&ResolvedTable> {
if self.tables.len() == 1 {
self.tables.values().next()
} else {
Option::None
}
}
pub fn is_row_scope(&self) -> bool {
!self.tables.is_empty() || self.outer.is_some()
}
pub fn nested_in(mut self, outer: TableScope) -> Self {
self.outer = Some(Box::new(outer));
self.statement_output = false;
self.cp_functions = false;
self.column_naming = ColumnNaming::AsWritten;
self
}
pub fn as_statement_output(mut self) -> Self {
self.statement_output = true;
self
}
pub fn is_statement_output(&self) -> bool {
self.statement_output
}
pub fn allowing_cp_functions(mut self) -> Self {
self.cp_functions = true;
self
}
pub fn allows_cp_functions(&self) -> bool {
self.cp_functions
}
pub fn with_output_names(&self, names: impl IntoIterator<Item = String>) -> Self {
let mut out = self.clone();
out.output_names.extend(names);
out
}
pub fn single(table: ResolvedTable) -> Result<Self> {
let mut scope = Self::new();
scope.add(table)?;
Ok(scope)
}
pub fn check_name(&self, table_ref: Option<&str>, column: &str) -> Result<()> {
let col = column.to_lowercase();
if let Some(tref) = table_ref {
let tref_lower = tref.to_lowercase();
return match self.tables.get(&tref_lower) {
Some(table) => self.validate_column(table, &col),
None => match &self.outer {
Some(outer) => outer.check_name(Some(&tref_lower), &col),
None => Err(SqlError::UnknownTable { name: tref_lower }),
},
};
}
self.resolve_bare(&col).map(|_| ())
}
pub fn resolve_bare(&self, column: &str) -> Result<BareColumn<'_>> {
let col = column.to_lowercase();
if self.output_names.iter().any(|n| n == &col) {
return Ok(BareColumn::Output);
}
let bare: Vec<&ResolvedTable> = self
.order
.iter()
.filter(|key| !self.qualified_only.contains(*key))
.map(|key| &self.tables[key])
.collect();
let declared: Vec<&ResolvedTable> = bare
.iter()
.copied()
.filter(|table| table.info.columns.iter().any(|c| c.name == col))
.collect();
match declared.as_slice() {
&[table] => return Ok(BareColumn::Relation(table)),
[] => {}
_ => return Err(SqlError::AmbiguousColumn { column: col }),
}
let accepting: Vec<&ResolvedTable> = bare
.iter()
.copied()
.filter(|table| self.column_exists(table, &col))
.collect();
match accepting.as_slice() {
&[table] => Ok(BareColumn::Relation(table)),
[] => match &self.outer {
Some(outer) => outer.resolve_bare(&col).map(|_| BareColumn::Outer),
None => Err(SqlError::UnknownColumn {
table: self
.order
.first()
.cloned()
.unwrap_or_else(|| "<unknown>".into()),
column: col,
}),
},
_ => Err(SqlError::AmbiguousColumn { column: col }),
}
}
pub fn with_column_naming(mut self, naming: ColumnNaming) -> Self {
self.column_naming = naming;
self
}
pub fn column_naming(&self) -> ColumnNaming {
self.column_naming
}
pub fn check_using_column(&self, right_ref: Option<&str>, column: &str) -> Result<()> {
let col = column.to_lowercase();
let right_key = right_ref.map(str::to_lowercase);
let right = right_key.as_deref().and_then(|key| self.tables.get(key));
let carriers = self
.order
.iter()
.filter(|key| !self.qualified_only.contains(*key))
.filter(|key| self.column_exists(&self.tables[*key], &col));
let has_both = match (right, right_key.as_deref()) {
(Some(right), Some(key)) => {
self.column_exists(right, &col) && carriers.into_iter().any(|k| k != key)
}
_ => carriers.count() >= 2,
};
if has_both {
Ok(())
} else {
Err(SqlError::UnknownColumn {
table: right_key.unwrap_or_else(|| "<unknown>".into()),
column: col,
})
}
}
pub fn tables_in_order(&self) -> impl Iterator<Item = &ResolvedTable> {
self.order.iter().map(|key| &self.tables[key])
}
pub fn table_by_ref(&self, ref_name: &str) -> Option<&ResolvedTable> {
self.tables.get(&ref_name.to_lowercase())
}
pub fn declared_column(&self, table_ref: Option<&str>, column: &str) -> Option<&ColumnInfo> {
let col = column.to_lowercase();
match table_ref {
Some(tref) => self
.table_by_ref(tref)?
.info
.columns
.iter()
.find(|c| c.name == col),
None => self
.tables_in_order()
.find_map(|t| t.info.columns.iter().find(|c| c.name == col)),
}
}
pub fn resolve_from(
catalog: &dyn SqlCatalog,
functions: &FunctionRegistry,
temporal: TemporalScope,
from: &[sqlparser::ast::TableWithJoins],
) -> Result<Self> {
let mut scope = Self::new();
let from_names = crate::resolver::derived::from_relation_names(from);
let factors = from.iter().flat_map(|twj| {
std::iter::once(&twj.relation).chain(twj.joins.iter().map(|j| &j.relation))
});
for factor in factors {
scope
.resolve_table_factor(catalog, functions, temporal, factor)
.map_err(|e| {
crate::resolver::derived::refuse_correlated_derived(e, factor, &from_names)
})?;
}
Ok(scope)
}
fn resolve_table_factor(
&mut self,
catalog: &dyn SqlCatalog,
functions: &FunctionRegistry,
temporal: TemporalScope,
factor: &sqlparser::ast::TableFactor,
) -> Result<()> {
if let Some(resolved) = crate::resolver::array_tvf::resolve_array_tvf(catalog, factor)? {
self.add(resolved)?;
return Ok(());
}
if let sqlparser::ast::TableFactor::Derived {
lateral,
subquery,
alias: Some(alias),
..
} = factor
{
let alias_str = crate::reserved::check_ast_identifier(&alias.name)?;
let declared: Vec<String> = alias
.columns
.iter()
.map(|column| crate::reserved::check_ast_identifier(&column.name))
.collect::<Result<_>>()?;
let plan = if *lateral {
None
} else {
Some(crate::planner::select::plan_query(
subquery, catalog, functions, temporal,
)?)
};
let info = crate::resolver::derived::infer_subquery_relation(
catalog,
&alias_str,
subquery,
plan.as_ref(),
functions,
temporal,
)?;
self.add(ResolvedTable {
name: alias_str.clone(),
alias: Some(alias_str),
info: crate::resolver::derived::rename_output_columns(info, &declared),
})?;
return Ok(());
}
if let Some((name, alias)) = table_name_from_factor(factor)? {
let info = catalog
.resolve_relation(DatabaseId::DEFAULT, &name)?
.ok_or_else(|| SqlError::UnknownTable { name: name.clone() })?;
self.add(ResolvedTable { name, alias, info })?;
}
Ok(())
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::{ResolvedTable, TableScope};
use crate::types::{CollectionInfo, EngineType};
pub(crate) fn open_scope(collection: &str) -> TableScope {
let info = CollectionInfo {
name: collection.into(),
engine: EngineType::DocumentSchemaless,
columns: Vec::new(),
primary_key: None,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: false,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
};
TableScope::single(ResolvedTable {
name: info.name.clone(),
alias: None,
info,
})
.expect("single-relation scope")
}
}
#[cfg(test)]
mod tests {
use super::test_support::open_scope;
use super::*;
use crate::types::{CollectionInfo, ColumnInfo, EngineType, SqlDataType};
use nodedb_types::PrimaryEngine;
fn strict_collection(name: &str, columns: Vec<&str>) -> CollectionInfo {
CollectionInfo {
name: name.into(),
engine: EngineType::DocumentStrict,
columns: columns
.into_iter()
.map(|c| ColumnInfo {
name: c.into(),
data_type: SqlDataType::String,
nullable: true,
is_primary_key: false,
default: None,
raw_type: None,
int_width: None,
float_width: None,
})
.collect(),
primary_key: None,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: false,
primary: PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
}
}
fn scope_with(info: CollectionInfo) -> TableScope {
let mut scope = TableScope::new();
scope
.add(ResolvedTable {
name: info.name.clone(),
alias: None,
info,
})
.expect("add failed");
scope
}
fn open_collection(name: &str) -> CollectionInfo {
CollectionInfo {
engine: EngineType::DocumentSchemaless,
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
..strict_collection(name, Vec::new())
}
}
fn array_relation(name: &str, columns: Vec<&str>) -> CollectionInfo {
CollectionInfo {
engine: EngineType::Array,
open_schema: false,
..strict_collection(name, columns)
}
}
fn join_scope(relations: Vec<(&str, CollectionInfo)>) -> TableScope {
let mut scope = TableScope::new();
for (alias, info) in relations {
scope
.add(ResolvedTable {
name: info.name.clone(),
alias: Some(alias.into()),
info,
})
.expect("add failed");
}
scope.with_column_naming(ColumnNaming::JoinRow)
}
fn owner<'a>(scope: &'a TableScope, column: &str) -> Option<&'a str> {
match scope.resolve_bare(column).expect("resolves") {
BareColumn::Relation(table) => Some(table.ref_name()),
BareColumn::Output | BareColumn::Outer => None,
}
}
#[test]
fn column_declared_by_one_relation_resolves_there() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
]);
assert_eq!(owner(&scope, "title"), Some("a"));
assert_eq!(owner(&scope, "amount"), Some("o"));
}
#[test]
fn declared_relation_wins_over_a_schemaless_one() {
let scope = join_scope(vec![
("e", open_collection("events")),
("a", strict_collection("articles", vec!["id", "title"])),
]);
assert_eq!(owner(&scope, "title"), Some("a"));
}
#[test]
fn column_declared_by_two_relations_is_ambiguous() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("b", strict_collection("blogs", vec!["id", "title"])),
]);
let err = scope.resolve_bare("title").expect_err("ambiguous");
assert!(
matches!(err, SqlError::AmbiguousColumn { ref column } if column == "title"),
"{err:?}"
);
}
#[test]
fn undeclared_column_resolves_to_the_only_schemaless_relation() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("e", open_collection("events")),
]);
assert_eq!(owner(&scope, "payload"), Some("e"));
}
#[test]
fn undeclared_column_over_two_schemaless_relations_is_ambiguous() {
let scope = join_scope(vec![
("e", open_collection("events")),
("d", open_collection("devices")),
]);
let err = scope.resolve_bare("region").expect_err("ambiguous");
assert!(
matches!(err, SqlError::AmbiguousColumn { ref column } if column == "region"),
"{err:?}"
);
}
#[test]
fn column_no_relation_accepts_is_unknown() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
]);
let err = scope.resolve_bare("ghost").expect_err("unknown");
assert!(matches!(err, SqlError::UnknownColumn { .. }), "{err:?}");
}
#[test]
fn output_name_resolves_before_relations() {
let scope = join_scope(vec![
("e", open_collection("events")),
("d", open_collection("devices")),
])
.with_output_names(["total".to_string()]);
assert!(matches!(
scope.resolve_bare("total").expect("resolves"),
BareColumn::Output
));
assert_eq!(
scope.bare_column_qualifier("total").expect("resolves"),
None
);
}
#[test]
fn join_row_naming_qualifies_a_bare_column_with_its_owner() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("e", open_collection("events")),
]);
assert_eq!(
scope.bare_column_qualifier("title").expect("resolves"),
Some("a".to_string())
);
assert_eq!(
scope.bare_column_qualifier("payload").expect("resolves"),
Some("e".to_string())
);
}
#[test]
fn as_written_naming_checks_but_keeps_a_bare_column_bare() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
])
.with_column_naming(ColumnNaming::AsWritten);
assert_eq!(
scope.bare_column_qualifier("title").expect("resolves"),
None
);
assert!(
scope.bare_column_qualifier("id").is_err(),
"still ambiguous"
);
}
#[test]
fn array_relation_columns_carry_no_qualifier() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("g", array_relation("genome", vec!["chrom", "qual"])),
]);
assert_eq!(scope.bare_column_qualifier("qual").expect("resolves"), None);
assert_eq!(scope.qualified_column_qualifier("g"), None);
assert_eq!(scope.qualified_column_qualifier("a"), Some("a".to_string()));
}
#[test]
fn output_row_naming_drops_relation_qualifiers() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
])
.with_column_naming(ColumnNaming::OutputRow);
assert_eq!(scope.qualified_column_qualifier("a"), None);
assert_eq!(
scope.bare_column_qualifier("title").expect("resolves"),
None
);
assert_eq!(
scope.qualified_column_qualifier("outer_ref"),
Some("outer_ref".to_string()),
"a qualifier naming no relation here is kept"
);
}
#[test]
fn nested_scope_does_not_inherit_join_row_naming() {
let outer = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
]);
let inner = scope_with(strict_collection("tags", vec!["tag"])).nested_in(outer);
assert_eq!(inner.column_naming(), ColumnNaming::AsWritten);
assert!(matches!(
inner.resolve_bare("amount").expect("resolves"),
BareColumn::Outer
));
}
#[test]
fn using_column_must_exist_on_both_sides() {
let scope = join_scope(vec![
("a", strict_collection("articles", vec!["id", "title"])),
("o", strict_collection("orders", vec!["id", "amount"])),
]);
scope
.check_using_column(Some("o"), "id")
.expect("both sides carry id");
assert!(scope.check_using_column(Some("o"), "title").is_err());
assert!(scope.check_using_column(Some("o"), "amount").is_err());
}
#[test]
fn using_column_over_two_schemaless_relations_is_not_ambiguous() {
let scope = join_scope(vec![
("e", open_collection("events")),
("d", open_collection("devices")),
]);
scope
.check_using_column(Some("d"), "device_id")
.expect("USING names the column on both sides");
}
#[test]
fn quoted_identifier_resolves_as_column() {
let scope = scope_with(strict_collection("users", vec!["userid", "email"]));
scope.check_name(None, "userid").expect("must resolve");
}
#[test]
fn unknown_column_in_strict_collection_yields_unknown_column_error() {
let scope = scope_with(strict_collection("users", vec!["id", "email"]));
let err = scope
.check_name(None, "ghost_col")
.expect_err("must reject an unknown column");
assert!(
matches!(err, SqlError::UnknownColumn { ref column, .. } if column == "ghost_col"),
"expected UnknownColumn(ghost_col), got {err:?}"
);
assert!(
!matches!(err, SqlError::Unsupported { .. }),
"must not surface Unsupported for a missing column"
);
}
#[test]
fn any_column_accepted_in_schemaless_collection() {
let scope = open_scope("events");
scope
.check_name(None, "ghost_col")
.expect("a schemaless relation must accept any column");
}
#[test]
fn qualified_column_resolves_correctly() {
let scope = scope_with(strict_collection("t", vec!["col", "other"]));
scope
.check_name(Some("t"), "col")
.expect("a qualified column must resolve");
}
#[test]
fn qualified_unknown_column_in_strict_collection() {
let scope = scope_with(strict_collection("t", vec!["id"]));
let err = scope
.check_name(Some("t"), "missing")
.expect_err("must reject an unknown column");
assert!(
matches!(err, SqlError::UnknownColumn { .. }),
"expected UnknownColumn, got {err:?}"
);
}
}