Skip to main content

diesel/sqlite/connection/
attach.rs

1use super::SqliteConnection;
2use crate::query_builder::{AstPass, QueryFragment, QueryId};
3use crate::query_dsl::RunQueryDslSupport;
4use crate::result::QueryResult;
5use crate::sqlite::Sqlite;
6use alloc::ffi::CString;
7
8impl SqliteConnection {
9    /// Attach the database file at `path` under `schema_name`.
10    ///
11    /// Runs [`ATTACH DATABASE ? AS ?`](https://www.sqlite.org/lang_attach.html) with
12    /// both operands bound as parameters, so no SQL string escaping is needed.
13    /// Diesel always opens connections with `SQLITE_OPEN_URI`, so a path beginning
14    /// with `file:` is interpreted as a URI exactly as it is in
15    /// [`SqliteConnection::establish`](crate::connection::Connection::establish).
16    ///
17    /// A missing file is created as an empty database unless
18    /// [`set_attach_create_enabled(false)`][Self::set_attach_create_enabled] is set,
19    /// and [`set_attach_write_enabled(false)`][Self::set_attach_write_enabled] attaches
20    /// read-only. The attach count is bounded (10 by default). A transaction across the
21    /// main and an attached file is crash-atomic per file only under WAL.
22    ///
23    /// # Example
24    ///
25    /// ```rust
26    /// # include!("../../doctest_setup.rs");
27    /// #
28    /// # fn main() {
29    /// #     run_test().unwrap();
30    /// # }
31    /// #
32    /// # fn run_test() -> QueryResult<()> {
33    /// #     let conn = &mut SqliteConnection::establish(":memory:").unwrap();
34    /// conn.attach_database(":memory:", "aux")?;
35    /// conn.detach_database("aux")?;
36    /// #     Ok(())
37    /// # }
38    /// ```
39    pub fn attach_database(&mut self, path: &str, schema_name: &str) -> QueryResult<()> {
40        use crate::query_dsl::RunQueryDsl;
41        CString::new(path)?;
42        CString::new(schema_name)?;
43        AttachDatabase { path, schema_name }
44            .execute(self)
45            .map(|_| ())
46    }
47
48    /// Detach the database previously attached under `schema_name`.
49    ///
50    /// Runs [`DETACH DATABASE ?`](https://www.sqlite.org/lang_detach.html) with the
51    /// schema name bound as a parameter. Detaching a schema still in use fails with an
52    /// ordinary error.
53    pub fn detach_database(&mut self, schema_name: &str) -> QueryResult<()> {
54        use crate::query_dsl::RunQueryDsl;
55        CString::new(schema_name)?;
56        DetachDatabase { schema_name }.execute(self).map(|_| ())
57    }
58}
59
60#[derive(const _: () =
    {
        use diesel;
        #[allow(non_camel_case_types)]
        impl<'a> diesel::query_builder::QueryId for AttachDatabase<'a> {
            type QueryId = AttachDatabase<'static>;
            const HAS_STATIC_QUERY_ID: bool = true;
            const IS_WINDOW_FUNCTION: bool = false;
        }
    };QueryId)]
61struct AttachDatabase<'a> {
62    path: &'a str,
63    schema_name: &'a str,
64}
65
66impl QueryFragment<Sqlite> for AttachDatabase<'_> {
67    fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Sqlite>) -> QueryResult<()> {
68        out.push_sql("ATTACH DATABASE ");
69        out.push_bind_param::<crate::sql_types::Text, _>(self.path)?;
70        out.push_sql(" AS ");
71        out.push_bind_param::<crate::sql_types::Text, _>(self.schema_name)?;
72        Ok(())
73    }
74}
75
76impl RunQueryDslSupport for AttachDatabase<'_> {}
77
78#[derive(const _: () =
    {
        use diesel;
        #[allow(non_camel_case_types)]
        impl<'a> diesel::query_builder::QueryId for DetachDatabase<'a> {
            type QueryId = DetachDatabase<'static>;
            const HAS_STATIC_QUERY_ID: bool = true;
            const IS_WINDOW_FUNCTION: bool = false;
        }
    };QueryId)]
