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 8722cafee feat(bindings/nodejs): Add DeleteOptions support for new options API (#6349) 8722cafee is described below commit 8722cafee760c946bf88e8de9e80128e7ed5ccce Author: Kingsword <kingswor...@gmail.com> AuthorDate: Fri Jul 11 15:46:43 2025 +0800 feat(bindings/nodejs): Add DeleteOptions support for new options API (#6349) --- bindings/nodejs/generated.d.ts | 9 ++- bindings/nodejs/src/capability.rs | 6 ++ bindings/nodejs/src/lib.rs | 21 +++-- bindings/nodejs/src/options.rs | 14 ++++ .../tests/suites/asyncDeleteOptions.suite.mjs | 92 ++++++++++++++++++++++ bindings/nodejs/tests/suites/index.mjs | 4 + .../tests/suites/syncDeleteOptions.suite.mjs | 92 ++++++++++++++++++++++ 7 files changed, 231 insertions(+), 7 deletions(-) diff --git a/bindings/nodejs/generated.d.ts b/bindings/nodejs/generated.d.ts index c442c09fc..82d4ad1e5 100644 --- a/bindings/nodejs/generated.d.ts +++ b/bindings/nodejs/generated.d.ts @@ -269,6 +269,9 @@ export interface ListOptions { */ deleted?: boolean } +export interface DeleteOptions { + version?: string +} export const enum EntryMode { /** FILE means the path has data to read. */ FILE = 0, @@ -400,6 +403,8 @@ export class Capability { get createDir(): boolean /** If operator supports delete. */ get delete(): boolean + /** If operator supports delete by version. */ + get deleteWithVersion(): boolean /** If operator supports copy. */ get copy(): boolean /** If operator supports rename. */ @@ -644,7 +649,7 @@ export class Operator { * await op.delete("test"); * ``` */ - delete(path: string): Promise<void> + delete(path: string, options?: DeleteOptions | undefined | null): Promise<void> /** * Delete the given path synchronously. * @@ -653,7 +658,7 @@ export class Operator { * op.deleteSync("test"); * ``` */ - deleteSync(path: string): void + deleteSync(path: string, options?: DeleteOptions | undefined | null): void /** * Remove given paths. * diff --git a/bindings/nodejs/src/capability.rs b/bindings/nodejs/src/capability.rs index 4033895bc..e77a5787a 100644 --- a/bindings/nodejs/src/capability.rs +++ b/bindings/nodejs/src/capability.rs @@ -221,6 +221,12 @@ impl Capability { self.0.delete } + /// If operator supports delete by version. + #[napi(getter)] + pub fn delete_with_version(&self) -> bool { + self.0.delete_with_version + } + /// If operator supports copy. #[napi(getter)] pub fn copy(&self) -> bool { diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs index e9e0c25ba..cc862194d 100644 --- a/bindings/nodejs/src/lib.rs +++ b/bindings/nodejs/src/lib.rs @@ -27,7 +27,7 @@ use std::time::Duration; use futures::AsyncReadExt; use futures::TryStreamExt; use napi::bindgen_prelude::*; -use opendal::options::{ListOptions, ReadOptions, ReaderOptions, StatOptions}; +use opendal::options::{DeleteOptions, ListOptions, ReadOptions, ReaderOptions, StatOptions}; mod capability; mod options; @@ -458,8 +458,16 @@ impl Operator { /// await op.delete("test"); /// ``` #[napi] - pub async fn delete(&self, path: String) -> Result<()> { - self.async_op.delete(&path).await.map_err(format_napi_error) + pub async fn delete( + &self, + path: String, + options: Option<options::DeleteOptions>, + ) -> Result<()> { + let options = options.map_or_else(DeleteOptions::default, DeleteOptions::from); + self.async_op + .delete_options(&path, options) + .await + .map_err(format_napi_error) } /// Delete the given path synchronously. @@ -469,8 +477,11 @@ impl Operator { /// op.deleteSync("test"); /// ``` #[napi] - pub fn delete_sync(&self, path: String) -> Result<()> { - self.blocking_op.delete(&path).map_err(format_napi_error) + pub fn delete_sync(&self, path: String, options: Option<options::DeleteOptions>) -> Result<()> { + let options = options.map_or_else(DeleteOptions::default, DeleteOptions::from); + self.blocking_op + .delete_options(&path, options) + .map_err(format_napi_error) } /// Remove given paths. diff --git a/bindings/nodejs/src/options.rs b/bindings/nodejs/src/options.rs index d1b45fb4d..994f0d74b 100644 --- a/bindings/nodejs/src/options.rs +++ b/bindings/nodejs/src/options.rs @@ -391,3 +391,17 @@ impl From<ListOptions> for opendal::options::ListOptions { } } } + +#[napi(object)] +#[derive(Default)] +pub struct DeleteOptions { + pub version: Option<String>, +} + +impl From<DeleteOptions> for opendal::options::DeleteOptions { + fn from(value: DeleteOptions) -> Self { + Self { + version: value.version, + } + } +} diff --git a/bindings/nodejs/tests/suites/asyncDeleteOptions.suite.mjs b/bindings/nodejs/tests/suites/asyncDeleteOptions.suite.mjs new file mode 100644 index 000000000..fe2e21559 --- /dev/null +++ b/bindings/nodejs/tests/suites/asyncDeleteOptions.suite.mjs @@ -0,0 +1,92 @@ +/* + * 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. + */ + +import { randomUUID } from 'node:crypto' +import { test, describe, expect, assert } from 'vitest' + +import { generateBytes } from '../utils.mjs' + +/** + * @param {import("../../index").Operator} op + */ +export function run(op) { + const capability = op.capability() + + describe.runIf(capability.write && capability.delete)('async delete options', () => { + test('test delete file', async () => { + const filename = `random_file_${randomUUID()}` + const content = generateBytes() + + await op.write(filename, content) + await op.delete(filename) + + assert.isFalse(await op.exists(filename)) + }) + + test.runIf(capability.createDir)('test delete empty dir', async () => { + const dirname = `random_dir_${randomUUID()}/` + await op.createDir(dirname) + await op.delete(dirname) + + assert.isFalse(await op.exists(dirname)) + }) + + test.runIf(capability.createDir)('test delete not existing', async () => { + const filename = `random_file_${randomUUID()}` + + await op.delete(filename) + assert.isFalse(await op.exists(filename)) + }) + + test.runIf(capability.deleteWithVersion)('test delete with version', async () => { + const filename = `random_file_${randomUUID()}` + const content = generateBytes() + + await op.write(filename, content) + const meta = await op.stat(filename) + const version = meta.version + + await op.delete(filename) + assert.isFalse(await op.exists(filename)) + + const metadata = await op.stat(filename, { version }) + expect(metadata.version).toBe(version) + + await op.delete(filename, { version }) + + await expect(op.stat(filename, { version })).rejects.toThrowError('NotFound') + }) + + test.runIf(capability.deleteWithVersion)('test delete with not existing version', async () => { + const filename1 = `random_file_${randomUUID()}` + const content1 = generateBytes() + + await op.write(filename1, content1) + const meta = await op.stat(filename1) + const version = meta.version + + const filename2 = `random_file_${randomUUID()}` + const content2 = generateBytes() + await op.write(filename2, content2) + + await op.delete(filename2, { version }) + await op.delete(filename1) + }) + }) +} diff --git a/bindings/nodejs/tests/suites/index.mjs b/bindings/nodejs/tests/suites/index.mjs index e012a6047..258a749ae 100644 --- a/bindings/nodejs/tests/suites/index.mjs +++ b/bindings/nodejs/tests/suites/index.mjs @@ -30,6 +30,8 @@ import { run as AsyncReadOptionsTestRun } from './asyncReadOptions.suite.mjs' import { run as SyncReadOptionsTestRun } from './syncReadOptions.suite.mjs' import { run as AsyncListOptionsTestRun } from './asyncListOptions.suite.mjs' import { run as SyncListOptionsTestRun } from './syncListOptions.suite.mjs' +import { run as AsyncDeleteOptionsTestRun } from './asyncDeleteOptions.suite.mjs' +import { run as SyncDeleteOptionsTestRun } from './syncDeleteOptions.suite.mjs' export function runner(testName, scheme) { if (!scheme) { @@ -65,5 +67,7 @@ export function runner(testName, scheme) { SyncReadOptionsTestRun(operator) AsyncListOptionsTestRun(operator) SyncListOptionsTestRun(operator) + AsyncDeleteOptionsTestRun(operator) + SyncDeleteOptionsTestRun(operator) }) } diff --git a/bindings/nodejs/tests/suites/syncDeleteOptions.suite.mjs b/bindings/nodejs/tests/suites/syncDeleteOptions.suite.mjs new file mode 100644 index 000000000..d6a98ed59 --- /dev/null +++ b/bindings/nodejs/tests/suites/syncDeleteOptions.suite.mjs @@ -0,0 +1,92 @@ +/* + * 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. + */ + +import { randomUUID } from 'node:crypto' +import { test, describe, expect, assert } from 'vitest' + +import { generateBytes } from '../utils.mjs' + +/** + * @param {import("../../index").Operator} op + */ +export function run(op) { + const capability = op.capability() + + describe.runIf(capability.write && capability.delete)('async delete options', () => { + test('test delete file', () => { + const filename = `random_file_${randomUUID()}` + const content = generateBytes() + + op.writeSync(filename, content) + op.deleteSync(filename) + + assert.isFalse(op.existsSync(filename)) + }) + + test.runIf(capability.createDir)('test delete empty dir', () => { + const dirname = `random_dir_${randomUUID()}/` + op.createDirSync(dirname) + op.deleteSync(dirname) + + assert.isFalse(op.existsSync(dirname)) + }) + + test.runIf(capability.createDir)('test delete not existing', () => { + const filename = `random_file_${randomUUID()}` + + op.deleteSync(filename) + assert.isFalse(op.existsSync(filename)) + }) + + test.runIf(capability.deleteWithVersion)('test delete with version', () => { + const filename = `random_file_${randomUUID()}` + const content = generateBytes() + + op.writeSync(filename, content) + const meta = op.statSync(filename) + const version = meta.version + + op.deleteSync(filename) + assert.isFalse(op.existsSync(filename)) + + const metadata = op.statSync(filename, { version }) + expect(metadata.version).toBe(version) + + op.deleteSync(filename, { version }) + + expect(() => op.statSync(filename, { version })).toThrowError('NotFound') + }) + + test.runIf(capability.deleteWithVersion)('test delete with not existing version', () => { + const filename1 = `random_file_${randomUUID()}` + const content1 = generateBytes() + + op.writeSync(filename1, content1) + const meta = op.statSync(filename1) + const version = meta.version + + const filename2 = `random_file_${randomUUID()}` + const content2 = generateBytes() + op.writeSync(filename2, content2) + + op.deleteSync(filename2, { version }) + op.deleteSync(filename1) + }) + }) +}