Skip to main content

linkrs_query/
attached.rs

1//! Attached-database catalog.
2//!
3//! Tracks external data sources registered through `ATTACH DATABASE` for the
4//! lifetime of the process. Federation (cross-source query routing) is not
5//! implemented: the catalog records the attachment so clients and
6//! `SHOW ATTACHED DATABASES` can observe it, and qualified `alias.table`
7//! references are rejected with an actionable error (see
8//! [`qualified_reference_message`]) that points at `IMPORT DATABASE` or
9//! `LOAD FROM` materialization instead.
10//!
11//! The registry is process-global (a `OnceLock` static), not session-scoped.
12//! Migrating it to session scope requires threading a session-level registry
13//! through the query API into planning and execution, which is deferred until
14//! multi-tenant isolation is actually needed.
15
16use std::collections::HashMap;
17use std::sync::OnceLock;
18
19use parking_lot::RwLock;
20
21/// A single attached data source.
22#[derive(Debug, Clone, PartialEq)]
23pub struct AttachedDatabase {
24    /// Alias used to reference the source.
25    pub alias: String,
26    /// Source path as written in the `ATTACH` statement.
27    pub path: String,
28    /// Declared source type (`None` when omitted).
29    pub db_type: Option<String>,
30}
31
32impl AttachedDatabase {
33    /// Create an attachment record, trimming surrounding whitespace.
34    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
48/// Register a data source under `info.alias`.
49///
50/// Fails when the alias is already attached.
51pub 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
60/// Remove the data source registered under `alias`, returning it.
61///
62/// Fails when no database is attached under `alias`.
63pub 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
70/// List all attached data sources ordered by alias.
71pub 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
77/// Remove all attachments. Intended for tests.
78pub fn clear_attached_databases() {
79    registry().write().clear();
80}
81
82/// Check whether `alias` currently names an attached database.
83pub fn is_attached(alias: &str) -> bool {
84    registry().read().contains_key(alias)
85}
86
87/// Build the error message for a qualified `alias.table` reference.
88///
89/// Attached aliases get a catalog-only hint pointing at materialization
90/// (`IMPORT DATABASE` / `LOAD FROM`); unknown aliases get a plain
91/// unsupported-qualified-name message so typos fail loudly.
92pub 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}