Skip to content

Commit ddfc7b0

Browse files
glbrnttMordil
authored andcommitted
Add SET options
Motivation: SET has a range of options for setting expirations and conditionally setting a value. Modification: - Add another `set` function with a range of options. Options are modelled as `struct`s backed by private `enum`s to allow additional options to be added without breaking API. - Added tests Result: Options may be specified with `set`, and resolves #67
1 parent 5749215 commit ddfc7b0

File tree

3 files changed

+206
-0
lines changed

3 files changed

+206
-0
lines changed

Sources/RediStack/Commands/StringCommands.swift

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,106 @@ extension RedisClient {
9393

9494
// MARK: Set
9595

96+
/// A condition which must hold true in order for a key to be set.
97+
///
98+
/// See [https://redis.io/commands/set](https://redis.io/commands/set)
99+
public struct RedisSetCommandCondition: Hashable {
100+
private enum Condition: String, Hashable {
101+
case keyExists = "XX"
102+
case keyDoesNotExist = "NX"
103+
}
104+
105+
private let condition: Condition?
106+
private init(_ condition: Condition?) {
107+
self.condition = condition
108+
}
109+
110+
/// The `RESPValue` representation of the condition.
111+
@usableFromInline
112+
internal var commandArgument: RESPValue? {
113+
return self.condition.map { RESPValue(from: $0.rawValue) }
114+
}
115+
}
116+
117+
extension RedisSetCommandCondition {
118+
/// No condition is required to be met in order to set the key's value.
119+
public static let none = RedisSetCommandCondition(.none)
120+
121+
/// Only set the key if it already exists.
122+
///
123+
/// Redis documentation refers to this as the option "XX".
124+
public static let keyExists = RedisSetCommandCondition(.keyExists)
125+
126+
/// Only set the key if it does not already exist.
127+
///
128+
/// Redis documentation refers to this as the option "NX".
129+
public static let keyDoesNotExist = RedisSetCommandCondition(.keyDoesNotExist)
130+
}
131+
132+
/// The expiration to apply when setting a key.
133+
///
134+
/// See [https://redis.io/commands/set](https://redis.io/commands/set)
135+
public struct RedisSetCommandExpiration: Hashable {
136+
private enum Expiration: Hashable {
137+
case keepExisting
138+
case seconds(Int)
139+
case milliseconds(Int)
140+
}
141+
142+
private let expiration: Expiration
143+
private init(_ expiration: Expiration) {
144+
self.expiration = expiration
145+
}
146+
147+
/// An array of `RESPValue`s representing this expiration.
148+
@usableFromInline
149+
internal func asCommandArguments() -> [RESPValue] {
150+
switch self.expiration {
151+
case .keepExisting:
152+
return [RESPValue(from: "KEEPTTL")]
153+
case .seconds(let amount):
154+
return [RESPValue(from: "EX"), amount.convertedToRESPValue()]
155+
case .milliseconds(let amount):
156+
return [RESPValue(from: "PX"), amount.convertedToRESPValue()]
157+
}
158+
}
159+
}
160+
161+
extension RedisSetCommandExpiration {
162+
/// Retain the existing expiration associated with the key, if one exists.
163+
///
164+
/// Redis documentation refers to this as "KEEPTTL".
165+
/// - 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.
166+
public static let keepExisting = RedisSetCommandExpiration(.keepExisting)
167+
168+
/// Expire the key after the given number of seconds.
169+
///
170+
/// Redis documentation refers to this as the option "EX".
171+
/// - Important: The actual amount used will be the specified value or `1`, whichever is larger.
172+
public static func seconds(_ amount: Int) -> RedisSetCommandExpiration {
173+
return RedisSetCommandExpiration(.seconds(max(amount, 1)))
174+
}
175+
176+
/// Expire the key after the given number of milliseconds.
177+
///
178+
/// Redis documentation refers to this as the option "PX".
179+
/// - Important: The actual amount used will be the specified value or `1`, whichever is larger.
180+
public static func milliseconds(_ amount: Int) -> RedisSetCommandExpiration {
181+
return RedisSetCommandExpiration(.milliseconds(max(amount, 1)))
182+
}
183+
}
184+
185+
/// The result of a `SET` command.
186+
public enum RedisSetCommandResult: Hashable {
187+
/// The command completed successfully.
188+
case ok
189+
190+
/// The command was not performed because a condition was not met.
191+
///
192+
/// See `RedisSetCommandCondition`.
193+
case conditionNotMet
194+
}
195+
96196
extension RedisClient {
97197
/// Append a value to the end of an existing entry.
98198
/// - 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 {
133233
.map { _ in () }
134234
}
135235

236+
/// Sets the key to the provided value with options to control how it is set.
237+
///
238+
/// [https://redis.io/commands/set](https://redis.io/commands/set)
239+
/// - Important: Regardless of the type of data stored at the key, it will be overwritten to a "string" data type.
240+
///
241+
/// ie. If the key is a reference to a Sorted Set, its value will be overwritten to be a "string" data type.
242+
///
243+
/// - Parameters:
244+
/// - key: The key to use to uniquely identify this value.
245+
/// - value: The value to set the key to.
246+
/// - condition: The condition under which the key should be set.
247+
/// - expiration: The expiration to use when setting the key. No expiration is set if `nil`.
248+
/// - Returns: A `NIO.EventLoopFuture` indicating the result of the operation;
249+
/// `.ok` if the operation was successful and `.conditionNotMet` if the specified `condition` was not met.
250+
///
251+
/// If the condition `.none` was used, then the result value will always be `.ok`.
252+
public func set<Value: RESPValueConvertible>(
253+
_ key: RedisKey,
254+
to value: Value,
255+
onCondition condition: RedisSetCommandCondition,
256+
expiration: RedisSetCommandExpiration? = nil
257+
) -> EventLoopFuture<RedisSetCommandResult> {
258+
var args: [RESPValue] = [
259+
.init(from: key),
260+
value.convertedToRESPValue()
261+
]
262+
263+
if let conditionArgument = condition.commandArgument {
264+
args.append(conditionArgument)
265+
}
266+
267+
if let expiration = expiration {
268+
args.append(contentsOf: expiration.asCommandArguments())
269+
}
270+
271+
return self.send(command: "SET", with: args)
272+
.map { return $0.isNull ? .conditionNotMet : .ok }
273+
}
274+
136275
/// Sets the key to the provided value if the key does not exist.
137276
///
138277
/// [https://redis.io/commands/setnx](https://redis.io/commands/setnx)

Tests/RediStackIntegrationTests/Commands/StringCommandsTests.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,70 @@ final class StringCommandsTests: RediStackIntegrationTestCase {
5252
XCTAssertEqual(val, "value")
5353
}
5454

55+
func test_set_condition() throws {
56+
XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyExists).wait(), .conditionNotMet)
57+
XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyDoesNotExist).wait(), .ok)
58+
XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyDoesNotExist).wait(), .conditionNotMet)
59+
XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .keyExists).wait(), .ok)
60+
XCTAssertEqual(try connection.set(#function, to: "value", onCondition: .none).wait(), .ok)
61+
}
62+
63+
func test_set_expiration() throws {
64+
let expireInSecondsKey = RedisKey("\(#function)-seconds")
65+
let expireInSecondsResult = connection.set(
66+
expireInSecondsKey,
67+
to: "value",
68+
onCondition: .none,
69+
expiration: .seconds(42)
70+
)
71+
XCTAssertEqual(try expireInSecondsResult.wait(), .ok)
72+
73+
let ttl = try connection.ttl(expireInSecondsKey).wait()
74+
switch ttl {
75+
case .keyDoesNotExist, .unlimited:
76+
XCTFail("Unexpected TTL for key \(expireInSecondsKey)")
77+
case .limited(let lifetime):
78+
XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0))
79+
XCTAssertLessThanOrEqual(lifetime.timeAmount, .seconds(42))
80+
}
81+
82+
let expireInMillisecondsKey = RedisKey("\(#function)-milliseconds")
83+
let expireInMillisecondsResult = connection.set(
84+
expireInMillisecondsKey,
85+
to: "value",
86+
onCondition: .none,
87+
expiration: .milliseconds(42_000)
88+
)
89+
90+
XCTAssertEqual(try expireInMillisecondsResult.wait(), .ok)
91+
92+
let pttl = try connection.ttl(expireInMillisecondsKey).wait()
93+
switch pttl {
94+
case .keyDoesNotExist, .unlimited:
95+
XCTFail("Unexpected TTL for key \(expireInMillisecondsKey)")
96+
case .limited(let lifetime):
97+
XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0))
98+
XCTAssertLessThanOrEqual(lifetime.timeAmount, .milliseconds(42_000))
99+
}
100+
}
101+
102+
func test_set_condition_and_expiration() throws {
103+
let setFailedResult = connection.set(#function, to: "value", onCondition: .keyExists, expiration: .seconds(42))
104+
XCTAssertEqual(try setFailedResult.wait(), .conditionNotMet)
105+
106+
let setResult = connection.set(#function, to: "value", onCondition: .keyDoesNotExist, expiration: .seconds(42))
107+
XCTAssertEqual(try setResult.wait(), .ok)
108+
109+
let ttl = try connection.ttl(#function).wait()
110+
switch ttl {
111+
case .keyDoesNotExist, .unlimited:
112+
XCTFail("Unexpected TTL for key \(#function)")
113+
case .limited(let lifetime):
114+
XCTAssertGreaterThan(lifetime.timeAmount, .nanoseconds(0))
115+
XCTAssertLessThanOrEqual(lifetime.timeAmount, .seconds(42))
116+
}
117+
}
118+
55119
func test_setnx() throws {
56120
XCTAssertTrue(try connection.setnx(#function, to: "value").wait())
57121
XCTAssertFalse(try connection.setnx(#function, to: "value").wait())

Tests/RediStackIntegrationTests/XCTestManifests.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ extension StringCommandsTests {
157157
("test_mget", test_mget),
158158
("test_mset", test_mset),
159159
("test_msetnx", test_msetnx),
160+
("test_set_condition_and_expiration", test_set_condition_and_expiration),
161+
("test_set_condition", test_set_condition),
162+
("test_set_expiration", test_set_expiration),
160163
("test_set", test_set),
161164
("test_setnx", test_setnx),
162165
]

0 commit comments

Comments
 (0)