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