Skip to main content

sqlite_vtable_opendal/backends/
dropbox.rs

1//! Dropbox storage backend implementation
2//!
3//! This backend allows querying Dropbox files and folders using SQL.
4//! Requires a Dropbox access token for authentication.
5
6use crate::backends::StorageBackend;
7use crate::error::Result;
8use crate::types::{FileMetadata, QueryConfig};
9use async_trait::async_trait;
10use futures_util::TryStreamExt;
11use opendal::{services::Dropbox, EntryMode, Operator};
12use std::path::Path;
13
14/// Dropbox storage backend
15///
16/// This backend uses OpenDAL's Dropbox service to list files from Dropbox.
17///
18/// # Authentication
19///
20/// Requires a Dropbox access token. You can obtain one from:
21/// https://www.dropbox.com/developers/apps
22///
23/// # Example
24///
25/// ```rust,ignore
26/// use sqlite_vtable_opendal::backends::dropbox::DropboxBackend;
27/// use sqlite_vtable_opendal::types::QueryConfig;
28///
29/// let backend = DropboxBackend::new("YOUR_ACCESS_TOKEN", "/");
30/// let config = QueryConfig::default();
31/// let files = backend.list_files(&config).await?;
32/// ```
33pub struct DropboxBackend {
34    /// Dropbox access token
35    access_token: String,
36    /// Base path in Dropbox (e.g., "/" for root)
37    base_path: String,
38}
39
40impl DropboxBackend {
41    /// Create a new Dropbox backend
42    ///
43    /// # Arguments
44    ///
45    /// * `access_token` - Dropbox API access token
46    /// * `base_path` - Base path to query from (e.g., "/" for root)
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// use sqlite_vtable_opendal::backends::dropbox::DropboxBackend;
52    ///
53    /// let backend = DropboxBackend::new("token", "/Documents");
54    /// ```
55    pub fn new(access_token: impl Into<String>, base_path: impl Into<String>) -> Self {
56        Self {
57            access_token: access_token.into(),
58            base_path: base_path.into(),
59        }
60    }
61
62    /// Create an OpenDAL operator for Dropbox
63    fn create_operator(&self) -> Result<Operator> {
64        let builder = Dropbox::default()
65            .access_token(&self.access_token)
66            .root(&self.base_path);
67
68        Ok(Operator::new(builder)?
69            .finish())
70    }
71}
72
73#[async_trait]
74impl StorageBackend for DropboxBackend {
75    async fn list_files(&self, config: &QueryConfig) -> Result<Vec<FileMetadata>> {
76        let operator = self.create_operator()?;
77        let mut results = Vec::new();
78
79        // Normalize the path
80        let normalized_path = if config.root_path.is_empty() || config.root_path == "/" {
81            "".to_string()
82        } else {
83            let clean_path = config.root_path.trim_matches('/');
84            if clean_path.is_empty() {
85                "".to_string()
86            } else {
87                format!("/{}", clean_path)
88            }
89        };
90
91        // Create lister
92        let mut lister = operator
93            .lister_with(&normalized_path)
94            .recursive(config.recursive)
95            .await?;
96
97        // Iterate through entries
98        while let Some(entry) = lister.try_next().await? {
99            let entry_path = entry.path();
100            let entry_mode = entry.metadata().mode();
101
102            // Skip the root directory itself
103            if entry_path.is_empty() || entry_path == "/" || entry_path == "." {
104                continue;
105            }
106
107            let full_path = if entry_path.starts_with('/') {
108                entry_path.to_string()
109            } else {
110                format!("/{}", entry_path)
111            };
112
113            // Extract file name from path
114            let name = Path::new(&full_path)
115                .file_name()
116                .map(|s| s.to_string_lossy().to_string())
117                .unwrap_or_else(|| {
118                    let clean_path = entry_path.trim_end_matches('/');
119                    Path::new(clean_path)
120                        .file_name()
121                        .map(|s| s.to_string_lossy().to_string())
122                        .unwrap_or_default()
123                });
124
125            if entry_mode == EntryMode::FILE {
126                // Fetch detailed metadata for files
127                let metadata = operator
128                    .stat(&full_path)
129                    .await
130                    ?;
131
132                // Optionally fetch content
133                let content = if config.fetch_content {
134                    operator
135                        .read(&full_path)
136                        .await
137                        .ok()
138                        .map(|bytes| bytes.to_vec())
139                } else {
140                    None
141                };
142
143                results.push(FileMetadata {
144                    name,
145                    path: full_path.clone(),
146                    size: metadata.content_length(),
147                    last_modified: metadata.last_modified().map(|dt| dt.to_string()),
148                    etag: metadata.content_md5().map(|md5| md5.to_string()),
149                    is_dir: false,
150                    content_type: Path::new(&full_path)
151                        .extension()
152                        .and_then(|ext| ext.to_str())
153                        .map(|ext| ext.to_string()),
154                    content,
155                });
156
157                // Apply limit if specified
158                if let Some(limit) = config.limit {
159                    if results.len() >= limit + config.offset {
160                        break;
161                    }
162                }
163            } else if entry_mode == EntryMode::DIR {
164                // Add directory entry
165                results.push(FileMetadata {
166                    name,
167                    path: full_path,
168                    size: 0,
169                    last_modified: None,
170                    etag: None,
171                    is_dir: true,
172                    content_type: Some("directory".to_string()),
173                    content: None,
174                });
175
176                // Apply limit if specified
177                if let Some(limit) = config.limit {
178                    if results.len() >= limit + config.offset {
179                        break;
180                    }
181                }
182            }
183        }
184
185        // Apply offset
186        if config.offset > 0 && config.offset < results.len() {
187            results = results.into_iter().skip(config.offset).collect();
188        }
189
190        Ok(results)
191    }
192
193    fn backend_name(&self) -> &'static str {
194        "dropbox"
195    }
196}
197
198/// Register the dropbox virtual table with SQLite
199///
200/// This function creates a virtual table module that allows querying
201/// Dropbox files using SQL.
202///
203/// # Arguments
204///
205/// * `conn` - SQLite connection
206/// * `module_name` - Name for the virtual table (e.g., "dropbox_files")
207/// * `access_token` - Dropbox access token
208/// * `base_path` - Base path in Dropbox (e.g., "/" for root)
209///
210/// # Example
211///
212/// ```rust,ignore
213/// use rusqlite::Connection;
214/// use sqlite_vtable_opendal::backends::dropbox;
215///
216/// let conn = Connection::open_in_memory()?;
217/// dropbox::register(&conn, "dropbox_files", "YOUR_TOKEN", "/")?;
218///
219/// // Now you can query: SELECT * FROM dropbox_files
220/// ```
221pub fn register(
222    conn: &rusqlite::Connection,
223    module_name: &str,
224    access_token: impl Into<String>,
225    base_path: impl Into<String>,
226) -> rusqlite::Result<()> {
227    use crate::types::{columns, QueryConfig};
228    use rusqlite::{
229        ffi,
230        vtab::{self, eponymous_only_module, IndexInfo, VTab, VTabCursor, VTabKind},
231    };
232    use std::os::raw::c_int;
233
234    let token = access_token.into();
235    let path = base_path.into();
236
237    // Create a specific table type for Dropbox
238    #[repr(C)]
239    struct DropboxTable {
240        base: ffi::sqlite3_vtab,
241        access_token: String,
242        base_path: String,
243    }
244
245    // Create a specific cursor type for Dropbox
246    #[repr(C)]
247    struct DropboxCursor {
248        base: ffi::sqlite3_vtab_cursor,
249        files: Vec<crate::types::FileMetadata>,
250        current_row: usize,
251        access_token: String,
252        base_path: String,
253    }
254
255    impl DropboxCursor {
256        fn new(access_token: String, base_path: String) -> Self {
257            Self {
258                base: ffi::sqlite3_vtab_cursor::default(),
259                files: Vec::new(),
260                current_row: 0,
261                access_token,
262                base_path,
263            }
264        }
265    }
266
267    unsafe impl VTabCursor for DropboxCursor {
268        fn filter(
269            &mut self,
270            _idx_num: c_int,
271            _idx_str: Option<&str>,
272            _args: &vtab::Filters<'_>,
273        ) -> rusqlite::Result<()> {
274            // Create backend and fetch files
275            let backend = DropboxBackend::new(&self.access_token, &self.base_path);
276            let config = QueryConfig::default();
277
278            // Fetch files from the backend (blocking the async call)
279            let files = tokio::task::block_in_place(|| {
280                tokio::runtime::Handle::current().block_on(async {
281                    backend.list_files(&config).await
282                })
283            })
284            .map_err(|e| rusqlite::Error::ModuleError(e.to_string()))?;
285
286            self.files = files;
287            self.current_row = 0;
288            Ok(())
289        }
290
291        fn next(&mut self) -> rusqlite::Result<()> {
292            self.current_row += 1;
293            Ok(())
294        }
295
296        fn eof(&self) -> bool {
297            self.current_row >= self.files.len()
298        }
299
300        fn column(&self, ctx: &mut vtab::Context, col_index: c_int) -> rusqlite::Result<()> {
301            if self.current_row >= self.files.len() {
302                return Ok(());
303            }
304
305            let file = &self.files[self.current_row];
306
307            match col_index {
308                columns::PATH => ctx.set_result(&file.path),
309                columns::SIZE => ctx.set_result(&(file.size as i64)),
310                columns::LAST_MODIFIED => ctx.set_result(&file.last_modified),
311                columns::ETAG => ctx.set_result(&file.etag),
312                columns::IS_DIR => ctx.set_result(&file.is_dir),
313                columns::CONTENT_TYPE => ctx.set_result(&file.content_type),
314                columns::NAME => ctx.set_result(&file.name),
315                columns::CONTENT => {
316                    if let Some(ref content) = file.content {
317                        ctx.set_result(&content.as_slice())
318                    } else {
319                        ctx.set_result::<Option<&[u8]>>(&None)
320                    }
321                }
322                _ => Ok(()),
323            }
324        }
325
326        fn rowid(&self) -> rusqlite::Result<i64> {
327            Ok(self.current_row as i64)
328        }
329    }
330
331    impl vtab::CreateVTab<'_> for DropboxTable {
332        const KIND: VTabKind = VTabKind::EponymousOnly;
333    }
334
335    unsafe impl VTab<'_> for DropboxTable {
336        type Aux = (String, String);
337        type Cursor = DropboxCursor;
338
339        fn connect(
340            _db: &mut vtab::VTabConnection,
341            aux: Option<&Self::Aux>,
342            _args: &[&[u8]],
343        ) -> rusqlite::Result<(String, Self)> {
344            let schema = "
345                CREATE TABLE x(
346                    path TEXT,
347                    size INTEGER,
348                    last_modified TEXT,
349                    etag TEXT,
350                    is_dir INTEGER,
351                    content_type TEXT,
352                    name TEXT,
353                    content BLOB
354                )
355            ";
356
357            let (access_token, base_path) = if let Some((token, path)) = aux {
358                (token.clone(), path.clone())
359            } else {
360                ("/".to_string(), "/".to_string())
361            };
362
363            Ok((
364                schema.to_owned(),
365                DropboxTable {
366                    base: ffi::sqlite3_vtab::default(),
367                    access_token,
368                    base_path,
369                },
370            ))
371        }
372
373        fn best_index(&self, info: &mut IndexInfo) -> rusqlite::Result<()> {
374            info.set_estimated_cost(1000.0);
375            Ok(())
376        }
377
378        fn open(&mut self) -> rusqlite::Result<Self::Cursor> {
379            Ok(DropboxCursor::new(
380                self.access_token.clone(),
381                self.base_path.clone(),
382            ))
383        }
384    }
385
386    conn.create_module(
387        module_name,
388        eponymous_only_module::<DropboxTable>(),
389        Some((token, path)),
390    )
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_backend_creation() {
399        let backend = DropboxBackend::new("test_token", "/Documents");
400        assert_eq!(backend.access_token, "test_token");
401        assert_eq!(backend.base_path, "/Documents");
402        assert_eq!(backend.backend_name(), "dropbox");
403    }
404
405    #[test]
406    fn test_backend_with_root_path() {
407        let backend = DropboxBackend::new("token", "/");
408        assert_eq!(backend.base_path, "/");
409    }
410
411    // Note: Integration tests with actual Dropbox API would require credentials
412    // and are better suited for manual testing or CI with secrets
413}