79struct DetachDatabase<'a> {
80    schema_name: &'a str,
81}
82
83impl QueryFragment<Sqlite> for DetachDatabase<'_> {
84    fn walk_ast<'b>(&'b self, mut out: AstPass<'_, 'b, Sqlite>) -> QueryResult<()> {
85        out.push_sql("DETACH DATABASE ");
86        out.push_bind_param::<crate::sql_types::Text, _>(self.schema_name)?;
87        Ok(())
88    }
89}
90
91impl RunQueryDslSupport for DetachDatabase<'_> {}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::dsl::sql;
97    use crate::prelude::*;
98    use crate::result::Error;
99    use crate::sql_types::Integer;
100
101    fn connection() -> SqliteConnection {
102        SqliteConnection::establish(":memory:").unwrap()
103    }
104
105    // These ATTACH tests need a real filesystem (temp files), which is not
106    // available on the wasm target, where SQLite is in-memory only.
107    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
108    fn temp_db_path(name: &str) -> (tempfile::TempDir, std::path::PathBuf) {
109        let dir = tempfile::tempdir().unwrap();
110        let path = dir.path().join(name);
111        (dir, path)
112    }
113
114    // Tables used by the ATTACH round-trip tests below. Their CREATE statements are
115    // DDL (raw SQL), but rows and reads go through the typed query DSL.
116    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
117    table! {
118        attach_owners (id) {
119            id -> Integer,
120            name -> Text,
121        }
122    }
123    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
124    table! {
125        aux.attach_pets (id) {
126            id -> Integer,
127            owner_id -> Integer,
128            name -> Text,
129        }
130    }
131    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
132    allow_tables_to_appear_in_same_query!(attach_owners, attach_pets);
133    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
134    table! {
135        attach_marker (id) {
136            id -> Integer,
137        }
138    }
139    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
140    table! {
141        ro.readonly_marker (id) {
142            id -> Integer,
143        }
144    }
145
146    // no miri as this returns a string
147    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
148    #[diesel_test_helper::test]
149    fn attach_database_supports_cross_schema_join_then_detach() {
150        use crate::connection::SimpleConnection;
151        let conn = &mut connection();
152
153        conn.attach_database(":memory:", "aux").unwrap();
154
155        // Schema-qualified CREATE is DDL and stays raw SQL. The rows and the join
156        // below use the typed query DSL.
157        conn.batch_execute(
158            "CREATE TABLE attach_owners (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
159             CREATE TABLE aux.attach_pets (id INTEGER PRIMARY KEY, owner_id INTEGER, name TEXT NOT NULL);",
160        )
161        .unwrap();
162
163        crate::insert_into(attach_owners::table)
164            .values(&[
165                (attach_owners::id.eq(1), attach_owners::name.eq("Sean")),
166                (attach_owners::id.eq(2), attach_owners::name.eq("Tess")),
167            ])
168            .execute(conn)
169            .unwrap();
170        crate::insert_into(attach_pets::table)
171            .values((
172                attach_pets::id.eq(1),
173                attach_pets::owner_id.eq(1),
174                attach_pets::name.eq("Ferris"),
175            ))
176            .execute(conn)
177            .unwrap();
178
179        let pet_owner = attach_owners::table
180            .inner_join(attach_pets::table.on(attach_pets::owner_id.eq(attach_owners::id)))
181            .filter(attach_pets::name.eq("Ferris"))
182            .select(attach_owners::name)
183            .get_result::<String>(conn)
184            .unwrap();
185        assert_eq!(pet_owner, "Sean");
186
187        conn.detach_database("aux").unwrap();
188
189        // The attached schema is gone, so querying it now fails.
190        assert!(
191            attach_pets::table
192                .select(attach_pets::name)
193                .get_result::<String>(conn)
194                .is_err()
195        );
196    }
197
198    // no miri, as that requires a fs call
199    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
200    #[diesel_test_helper::test]
201    fn attach_database_binds_path_verbatim_without_quoting() {
202        // A single quote in the path would break a hand-assembled ATTACH statement.
203        // Bound parameters take the path verbatim.
204        let (_dir, path) = temp_db_path("o'brien.db");
205
206        conn_attach_roundtrip(&path);
207
208        // The table created through ATTACH persisted to the literal file, so a
209        // fresh connection to that exact path can read it.
210        let mut direct = SqliteConnection::establish(path.to_str().unwrap()).unwrap();
211        let count = attach_marker::table
212            .count()
213            .get_result::<i64>(&mut direct)
214            .unwrap();
215        assert_eq!(count, 0);
216    }
217
218    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
219    fn conn_attach_roundtrip(path: &std::path::Path) {
220        use crate::connection::SimpleConnection;
221        let conn = &mut connection();
222        conn.attach_database(path.to_str().unwrap(), "verbatim")
223            .unwrap();
224        conn.batch_execute("CREATE TABLE verbatim.attach_marker (id INTEGER PRIMARY KEY)")
225            .unwrap();
226        conn.detach_database("verbatim").unwrap();
227    }
228
229    // no miri as that requires a fs call
230    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
231    #[diesel_test_helper::test]
232    fn attach_database_interprets_file_uri_query_parameters() {
233        // Seed a database with a row to read through the attached schema.
234        let (_dir, path) = temp_db_path("uri_seed.db");
235        {
236            let mut seed = SqliteConnection::establish(path.to_str().unwrap()).unwrap();
237            crate::sql_query("CREATE TABLE t (id INTEGER PRIMARY KEY)")
238                .execute(&mut seed)
239                .unwrap();
240            crate::sql_query("INSERT INTO t (id) VALUES (1)")
241                .execute(&mut seed)
242                .unwrap();
243        }
244
245        // Attach with a `file:` URI and `mode=ro`. If SQLite treated the bound
246        // string as a literal filename it would fail to find the file; interpreting
247        // it as a URI opens the real file in read-only mode instead.
248        let uri = format!("file:{}?mode=ro", path.display());
249        let conn = &mut connection();
250        conn.attach_database(&uri, "ro_schema").unwrap();
251
252        // Read from the attached schema: proves the ATTACH opened a real file.
253        let id: i64 = sql::<crate::sql_types::BigInt>("SELECT id FROM ro_schema.t")
254            .get_result(conn)
255            .unwrap();
256        assert_eq!(id, 1);
257
258        // Write fails: the URI `mode=ro` parameter took effect.
259        assert!(
260            crate::sql_query("INSERT INTO ro_schema.t (id) VALUES (2)")
261                .execute(conn)
262                .is_err()
263        );
264
265        conn.detach_database("ro_schema").unwrap();
266    }
267
268    #[diesel_test_helper::test]
269    #[cfg(not(miri))] // ffi string access
270    fn attach_and_detach_surface_errors_without_panicking() {
271        let conn = &mut connection();
272
273        // A duplicate schema name on attach is an error, not a panic.
274        conn.attach_database(":memory:", "dup").unwrap();
275        assert!(conn.attach_database(":memory:", "dup").is_err());
276        conn.detach_database("dup").unwrap();
277
278        // Detaching an unknown schema is likewise an error. Detaching one still in
279        // use cannot occur through the safe API: an in-flight iterator holds
280        // `&mut conn`, so no detach can overlap it.
281        assert!(conn.detach_database("never_attached").is_err());
282    }
283
284    #[diesel_test_helper::test]
285    fn attach_database_binds_schema_name_verbatim_without_identifier_quoting() {
286        use crate::connection::SimpleConnection;
287        let conn = &mut connection();
288
289        // A space or quote in the schema name would need identifier quoting in a
290        // hand-assembled statement. Bound as a parameter it is taken verbatim. The
291        // reference below stays raw SQL: such a schema is not expressible via `table!`.
292        let schema = "weird 'schema";
293        conn.attach_database(":memory:", schema).unwrap();
294
295        conn.batch_execute(
296            r#"CREATE TABLE "weird 'schema".t (id INTEGER PRIMARY KEY);
297             INSERT INTO "weird 'schema".t (id) VALUES (7);"#,
298        )
299        .unwrap();
300        let id = sql::<Integer>(r#"SELECT id FROM "weird 'schema".t"#)
301            .get_result::<i32>(conn)
302            .unwrap();
303        assert_eq!(id, 7);
304
305        conn.detach_database(schema).unwrap();
306    }
307
308    // no miri as that requires a fs call
309    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
310    #[diesel_test_helper::test]
311    fn attach_database_honors_create_and_write_hardening_knobs() {
312        use crate::connection::SimpleConnection;
313        let conn = &mut connection();
314
315        // ATTACH_CREATE and ATTACH_WRITE need SQLite 3.49.0+, so skip on older libraries.
316        if conn.set_attach_create_enabled(false).is_err() {
317            return;
318        }
319
320        // Create disabled: attaching a nonexistent path fails and materializes nothing.
321        let (_dir_missing, missing) = temp_db_path("nocreate.db");
322        assert!(
323            conn.attach_database(missing.to_str().unwrap(), "missing")
324                .is_err()
325        );
326        assert!(!missing.exists());
327
328        // Write disabled: an existing database attaches read-only, so writes fail.
329        conn.set_attach_write_enabled(false).unwrap();
330        let (_dir_existing, existing) = temp_db_path("readonly.db");
331        {
332            let mut seed = SqliteConnection::establish(existing.to_str().unwrap()).unwrap();
333            seed.batch_execute("CREATE TABLE readonly_marker (id INTEGER)")
334                .unwrap();
335        }
336        conn.attach_database(existing.to_str().unwrap(), "ro")
337            .unwrap();
338        assert!(
339            crate::insert_into(readonly_marker::table)
340                .values(readonly_marker::id.eq(1))
341                .execute(conn)
342                .is_err()
343        );
344        conn.detach_database("ro").unwrap();
345    }
346
347    // no miri as that requires a fs call
348    #[cfg(not(any(all(target_family = "wasm", target_os = "unknown"), miri)))]
349    #[diesel_test_helper::test]
350    fn attach_database_rejects_nul_in_path() {
351        let (_dir, path) = temp_db_path("nul-truncated.db");
352
353        let conn = &mut connection();
354        assert!(matches!(
355            conn.attach_database(&format!("{}\0ignored.db", path.display()), "nul"),
356            Err(Error::InvalidCString(_))
357        ));
358        assert!(!path.exists());
359    }
360
361    #[diesel_test_helper::test]
362    #[cfg(not(miri))] // ffi string access
363    fn attach_database_rejects_nul_in_schema_name() {
364        let conn = &mut connection();
365        assert!(matches!(
366            conn.attach_database(":memory:", "ab\0cd"),
367            Err(Error::InvalidCString(_))
368        ));
369        // Nothing was attached, so the truncated name must not be detachable.
370        assert!(conn.detach_database("ab").is_err());
371    }
372
373    #[diesel_test_helper::test]
374    fn detach_database_rejects_nul_in_schema_name() {
375        let conn = &mut connection();
376        assert!(matches!(
377            conn.detach_database("ab\0cd"),
378            Err(Error::InvalidCString(_))
379        ));
380    }
381}