sqlite_vtable_opendal/backends/
dropbox.rs1use 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
14pub struct DropboxBackend {
34 access_token: String,
36 base_path: String,
38}
39
40impl DropboxBackend {
41 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 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 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 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 while let Some(entry) = lister.try_next().await? {
108 let entry_path = entry.path();
109 let entry_mode = entry.metadata().mode();
110
111 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 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 let metadata = operator
137 .stat(&full_path)
138 .await
139 ?;
140
141 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 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 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 if let Some(limit) = config.limit {
187 if results.len() >= limit + config.offset {
188 break;
189 }
190 }
191 }
192 }
193
194 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
207pub 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 #[repr(C)]
248 struct DropboxTable {
249 base: ffi::sqlite3_vtab,
250 access_token: String,
251 base_path: String,
252 }
253
254 #[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 let backend = DropboxBackend::new(&self.access_token, &self.base_path);
285 let config = QueryConfig::default();
286
287 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 }