Skip to content

Implement Add and Replace support #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Sources/SwiftMemcache/Extensions/ByteBuffer+SwiftMemcache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,17 @@ extension ByteBuffer {
}

if let storageMode = flags.storageMode {
self.writeInteger(UInt8.whitespace)
self.writeInteger(UInt8.M)
switch storageMode {
case .add:
self.writeInteger(UInt8.E)
case .append:
self.writeInteger(UInt8.whitespace)
self.writeInteger(UInt8.M)
self.writeInteger(UInt8.A)
case .prepend:
self.writeInteger(UInt8.whitespace)
self.writeInteger(UInt8.M)
self.writeInteger(UInt8.P)
case .replace:
self.writeInteger(UInt8.R)
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/SwiftMemcache/Extensions/UInt8+Characters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ extension UInt8 {
static var M: UInt8 = .init(ascii: "M")
static var P: UInt8 = .init(ascii: "P")
static var A: UInt8 = .init(ascii: "A")
static var E: UInt8 = .init(ascii: "E")
static var R: UInt8 = .init(ascii: "R")
static var zero: UInt8 = .init(ascii: "0")
static var nine: UInt8 = .init(ascii: "9")
}
84 changes: 84 additions & 0 deletions Sources/SwiftMemcache/MemcachedConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public actor MemcachedConnection {
case unexpectedNilResponse
/// Indicates that the key was not found.
case keyNotFound
/// Indicates that the key already exist
case keyExist
}

private var state: State
Expand Down Expand Up @@ -340,4 +342,86 @@ public actor MemcachedConnection {
throw MemcachedConnectionError.connectionShutdown
}
}

// MARK: - Adding a Value

/// Adds a new key-value pair in the Memcached server.
/// The operation will fail if the key already exists.
///
/// - Parameters:
/// - key: The key to add the value to.
/// - value: The `MemcachedValue` to add.
/// - Throws: A `MemcachedConnectionError.connectionShutdown` if the connection to the Memcached server is shut down.
/// - Throws: A `MemcachedConnectionError.keyExist` if the key already exists in the Memcached server.
/// - Throws: A `MemcachedConnectionError.unexpectedNilResponse` if an unexpected response code is returned.
public func add(_ key: String, value: some MemcachedValue) async throws {
switch self.state {
case .initial(_, let bufferAllocator, _, _),
.running(let bufferAllocator, _, _, _):

var buffer = bufferAllocator.buffer(capacity: 0)
value.writeToBuffer(&buffer)
var flags: MemcachedFlags

flags = MemcachedFlags()
flags.storageMode = .add

let command = MemcachedRequest.SetCommand(key: key, value: buffer, flags: flags)
let request = MemcachedRequest.set(command)

let response = try await sendRequest(request)

switch response.returnCode {
case .HD:
return
case .NS:
throw MemcachedConnectionError.keyExist
default:
throw MemcachedConnectionError.unexpectedNilResponse
}

case .finished:
throw MemcachedConnectionError.connectionShutdown
}
}

// MARK: - Replacing a Value

/// Replace the value for an existing key in the Memcache server.
/// The operation will fail if the key does not exist.
///
/// - Parameters:
/// - key: The key to replace the value for.
/// - value: The `MemcachedValue` to replace.
/// - Throws: A `MemcachedConnectionError` if the connection to the Memcached server is shut down.
public func replace(_ key: String, value: some MemcachedValue) async throws {
switch self.state {
case .initial(_, let bufferAllocator, _, _),
.running(let bufferAllocator, _, _, _):

var buffer = bufferAllocator.buffer(capacity: 0)
value.writeToBuffer(&buffer)
var flags: MemcachedFlags

flags = MemcachedFlags()
flags.storageMode = .replace

let command = MemcachedRequest.SetCommand(key: key, value: buffer, flags: flags)
let request = MemcachedRequest.set(command)

let response = try await sendRequest(request)

switch response.returnCode {
case .HD:
return
case .NS:
throw MemcachedConnectionError.keyNotFound
default:
throw MemcachedConnectionError.unexpectedNilResponse
}

case .finished:
throw MemcachedConnectionError.connectionShutdown
}
}
}
6 changes: 5 additions & 1 deletion Sources/SwiftMemcache/MemcachedFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ public enum TimeToLive: Equatable, Hashable {
}

/// Enum representing the Memcached 'ms' (meta set) command modes (corresponding to the 'M' flag).
public enum StorageMode: Equatable, Hashable {
enum StorageMode: Equatable, Hashable {
/// The "add" command. If the item exists, LRU is bumped and NS is returned.
case add
/// The 'append' command. If the item exists, append the new value to its data.
case append
/// The 'prepend' command. If the item exists, prepend the new value to its data.
case prepend
/// The "replace" command. The new value is set only if the item already exists.
case replace
}

extension MemcachedFlags: Hashable {}
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,134 @@ final class MemcachedIntegrationTest: XCTestCase {
}
}

func testAddValue() async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
XCTAssertNoThrow(try! group.syncShutdownGracefully())
}
let memcachedConnection = MemcachedConnection(host: "memcached", port: 11211, eventLoopGroup: group)

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await memcachedConnection.run() }

// Add a value to a key
let addValue = "foo"

// Attempt to delete the key, but ignore the error if it doesn't exist
do {
try await memcachedConnection.delete("adds")
} catch {
if "\(error)" != "keyNotFound" {
throw error
}
}

// Proceed with adding the key-value pair
try await memcachedConnection.add("adds", value: addValue)

// Get value for the key after add operation
let addedValue: String? = try await memcachedConnection.get("adds")
XCTAssertEqual(addedValue, addValue, "Received value should be the same as the added value")

group.cancelAll()
}
}

