use std::collections::HashMap;
use std::sync::OnceLock;
use parking_lot::RwLock;
#[derive(Debug, Clone, PartialEq)]
pub struct AttachedDatabase {
pub alias: String,
pub path: String,
pub db_type: Option<String>,
}
impl AttachedDatabase {
pub fn new(alias: String, path: String, db_type: Option<String>) -> Self {
Self {
alias,
path,
db_type,
}
}
}
fn registry() -> &'static RwLock<HashMap<String, AttachedDatabase>> {
static REGISTRY: OnceLock<RwLock<HashMap<String, AttachedDatabase>>> = OnceLock::new();
REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
}
pub fn attach_database(info: AttachedDatabase) -> Result<(), String> {
let mut guard = registry().write();
if guard.contains_key(&info.alias) {
return Err(format!("Database '{}' is already attached", info.alias));
}
guard.insert(info.alias.clone(), info);
Ok(())
}
pub fn detach_database(alias: &str) -> Result<AttachedDatabase, String> {
registry()
.write()
.remove(alias)
.ok_or_else(|| format!("No database attached as '{}'", alias))
}
pub fn list_attached_databases() -> Vec<AttachedDatabase> {
let mut entries: Vec<AttachedDatabase> = registry().read().values().cloned().collect();
entries.sort_by(|a, b| a.alias.cmp(&b.alias));
entries
}
pub fn clear_attached_databases() {
registry().write().clear();
}
pub fn is_attached(alias: &str) -> bool {
registry().read().contains_key(alias)
}
pub fn qualified_reference_message(alias: &str, table: &str) -> String {
if is_attached(alias) {
format!(
"Database '{alias}' is attached as catalog-only; cross-source queries are not yet supported. \
Use IMPORT DATABASE or LOAD FROM to materialize '{table}' into the current space"
)
} else {
format!(
"Qualified table name '{alias}.{table}' is not supported; use '{table}' within the current space \
(cross-source ATTACH queries are not yet implemented)"
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attach_lists_and_detaches_in_alias_order() {
clear_attached_databases();
attach_database(AttachedDatabase::new(
"b".to_string(),
"/tmp/b".to_string(),
None,
))
.expect("first attach succeeds");
attach_database(AttachedDatabase::new(
"a".to_string(),
"/tmp/a".to_string(),
Some("KUZU".to_string()),
))
.expect("second attach succeeds");
assert!(attach_database(AttachedDatabase::new(
"a".to_string(),
"/tmp/other".to_string(),
None,
))
.is_err());
let listed = list_attached_databases();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].alias, "a");
assert_eq!(listed[0].db_type.as_deref(), Some("KUZU"));
let removed = detach_database("a").expect("detach succeeds");
assert_eq!(removed.path, "/tmp/a");
assert!(detach_database("a").is_err());
clear_attached_databases();
assert!(list_attached_databases().is_empty());
}
#[test]
fn qualified_reference_message_hints_materialization_for_attached() {
clear_attached_databases();
attach_database(AttachedDatabase::new(
"analytics".to_string(),
"/tmp/analytics".to_string(),
None,
))
.expect("attach succeeds");
assert!(is_attached("analytics"));
assert!(!is_attached("ghost"));
let msg = qualified_reference_message("analytics", "Person");
assert!(msg.contains("catalog-only"));
assert!(msg.contains("IMPORT DATABASE"));
let unknown = qualified_reference_message("ghost", "Person");
assert!(unknown.contains("not supported"));
clear_attached_databases();
}
}