Skip to content

Commit cb2bca1

Browse files
authored
GEO custom command responses (#128)
* GEO custom command responses Signed-off-by: Adam Fowler <[email protected]> * Fix reading integers from bulkString Signed-off-by: Adam Fowler <[email protected]> * Fix tests after rebase Signed-off-by: Adam Fowler <[email protected]> --------- Signed-off-by: Adam Fowler <[email protected]>
1 parent 783cc25 commit cb2bca1

File tree

6 files changed

+182
-11
lines changed

6 files changed

+182
-11
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the valkey-swift project
4+
//
5+
// Copyright (c) 2025 the valkey-swift authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See valkey-swift/CONTRIBUTORS.txt for the list of valkey-swift authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
public struct GeoCoordinates: RESPTokenDecodable, Sendable {
16+
public let longitude: Double
17+
public let latitude: Double
18+
19+
public init(fromRESP token: RESPToken) throws {
20+
(self.longitude, self.latitude) = try token.decodeArrayElements()
21+
}
22+
}
23+
24+
public typealias GEODISTResponse = Double?
25+
extension GEODIST {
26+
public typealias Response = GEODISTResponse
27+
}
28+
29+
extension GEOPOS {
30+
public typealias Response = [GeoCoordinates?]
31+
}
32+
33+
extension GEOSEARCH {
34+
/// Search entry for GEOSEARCH command.
35+
///
36+
/// Given the response for GEOSEARCH is dependent on which `with` attributes flags
37+
/// are set in the command it is not possible to know the structure of the response
38+
/// beforehand. The order the attributes are in the array, if relevant with flag is
39+
/// set, is distance, hash and coordinates. These can be decoded respectively as a
40+
/// `Double`, `String` and ``GeoCoordinate``
41+
public struct SearchEntry: RESPTokenDecodable, Sendable {
42+
public let member: String
43+
public let attributes: [RESPToken]
44+
45+
public init(fromRESP token: RESPToken) throws {
46+
switch token.value {
47+
case .array(let array):
48+
var arrayIterator = array.makeIterator()
49+
guard let member = arrayIterator.next() else {
50+
throw RESPParsingError(code: .unexpectedType, buffer: token.base)
51+
}
52+
self.member = try String(fromRESP: member)
53+
self.attributes = array.dropFirst().map { $0 }
54+
55+
case .bulkString(let buffer):
56+
self.member = String(buffer: buffer)
57+
self.attributes = []
58+
59+
default:
60+
throw RESPParsingError(code: .unexpectedType, buffer: token.base)
61+
}
62+
}
63+
}
64+
public typealias Response = [SearchEntry]
65+
}

Sources/Valkey/Commands/GeoCommands.swift

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,6 @@ public struct GEODIST<Member1: RESPStringRenderable, Member2: RESPStringRenderab
106106
}
107107
}
108108
}
109-
public typealias Response = ByteBuffer?
110-
111109
public var key: ValkeyKey
112110
public var member1: Member1
113111
public var member2: Member2
@@ -154,8 +152,6 @@ public struct GEOHASH: ValkeyCommand {
154152
/// Returns the longitude and latitude of members from a geospatial index.
155153
@_documentation(visibility: internal)
156154
public struct GEOPOS: ValkeyCommand {
157-
public typealias Response = RESPToken.Array
158-
159155
public var key: ValkeyKey
160156
public var members: [String]
161157

@@ -844,8 +840,6 @@ public struct GEOSEARCH: ValkeyCommand {
844840
RESPPureToken("ANY", any).encode(into: &commandEncoder)
845841
}
846842
}
847-
public typealias Response = RESPToken.Array
848-
849843
public var key: ValkeyKey
850844
public var from: From
851845
public var by: By
@@ -1141,7 +1135,7 @@ extension ValkeyConnectionProtocol {
11411135
member1: Member1,
11421136
member2: Member2,
11431137
unit: GEODIST<Member1, Member2>.Unit? = nil
1144-
) async throws -> ByteBuffer? {
1138+
) async throws -> GEODISTResponse {
11451139
try await send(command: GEODIST(key, member1: member1, member2: member2, unit: unit))
11461140
}
11471141

@@ -1163,7 +1157,7 @@ extension ValkeyConnectionProtocol {
11631157
/// - Complexity: O(1) for each member requested.
11641158
/// - Response: [Array]: An array where each element is a two elements array representing longitude and latitude (x,y) of each member name passed as argument to the command
11651159
@inlinable
1166-
public func geopos(_ key: ValkeyKey, members: [String] = []) async throws -> RESPToken.Array {
1160+
public func geopos(_ key: ValkeyKey, members: [String] = []) async throws -> GEOPOS.Response {
11671161
try await send(command: GEOPOS(key, members: members))
11681162
}
11691163

@@ -1341,7 +1335,7 @@ extension ValkeyConnectionProtocol {
13411335
withcoord: Bool = false,
13421336
withdist: Bool = false,
13431337
withhash: Bool = false
1344-
) async throws -> RESPToken.Array {
1338+
) async throws -> GEOSEARCH.Response {
13451339
try await send(
13461340
command: GEOSEARCH(
13471341
key,

Sources/Valkey/RESP/RESPTokenDecodable.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,13 @@ extension Int: RESPTokenDecodable {
175175
}
176176
self = value
177177

178-
case .bulkString,
179-
.simpleString,
178+
case .bulkString(let buffer):
179+
guard let value = Int(String(buffer: buffer)) else {
180+
throw RESPParsingError(code: .canNotParseInteger, buffer: token.base)
181+
}
182+
self = value
183+
184+
case .simpleString,
180185
.bulkError,
181186
.simpleError,
182187
.verbatimString,
@@ -200,6 +205,19 @@ extension Double: RESPTokenDecodable {
200205
switch token.value {
201206
case .double(let value):
202207
self = value
208+
209+
case .number(let value):
210+
guard let double = Double(exactly: value) else {
211+
throw RESPParsingError(code: .unexpectedType, buffer: token.base)
212+
}
213+
self = double
214+
215+
case .bulkString(let buffer):
216+
guard let value = Double(String(buffer: buffer)) else {
217+
throw RESPParsingError(code: .canNotParseDouble, buffer: token.base)
218+
}
219+
self = value
220+
203221
default:
204222
throw RESPParsingError(code: .unexpectedType, buffer: token.base)
205223
}

Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ func renderValkeyCommands(_ commands: [String: ValkeyCommand], fullCommandList:
1818
"BZPOPMAX",
1919
"BZPOPMIN",
2020
"CLUSTER SHARDS",
21+
"GEODIST",
22+
"GEOPOS",
23+
"GEOSEARCH",
2124
"LMPOP",
2225
"SPOP",
2326
"SSCAN",

Tests/IntegrationTests/ValkeyTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,4 +798,32 @@ struct GeneratedCommands {
798798
}
799799
}
800800
}
801+
802+
@available(valkeySwift 1.0, *)
803+
@Test
804+
func testGEOPOS() async throws {
805+
var logger = Logger(label: "Valkey")
806+
logger.logLevel = .trace
807+
try await withValkeyConnection(.hostname(valkeyHostname, port: 6379), logger: logger) { connection in
808+
try await withKey(connection: connection) { key in
809+
let count = try await connection.geoadd(
810+
key,
811+
datas: [.init(longitude: 1.0, latitude: 53.0, member: "Edinburgh"), .init(longitude: 1.4, latitude: 53.5, member: "Glasgow")]
812+
)
813+
#expect(count == 2)
814+
let search = try await connection.geosearch(
815+
key,
816+
from: .fromlonlat(.init(longitude: 0.0, latitude: 53.0)),
817+
by: .circle(.init(radius: 10000, unit: .mi)),
818+
withcoord: true,
819+
withdist: true,
820+
withhash: true
821+
)
822+
print(search.map { $0.member })
823+
try print(search.map { try $0.attributes[0].decode(as: Double.self) })
824+
try print(search.map { try $0.attributes[1].decode(as: String.self) })
825+
try print(search.map { try $0.attributes[2].decode(as: GeoCoordinates.self) })
826+
}
827+
}
828+
}
801829
}

Tests/ValkeyTests/CommandTests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,4 +700,67 @@ struct CommandTests {
700700
}
701701
}
702702
}
703+
704+
struct GeoCommands {
705+
@Test
706+
@available(valkeySwift 1.0, *)
707+
func geopos() async throws {
708+
let channel = NIOAsyncTestingChannel()
709+
let logger = Logger(label: "test")
710+
let connection = try await ValkeyConnection.setupChannelAndConnect(channel, configuration: .init(), logger: logger)
711+
try await channel.processHello()
712+
713+
try await withThrowingTaskGroup(of: Void.self) { group in
714+
group.addTask {
715+
let values = try await connection.geopos("key1", members: ["Edinburgh", "Glasgow", "Dundee"])
716+
#expect(values.count == 3)
717+
let edinburgh = try #require(values[0])
718+
#expect(values[1] == nil)
719+
let dundee = try #require(values[2])
720+
#expect(edinburgh.longitude == 1)
721+
#expect(edinburgh.latitude == 2)
722+
#expect(dundee.longitude == 3)
723+
#expect(dundee.latitude == 4)
724+
}
725+
group.addTask {
726+
let outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self)
727+
#expect(outbound == RESPToken(.command(["GEOPOS", "key1", "Edinburgh", "Glasgow", "Dundee"])).base)
728+
try await channel.writeInbound(
729+
RESPToken(
730+
.array([
731+
.array([.bulkString("1.0"), .bulkString("2.0")]),
732+
.null,
733+
.array([.bulkString("3.0"), .bulkString("4.0")]),
734+
])
735+
).base
736+
)
737+
}
738+
try await group.waitForAll()
739+
}
740+
}
741+
742+
@Test
743+
@available(valkeySwift 1.0, *)
744+
func geodist() async throws {
745+
let channel = NIOAsyncTestingChannel()
746+
let logger = Logger(label: "test")
747+
let connection = try await ValkeyConnection.setupChannelAndConnect(channel, configuration: .init(), logger: logger)
748+
try await channel.processHello()
749+
750+
try await withThrowingTaskGroup(of: Void.self) { group in
751+
group.addTask {
752+
let distance = try await connection.geodist("key1", member1: "Edinburgh", member2: "Glasgow")
753+
#expect(distance == 42)
754+
}
755+
group.addTask {
756+
let outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self)
757+
#expect(outbound == RESPToken(.command(["GEODIST", "key1", "Edinburgh", "Glasgow"])).base)
758+
try await channel.writeInbound(
759+
RESPToken(.bulkString("42.0")).base
760+
)
761+
}
762+
try await group.waitForAll()
763+
}
764+
}
765+
}
703766
}

0 commit comments

Comments
 (0)