func testAddValueKeyExists() async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
XCTAssertNoThrow(try! group.syncShutdownGracefully())
}
let memcachedConnection = MemcachedConnection(host: "memcached", port: 11211, eventLoopGroup: group)

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await memcachedConnection.run() }

// Add a value to a key
let initialValue = "foo"
let newValue = "bar"

// Attempt to delete the key, but ignore the error if it doesn't exist
do {
try await memcachedConnection.delete("adds")
} catch {
if "\(error)" != "keyNotFound" {
throw error
Comment on lines +360 to +361
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we really need to work on better public errors so we allow users to catch this without relying on the string description of the error. Let's create an issue for this.

}
}

// Set an initial value for the key
try await memcachedConnection.add("adds", value: initialValue)

do {
// Attempt to add a new value to the existing key
try await memcachedConnection.add("adds", value: newValue)
XCTFail("Expected an error indicating the key exists, but no error was thrown.")
} catch {
// Check if the error description or localized description matches the expected error
if "\(error)" != "keyExist" {
XCTFail("Unexpected error: \(error)")
}
}

group.cancelAll()
}
}

func testReplaceValue() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a test where the key/value pair doesn't exist yet

let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
XCTAssertNoThrow(try! group.syncShutdownGracefully())
}
let memcachedConnection = MemcachedConnection(host: "memcached", port: 11211, eventLoopGroup: group)

try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await memcachedConnection.run() }

// Set key and initial value
let initialValue = "foo"
try await memcachedConnection.set("greet", value: initialValue)

// Replace value for the key
let replaceValue = "hi"
try await memcachedConnection.replace("greet", value: replaceValue)

// Get value for the key after replace operation
let replacedValue: String? = try await memcachedConnection.get("greet")
XCTAssertEqual(replacedValue, replaceValue, "Received value should be the same as the replaceValue")

group.cancelAll()
}
}

func testReplaceNonExistentKey() async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
XCTAssertNoThrow(try! group.syncShutdownGracefully())
}
let memcachedConnection = MemcachedConnection(host: "memcached", port: 11211, eventLoopGroup: group)

await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { try await memcachedConnection.run() }

do {
// Ensure the key is clean
try await memcachedConnection.delete("nonExistentKey")
// Attempt to replace value for a non-existent key
let replaceValue = "testValue"
try await memcachedConnection.replace("nonExistentKey", value: replaceValue)
XCTFail("Expected an error indicating the key was not found, but no error was thrown.")
} catch {
// Check if the error description or localized description matches the expected error
if "\(error)" != "keyNotFound" {
XCTFail("Unexpected error: \(error)")
}
}

group.cancelAll()
}
}

func testMemcachedConnectionWithUInt() async throws {
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
Expand Down
20 changes: 20 additions & 0 deletions Tests/SwiftMemcacheTests/UnitTest/MemcachedFlagsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ final class MemcachedFlagsTests: XCTestCase {
}
}

func testStorageModeAdd() {
var flags = MemcachedFlags()
flags.storageMode = .add
if case .add? = flags.storageMode {
XCTAssertTrue(true)
} else {
XCTFail("Flag storageMode is not .add")
}
}

func testStorageModeAppend() {
var flags = MemcachedFlags()
flags.storageMode = .append
Expand All @@ -57,4 +67,14 @@ final class MemcachedFlagsTests: XCTestCase {
XCTFail("Flag storageMode is not .prepend")
}
}

func testStorageModeReplace() {
var flags = MemcachedFlags()
flags.storageMode = .replace
if case .replace? = flags.storageMode {
XCTAssertTrue(true)
} else {
XCTFail("Flag storageMode is not .replace")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,38 +48,37 @@ final class MemcachedRequestEncoderTests: XCTestCase {
XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData)
}

func testEncodeAppendRequest() {
func testEncodeStorageRequest(withMode mode: StorageMode, expectedEncodedData: String) {
// Prepare a MemcachedRequest
var buffer = ByteBufferAllocator().buffer(capacity: 2)
buffer.writeString("hi")

var flags = MemcachedFlags()
flags.storageMode = .append
flags.storageMode = mode
let command = MemcachedRequest.SetCommand(key: "foo", value: buffer, flags: flags)
let request = MemcachedRequest.set(command)

// pass our request through the encoder
let outBuffer = self.encodeRequest(request)

let expectedEncodedData = "ms foo 2 MA\r\nhi\r\n"
// assert the encoded request
XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData)
}

func testEncodePrependRequest() {
// Prepare a MemcachedRequest
var buffer = ByteBufferAllocator().buffer(capacity: 2)
buffer.writeString("hi")
func testEncodeAppendRequest() {
self.testEncodeStorageRequest(withMode: .append, expectedEncodedData: "ms foo 2 MA\r\nhi\r\n")
}

var flags = MemcachedFlags()
flags.storageMode = .prepend
let command = MemcachedRequest.SetCommand(key: "foo", value: buffer, flags: flags)
let request = MemcachedRequest.set(command)
func testEncodePrependRequest() {
self.testEncodeStorageRequest(withMode: .prepend, expectedEncodedData: "ms foo 2 MP\r\nhi\r\n")
}

// pass our request through the encoder
let outBuffer = self.encodeRequest(request)
func testEncodeAddRequest() {
self.testEncodeStorageRequest(withMode: .add, expectedEncodedData: "ms foo 2 ME\r\nhi\r\n")
}

let expectedEncodedData = "ms foo 2 MP\r\nhi\r\n"
XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData)
func testEncodeReplaceRequest() {
self.testEncodeStorageRequest(withMode: .replace, expectedEncodedData: "ms foo 2 MR\r\nhi\r\n")
}

func testEncodeTouchRequest() {
Expand Down