diff --git a/Sources/SwiftMemcache/Extensions/ByteBuffer+SwiftMemcache.swift b/Sources/SwiftMemcache/Extensions/ByteBuffer+SwiftMemcache.swift index 1ad9333..5df7c6d 100644 --- a/Sources/SwiftMemcache/Extensions/ByteBuffer+SwiftMemcache.swift +++ b/Sources/SwiftMemcache/Extensions/ByteBuffer+SwiftMemcache.swift @@ -86,6 +86,19 @@ extension ByteBuffer { } } } + + if let storageMode = flags.storageMode { + switch storageMode { + 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) + } + } } } diff --git a/Sources/SwiftMemcache/Extensions/UInt8+Characters.swift b/Sources/SwiftMemcache/Extensions/UInt8+Characters.swift index bda618f..e77f5bd 100644 --- a/Sources/SwiftMemcache/Extensions/UInt8+Characters.swift +++ b/Sources/SwiftMemcache/Extensions/UInt8+Characters.swift @@ -22,6 +22,9 @@ extension UInt8 { static var d: UInt8 = .init(ascii: "d") static var v: UInt8 = .init(ascii: "v") static var T: UInt8 = .init(ascii: "T") + static var M: UInt8 = .init(ascii: "M") + static var P: UInt8 = .init(ascii: "P") + static var A: UInt8 = .init(ascii: "A") static var zero: UInt8 = .init(ascii: "0") static var nine: UInt8 = .init(ascii: "9") } diff --git a/Sources/SwiftMemcache/MemcachedConnection.swift b/Sources/SwiftMemcache/MemcachedConnection.swift index 05549b7..0dd79c4 100644 --- a/Sources/SwiftMemcache/MemcachedConnection.swift +++ b/Sources/SwiftMemcache/MemcachedConnection.swift @@ -280,4 +280,64 @@ public actor MemcachedConnection { throw MemcachedConnectionError.connectionShutdown } } + + // MARK: - Prepending a Value + + /// Prepend a value to an existing key in the Memcache server. + /// + /// - Parameters: + /// - key: The key to prepend the value to. + /// - value: The `MemcachedValue` to prepend. + /// - Throws: A `MemcachedConnectionError` if the connection to the Memcached server is shut down. + public func prepend(_ 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 = .prepend + + let command = MemcachedRequest.SetCommand(key: key, value: buffer, flags: flags) + let request = MemcachedRequest.set(command) + + _ = try await self.sendRequest(request) + + case .finished: + throw MemcachedConnectionError.connectionShutdown + } + } + + // MARK: - Appending a Value + + /// Append a value to an existing key in the Memcache server. + /// + /// - Parameters: + /// - key: The key to append the value to. + /// - value: The `MemcachedValue` to append. + /// - Throws: A `MemcachedConnectionError` if the connection to the Memcached server is shut down. + public func append(_ 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 = .append + + let command = MemcachedRequest.SetCommand(key: key, value: buffer, flags: flags) + let request = MemcachedRequest.set(command) + + _ = try await self.sendRequest(request) + + case .finished: + throw MemcachedConnectionError.connectionShutdown + } + } } diff --git a/Sources/SwiftMemcache/MemcachedFlags.swift b/Sources/SwiftMemcache/MemcachedFlags.swift index 08d1619..b112bbd 100644 --- a/Sources/SwiftMemcache/MemcachedFlags.swift +++ b/Sources/SwiftMemcache/MemcachedFlags.swift @@ -31,6 +31,12 @@ struct MemcachedFlags { /// If set, the item is considered to be expired after this number of seconds. var timeToLive: TimeToLive? + /// Mode for the 'ms' (meta set) command (corresponding to the 'M' flag). + /// + /// Represents the mode of the 'ms' command, which determines the behavior of the data operation. + /// The default mode is 'set'. + var storageMode: StorageMode? + init() {} } @@ -42,4 +48,12 @@ public enum TimeToLive: Equatable, Hashable { case expiresAt(ContinuousClock.Instant) } +/// Enum representing the Memcached 'ms' (meta set) command modes (corresponding to the 'M' flag). +public enum StorageMode: Equatable, Hashable { + /// 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 +} + extension MemcachedFlags: Hashable {} diff --git a/Tests/SwiftMemcacheTests/IntegrationTest/MemcachedIntegrationTests.swift b/Tests/SwiftMemcacheTests/IntegrationTest/MemcachedIntegrationTests.swift index a2ed847..9fd3320 100644 --- a/Tests/SwiftMemcacheTests/IntegrationTest/MemcachedIntegrationTests.swift +++ b/Tests/SwiftMemcacheTests/IntegrationTest/MemcachedIntegrationTests.swift @@ -254,6 +254,58 @@ final class MemcachedIntegrationTest: XCTestCase { } } + func testPrependValue() 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() } + + // Set key and initial value + let initialValue = "foo" + try await memcachedConnection.set("greet", value: initialValue) + + // Prepend value to key + let prependValue = "Hi" + try await memcachedConnection.prepend("greet", value: prependValue) + + // Get value for key after prepend operation + let updatedValue: String? = try await memcachedConnection.get("greet") + XCTAssertEqual(updatedValue, prependValue + initialValue, "Received value should be the same as the concatenation of prependValue and initialValue") + + group.cancelAll() + } + } + + func testAppendValue() 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() } + + // Set key and initial value + let initialValue = "hi" + try await memcachedConnection.set("greet", value: initialValue) + + // Append value to key + let appendValue = "foo" + try await memcachedConnection.append("greet", value: appendValue) + + // Get value for key after append operation + let updatedValue: String? = try await memcachedConnection.get("greet") + XCTAssertEqual(updatedValue, initialValue + appendValue, "Received value should be the same as the concatenation of initialValue and appendValue") + + group.cancelAll() + } + } + func testMemcachedConnectionWithUInt() async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) defer { diff --git a/Tests/SwiftMemcacheTests/UnitTest/MemcachedFlagsTests.swift b/Tests/SwiftMemcacheTests/UnitTest/MemcachedFlagsTests.swift index cba5067..5d51533 100644 --- a/Tests/SwiftMemcacheTests/UnitTest/MemcachedFlagsTests.swift +++ b/Tests/SwiftMemcacheTests/UnitTest/MemcachedFlagsTests.swift @@ -37,4 +37,24 @@ final class MemcachedFlagsTests: XCTestCase { XCTFail("Flag timeToLive is nil") } } + + func testStorageModeAppend() { + var flags = MemcachedFlags() + flags.storageMode = .append + if case .append? = flags.storageMode { + XCTAssertTrue(true) + } else { + XCTFail("Flag storageMode is not .append") + } + } + + func testStorageModePrepend() { + var flags = MemcachedFlags() + flags.storageMode = .prepend + if case .prepend? = flags.storageMode { + XCTAssertTrue(true) + } else { + XCTFail("Flag storageMode is not .prepend") + } + } } diff --git a/Tests/SwiftMemcacheTests/UnitTest/MemcachedRequestEncoderTests.swift b/Tests/SwiftMemcacheTests/UnitTest/MemcachedRequestEncoderTests.swift index 8dad036..2734d76 100644 --- a/Tests/SwiftMemcacheTests/UnitTest/MemcachedRequestEncoderTests.swift +++ b/Tests/SwiftMemcacheTests/UnitTest/MemcachedRequestEncoderTests.swift @@ -24,6 +24,16 @@ final class MemcachedRequestEncoderTests: XCTestCase { self.encoder = MemcachedRequestEncoder() } + func encodeRequest(_ request: MemcachedRequest) -> ByteBuffer { + var outBuffer = ByteBufferAllocator().buffer(capacity: 0) + do { + try self.encoder.encode(data: request, out: &outBuffer) + } catch { + XCTFail("Encoding failed with error: \(error)") + } + return outBuffer + } + func testEncodeSetRequest() { // Prepare a MemcachedRequest var buffer = ByteBufferAllocator().buffer(capacity: 2) @@ -32,17 +42,46 @@ final class MemcachedRequestEncoderTests: XCTestCase { let request = MemcachedRequest.set(command) // pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + let outBuffer = self.encodeRequest(request) let expectedEncodedData = "ms foo 2\r\nhi\r\n" XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) } + func testEncodeAppendRequest() { + // Prepare a MemcachedRequest + var buffer = ByteBufferAllocator().buffer(capacity: 2) + buffer.writeString("hi") + + var flags = MemcachedFlags() + flags.storageMode = .append + 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" + XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) + } + + func testEncodePrependRequest() { + // Prepare a MemcachedRequest + var buffer = ByteBufferAllocator().buffer(capacity: 2) + buffer.writeString("hi") + + var flags = MemcachedFlags() + flags.storageMode = .prepend + 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 MP\r\nhi\r\n" + XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) + } + func testEncodeTouchRequest() { // Prepare a MemcachedRequest var flags = MemcachedFlags() @@ -53,12 +92,7 @@ final class MemcachedRequestEncoderTests: XCTestCase { let request = MemcachedRequest.get(command) // pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + let outBuffer = self.encodeRequest(request) let expectedEncodedData = "mg foo T89\r\n" XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) @@ -75,12 +109,7 @@ final class MemcachedRequestEncoderTests: XCTestCase { let request = MemcachedRequest.get(command) // pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + let outBuffer = self.encodeRequest(request) // Time-To-Live has been transformed to a Unix timestamp var timespec = timespec() @@ -109,12 +138,7 @@ final class MemcachedRequestEncoderTests: XCTestCase { let request = MemcachedRequest.get(command) // pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + let outBuffer = self.encodeRequest(request) let expectedEncodedData = "mg foo T0\r\n" XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) @@ -128,13 +152,8 @@ final class MemcachedRequestEncoderTests: XCTestCase { let request = MemcachedRequest.get(command) - // Pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + // pass our request through the encoder + let outBuffer = self.encodeRequest(request) let expectedEncodedData = "mg foo v\r\n" XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData) @@ -145,13 +164,8 @@ final class MemcachedRequestEncoderTests: XCTestCase { let command = MemcachedRequest.DeleteCommand(key: "foo") let request = MemcachedRequest.delete(command) - // Pass our request through the encoder - var outBuffer = ByteBufferAllocator().buffer(capacity: 0) - do { - try self.encoder.encode(data: request, out: &outBuffer) - } catch { - XCTFail("Encoding failed with error: \(error)") - } + // pass our request through the encoder + let outBuffer = self.encodeRequest(request) let expectedEncodedData = "md foo\r\n" XCTAssertEqual(outBuffer.getString(at: 0, length: outBuffer.readableBytes), expectedEncodedData)