1use fsqlite_error::{FrankenError, Result};
8use tracing::{debug, info};
9
10pub const SQLITE_MAX_ATTACHED: usize = 10;
12
13#[derive(Debug, Clone)]
19pub struct AttachedDb {
20 pub schema: String,
22 pub path: String,
24}
25
26#[derive(Debug)]
35pub struct SchemaRegistry {
36 attached: Vec<AttachedDb>,
38}
39
40impl SchemaRegistry {
41 #[must_use]
43 pub fn new() -> Self {
44 Self {
45 attached: Vec::new(),
46 }
47 }
48
49 pub fn attach(&mut self, schema: String, path: String) -> Result<()> {
55 let lower = schema.to_ascii_lowercase();
56
57 if lower == "main" || lower == "temp" {
59 return Err(FrankenError::internal(format!(
60 "cannot attach with reserved schema name: {schema}"
61 )));
62 }
63
64 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 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 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 #[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 #[must_use]
133 pub fn count(&self) -> usize {
134 self.attached.len()
135 }
136
137 #[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 #[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#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[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]
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 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 assert!(reg.is_valid_schema("main"));
195 assert!(reg.is_valid_schema("temp"));
196 }
197
198 #[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]
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 let result = reg.attach("overflow".to_owned(), "/tmp/overflow.db".to_owned());
222 assert!(result.is_err());
223 }
224
225 #[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 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]
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]
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]
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}