vortex_io/object_store/
filesystem.rs1use std::fmt::Debug;
7use std::fmt::Formatter;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use futures::StreamExt;
12use futures::stream::BoxStream;
13use object_store::ObjectStore;
14use object_store::ObjectStoreExt;
15use object_store::path::Path;
16use vortex_error::VortexResult;
17use vortex_error::vortex_err;
18
19use crate::VortexReadAt;
20use crate::filesystem::FileListing;
21use crate::filesystem::FileSystem;
22use crate::object_store::ObjectStoreReadAt;
23use crate::runtime::Handle;
24
25pub struct ObjectStoreFileSystem {
29 store: Arc<dyn ObjectStore>,
30 handle: Handle,
31}
32
33impl Debug for ObjectStoreFileSystem {
34 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("ObjectStoreFileSystem")
36 .field("store", &self.store)
37 .finish()
38 }
39}
40
41impl ObjectStoreFileSystem {
42 pub fn new(store: Arc<dyn ObjectStore>, handle: Handle) -> Self {
44 Self { store, handle }
45 }
46
47 pub fn local(handle: Handle) -> Self {
50 Self::new(
51 Arc::new(object_store::local::LocalFileSystem::new()),
52 handle,
53 )
54 }
55}
56
57fn to_object_path(path: &str) -> Path {
69 Path::parse(path).unwrap_or_else(|_| Path::from(path))
70}
71
72fn listing_from_meta(location: &Path, size: u64) -> FileListing {
73 FileListing {
74 path: location.to_string(),
75 size: Some(size),
76 }
77}
78
79#[async_trait]
80impl FileSystem for ObjectStoreFileSystem {
81 fn list(&self, prefix: &str) -> BoxStream<'_, VortexResult<FileListing>> {
82 let path = if prefix.is_empty() {
83 None
84 } else {
85 Some(to_object_path(prefix))
86 };
87 self.store
88 .list(path.as_ref())
89 .map(|result| {
90 result
91 .map(|meta| listing_from_meta(&meta.location, meta.size))
92 .map_err(Into::into)
93 })
94 .boxed()
95 }
96
97 async fn head(&self, path: &str) -> VortexResult<Option<FileListing>> {
98 match self.store.head(&to_object_path(path)).await {
101 Ok(meta) => Ok(Some(listing_from_meta(&meta.location, meta.size))),
102 Err(object_store::Error::NotFound { .. }) => Ok(None),
103 Err(e) => Err(e.into()),
104 }
105 }
106
107 async fn open_read(&self, path: &str) -> VortexResult<Arc<dyn VortexReadAt>> {
108 Ok(Arc::new(ObjectStoreReadAt::new(
109 Arc::clone(&self.store),
110 to_object_path(path),
111 self.handle.clone(),
112 )))
113 }
114
115 async fn delete(&self, path: &str) -> VortexResult<()> {
116 self.store
117 .delete(
118 &Path::from_url_path(path)
119 .map_err(|_| vortex_err!("invalid path for url {path}"))?,
120 )
121 .await?;
122 Ok(())
123 }
124}
125
126#[cfg(test)]
129#[cfg(feature = "tokio")]
130mod tests {
131 use futures::TryStreamExt;
132 use object_store::ObjectStoreExt;
133 use object_store::local::LocalFileSystem;
134 use object_store::memory::InMemory;
135 use rstest::rstest;
136
137 use super::*;
138 use crate::filesystem::FileSystem;
139 use crate::runtime::Handle;
140
141 async fn memory_fs(files: &[(&str, usize)]) -> VortexResult<ObjectStoreFileSystem> {
146 let store = Arc::new(InMemory::new()) as Arc<dyn ObjectStore>;
147 for &(path, size) in files {
148 store
149 .put(&to_object_path(path), vec![0u8; size].into())
150 .await?;
151 }
152 let handle = Handle::find().expect("tokio runtime available within #[tokio::test]");
153 Ok(ObjectStoreFileSystem::new(store, handle))
154 }
155
156 #[tokio::test]
160 async fn test_glob_exact_existing_path() -> VortexResult<()> {
161 let fs = memory_fs(&[("data/file.vortex", 1024)]).await?;
162 let fs_dyn: &dyn FileSystem = &fs;
163 let results: Vec<FileListing> = fs_dyn.glob("data/file.vortex")?.try_collect().await?;
164 assert_eq!(results.len(), 1);
165 assert_eq!(results[0].path, "data/file.vortex");
166 assert_eq!(results[0].size, Some(1024));
167 Ok(())
168 }
169
170 #[tokio::test]
171 async fn test_glob_exact_missing_path_is_empty() -> VortexResult<()> {
172 let fs = memory_fs(&[("data/other.vortex", 1)]).await?;
173 let fs_dyn: &dyn FileSystem = &fs;
174 let results: Vec<FileListing> = fs_dyn.glob("data/missing.vortex")?.try_collect().await?;
175 assert!(results.is_empty());
176 Ok(())
177 }
178
179 #[tokio::test]
181 async fn test_glob_exact_path_ignores_prefix_siblings() -> VortexResult<()> {
182 let fs = memory_fs(&[("foo.vortex", 10), ("foo.vortex.backup", 20)]).await?;
183 let fs_dyn: &dyn FileSystem = &fs;
184 let results: Vec<FileListing> = fs_dyn.glob("foo.vortex")?.try_collect().await?;
185 assert_eq!(results.len(), 1);
186 assert_eq!(results[0].path, "foo.vortex");
187 assert_eq!(results[0].size, Some(10));
188 Ok(())
189 }
190
191 #[tokio::test]
197 #[rstest]
198 #[case::tilde("dir/a~b.vortex")]
199 #[case::percent("dir/a%20b.vortex")]
200 #[case::brackets("dir/a[1].vortex")]
201 #[case::hash("dir/a#b.vortex")]
202 #[case::braces("dir/a{x}.vortex")]
203 #[case::caret("dir/a^b.vortex")]
204 #[case::backslash_tilde("dir/a\\~b.vortex")]
205 #[case::space("dir/a b.vortex")]
206 async fn test_head_open_read_round_trip_special_chars(#[case] path: &str) -> VortexResult<()> {
207 let fs = memory_fs(&[(path, 5)]).await?;
208 assert_eq!(
210 fs.head(path).await?,
211 Some(FileListing {
212 path: path.to_string(),
213 size: Some(5),
214 })
215 );
216 assert_eq!(fs.open_read(path).await?.size().await?, 5);
218 Ok(())
219 }
220
221 #[tokio::test]
225 #[rstest]
226 #[case::tilde("dir/a~b.vortex")]
227 #[case::percent("dir/a%20b.vortex")]
228 #[case::hash("dir/a#b.vortex")]
229 #[case::backslash_tilde("dir/a\\~b.vortex")]
230 #[case::space("dir/a b.vortex")]
231 #[case::plain("dir/plain.vortex")]
232 async fn test_glob_round_trip_special_chars(#[case] path: &str) -> VortexResult<()> {
233 let fs = memory_fs(&[(path, 5)]).await?;
234 let fs_dyn: &dyn FileSystem = &fs;
235 let expected = FileListing {
236 path: path.to_string(),
237 size: Some(5),
238 };
239
240 let exact: Vec<FileListing> = fs_dyn.glob(path)?.try_collect().await?;
242 assert_eq!(exact, vec![expected.clone()]);
243
244 let wild: Vec<FileListing> = fs_dyn.glob("dir/*.vortex")?.try_collect().await?;
246 assert!(
247 wild.contains(&expected),
248 "wildcard glob should list {path:?}, got {wild:?}"
249 );
250
251 assert_eq!(fs.open_read(path).await?.size().await?, 5);
253 Ok(())
254 }
255
256 #[tokio::test]
261 async fn test_local_filesystem_special_char_round_trip() -> anyhow::Result<()> {
262 let dir = tempfile::tempdir()?;
263 std::fs::write(dir.path().join("a~b.vortex"), [0u8; 5])?;
264 let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?) as Arc<dyn ObjectStore>;
265 let fs = ObjectStoreFileSystem::new(store, Handle::find().expect("tokio runtime"));
266 let fs_dyn: &dyn FileSystem = &fs;
267 let expected = FileListing {
268 path: "a~b.vortex".to_string(),
269 size: Some(5),
270 };
271
272 let wild: Vec<FileListing> = fs_dyn.glob("*.vortex")?.try_collect().await?;
273 assert!(wild.contains(&expected), "wildcard glob got {wild:?}");
274
275 let exact: Vec<FileListing> = fs_dyn.glob("a~b.vortex")?.try_collect().await?;
276 assert_eq!(exact, vec![expected]);
277
278 assert_eq!(fs.open_read("a~b.vortex").await?.size().await?, 5);
279 Ok(())
280 }
281
282 #[tokio::test]
283 async fn test_head_existing_and_missing() -> VortexResult<()> {
284 let fs = memory_fs(&[("a/b.vortex", 7)]).await?;
285 assert_eq!(
286 fs.head("a/b.vortex").await?,
287 Some(FileListing {
288 path: "a/b.vortex".to_string(),
289 size: Some(7),
290 })
291 );
292 assert_eq!(fs.head("a/missing.vortex").await?, None);
293 Ok(())
294 }
295}