| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| use anyhow::Result; |
| use futures::TryStreamExt; |
| use opendal::layers::CapabilityOverrideLayer; |
| use opendal::raw::OpDelete; |
| |
| use crate::*; |
| |
| pub fn tests(op: &Operator, tests: &mut Vec<Trial>) { |
| let cap = op.info().capability(); |
| |
| if cap.stat && cap.delete && cap.write { |
| tests.extend(async_trials!( |
| op, |
| test_delete_file, |
| test_delete_empty_dir, |
| test_delete_with_special_chars, |
| test_delete_not_existing, |
| test_delete_stream, |
| test_remove_one_file, |
| test_delete_with_version, |
| test_delete_with_not_existing_version, |
| test_batch_delete, |
| test_batch_delete_with_version |
| )); |
| if cap.delete_with_recursive { |
| tests.extend(async_trials!(op, test_delete_with_recursive_basic)); |
| } |
| if cap.list_with_recursive { |
| tests.extend(async_trials!(op, test_remove_all_basic)); |
| if !cap.create_dir { |
| tests.extend(async_trials!(op, test_remove_all_with_prefix_exists)); |
| } |
| } |
| } |
| } |
| |
| /// Delete existing file should succeed. |
| pub async fn test_delete_file(op: Operator) -> Result<()> { |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| |
| op.write(&path, content).await.expect("write must succeed"); |
| |
| op.delete(&path).await?; |
| |
| // Stat it again to check. |
| assert!(!op.exists(&path).await?); |
| |
| Ok(()) |
| } |
| |
| /// Delete empty dir should succeed. |
| pub async fn test_delete_empty_dir(op: Operator) -> Result<()> { |
| if !op.info().capability().create_dir { |
| return Ok(()); |
| } |
| |
| let path = TEST_FIXTURE.new_dir_path(); |
| |
| op.create_dir(&path).await.expect("create must succeed"); |
| |
| op.delete(&path).await?; |
| |
| Ok(()) |
| } |
| |
| /// Delete file with special chars should succeed. |
| pub async fn test_delete_with_special_chars(op: Operator) -> Result<()> { |
| let path = format!("{} !@#$%^&()_+-=;',.txt", uuid::Uuid::new_v4()); |
| let (path, content, _) = TEST_FIXTURE.new_file_with_path(op.clone(), &path); |
| |
| op.write(&path, content).await.expect("write must succeed"); |
| |
| op.delete(&path).await?; |
| |
| // Stat it again to check. |
| assert!(!op.exists(&path).await?); |
| |
| Ok(()) |
| } |
| |
| /// Delete not existing file should also succeed. |
| pub async fn test_delete_not_existing(op: Operator) -> Result<()> { |
| let path = uuid::Uuid::new_v4().to_string(); |
| |
| op.delete(&path).await?; |
| |
| Ok(()) |
| } |
| |
| /// Remove one file |
| pub async fn test_remove_one_file(op: Operator) -> Result<()> { |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| |
| op.write(&path, content.clone()) |
| .await |
| .expect("write must succeed"); |
| |
| op.delete_iter(vec![path.clone()]).await?; |
| |
| // Stat it again to check. |
| assert!(!op.exists(&path).await?); |
| |
| op.write(&format!("/{path}"), content) |
| .await |
| .expect("write must succeed"); |
| |
| op.delete_iter(vec![path.clone()]).await?; |
| |
| // Stat it again to check. |
| assert!(!op.exists(&path).await?); |
| |
| Ok(()) |
| } |
| |
| /// Delete via stream. |
| pub async fn test_delete_stream(op: Operator) -> Result<()> { |
| if !op.info().capability().create_dir { |
| return Ok(()); |
| } |
| // Gdrive think that this test is an abuse of their service and redirect us |
| // to an infinite loop. Let's ignore this test for gdrive. |
| #[cfg(feature = "services-gdrive")] |
| if op.info().scheme() == services::GDRIVE_SCHEME { |
| return Ok(()); |
| } |
| |
| let dir = uuid::Uuid::new_v4().to_string(); |
| op.create_dir(&format!("{dir}/")) |
| .await |
| .expect("create must succeed"); |
| |
| let expected: Vec<_> = (0..100).collect(); |
| for path in expected.iter() { |
| op.write(&format!("{dir}/{path}"), "delete_stream").await?; |
| } |
| |
| let mut deleter = op.deleter().await?; |
| deleter |
| .delete_iter(expected.iter().map(|v| format!("{dir}/{v}"))) |
| .await?; |
| deleter.close().await?; |
| |
| // Stat it again to check. |
| for path in expected.iter() { |
| assert!( |
| !op.exists(&format!("{dir}/{path}")).await?, |
| "{path} should be removed" |
| ) |
| } |
| |
| Ok(()) |
| } |
| |
| async fn test_blocking_remove_all_with_objects( |
| op: Operator, |
| parent: String, |
| paths: impl IntoIterator<Item = &'static str>, |
| ) -> Result<()> { |
| for path in paths { |
| let path = format!("{parent}/{path}"); |
| let (content, _) = gen_bytes(op.info().capability()); |
| op.write(&path, content).await.expect("write must succeed"); |
| } |
| |
| op.delete_with(&parent).recursive(true).await?; |
| |
| let found = op |
| .lister_with(&format!("{parent}/")) |
| .recursive(true) |
| .await |
| .expect("list must succeed") |
| .try_next() |
| .await |
| .expect("list must succeed") |
| .is_some(); |
| |
| assert!(!found, "all objects should be removed"); |
| |
| Ok(()) |
| } |
| |
| /// Remove all under a prefix |
| pub async fn test_remove_all_basic(op: Operator) -> Result<()> { |
| #[cfg(feature = "services-hf")] |
| { |
| if op.info().scheme() == services::HF_SCHEME { |
| // Hugging Face only guarantees recursive listing for repository trees, |
| // while this case expects prefix-recursive semantics for a non-directory path. |
| return Ok(()); |
| } |
| } |
| |
| let parent = uuid::Uuid::new_v4().to_string(); |
| test_blocking_remove_all_with_objects(op, parent, ["a/b", "a/c", "a/d/e"]).await |
| } |
| |
| /// Remove all under a prefix, while the prefix itself is also an object. |
| /// |
| /// This test requires flat key storage where a path can be both a file |
| /// and a directory prefix simultaneously (e.g., S3). Services with real |
| /// directory semantics (e.g., git-based repos) cannot support this |
| /// because a path cannot be both a file and a directory. |
| pub async fn test_remove_all_with_prefix_exists(op: Operator) -> Result<()> { |
| #[cfg(feature = "services-hf")] |
| { |
| if op.info().scheme() == services::HF_SCHEME { |
| // Hugging Face does not provide a stable recursive delete contract |
| // for repository trees under concurrent commits. |
| return Ok(()); |
| } |
| } |
| |
| let parent = uuid::Uuid::new_v4().to_string(); |
| let (content, _) = gen_bytes(op.info().capability()); |
| op.write(&parent, content) |
| .await |
| .expect("write must succeed"); |
| |
| test_blocking_remove_all_with_objects(op, parent, ["a", "a/b", "a/c", "a/b/e"]).await |
| } |
| |
| pub async fn test_delete_with_version(op: Operator) -> Result<()> { |
| if !op.info().capability().delete_with_version { |
| return Ok(()); |
| } |
| |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| |
| op.write(path.as_str(), content) |
| .await |
| .expect("write must success"); |
| let meta = op.stat(path.as_str()).await.expect("stat must success"); |
| let version = meta.version().expect("must have version"); |
| |
| op.delete(path.as_str()).await.expect("delete must success"); |
| assert!(!op.exists(path.as_str()).await?); |
| |
| // After a simple delete, the data can still be accessed using its version. |
| let meta = op |
| .stat_with(path.as_str()) |
| .version(version) |
| .await |
| .expect("stat must success"); |
| assert_eq!(version, meta.version().expect("must have version")); |
| |
| // After deleting with the version, the data is removed permanently |
| op.delete_with(path.as_str()) |
| .version(version) |
| .await |
| .expect("delete must success"); |
| let ret = op.stat_with(path.as_str()).version(version).await; |
| assert!(ret.is_err()); |
| assert_eq!(ret.unwrap_err().kind(), ErrorKind::NotFound); |
| |
| Ok(()) |
| } |
| |
| pub async fn test_delete_with_not_existing_version(op: Operator) -> Result<()> { |
| if !op.info().capability().delete_with_version { |
| return Ok(()); |
| } |
| |
| // retrieve a valid version |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| op.write(path.as_str(), content) |
| .await |
| .expect("write must success"); |
| let version = op |
| .stat(path.as_str()) |
| .await |
| .expect("stat must success") |
| .version() |
| .expect("must have stat") |
| .to_string(); |
| |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| op.write(path.as_str(), content) |
| .await |
| .expect("write must success"); |
| let ret = op |
| .delete_with(path.as_str()) |
| .version(version.as_str()) |
| .await; |
| assert!(ret.is_ok()); |
| |
| Ok(()) |
| } |
| |
| pub async fn test_delete_with_recursive_basic(op: Operator) -> Result<()> { |
| if !op.info().capability().delete_with_recursive { |
| return Ok(()); |
| } |
| |
| #[cfg(feature = "services-hf")] |
| { |
| if op.info().scheme() == services::HF_SCHEME { |
| // Hugging Face does not provide a stable recursive delete contract |
| // for repository trees under concurrent commits. |
| return Ok(()); |
| } |
| } |
| |
| let base = format!("delete_recursive_{}/", uuid::Uuid::new_v4()); |
| |
| let files = [ |
| format!("{base}file.txt"), |
| format!("{base}dir1/file1.txt"), |
| format!("{base}dir1/dir2/file2.txt"), |
| ]; |
| |
| for path in &files { |
| op.write(path, "delete recursive").await?; |
| } |
| |
| op.delete_with(&base).recursive(true).await?; |
| |
| let mut l = op.lister_with(&base).recursive(true).await?; |
| assert!( |
| l.try_next().await?.is_none(), |
| "all entries should be removed" |
| ); |
| |
| for path in &files { |
| assert!(!op.exists(path).await?, "{path} should be removed"); |
| } |
| |
| Ok(()) |
| } |
| |
| pub async fn test_batch_delete(op: Operator) -> Result<()> { |
| let mut cap = op.info().capability(); |
| if cap.delete_max_size.unwrap_or(1) <= 1 { |
| return Ok(()); |
| } |
| |
| cap.delete_max_size = Some(2); |
| let op = op.layer(CapabilityOverrideLayer::new(move |_| cap)); |
| |
| let mut files = Vec::new(); |
| for _ in 0..5 { |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| op.write(path.as_str(), content) |
| .await |
| .expect("write must succeed"); |
| files.push(path); |
| } |
| |
| op.delete_iter(files.clone()) |
| .await |
| .expect("batch delete must succeed"); |
| |
| for path in files { |
| let stat = op.stat(path.as_str()).await; |
| assert!(stat.is_err()); |
| assert_eq!(stat.unwrap_err().kind(), ErrorKind::NotFound); |
| } |
| |
| Ok(()) |
| } |
| |
| pub async fn test_batch_delete_with_version(op: Operator) -> Result<()> { |
| let mut cap = op.info().capability(); |
| if !cap.delete_with_version { |
| return Ok(()); |
| } |
| if cap.delete_max_size.unwrap_or(1) <= 1 { |
| return Ok(()); |
| } |
| |
| cap.delete_max_size = Some(2); |
| let op = op.layer(CapabilityOverrideLayer::new(move |_| cap)); |
| |
| let mut files = Vec::new(); |
| for _ in 0..5 { |
| let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); |
| op.write(path.as_str(), content) |
| .await |
| .expect("write must succeed"); |
| let meta = op.stat(path.as_str()).await.expect("stat must succeed"); |
| let version = meta.version().expect("must have version"); |
| let op_args = OpDelete::new().with_version(version); |
| files.push((path, op_args)); |
| } |
| |
| op.delete_iter(files.clone()) |
| .await |
| .expect("batch delete must succeed"); |
| |
| for (path, args) in files { |
| let stat = op |
| .stat_with(path.as_str()) |
| .version(args.version().unwrap()) |
| .await; |
| assert!(stat.is_err()); |
| assert_eq!(stat.unwrap_err().kind(), ErrorKind::NotFound); |
| } |
| |
| Ok(()) |
| } |