From c9fe453858977c11264e129ab02d65691aea6b5a Mon Sep 17 00:00:00 2001 From: Dylan Abraham Date: Wed, 6 May 2026 10:40:52 -0700 Subject: [PATCH] Add borrowed-string helpers on Context Signed-off-by: Dylan Abraham --- Cargo.toml | 4 ++++ examples/borrowed_strings.rs | 45 ++++++++++++++++++++++++++++++++++++ src/context/mod.rs | 34 +++++++++++++++++++++++++++ tests/integration.rs | 27 ++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 examples/borrowed_strings.rs diff --git a/Cargo.toml b/Cargo.toml index ada65348..9422ec03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ crate-type = ["cdylib"] name = "string" crate-type = ["cdylib"] +[[example]] +name = "borrowed_strings" +crate-type = ["cdylib"] + [[example]] name = "configuration" crate-type = ["cdylib"] diff --git a/examples/borrowed_strings.rs b/examples/borrowed_strings.rs new file mode 100644 index 00000000..de4a5fb8 --- /dev/null +++ b/examples/borrowed_strings.rs @@ -0,0 +1,45 @@ +use valkey_module::alloc::ValkeyAlloc; +use valkey_module::{valkey_module, Context, ValkeyError, ValkeyResult, ValkeyString, ValkeyValue}; + +/// Concatenates the raw bytes of all arguments after the command name and +/// echoes them back via [`Context::reply_with_valkey_string`], using +/// [`Context::create_string_from_slice`] to materialize the [`ValkeyString`] +/// without going through `CString::new`. Round-trips bytes containing NUL. +fn echo_via_string(ctx: &Context, args: Vec) -> ValkeyResult { + if args.len() < 2 { + return Err(ValkeyError::WrongArity); + } + let mut buf = Vec::new(); + for arg in args.iter().skip(1) { + buf.extend_from_slice(arg.as_slice()); + } + let s = ctx.create_string_from_slice(&buf); + ctx.reply_with_valkey_string(&s); + Ok(ValkeyValue::NoReply) +} + +/// Concatenates the raw bytes of all arguments after the command name and +/// echoes them back via [`Context::reply_with_slice`], without ever +/// constructing a [`ValkeyString`] or [`ValkeyValue`]. +fn echo_via_slice(ctx: &Context, args: Vec) -> ValkeyResult { + if args.len() < 2 { + return Err(ValkeyError::WrongArity); + } + let mut buf = Vec::new(); + for arg in args.iter().skip(1) { + buf.extend_from_slice(arg.as_slice()); + } + ctx.reply_with_slice(&buf); + Ok(ValkeyValue::NoReply) +} + +valkey_module! { + name: "borrowed_strings", + version: 1, + allocator: (ValkeyAlloc, ValkeyAlloc), + data_types: [], + commands: [ + ["borrowed_strings.echo_string", echo_via_string, "readonly fast", 0, 0, 0], + ["borrowed_strings.echo_slice", echo_via_slice, "readonly fast", 0, 0, 0], + ], +} diff --git a/src/context/mod.rs b/src/context/mod.rs index c3ba27b1..a23d371d 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -492,6 +492,28 @@ impl Context { unsafe { raw::RedisModule_ReplyWithError.unwrap()(self.ctx, msg.as_ptr()).into() } } + /// Reply with a borrowed byte slice, without copying into a [`ValkeyValue`]. + /// + /// Use this on hot paths where you already hold a `&[u8]` (for example a + /// slice borrowed from an open key) and do not want to allocate a `Vec` + /// just to construct [`ValkeyValue::StringBuffer`]. Wraps + /// [`ValkeyModule_ReplyWithStringBuffer`](https://valkey.io/topics/modules-api-ref/#ValkeyModule_ReplyWithStringBuffer). + #[allow(clippy::must_use_candidate)] + pub fn reply_with_slice(&self, s: &[u8]) -> raw::Status { + raw::reply_with_string_buffer(self.ctx, s.as_ptr().cast::(), s.len()) + } + + /// Reply with a borrowed [`ValkeyString`], without cloning it. + /// + /// [`Self::reply`] with [`ValkeyValue::BulkValkeyString`] requires owning + /// the `ValkeyString`. When the value is borrowed (for example from an + /// open key) this method hands the pointer straight to the C API. Wraps + /// [`ValkeyModule_ReplyWithString`](https://valkey.io/topics/modules-api-ref/#ValkeyModule_ReplyWithString). + #[allow(clippy::must_use_candidate)] + pub fn reply_with_valkey_string(&self, s: &ValkeyString) -> raw::Status { + raw::reply_with_string(self.ctx, s.inner) + } + #[cfg(feature = "min-valkey-compatibility-version-8-0")] pub fn add_acl_category(&self, s: &str) -> raw::Status { let acl_flags = Self::str_as_legal_resp_string(s); @@ -680,6 +702,18 @@ impl Context { ValkeyString::create(NonNull::new(self.ctx), s) } + /// Binary-safe variant of [`Self::create_string`]. + /// + /// [`Self::create_string`] routes its input through `CString::new`, which + /// allocates and panics on inputs containing a NUL byte. This method + /// hands the slice straight to + /// [`ValkeyModule_CreateString`](https://valkey.io/topics/modules-api-ref/#ValkeyModule_CreateString), + /// so any byte sequence is accepted unchanged. + #[must_use] + pub fn create_string_from_slice(&self, s: &[u8]) -> ValkeyString { + ValkeyString::create_from_slice(self.ctx, s) + } + #[must_use] pub const fn get_raw(&self) -> *mut raw::RedisModuleCtx { self.ctx diff --git a/tests/integration.rs b/tests/integration.rs index f200fe3d..c1c5dd77 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1993,3 +1993,30 @@ fn test_swapdb_event() -> Result<()> { Ok(()) } + +#[test] +fn test_borrowed_strings() -> Result<()> { + let port: u16 = 6529; + let _guards = vec![start_valkey_server_with_module("borrowed_strings", port) + .with_context(|| FAILED_TO_START_SERVER)?]; + let mut con = get_valkey_connection(port).with_context(|| FAILED_TO_CONNECT_TO_SERVER)?; + + // Bytes containing embedded NULs would panic if routed through + // `Context::create_string`'s `CString::new`, but the `_from_slice` + // variant accepts arbitrary bytes. + let with_nul: &[u8] = &[b'a', 0, b'b', 0, b'c']; + + let res: Vec = redis::cmd("borrowed_strings.echo_string") + .arg(with_nul) + .arg(&[0u8, 0, 0][..]) + .query(&mut con)?; + assert_eq!(res, [b'a', 0, b'b', 0, b'c', 0, 0, 0]); + + let res: Vec = redis::cmd("borrowed_strings.echo_slice") + .arg(with_nul) + .arg(&[0u8, 0, 0][..]) + .query(&mut con)?; + assert_eq!(res, [b'a', 0, b'b', 0, b'c', 0, 0, 0]); + + Ok(()) +}