1use std::collections::HashMap;
17use std::sync::OnceLock;
18
19use parking_lot::RwLock;
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct AttachedDatabase {
24 pub alias: String,
26 pub path: String,
28 pub db_type: Option<String>,
30}
31
32impl AttachedDatabase {
33 pub fn new(alias: String, path: String, db_type: Option<String>) -> Self {
35 Self {
36 alias,
37 path,
38 db_type,
39 }
40 }
41}
42
43fn registry() -> &'static RwLock<HashMap<String, AttachedDatabase>> {
44 static REGISTRY: OnceLock<RwLock<HashMap<String, AttachedDatabase>>> = OnceLock::new();
45 REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
46}
47
48pub fn attach_database(info: AttachedDatabase) -> Result<(), String> {
52 let mut guard = registry().write();
53 if guard.contains_key(&info.alias) {
54 return Err(format!("Database '{}' is already attached", info.alias));
55 }
56 guard.insert(info.alias.clone(), info);
57 Ok(())
58}
59
60pub fn detach_database(alias: &str) -> Result<AttachedDatabase, String> {
64 registry()
65 .write()
66 .remove(alias)
67 .ok_or_else(|| format!("No database attached as '{}'", alias))
68}
69
70pub fn list_attached_databases() -> Vec<AttachedDatabase> {
72 let mut entries: Vec<AttachedDatabase> = registry().read().values().cloned().collect();
73 entries.sort_by(|a, b| a.alias.cmp(&b.alias));
74 entries
75}
76
77pub fn clear_attached_databases() {
79 registry().write().clear();
80}
81
82pub fn is_attached(alias: &str) -> bool {
84 registry().read().contains_key(alias)
85}
86
87pub fn qualified_reference_message(alias: &str, table: &str) -> String {
93 if is_attached(alias) {
94 format!(
95 "Database '{alias}' is attached as catalog-only; cross-source queries are not yet supported. \
96 Use IMPORT DATABASE or LOAD FROM to materialize '{table}' into the current space"
97 )
98 } else {
99 format!(
100 "Qualified table name '{alias}.{table}' is not supported; use '{table}' within the current space \
101 (cross-source ATTACH queries are not yet implemented)"
102 )
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn attach_lists_and_detaches_in_alias_order() {
112 clear_attached_databases();
113 attach_database(AttachedDatabase::new(
114 "b".to_string(),
115 "/tmp/b".to_string(),
116 None,
117 ))
118 .expect("first attach succeeds");
119 attach_database(AttachedDatabase::new(
120 "a".to_string(),
121 "/tmp/a".to_string(),
122 Some("KUZU".to_string()),
123 ))
124 .expect("second attach succeeds");
125 assert!(attach_database(AttachedDatabase::new(
126 "a".to_string(),
127 "/tmp/other".to_string(),
128 None,
129 ))
130 .is_err());
131
132 let listed = list_attached_databases();
133 assert_eq!(listed.len(), 2);
134 assert_eq!(listed[0].alias, "a");
135 assert_eq!(listed[0].db_type.as_deref(), Some("KUZU"));
136
137 let removed = detach_database("a").expect("detach succeeds");
138 assert_eq!(removed.path, "/tmp/a");
139 assert!(detach_database("a").is_err());
140 clear_attached_databases();
141 assert!(list_attached_databases().is_empty());
142 }
143
144 #[test]
145 fn qualified_reference_message_hints_materialization_for_attached() {
146 clear_attached_databases();
147 attach_database(AttachedDatabase::new(
148 "analytics".to_string(),
149 "/tmp/analytics".to_string(),
150 None,
151 ))
152 .expect("attach succeeds");
153 assert!(is_attached("analytics"));
154 assert!(!is_attached("ghost"));
155 let msg = qualified_reference_message("analytics", "Person");
156 assert!(msg.contains("catalog-only"));
157 assert!(msg.contains("IMPORT DATABASE"));
158 let unknown = qualified_reference_message("ghost", "Person");
159 assert!(unknown.contains("not supported"));
160 clear_attached_databases();
161 }
162}