diff --git a/Sources/RediStack/Commands/StringCommands.swift b/Sources/RediStack/Commands/StringCommands.swift index 7e7db02..0d7ac57 100644 --- a/Sources/RediStack/Commands/StringCommands.swift +++ b/Sources/RediStack/Commands/StringCommands.swift @@ -93,6 +93,106 @@ extension RedisClient { // MARK: Set +/// A condition which must hold true in order for a key to be set. +/// +/// See [https://redis.io/commands/set](https://redis.io/commands/set) +public struct RedisSetCommandCondition: Hashable { + private enum Condition: String, Hashable { + case keyExists = "XX" + case keyDoesNotExist = "NX" + } + + private let condition: Condition? + private init(_ condition: Condition?) { + self.condition = condition + } + + /// The `RESPValue` representation of the condition. + @usableFromInline + internal var commandArgument: RESPValue? { + return self.condition.map { RESPValue(from: $0.rawValue) } + } +} + +extension RedisSetCommandCondition { + /// No condition is required to be met in order to set the key's value. + public static let none = RedisSetCommandCondition(.none) + + /// Only set the key if it already exists. + /// + /// Redis documentation refers to this as the option "XX". + public static let keyExists = RedisSetCommandCondition(.keyExists) + + /// Only set the key if it does not already exist. + /// + /// Redis documentation refers to this as the option "NX". + public static let keyDoesNotExist = RedisSetCommandCondition(.keyDoesNotExist) +} + +/// The expiration to apply when setting a key. +/// +/// See [https://redis.io/commands/set](https://redis.io/commands/set) +public struct RedisSetCommandExpiration: Hashable { + private enum Expiration: Hashable { + case keepExisting + case seconds(Int) + case milliseconds(Int) + } + + private let expiration: Expiration + private init(_ expiration: Expiration) { + self.expiration = expiration + } + + /// An array of `RESPValue`s representing this expiration. + @usableFromInline + internal func asCommandArguments() -> [RESPValue] { + switch self.expiration { + case .keepExisting: + return [RESPValue(from: "KEEPTTL")] + case .seconds(let amount): + return [RESPValue(from: "EX"), amount.convertedToRESPValue()] + case .milliseconds(let amount): + return [RESPValue(from: "PX"), amount.convertedToRESPValue()] + } + } +} + +extension RedisSetCommandExpiration { + /// Retain the existing expiration associated with the key, if one exists. + /// + /// Redis documentation refers to this as "KEEPTTL". + /// - Important: This is option is only available in Redis 6.0+. An error will be returned if this value is sent in lower versions of Redis. + public static let keepExisting = RedisSetCommandExpiration(.keepExisting) + + /// Expire the key after the given number of seconds. + /// + /// Redis documentation refers to this as the option "EX". + /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. + public static func seconds(_ amount: Int) -> RedisSetCommandExpiration { + return RedisSetCommandExpiration(.seconds(max(amount, 1))) + } + + /// Expire the key after the given number of milliseconds. + /// + /// Redis documentation refers to this as the option "PX". + /// - Important: The actual amount used will be the specified value or `1`, whichever is larger. + public static func milliseconds(_ amount: Int) -> RedisSetCommandExpiration { + return RedisSetCommandExpiration(.milliseconds(max(amount, 1))) + } +} + +/// The result of a `SET` command. +public enum RedisSetCommandResult: Hashable { + /// The command completed successfully. + case ok + + /// The command was not performed because a condition was not met. + /// + /// See `RedisSetCommandCondition`. + case conditionNotMet +} + extension RedisClient { /// Append a value to the end of an existing entry. /// - Note: If the key does not exist, it is created and set as an empty string, so `APPEND` will be similar to `SET` in this special case. @@ -133,6 +233,45 @@ extension RedisClient { .map { _ in () } } + /// Sets the key to the provided value with options to control how it is set. + /// + /// [https://redis.io/commands/set](https://redis.io/commands/set) + /// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type. + /// + /// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type. + /// + /// - Parameters: + /// - key: The key to use to uniquely identify this value. + /// - value: The value to set the key to. + /// - condition: The condition under which the key should be set. + /// - expiration: The expiration to use when setting the key. No expiration is set if `nil`. + /// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation; + /// `.ok` if the operation was successful and `.conditionNotMet` if the specified `condition` was not met. + /// + /// If the condition `.none` was used, then the result value will always be `.ok`. + public func set( + _ key: RedisKey, + to value: Value, + onCondition condition: RedisSetCommandCondition, + expiration: RedisSetCommandExpiration? = nil + ) -> EventLoopFuture { + var args: [RESPValue] = [ + .init(from: key), + value.convertedToRESPValue() + ] + + if let conditionArgument = condition.commandArgument { + args.append(conditionArgument) + } + + if let expiration = expiration { + args.append(contentsOf: expiration.asCommandArguments()) + } + + return self.send(command: "SET", with: args) + .map { return $0.isNull ? .conditionNotMet : .ok } + } + /// Sets the key to the provided value if the key does not exist. /// /// [https://redis.io/commands/setnx](https://redis.io/commands/setnx) diff --git a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift index 5f3c210..9fe5d71 100644 --- a/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift +++ b/Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift @@ -52,6 +52,70 @@ final class StringCommandsTests: RediStackIntegrationTestCase { XCTAssertEqual(val, "value") } + func test_set_condition() throws { + XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyExists).wait(), .conditionNotMet) + XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyDoesNotExist).wait(), .ok) + XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyDoesNotExist).wait(), .conditionNotMet) + XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyExists).wait(), .ok) + XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .none).wait(), .ok) + } + + func test_set_expiration() throws { + let expireInSecondsKey = RedisKey("\(#function)-seconds") + let expireInSecondsResult = connection.set( + expireInSecondsKey, + to: "value", + onCondition: .none, + expiration: .seconds(42) + ) + XCTAssertEqual(try expireInSecondsResult.wait(), .ok) + + let ttl = try connection.ttl(expireInSecondsKey).wait() + switch ttl { + case .keyDoesNotExist, .unlimited: + XCTFail("Unexpected TTL for key \(expireInSecondsKey)") + case .limited(let lifetime): + XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0)) + XCTAssertLessThanOrEqual(lifetime.timeAmount, .seconds(42)) + } + + let expireInMillisecondsKey = RedisKey("\(#function)-milliseconds") + let expireInMillisecondsResult = connection.set( + expireInMillisecondsKey, + to: "value", + onCondition: .none, + expiration: .milliseconds(42_000) + ) + + XCTAssertEqual(try expireInMillisecondsResult.wait(), .ok) + + let pttl = try connection.ttl(expireInMillisecondsKey).wait() + switch pttl { + case .keyDoesNotExist, .unlimited: + XCTFail("Unexpected TTL for key \(expireInMillisecondsKey)") + case .limited(let lifetime): + XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0)) + XCTAssertLessThanOrEqual(lifetime.timeAmount, .milliseconds(42_000)) + } + } + + func test_set_condition_and_expiration() throws { + let setFailedResult = connection.set(#function, to: "value", onCondition: .keyExists, expiration: .seconds(42)) + XCTAssertEqual(try setFailedResult.wait(), .conditionNotMet) + + let setResult = connection.set(#function, to: "value", onCondition: .keyDoesNotExist, expiration: .seconds(42)) + XCTAssertEqual(try setResult.wait(), .ok) + + let ttl = try connection.ttl(#function).wait() + switch ttl { + case .keyDoesNotExist, .unlimited: + XCTFail("Unexpected TTL for key \(#function)") + case .limited(let lifetime): + XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0)) + XCTAssertLessThanOrEqual(lifetime.timeAmount, .seconds(42)) + } + } + func test_setnx() throws { XCTAssertTrue(try connection.setnx(#function, to: "value").wait()) XCTAssertFalse(try connection.setnx(#function, to: "value").wait()) diff --git a/Tests/RediStackIntegrationTests/XCTestManifests.swift b/Tests/RediStackIntegrationTests/XCTestManifests.swift index d953e17..4560d07 100644 --- a/Tests/RediStackIntegrationTests/XCTestManifests.swift +++ b/Tests/RediStackIntegrationTests/XCTestManifests.swift @@ -157,6 +157,9 @@ extension StringCommandsTests { ("test_mget", test_mget), ("test_mset", test_mset), ("test_msetnx", test_msetnx), + ("test_set_condition_and_expiration", test_set_condition_and_expiration), + ("test_set_condition", test_set_condition), + ("test_set_expiration", test_set_expiration), ("test_set", test_set), ("test_setnx", test_setnx), ]