Skip to main content

fsqlite_core/
attach.rs

1//! ATTACH/DETACH database schema registry (ยง12.11, bd-7pxb).
2//!
3//! Each attached database gets a schema namespace. Tables are accessible as
4//! `schema-name.table-name`. The main database is always `main`, the temp
5//! database is always `temp`. Maximum 10 attached databases (`SQLITE_MAX_ATTACHED`).
6
7use fsqlite_error::{FrankenError, Result};
8use tracing::{debug, info};
9
10/// Maximum number of attached databases (not counting `main` and `temp`).
11pub const SQLITE_MAX_ATTACHED: usize = 10;
12
13// ---------------------------------------------------------------------------
14// Attached database entry
15// ---------------------------------------------------------------------------
16
17/// Metadata for a single attached database.
18#[derive(Debug, Clone)]
19pub struct AttachedDb {
20    /// Schema name (used in `schema.table` references).
21    pub schema: String,
22    /// File path or URI for the database file.
23    pub path: String,
24}
25
26// ---------------------------------------------------------------------------
27// Schema registry
28// ---------------------------------------------------------------------------
29
30/// Registry of attached databases for a connection.
31///
32/// The `main` and `temp` schemas are always present and cannot be detached.
33/// Up to `SQLITE_MAX_ATTACHED` additional databases can be attached.
34#[derive(Debug)]
35pub struct SchemaRegistry {
36    /// Additional attached databases (not including `main`/`temp`).
37    attached: Vec<AttachedDb>,
38}
39
40impl SchemaRegistry {
41    /// Create a new registry with only `main` and `temp`.
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            attached: Vec::new(),
46        }
47    }
48
49    /// Attach a database file with the given schema name.
50    ///
51    /// # Errors
52    /// Returns error if the name is already in use, or if the maximum number
53    /// of attached databases would be exceeded (invariant #8).
54    pub fn attach(&mut self, schema: String, path: String) -> Result<()> {
55        let lower = schema.to_ascii_lowercase();
56
57        // Cannot re-use reserved names.
58        if lower == "main" || lower == "temp" {
59            return Err(FrankenError::internal(format!(
60                "cannot attach with reserved schema name: {schema}"
61            )));
62        }
63
64        // Check for duplicate.
65        if self
66            .attached
67            .iter()
68            .any(|db| db.schema.eq_ignore_ascii_case(&schema))
69        {
70            return Err(FrankenError::internal(format!(
71                "database already attached with schema name: {schema}"
72            )));
73        }
74
75        // Enforce SQLITE_MAX_ATTACHED (invariant #8).
76        if self.attached.len() >= SQLITE_MAX_ATTACHED {
77            return Err(FrankenError::internal(format!(
78                "too many attached databases (max {SQLITE_MAX_ATTACHED})"
79            )));
80        }
81
82        info!(
83            schema = %schema,
84            path = %path,
85            "database attached"
86        );
87
88        self.attached.push(AttachedDb { schema, path });
89        Ok(())
90    }
91
92    /// Detach a database by schema name.
93    ///
94    /// # Errors
95    /// Returns error if the schema name is not found or is reserved.
96    pub fn detach(&mut self, schema: &str) -> Result<()> {
97        let lower = schema.to_ascii_lowercase();
98
99        if lower == "main" || lower == "temp" {
100            return Err(FrankenError::internal(format!(
101                "cannot detach reserved schema: {schema}"
102            )));
103        }
104
105        let pos = self
106            .attached
107            .iter()
108            .position(|db| db.schema.eq_ignore_ascii_case(schema))
109            .ok_or_else(|| FrankenError::internal(format!("no such database: {schema}")))?;
110
111        let removed = self.attached.remove(pos);
112        debug!(
113            schema = %removed.schema,
114            path = %removed.path,
115            "database detached"
116        );
117
118        Ok(())
119    }
120
121    /// Look up an attached database by schema name.
122    ///
123    /// Returns `None` for `main`/`temp` (they are implicit) and for unknown names.
124    #[must_use]
125    pub fn find(&self, schema: &str) -> Option<&AttachedDb> {
126        self.attached
127            .iter()
128            .find(|db| db.schema.eq_ignore_ascii_case(schema))
129    }
130
131    /// Number of attached databases (not counting `main`/`temp`).
132    #[must_use]
133    pub fn count(&self) -> usize {
134        self.attached.len()
135    }
136
137    /// Resolve a schema-qualified name. Returns `true` if the schema is
138    /// `main`, `temp`, or a currently attached database.
139    #[must_use]
140    pub fn is_valid_schema(&self, schema: &str) -> bool {
141        let lower = schema.to_ascii_lowercase();
142        lower == "main" || lower == "temp" || self.find(schema).is_some()
143    }
144
145    /// List all schema names (including `main` and `temp`).
146    #[must_use]
147    pub fn all_schemas(&self) -> Vec<&str> {
148        let mut names: Vec<&str> = vec!["main", "temp"];
149        for db in &self.attached {
150            names.push(&db.schema);
151        }
152        names
153    }
154}
155
156impl Default for SchemaRegistry {
157    fn default() -> Self {
158        Self::new()
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Tests
164// ---------------------------------------------------------------------------
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    // === Test 15: ATTACH creates accessible schema ===
171    #[test]
172    fn test_attach_database() {
173        let mut reg = SchemaRegistry::new();
174        reg.attach("aux".to_owned(), "/tmp/aux.db".to_owned())
175            .unwrap();
176        assert_eq!(reg.count(), 1);
177        assert!(reg.is_valid_schema("aux"));
178    }
179
180    // === Test 16: Schema-qualified access ===
181    #[test]
182    fn test_attach_schema_qualified_access() {
183        let mut reg = SchemaRegistry::new();
184        reg.attach("mydb".to_owned(), "/tmp/mydb.db".to_owned())
185            .unwrap();
186
187        // Schema is accessible.
188        assert!(reg.is_valid_schema("mydb"));
189        let db = reg.find("mydb").unwrap();
190        assert_eq!(db.schema, "mydb");
191        assert_eq!(db.path, "/tmp/mydb.db");
192
193        // Main and temp are always valid (invariant #9).
194        assert!(reg.is_valid_schema("main"));
195        assert!(reg.is_valid_schema("temp"));
196    }
197
198    // === Test 17: DETACH removes attached database ===
199    #[test]
200    fn test_detach_database() {
201        let mut reg = SchemaRegistry::new();
202        reg.attach("aux".to_owned(), "/tmp/aux.db".to_owned())
203            .unwrap();
204        assert_eq!(reg.count(), 1);
205        reg.detach("aux").unwrap();
206        assert_eq!(reg.count(), 0);
207        assert!(!reg.is_valid_schema("aux"));
208    }
209
210    // === Test 18: Cannot attach more than SQLITE_MAX_ATTACHED (invariant #8) ===
211    #[test]
212    fn test_attach_max_limit() {
213        let mut reg = SchemaRegistry::new();
214        for i in 0..SQLITE_MAX_ATTACHED {
215            reg.attach(format!("db{i}"), format!("/tmp/db{i}.db"))
216                .unwrap();
217        }
218        assert_eq!(reg.count(), SQLITE_MAX_ATTACHED);
219
220        // The 11th attach should fail.
221        let result = reg.attach("overflow".to_owned(), "/tmp/overflow.db".to_owned());
222        assert!(result.is_err());
223    }
224
225    // === Test 19: Cross-database transaction tracking ===
226    // Note: Full cross-database atomic WAL transactions via 2PC are
227    // covered in bd-d2m7. This test verifies multi-schema awareness.
228    #[test]
229    fn test_cross_database_transaction() {
230        let mut reg = SchemaRegistry::new();
231        reg.attach("aux1".to_owned(), "/tmp/aux1.db".to_owned())
232            .unwrap();
233        reg.attach("aux2".to_owned(), "/tmp/aux2.db".to_owned())
234            .unwrap();
235
236        // All schemas visible.
237        let schemas = reg.all_schemas();
238        assert!(schemas.contains(&"main"));
239        assert!(schemas.contains(&"temp"));
240        assert!(schemas.contains(&"aux1"));
241        assert!(schemas.contains(&"aux2"));
242    }
243
244    // === Test: Cannot detach main/temp ===
245    #[test]
246    fn test_cannot_detach_reserved() {
247        let mut reg = SchemaRegistry::new();
248        assert!(reg.detach("main").is_err());
249        assert!(reg.detach("temp").is_err());
250    }
251
252    // === Test: Cannot attach duplicate schema name ===
253    #[test]
254    fn test_attach_duplicate() {
255        let mut reg = SchemaRegistry::new();
256        reg.attach("aux".to_owned(), "/tmp/aux.db".to_owned())
257            .unwrap();
258        assert!(
259            reg.attach("aux".to_owned(), "/tmp/other.db".to_owned())
260                .is_err()
261        );
262    }
263
264    // === Test: Case-insensitive schema lookup ===
265    #[test]
266    fn test_schema_case_insensitive() {
267        let mut reg = SchemaRegistry::new();
268        reg.attach("MyDb".to_owned(), "/tmp/mydb.db".to_owned())
269            .unwrap();
270        assert!(reg.is_valid_schema("mydb"));
271        assert!(reg.is_valid_schema("MYDB"));
272        assert!(reg.is_valid_schema("MyDb"));
273    }
274}