This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new 47554c623 feat(core): add conditional rename option (#7815)
47554c623 is described below

commit 47554c6236947eccc81c8bbcbebb6ebd26e544b5
Author: hfutatzhanghb <[email protected]>
AuthorDate: Fri Jul 3 21:31:44 2026 +0800

    feat(core): add conditional rename option (#7815)
    
    * feat(core): support conditional rename
    
    Add rename_with(...).if_not_exists(true) and RenameOptions to the
    public API, mirroring the existing write and copy if_not_exists pattern.
    
    Changes:
    - ops.rs: add if_not_exists field to OpRename
    - operator.rs: add rename_with, rename_options, rename_inner
    - operator_futures.rs: add FutureRename with if_not_exists builder
    - options.rs: add RenameOptions
    - capability.rs: add rename_with_if_not_exists
    - correctness_check.rs: gate if_not_exists on capability
    - blocking/operator.rs: add rename_options
    - hdfs: implement conditional rename using native no-overwrite
    - async_rename.rs: add behavior tests
    - vs_object_store.md: update comparison table
    
    * chore: trigger ci
    
    ---------
    
    Co-authored-by: [email protected] <[email protected]>
---
 core/core/src/blocking/operator.rs                |  28 ++++-
 core/core/src/docs/comparisons/vs_object_store.md |   4 +-
 core/core/src/layers/correctness_check.rs         |  34 +++++-
 core/core/src/raw/ops.rs                          |  25 ++++-
 core/core/src/types/capability.rs                 |   2 +
 core/core/src/types/operator/operator.rs          | 101 ++++++++++++++++--
 core/core/src/types/operator/operator_futures.rs  |  29 ++++++
 core/core/src/types/options.rs                    |  18 ++++
 core/services/hdfs/src/backend.rs                 |   5 +-
 core/services/hdfs/src/core.rs                    |  44 +++++++-
 core/tests/behavior/async_rename.rs               | 121 ++++++++++++++++++++++
 11 files changed, 387 insertions(+), 24 deletions(-)

diff --git a/core/core/src/blocking/operator.rs 
b/core/core/src/blocking/operator.rs
index ddae26fff..1fe607858 100644
--- a/core/core/src/blocking/operator.rs
+++ b/core/core/src/blocking/operator.rs
@@ -607,7 +607,7 @@ impl Operator {
     ///
     /// - `from` and `to` must be a file.
     /// - `to` will be overwritten if it exists.
-    /// - If `from` and `to` are the same, a `IsSameFile` error will occur.
+    /// - If `from` and `to` are the same, an `IsSameFile` error will occur.
     ///
     /// # Examples
     ///
@@ -622,10 +622,34 @@ impl Operator {
     /// # }
     /// ```
     pub fn rename(&self, from: &str, to: &str) -> Result<()> {
+        self.rename_options(from, to, options::RenameOptions::default())
+    }
+
+    /// Rename a file from `from` to `to` with additional options.
+    ///
+    /// # Options
+    ///
+    /// Visit [`options::RenameOptions`] for all available options.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use opendal_core::blocking;
+    /// use opendal_core::options::RenameOptions;
+    /// use opendal_core::Result;
+    ///
+    /// fn rename_with_options(op: blocking::Operator) -> Result<()> {
+    ///     let mut opts = RenameOptions::default();
+    ///     opts.if_not_exists = true;
+    ///     op.rename_options("path/to/file", "path/to/file2", opts)?;
+    ///     Ok(())
+    /// }
+    /// ```
+    pub fn rename_options(&self, from: &str, to: &str, opts: 
options::RenameOptions) -> Result<()> {
         let op = self.op.clone();
         let from = from.to_string();
         let to = to.to_string();
-        self.spawn_block(async move { op.rename(&from, &to).await })?
+        self.spawn_block(async move { op.rename_options(&from, &to, 
opts).await })?
     }
 
     /// Delete given path.
diff --git a/core/core/src/docs/comparisons/vs_object_store.md 
b/core/core/src/docs/comparisons/vs_object_store.md
index 04162014c..d5d1b8eeb 100644
--- a/core/core/src/docs/comparisons/vs_object_store.md
+++ b/core/core/src/docs/comparisons/vs_object_store.md
@@ -145,8 +145,8 @@ opendal has an idea called 
[`Capability`][crate::Capability], so it's services m
 | list      | list_with_delimiter  | -                                         
   |
 | -         | copy                 | -                                         
   |
 | -         | copy_if_not_exists   | -                                         
   |
-| -         | rename               | -                                         
   |
-| -         | rename_if_not_exists | -                                         
   |
+| rename    | rename               | -                                         
   |
+| rename_with(if_not_exists) | rename_if_not_exists | -                        
    |
 | presign   | -                    | get a presign URL of object               
   |
 | multipart | multipart            | both support, but API is different        
   |
 | blocking  | -                    | opendal supports blocking API             
   |
diff --git a/core/core/src/layers/correctness_check.rs 
b/core/core/src/layers/correctness_check.rs
index a9fa50b56..b2de8ce35 100644
--- a/core/core/src/layers/correctness_check.rs
+++ b/core/core/src/layers/correctness_check.rs
@@ -248,6 +248,16 @@ impl Service for CorrectnessService {
         to: &str,
         args: OpRename,
     ) -> Result<RpRename> {
+        let capability = self.capability();
+        let scheme = self.info().scheme();
+        if args.if_not_exists() && !capability.rename_with_if_not_exists {
+            return Err(new_unsupported_error(
+                scheme,
+                Operation::Rename,
+                "if_not_exists",
+            ));
+        }
+
         self.inner.rename(ctx, from, to, args).await
     }
 
@@ -390,10 +400,7 @@ mod tests {
             _: &str,
             _: OpRename,
         ) -> Result<RpRename> {
-            Err(Error::new(
-                ErrorKind::Unsupported,
-                "operation is not supported",
-            ))
+            Ok(RpRename::default())
         }
 
         async fn presign(&self, _: &OperationContext, _: &str, _: OpPresign) 
-> Result<RpPresign> {
@@ -562,4 +569,23 @@ mod tests {
         let res = op.delete_with("path").version("version").await;
         assert!(res.is_ok())
     }
+
+    #[tokio::test]
+    async fn test_rename_with_if_not_exists() {
+        let op = new_test_operator(Capability {
+            rename: true,
+            ..Default::default()
+        });
+        let res = op.rename_with("from", "to").if_not_exists(true).await;
+        assert!(res.is_err());
+        assert_eq!(res.unwrap_err().kind(), ErrorKind::Unsupported);
+
+        let op = new_test_operator(Capability {
+            rename: true,
+            rename_with_if_not_exists: true,
+            ..Default::default()
+        });
+        let res = op.rename_with("from", "to").if_not_exists(true).await;
+        assert!(res.is_ok());
+    }
 }
diff --git a/core/core/src/raw/ops.rs b/core/core/src/raw/ops.rs
index 5e2d7679c..5a082c634 100644
--- a/core/core/src/raw/ops.rs
+++ b/core/core/src/raw/ops.rs
@@ -987,11 +987,32 @@ impl From<options::CopyOptions> for (OpCopy, OpCopier) {
 
 /// Args for `rename` operation.
 #[derive(Debug, Clone, Default)]
-pub struct OpRename {}
+pub struct OpRename {
+    if_not_exists: bool,
+}
 
 impl OpRename {
-    /// Create a new `OpMove`.
+    /// Create a new `OpRename`.
     pub fn new() -> Self {
         Self::default()
     }
+
+    /// Set the if_not_exists flag for the operation.
+    pub fn with_if_not_exists(mut self, if_not_exists: bool) -> Self {
+        self.if_not_exists = if_not_exists;
+        self
+    }
+
+    /// Get if_not_exists flag.
+    pub fn if_not_exists(&self) -> bool {
+        self.if_not_exists
+    }
+}
+
+impl From<options::RenameOptions> for OpRename {
+    fn from(value: options::RenameOptions) -> Self {
+        Self {
+            if_not_exists: value.if_not_exists,
+        }
+    }
 }
diff --git a/core/core/src/types/capability.rs 
b/core/core/src/types/capability.rs
index f24064a58..9994f6e5c 100644
--- a/core/core/src/types/capability.rs
+++ b/core/core/src/types/capability.rs
@@ -164,6 +164,8 @@ pub struct Capability {
 
     /// Indicates if rename operations are supported.
     pub rename: bool,
+    /// Indicates if conditional rename operations with if-not-exists are 
supported.
+    pub rename_with_if_not_exists: bool,
 
     /// Indicates if list operations are supported.
     pub list: bool,
diff --git a/core/core/src/types/operator/operator.rs 
b/core/core/src/types/operator/operator.rs
index 02e5eb7ad..a29233f3d 100644
--- a/core/core/src/types/operator/operator.rs
+++ b/core/core/src/types/operator/operator.rs
@@ -1389,24 +1389,111 @@ impl Operator {
     /// # }
     /// ```
     pub async fn rename(&self, from: &str, to: &str) -> Result<()> {
+        self.rename_options(from, to, options::RenameOptions::default())
+            .await
+    }
+
+    /// Rename a file from `from` to `to` with additional options.
+    ///
+    /// # Notes
+    ///
+    /// - `from` and `to` must be a file.
+    /// - If `from` and `to` are the same, an `IsSameFile` error will occur.
+    ///
+    /// # Options
+    ///
+    /// Visit [`options::RenameOptions`] for all available options.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use opendal_core::Operator;
+    /// use opendal_core::Result;
+    ///
+    /// async fn rename_with_options(op: Operator) -> Result<()> {
+    ///     op.rename_with("path/to/file", "path/to/file2")
+    ///         .if_not_exists(true)
+    ///         .await?;
+    ///     Ok(())
+    /// }
+    /// ```
+    pub fn rename_with(
+        &self,
+        from: &str,
+        to: &str,
+    ) -> FutureRename<impl Future<Output = Result<()>>> {
+        let from = normalize_path(from);
+        let to = normalize_path(to);
+
+        OperatorFuture::new(
+            self.context().clone(),
+            self.service().clone(),
+            from,
+            (options::RenameOptions::default(), to),
+            Self::rename_inner,
+        )
+    }
+
+    /// Rename a file from `from` to `to` with additional options.
+    ///
+    /// # Options
+    ///
+    /// Visit [`options::RenameOptions`] for all available options.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use opendal_core::options::RenameOptions;
+    /// use opendal_core::Operator;
+    /// use opendal_core::Result;
+    ///
+    /// async fn rename_with_test(op: Operator) -> Result<()> {
+    ///     let mut opts = RenameOptions::default();
+    ///     opts.if_not_exists = true;
+    ///     op.rename_options("path/to/file", "path/to/file2", opts)
+    ///         .await?;
+    ///     Ok(())
+    /// }
+    /// ```
+    pub async fn rename_options(
+        &self,
+        from: &str,
+        to: &str,
+        opts: impl Into<options::RenameOptions>,
+    ) -> Result<()> {
         let from = normalize_path(from);
+        let to = normalize_path(to);
+        let opts = opts.into();
+
+        Self::rename_inner(
+            self.context().clone(),
+            self.service().clone(),
+            from,
+            (opts, to),
+        )
+        .await
+    }
 
+    async fn rename_inner(
+        ctx: OperationContext,
+        srv: Servicer,
+        from: String,
+        (opts, to): (options::RenameOptions, String),
+    ) -> Result<()> {
         if !validate_path(&from, EntryMode::FILE) {
             return Err(
                 Error::new(ErrorKind::IsADirectory, "from path is a directory")
                     .with_operation(Operation::Rename)
-                    .with_context("service", self.info().scheme())
+                    .with_context("service", srv.info().scheme())
                     .with_context("from", from),
             );
         }
 
-        let to = normalize_path(to);
-
         if !validate_path(&to, EntryMode::FILE) {
             return Err(
                 Error::new(ErrorKind::IsADirectory, "to path is a directory")
                     .with_operation(Operation::Rename)
-                    .with_context("service", self.info().scheme())
+                    .with_context("service", srv.info().scheme())
                     .with_context("to", to),
             );
         }
@@ -1415,15 +1502,13 @@ impl Operator {
             return Err(
                 Error::new(ErrorKind::IsSameFile, "from and to paths are same")
                     .with_operation(Operation::Rename)
-                    .with_context("service", self.info().scheme())
+                    .with_context("service", srv.info().scheme())
                     .with_context("from", from)
                     .with_context("to", to),
             );
         }
 
-        self.srv
-            .rename(&self.ctx, &from, &to, OpRename::new())
-            .await?;
+        srv.rename(&ctx, &from, &to, opts.into()).await?;
 
         Ok(())
     }
diff --git a/core/core/src/types/operator/operator_futures.rs 
b/core/core/src/types/operator/operator_futures.rs
index 3c521ec61..539934bdd 100644
--- a/core/core/src/types/operator/operator_futures.rs
+++ b/core/core/src/types/operator/operator_futures.rs
@@ -1538,3 +1538,32 @@ impl<F: Future<Output = Result<Copier>>> FutureCopier<F> 
{
         self
     }
 }
+
+/// Future that generated by [`Operator::rename_with`].
+///
+/// Users can add more options by public functions provided by this struct.
+pub type FutureRename<F> = OperatorFuture<(options::RenameOptions, String), 
(), F>;
+
+impl<F: Future<Output = Result<()>>> FutureRename<F> {
+    /// Sets the condition that rename operation will succeed only if target 
does not exist.
+    ///
+    /// Refer to [`options::RenameOptions::if_not_exists`] for more details.
+    ///
+    /// ### Example
+    ///
+    /// ```
+    /// use opendal_core::Operator;
+    /// use opendal_core::Result;
+    ///
+    /// async fn rename_with_options(op: Operator) -> Result<()> {
+    ///     op.rename_with("source/path", "target/path")
+    ///         .if_not_exists(true)
+    ///         .await?;
+    ///     Ok(())
+    /// }
+    /// ```
+    pub fn if_not_exists(mut self, v: bool) -> Self {
+        self.args.0.if_not_exists = v;
+        self
+    }
+}
diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs
index c35dd49b7..6972225fd 100644
--- a/core/core/src/types/options.rs
+++ b/core/core/src/types/options.rs
@@ -601,3 +601,21 @@ pub struct CopyOptions {
     /// step. Services that cannot split copy operations can ignore it.
     pub chunk: Option<usize>,
 }
+
+/// Options for rename operations.
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub struct RenameOptions {
+    /// Sets the condition that rename operation will succeed only if target 
does not exist.
+    ///
+    /// ### Capability
+    ///
+    /// Check [`Capability::rename_with_if_not_exists`] before using this 
feature.
+    ///
+    /// ### Behavior
+    ///
+    /// - If the target does not exist, the rename operation succeeds.
+    /// - If the target exists, the operation returns 
[`ErrorKind::ConditionNotMatch`].
+    /// - If the service does not support this condition, the operation returns
+    ///   [`ErrorKind::Unsupported`].
+    pub if_not_exists: bool,
+}
diff --git a/core/services/hdfs/src/backend.rs 
b/core/services/hdfs/src/backend.rs
index b7805027f..71e2b4a55 100644
--- a/core/services/hdfs/src/backend.rs
+++ b/core/services/hdfs/src/backend.rs
@@ -171,6 +171,7 @@ impl Builder for HdfsBuilder {
                     list: true,
 
                     rename: true,
+                    rename_with_if_not_exists: true,
 
                     shared: true,
 
@@ -273,9 +274,9 @@ impl Service for HdfsBackend {
         _ctx: &OperationContext,
         from: &str,
         to: &str,
-        _args: OpRename,
+        args: OpRename,
     ) -> Result<RpRename> {
-        self.core.hdfs_rename(from, to)?;
+        self.core.hdfs_rename(from, to, &args)?;
         Ok(RpRename::new())
     }
 
diff --git a/core/services/hdfs/src/core.rs b/core/services/hdfs/src/core.rs
index 0d8767dca..23658458e 100644
--- a/core/services/hdfs/src/core.rs
+++ b/core/services/hdfs/src/core.rs
@@ -22,6 +22,19 @@ use std::sync::Arc;
 use opendal_core::raw::*;
 use opendal_core::*;
 
+fn map_hdfs_rename_error(err: io::Error, if_not_exists: bool, to_path: &str) 
-> Error {
+    if if_not_exists && err.kind() == io::ErrorKind::AlreadyExists {
+        return Error::new(
+            ErrorKind::ConditionNotMatch,
+            "target path already exists while if_not_exists is set",
+        )
+        .with_context("to", to_path)
+        .set_source(err);
+    }
+
+    new_std_io_error(err)
+}
+
 /// HdfsCore contains code that directly interacts with HDFS.
 #[derive(Clone)]
 pub struct HdfsCore {
@@ -146,7 +159,7 @@ impl HdfsCore {
         }
     }
 
-    pub fn hdfs_rename(&self, from: &str, to: &str) -> Result<()> {
+    pub fn hdfs_rename(&self, from: &str, to: &str, args: &OpRename) -> 
Result<()> {
         let from_path = build_rooted_abs_path(&self.root, from);
         self.client.metadata(&from_path).map_err(new_std_io_error)?;
 
@@ -166,7 +179,7 @@ impl HdfsCore {
                             ErrorKind::Unexpected,
                             "path should have parent but not, it must be 
malformed",
                         )
-                        .with_context("input", &to_path)
+                        .with_context("to", &to_path)
                     })?
                     .to_path_buf();
 
@@ -176,20 +189,43 @@ impl HdfsCore {
             }
             Ok(metadata) => {
                 if metadata.is_file() {
+                    if args.if_not_exists() {
+                        return Err(Error::new(
+                            ErrorKind::ConditionNotMatch,
+                            "target path already exists while if_not_exists is 
set",
+                        )
+                        .with_context("to", &to_path));
+                    }
                     self.client
                         .remove_file(&to_path)
                         .map_err(new_std_io_error)?;
                 } else {
                     return Err(Error::new(ErrorKind::IsADirectory, "path 
should be a file")
-                        .with_context("input", &to_path));
+                        .with_context("to", &to_path));
                 }
             }
         }
 
         self.client
             .rename_file(&from_path, &to_path)
-            .map_err(new_std_io_error)?;
+            .map_err(|err| map_hdfs_rename_error(err, args.if_not_exists(), 
&to_path))?;
 
         Ok(())
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn map_existing_target_error_to_condition_not_match() {
+        let err = map_hdfs_rename_error(
+            io::Error::new(io::ErrorKind::AlreadyExists, "target exists"),
+            true,
+            "/target",
+        );
+
+        assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
+    }
+}
diff --git a/core/tests/behavior/async_rename.rs 
b/core/tests/behavior/async_rename.rs
index 530c0419b..edb530a3b 100644
--- a/core/tests/behavior/async_rename.rs
+++ b/core/tests/behavior/async_rename.rs
@@ -34,6 +34,15 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
             test_rename_overwrite
         ))
     }
+
+    if cap.read && cap.write && cap.rename && cap.rename_with_if_not_exists {
+        tests.extend(async_trials!(
+            op,
+            test_rename_with_if_not_exists,
+            test_rename_with_if_not_exists_nested,
+            test_rename_with_if_not_exists_returns_condition_not_match
+        ))
+    }
 }
 
 /// Rename a file and test with stat.
@@ -206,3 +215,115 @@ pub async fn test_rename_overwrite(op: Operator) -> 
Result<()> {
     op.delete(&target_path).await.expect("delete must succeed");
     Ok(())
 }
+
+/// Rename to a non-exist path should succeed when if_not_exists is set.
+pub async fn test_rename_with_if_not_exists(op: Operator) -> Result<()> {
+    let source_path = uuid::Uuid::new_v4().to_string();
+    let (source_content, _) = gen_bytes(op.info().capability());
+
+    op.write(&source_path, source_content.clone()).await?;
+
+    let target_path = uuid::Uuid::new_v4().to_string();
+
+    op.rename_with(&source_path, &target_path)
+        .if_not_exists(true)
+        .await?;
+
+    let err = op.stat(&source_path).await.expect_err("stat must fail");
+    assert_eq!(err.kind(), ErrorKind::NotFound);
+
+    let target_content = op
+        .read(&target_path)
+        .await
+        .expect("read must succeed")
+        .to_bytes();
+    assert_eq!(
+        sha256_digest(target_content),
+        sha256_digest(&source_content),
+    );
+
+    op.delete(&source_path).await.expect("delete must succeed");
+    op.delete(&target_path).await.expect("delete must succeed");
+    Ok(())
+}
+
+/// Rename to a nested path should succeed when if_not_exists is set.
+pub async fn test_rename_with_if_not_exists_nested(op: Operator) -> Result<()> 
{
+    let source_path = uuid::Uuid::new_v4().to_string();
+    let (source_content, _) = gen_bytes(op.info().capability());
+
+    op.write(&source_path, source_content.clone()).await?;
+
+    let target_path = format!(
+        "{}/{}/{}",
+        uuid::Uuid::new_v4(),
+        uuid::Uuid::new_v4(),
+        uuid::Uuid::new_v4()
+    );
+
+    op.rename_with(&source_path, &target_path)
+        .if_not_exists(true)
+        .await?;
+
+    let err = op.stat(&source_path).await.expect_err("stat must fail");
+    assert_eq!(err.kind(), ErrorKind::NotFound);
+
+    let target_content = op
+        .read(&target_path)
+        .await
+        .expect("read must succeed")
+        .to_bytes();
+    assert_eq!(
+        sha256_digest(target_content),
+        sha256_digest(&source_content),
+    );
+
+    op.delete(&source_path).await.expect("delete must succeed");
+    op.delete(&target_path).await.expect("delete must succeed");
+    Ok(())
+}
+
+/// Rename to an existing path should return ConditionNotMatch when 
if_not_exists is set.
+pub async fn test_rename_with_if_not_exists_returns_condition_not_match(
+    op: Operator,
+) -> Result<()> {
+    let source_path = uuid::Uuid::new_v4().to_string();
+    let (source_content, _) = gen_bytes(op.info().capability());
+    op.write(&source_path, source_content.clone()).await?;
+
+    let target_path = uuid::Uuid::new_v4().to_string();
+    let (target_content, _) = gen_bytes(op.info().capability());
+    assert_ne!(source_content, target_content);
+    op.write(&target_path, target_content.clone()).await?;
+
+    let err = op
+        .rename_with(&source_path, &target_path)
+        .if_not_exists(true)
+        .await
+        .expect_err("rename must fail");
+    assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
+
+    let source_content_after = op
+        .read(&source_path)
+        .await
+        .expect("read source must succeed")
+        .to_bytes();
+    assert_eq!(
+        sha256_digest(source_content_after),
+        sha256_digest(&source_content),
+    );
+
+    let target_content_after = op
+        .read(&target_path)
+        .await
+        .expect("read target must succeed")
+        .to_bytes();
+    assert_eq!(
+        sha256_digest(target_content_after),
+        sha256_digest(&target_content),
+    );
+
+    op.delete(&source_path).await.expect("delete must succeed");
+    op.delete(&target_path).await.expect("delete must succeed");
+    Ok(())
+}

Reply via email to