diff --git a/Sources/NIORedis/Commands/HashCommands.swift b/Sources/NIORedis/Commands/HashCommands.swift new file mode 100644 index 0000000..f627438 --- /dev/null +++ b/Sources/NIORedis/Commands/HashCommands.swift @@ -0,0 +1,197 @@ +import NIO + +extension RedisCommandExecutor { + /// Sets the hash field stored at the provided key with the value specified. + /// + /// See [https://redis.io/commands/hset](https://redis.io/commands/hset) + /// - Returns: `true` if the hash was created, `false` if it was updated. + @inlinable + public func hset(_ key: String, field: String, to value: String) -> EventLoopFuture { + return send(command: "HSET", with: [key, field, value]) + .mapFromRESP(to: Int.self) + .map { return $0 == 1 } + } + + /// Sets the specified fields to the values provided, overwriting existing values. + /// + /// See [https://redis.io/commands/hmset](https://redis.io/commands/hmset) + @inlinable + public func hmset(_ key: String, to fields: [String: String]) -> EventLoopFuture { + assert(fields.count > 0, "At least 1 key-value pair should be specified") + + let args: [RESPValueConvertible] = fields.reduce(into: [], { (result, element) in + result.append(element.key) + result.append(element.value) + }) + + return send(command: "HMSET", with: [key] + args) + .map { _ in () } + } + + /// Sets the specified hash field to the value provided only if the field does not exist. + /// + /// See [https://redis.io/commands/hsetnx](https://redis.io/commands/hsetnx) + /// - Returns: The success of setting the field's value. + @inlinable + public func hsetnx(_ key: String, field: String, to value: String) -> EventLoopFuture { + return send(command: "HSETNX", with: [key, field, value]) + .mapFromRESP(to: Int.self) + .map { return $0 == 1 } + } + + /// Gets the value stored in the hash field at the key provided. + /// + /// See [https://redis.io/commands/hget](https://redis.io/commands/hget) + @inlinable + public func hget(_ key: String, field: String) -> EventLoopFuture { + return send(command: "HGET", with: [key, field]) + .map { return String($0) } + } + + /// Returns the values stored in the fields specified at the key provided. + /// + /// See [https://redis.io/commands/hmget](https://redis.io/commands/hmget) + /// - Returns: A list of values in the same order as the `fields` argument. + @inlinable + public func hmget(_ key: String, fields: [String]) -> EventLoopFuture<[String?]> { + assert(fields.count > 0, "At least 1 field should be specified") + + return send(command: "HMGET", with: [key] + fields) + .mapFromRESP(to: [RESPValue].self) + .map { return $0.map(String.init) } + } + + /// Returns all the fields and values stored at the provided key. + /// + /// See [https://redis.io/commands/hgetall](https://redis.io/commands/hgetall) + /// - Returns: A key-value pair list of fields and their values. + @inlinable + public func hgetall(from key: String) -> EventLoopFuture<[String: String]> { + return send(command: "HGETALL", with: [key]) + .mapFromRESP(to: [String].self) + .map(Self.mapHashResponseToDictionary) + } + + /// Removes the specified fields from the hash stored at the key provided. + /// + /// See [https://redis.io/commands/hdel](https://redis.io/commands/hdel) + /// - Returns: The number of fields that were deleted. + @inlinable + public func hdel(_ key: String, fields: [String]) -> EventLoopFuture { + assert(fields.count > 0, "At least 1 field should be specified") + + return send(command: "HDEL", with: [key] + fields) + .mapFromRESP() + } + + /// Checks if the provided key and field exist. + /// + /// See [https://redis.io/commands/hexists](https://redis.io/commands/hexists) + @inlinable + public func hexists(_ key: String, field: String) -> EventLoopFuture { + return send(command: "HEXISTS", with: [key, field]) + .mapFromRESP(to: Int.self) + .map { return $0 == 1 } + } + + /// Returns the number of fields contained in the hash stored at the key provided. + /// + /// See [https://redis.io/commands/hlen](https://redis.io/commands/hlen) + /// - Returns: The number of fields in the hash, or 0 if the key doesn't exist. + @inlinable + public func hlen(of key: String) -> EventLoopFuture { + return send(command: "HLEN", with: [key]) + .mapFromRESP() + } + + /// Returns hash field's value length as a string, stored at the provided key. + /// + /// See [https://redis.io/commands/hstrlen](https://redis.io/commands/hstrlen) + @inlinable + public func hstrlen(of key: String, field: String) -> EventLoopFuture { + return send(command: "HSTRLEN", with: [key, field]) + .mapFromRESP() + } + + /// Returns all field names in the hash stored at the key provided. + /// + /// See [https://redis.io/commands/hkeys](https://redis.io/commands/hkeys) + /// - Returns: An array of field names, or an empty array. + @inlinable + public func hkeys(storedAt key: String) -> EventLoopFuture<[String]> { + return send(command: "HKEYS", with: [key]) + .mapFromRESP() + } + + /// Returns all of the field values stored in hash at the key provided. + /// + /// See [https://redis.io/commands/hvals](https://redis.io/commands/hvals) + @inlinable + public func hvals(storedAt key: String) -> EventLoopFuture<[String]> { + return send(command: "HVALS", with: [key]) + .mapFromRESP() + } + + /// Increments the field value stored at the key provided, and returns the new value. + /// + /// See [https://redis.io/commands/hincrby](https://redis.io/commands/hincrby) + @inlinable + public func hincrby(_ key: String, field: String, by amount: Int) -> EventLoopFuture { + return send(command: "HINCRBY", with: [key, field, amount]) + .mapFromRESP() + } + + /// Increments the field value stored at the key provided, and returns the new value. + /// + /// See [https://redis.io/commands/hincrbyfloat](https://redis.io/commands/hincrbyfloat) + @inlinable + public func hincrbyfloat(_ key: String, field: String, by amount: T) -> EventLoopFuture + where T: RESPValueConvertible + { + return send(command: "HINCRBYFLOAT", with: [key, field, amount]) + .mapFromRESP() + } + + /// Incrementally iterates over all fields in the hash stored at the key provided. + /// + /// [https://redis.io/commands/scan](https://redis.io/commands/scan) + /// - Parameters: + /// - key: The key of the hash. + /// - atPosition: The position to start the scan from. + /// - count: The number of elements to advance by. Redis default is 10. + /// - matching: A glob-style pattern to filter values to be selected from the result set. + /// - Returns: A cursor position for additional invocations with a limited collection of values stored at the keys. + @inlinable + public func hscan( + _ key: String, + atPosition pos: Int = 0, + count: Int? = nil, + matching match: String? = nil) -> EventLoopFuture<(Int, [String: String])> + { + return _scan(command: "HSCAN", resultType: [String].self, key, pos, count, match) + .map { + let values = Self.mapHashResponseToDictionary($0.1) + return ($0.0, values) + } + } +} + +extension RedisCommandExecutor { + @inline(__always) + @usableFromInline + static func mapHashResponseToDictionary(_ values: [String]) -> [String: String] { + guard values.count > 0 else { return [:] } + + var result: [String: String] = [:] + + var index = 0 + repeat { + let field = values[index] + let value = values[index + 1] + result[field] = value + index += 2 + } while (index < values.count) + + return result + } +} diff --git a/Tests/NIORedisTests/Commands/HashCommandsTests.swift b/Tests/NIORedisTests/Commands/HashCommandsTests.swift new file mode 100644 index 0000000..3dd2255 --- /dev/null +++ b/Tests/NIORedisTests/Commands/HashCommandsTests.swift @@ -0,0 +1,183 @@ +@testable import NIORedis +import XCTest + +final class HashCommandsTests: XCTestCase { + private let redis = RedisDriver(ownershipModel: .internal(threadCount: 1)) + deinit { try? redis.terminate() } + + private var connection: RedisConnection! + + override func setUp() { + do { + connection = try redis.makeConnection().wait() + } catch { + XCTFail("Failed to create NIORedisConnection!") + } + } + + override func tearDown() { + _ = try? connection.send(command: "FLUSHALL").wait() + connection.close() + connection = nil + } + + func test_hset() throws { + var result = try connection.hset(#function, field: "test", to: "\(#line)").wait() + XCTAssertTrue(result) + result = try connection.hset(#function, field: "test", to: "\(#line)").wait() + XCTAssertFalse(result) + } + + func test_hmset() throws { + XCTAssertNoThrow(try connection.hmset(#function, to: ["field": "30"]).wait()) + let value = try connection.hget(#function, field: "field").wait() + XCTAssertEqual(value, "30") + } + + func test_hsetnx() throws { + var success = try connection.hsetnx(#function, field: "field", to: "foo").wait() + XCTAssertTrue(success) + success = try connection.hsetnx(#function, field: "field", to: "30").wait() + XCTAssertFalse(success) + + let value = try connection.hget(#function, field: "field").wait() + XCTAssertEqual(value, "foo") + } + + func test_hget() throws { + _ = try connection.hset(#function, field: "test", to: "30").wait() + let value = try connection.hget(#function, field: "test").wait() + XCTAssertEqual(value, "30") + } + + func test_hmget() throws { + _ = try connection.hmset(#function, to: ["first": "foo", "second": "bar"]).wait() + let values = try connection.hmget(#function, fields: ["first", "second", "fake"]).wait() + XCTAssertEqual(values[0], "foo") + XCTAssertEqual(values[1], "bar") + XCTAssertNil(values[2]) + } + + func test_hgetall() throws { + let dataset = ["first": "foo", "second": "bar"] + _ = try connection.hmset(#function, to: dataset).wait() + let hashes = try connection.hgetall(from: #function).wait() + XCTAssertEqual(hashes, dataset) + } + + func test_hdel() throws { + _ = try connection.hmset(#function, to: ["first": "foo", "second": "bar"]).wait() + let count = try connection.hdel(#function, fields: ["first", "second", "fake"]).wait() + XCTAssertEqual(count, 2) + } + + func test_hexists() throws { + var exists = try connection.hexists(#function, field: "foo").wait() + XCTAssertFalse(exists) + _ = try connection.hset(#function, field: "foo", to: "\(#line)").wait() + exists = try connection.hexists(#function, field: "foo").wait() + XCTAssertTrue(exists) + } + + func test_hlen() throws { + var count = try connection.hlen(of: #function).wait() + XCTAssertEqual(count, 0) + _ = try connection.hset(#function, field: "first", to: "\(#line)").wait() + count = try connection.hlen(of: #function).wait() + XCTAssertEqual(count, 1) + _ = try connection.hset(#function, field: "second", to: "\(#line)").wait() + count = try connection.hlen(of: #function).wait() + XCTAssertEqual(count, 2) + } + + func test_hstrlen() throws { + _ = try connection.hset(#function, field: "first", to: "foo").wait() + var size = try connection.hstrlen(of: #function, field: "first").wait() + XCTAssertEqual(size, 3) + _ = try connection.hset(#function, field: "second", to: "300").wait() + size = try connection.hstrlen(of: #function, field: "second").wait() + XCTAssertEqual(size, 3) + } + + func test_hkeys() throws { + let dataset = [ + "first": "3", + "second": "foo" + ] + _ = try connection.hmset(#function, to: dataset).wait() + let keys = try connection.hkeys(storedAt: #function).wait() + XCTAssertEqual(Array(dataset.keys), keys) + } + + func test_hvals() throws { + let dataset = [ + "first": "3", + "second": "foo" + ] + _ = try connection.hmset(#function, to: dataset).wait() + let values = try connection.hvals(storedAt: #function).wait() + XCTAssertEqual(Array(dataset.values), values) + } + + func test_hincrby() throws { + _ = try connection.hset(#function, field: "first", to: "3").wait() + var value = try connection.hincrby(#function, field: "first", by: 10).wait() + XCTAssertEqual(value, 13) + value = try connection.hincrby(#function, field: "first", by: -15).wait() + XCTAssertEqual(value, -2) + } + + func test_hincrbyfloat() throws { + _ = try connection.hset(#function, field: "first", to: "3.14").wait() + + let double = try connection.hincrbyfloat(#function, field: "first", by: Double(3.14)).wait() + XCTAssertEqual(double, 6.28) + + let float = try connection.hincrbyfloat(#function, field: "first", by: Float(-10.23523)).wait() + XCTAssertEqual(float, -3.95523) + } + + func test_hscan() throws { + var dataset: [String: String] = [:] + for index in 1...15 { + let key = "key\(index)\(index % 2 == 0 ? "_even" : "_odd")" + dataset[key] = "\(index)" + } + _ = try connection.hmset(#function, to: dataset).wait() + + var (cursor, fields) = try connection.hscan(#function, count: 5).wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(fields.count, 5) + + (_, fields) = try connection.hscan(#function, atPosition: cursor, count: 8).wait() + XCTAssertGreaterThanOrEqual(fields.count, 8) + + (cursor, fields) = try connection.hscan(#function, matching: "*_odd").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(fields.count, 1) + XCTAssertLessThanOrEqual(fields.count, 8) + + (cursor, fields) = try connection.hscan(#function, matching: "*_ev*").wait() + XCTAssertGreaterThanOrEqual(cursor, 0) + XCTAssertGreaterThanOrEqual(fields.count, 1) + XCTAssertLessThanOrEqual(fields.count, 7) + } + + static var allTests = [ + ("test_hset", test_hset), + ("test_hmset", test_hmset), + ("test_hsetnx", test_hsetnx), + ("test_hget", test_hget), + ("test_hmget", test_hmget), + ("test_hgetall", test_hgetall), + ("test_hdel", test_hdel), + ("test_hexists", test_hexists), + ("test_hlen", test_hlen), + ("test_hstrlen", test_hstrlen), + ("test_hkeys", test_hkeys), + ("test_hvals", test_hvals), + ("test_hincrby", test_hincrby), + ("test_hincrbyfloat", test_hincrbyfloat), + ("test_hscan", test_hscan), + ] +} diff --git a/Tests/NIORedisTests/XCTestManifests.swift b/Tests/NIORedisTests/XCTestManifests.swift index 64f734b..0ec3209 100644 --- a/Tests/NIORedisTests/XCTestManifests.swift +++ b/Tests/NIORedisTests/XCTestManifests.swift @@ -11,7 +11,8 @@ public func allTests() -> [XCTestCaseEntry] { testCase(RESPEncoderParsingTests.allTests), testCase(BasicCommandsTests.allTests), testCase(SetCommandsTests.allTests), - testCase(RedisPipelineTests.allTests) + testCase(RedisPipelineTests.allTests), + testCase(HashCommandsTests.allTests) ] } #endif