diff --git a/docs/src/content/docs/commands/GEOPOS.md b/docs/src/content/docs/commands/GEOPOS.md new file mode 100644 index 000000000..2d2d16ae7 --- /dev/null +++ b/docs/src/content/docs/commands/GEOPOS.md @@ -0,0 +1,58 @@ +--- +title: GEOPOS +description: The `GEOPOS` command in DiceDB is used to return the longitude, latitude to a specified key, as stored in the sorted set. +--- + +The `GEOPOS` command in DiceDB is used to return the longitude, latitude to a specified key which is stored in a sorted set. When elements are added via `GEOADD` then they are stored in 52 bit geohash hence the values returned by `GEOPOS` might have small margins of error. + +## Syntax + +```bash +GEOPOS key [member [member ...]] +``` +## Parameters +| Parameter | Description | Type | Required | +| --------- | --------------------------------------------------------------------------------- | ------ | -------- | +| key | The name of the sorted set key whose member's coordinates are to be returned | string | Yes | +| member | A unique identifier for the location. | string | Yes | +## Return Values +| Condition | Return Value | +| ------------------------------------------------------------ | ----------------------------------------------------------- | +| Coordinates exist for the specified member(s) | Returns an ordered list of coordinates (longitude, latitude) for each specified member | +| Coordinates do not exist for the specified member(s) | Returns `(nil)` for each member without coordinates +| Incorrect Argument Count |`ERR wrong number of arguments for 'geopos' command` | +| Key does not exist in the sorted set |`Error: nil` | +## Behaviour +When the GEOPOS command is issued, DiceDB performs the following steps: +1. It checks if argument count is valid or not. If not an error is thrown. +2. It checks the validity of the key. +3. If the key is invalid then an error is returned. +4. Else it checks the members provided after the key. +5. For each member it checks the coordinates of the member. +6. If the coordinates exist then it is returned in an ordered list of latitude, longitude for the particular member. +7. If the coordinates do not exist then a ``(nil)`` is returned for that member. + +## Errors +1. `Wrong number of arguments for 'GEOPOS' command` + - Error Message: (error) ERR wrong number of arguments for 'geoadd' command. + - Occurs when the command is executed with an incorrect number of arguments. +2. `Wrong key for 'GEOPOS' command` + - Error Message: Error: nil + - Occurs when the command is executed with a key that does not exist in the sorted set. + +## Example Usage + +Here are a few examples demonstrating the usage of the GEOPOS command: +### Example: Fetching the latitude, longitude of an existing member of the set +```bash +127.0.0.1:7379> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania" +2 +127.0.0.1:7379> GEOPOS Sicily "Palermo" +1) 1) 13.361387 +2) 38.115556 +``` +### Example: Fetching the latitude, longitude of a member not in the set +```bash +127.0.0.1:7379> GEOPOS Sicily "Agrigento" +1) (nil) +``` \ No newline at end of file diff --git a/integration_tests/commands/http/geo_test.go b/integration_tests/commands/http/geo_test.go index a8e5c6e93..2943c3fa2 100644 --- a/integration_tests/commands/http/geo_test.go +++ b/integration_tests/commands/http/geo_test.go @@ -84,3 +84,59 @@ func TestGeoDist(t *testing.T) { }) } } + +func TestGeoPos(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + }{ + { + name: "GEOPOS for existing points", + commands: []HTTPCommand{ + {Command: "GEOADD", Body: map[string]interface{}{"key": "index", "values": []interface{}{"13.361389", "38.115556", "Palermo"}}}, + {Command: "GEOPOS", Body: map[string]interface{}{"key": "index", "values": []interface{}{"Palermo"}}}, + }, + expected: []interface{}{ + float64(1), + []interface{}{[]interface{}{float64(13.361387), float64(38.115556)}}, + }, + }, + { + name: "GEOPOS for non-existing points", + commands: []HTTPCommand{ + {Command: "GEOPOS", Body: map[string]interface{}{"key": "index", "values": []interface{}{"NonExisting"}}}, + }, + expected: []interface{}{[]interface{}{nil}}, + }, + { + name: "GEOPOS for non-existing index", + commands: []HTTPCommand{ + {Command: "GEOPOS", Body: map[string]interface{}{"key": "NonExisting", "values": []interface{}{"Palermo"}}}, + }, + expected: []interface{}{nil}, + }, + { + name: "GEOPOS for a key not used for setting geospatial values", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "k", "value": "v"}}, + {Command: "GEOPOS", Body: map[string]interface{}{"key": "k", "values": []interface{}{"v"}}}, + }, + expected: []interface{}{ + "OK", + "WRONGTYPE Operation against a key holding the wrong kind of value", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %v", cmd) + } + }) + } +} diff --git a/integration_tests/commands/resp/geo_test.go b/integration_tests/commands/resp/geo_test.go index 57bd2656a..b6eefc927 100644 --- a/integration_tests/commands/resp/geo_test.go +++ b/integration_tests/commands/resp/geo_test.go @@ -84,3 +84,63 @@ func TestGeoDist(t *testing.T) { }) } } + +func TestGeoPos(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + testCases := []struct { + name string + cmds []string + expect []interface{} + delays []time.Duration + }{ + { + name: "GEOPOS b/w existing points", + cmds: []string{ + "GEOADD index 13.361389 38.115556 Palermo", + "GEOPOS index Palermo", + }, + expect: []interface{}{ + int64(1), + []interface{}{[]interface{}{"13.361387", "38.115556"}}, + }, + }, + { + name: "GEOPOS for non existing points", + cmds: []string{ + "GEOPOS index NonExisting", + }, + expect: []interface{}{ + []interface{}{"(nil)"}, + }, + }, + { + name: "GEOPOS for non existing index", + cmds: []string{ + "GEOPOS NonExisting Palermo", + }, + expect: []interface{}{"(nil)"}, + }, + { + name: "GEOPOS for a key not used for setting geospatial values", + cmds: []string{ + "SET k v", + "GEOPOS k v", + }, + expect: []interface{}{ + "OK", + "WRONGTYPE Operation against a key holding the wrong kind of value", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.cmds { + result := FireCommand(conn, cmd) + assert.Equal(t, tc.expect[i], result, "Value mismatch for cmd %s", cmd) + } + }) + } +} diff --git a/integration_tests/commands/websocket/geo_test.go b/integration_tests/commands/websocket/geo_test.go index e2c5d240b..e7402f7de 100644 --- a/integration_tests/commands/websocket/geo_test.go +++ b/integration_tests/commands/websocket/geo_test.go @@ -85,3 +85,63 @@ func TestGeoDist(t *testing.T) { }) } } + +func TestGeoPos(t *testing.T) { + exec := NewWebsocketCommandExecutor() + conn := exec.ConnectToServer() + defer conn.Close() + + testCases := []struct { + name string + cmds []string + expect []interface{} + }{ + { + name: "GEOPOS b/w existing points", + cmds: []string{ + "GEOADD index 13.361389 38.115556 Palermo", + "GEOPOS index Palermo", + }, + expect: []interface{}{ + float64(1), + []interface{}{[]interface{}{float64(13.361387), float64(38.115556)}}, + }, + }, + { + name: "GEOPOS for non existing points", + cmds: []string{ + "GEOPOS index NonExisting", + }, + expect: []interface{}{[]interface{}{nil}}, + }, + { + name: "GEOPOS for non existing index", + cmds: []string{ + "GEOPOS NonExisting Palermo", + }, + expect: []interface{}{nil}, + }, + { + name: "GEOPOS for a key not used for setting geospatial values", + cmds: []string{ + "SET k v", + "GEOPOS k v", + }, + expect: []interface{}{ + "OK", + "WRONGTYPE Operation against a key holding the wrong kind of value", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.cmds { + result, err := exec.FireCommandAndReadResponse(conn, cmd) + assert.Nil(t, err) + assert.Equal(t, tc.expect[i], result, "Value mismatch for cmd %s", cmd) + } + }) + } +} + diff --git a/internal/eval/commands.go b/internal/eval/commands.go index 8b2d94bb7..358279ade 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -1213,6 +1213,14 @@ var ( NewEval: evalGEODIST, KeySpecs: KeySpecs{BeginIndex: 1}, } + geoPosCmdMeta = DiceCmdMeta{ + Name: "GEOPOS", + Info: `Returns the latitude and longitude of the members identified by the particular index.`, + Arity: -3, + NewEval: evalGEOPOS, + IsMigrated: true, + KeySpecs: KeySpecs{BeginIndex: 1}, + } jsonstrappendCmdMeta = DiceCmdMeta{ Name: "JSON.STRAPPEND", Info: `JSON.STRAPPEND key [path] value @@ -1354,6 +1362,7 @@ func init() { DiceCmds["FLUSHDB"] = flushdbCmdMeta DiceCmds["GEOADD"] = geoAddCmdMeta DiceCmds["GEODIST"] = geoDistCmdMeta + DiceCmds["GEOPOS"] = geoPosCmdMeta DiceCmds["GET"] = getCmdMeta DiceCmds["GETBIT"] = getBitCmdMeta DiceCmds["GETDEL"] = getDelCmdMeta diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 41b0088df..284f8431e 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -130,6 +130,7 @@ func TestEval(t *testing.T) { testEvalBitFieldRO(t, store) testEvalGEOADD(t, store) testEvalGEODIST(t, store) + testEvalGEOPOS(t, store) testEvalJSONSTRAPPEND(t, store) testEvalINCR(t, store) testEvalINCRBY(t, store) @@ -8247,6 +8248,96 @@ func testEvalGEODIST(t *testing.T, store *dstore.Store) { runMigratedEvalTests(t, tests, evalGEODIST, store) } +func testEvalGEOPOS(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "GEOPOS for existing single point": { + setup: func() { + evalGEOADD([]string{"index", "13.361387", "38.115556", "Palermo"}, store) + }, + input: []string{"index", "Palermo"}, + migratedOutput: EvalResponse{ + Result: []interface{}{[]interface{}{float64(13.361387), float64(38.115556)}}, + Error: nil, + }, + }, + "GEOPOS for multiple existing points": { + setup: func() { + evalGEOADD([]string{"points", "13.361387", "38.115556", "Palermo"}, store) + evalGEOADD([]string{"points", "15.087265", "37.502668", "Catania"}, store) + }, + input: []string{"points", "Palermo", "Catania"}, + migratedOutput: EvalResponse{ + Result: []interface{}{ + []interface{}{float64(13.361387), float64(38.115556)}, + []interface{}{float64(15.087265), float64(37.502668)}, + }, + Error: nil, + }, + }, + "GEOPOS for a point that does not exist": { + setup: func() { + evalGEOADD([]string{"index", "13.361387", "38.115556", "Palermo"}, store) + }, + input: []string{"index", "NonExisting"}, + migratedOutput: EvalResponse{ + Result: []interface{}{nil}, + Error: nil, + }, + }, + "GEOPOS for multiple points, one existing and one non-existing": { + setup: func() { + evalGEOADD([]string{"index", "13.361387", "38.115556", "Palermo"}, store) + }, + input: []string{"index", "Palermo", "NonExisting"}, + migratedOutput: EvalResponse{ + Result: []interface{}{ + []interface{}{float64(13.361387), float64(38.115556)}, + nil, + }, + Error: nil, + }, + }, + "GEOPOS for empty index": { + setup: func() { + evalGEOADD([]string{"", "13.361387", "38.115556", "Palermo"}, store) + }, + input: []string{"", "Palermo"}, + migratedOutput: EvalResponse{ + Result: []interface{}{ + []interface{}{float64(13.361387), float64(38.115556)}, + }, + Error: nil, + }, + }, + "GEOPOS with no members in key": { + input: []string{"index", "Palermo"}, + migratedOutput: EvalResponse{ + Result: clientio.NIL, + Error: nil, + }, + }, + "GEOPOS with invalid number of arguments": { + input: []string{"index"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("GEOPOS"), + }, + }, + "GEOPOS for a key not used for setting geospatial values": { + setup: func() { + evalSET([]string{"k", "v"}, store) + }, + input: []string{"k", "v"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: errors.New("WRONGTYPE Operation against a key holding the wrong kind of value"), + }, + }, + } + + runMigratedEvalTests(t, tests, evalGEOPOS, store) +} + func testEvalJSONSTRAPPEND(t *testing.T, store *dstore.Store) { tests := map[string]evalTestCase{ "append to single field": { diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 015d20b00..8583cedad 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -6222,6 +6222,58 @@ func evalGEODIST(args []string, store *dstore.Store) *EvalResponse { } } +func evalGEOPOS(args []string, store *dstore.Store) *EvalResponse { + if len(args) < 2 { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongArgumentCount("GEOPOS"), + } + } + + key := args[0] + obj := store.Get(key) + + if obj == nil { + return &EvalResponse{ + Result: clientio.NIL, + Error: nil, + } + } + + ss, err := sortedset.FromObject(obj) + + if err != nil { + return &EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + results := make([]interface{}, len(args)-1) + + for index := 1; index < len(args); index++ { + member := args[index] + hash, ok := ss.Get(member) + + if !ok { + results[index-1] = (nil) + continue + } + + lat, lon := geo.DecodeHash(hash) + + latFloat, _ := strconv.ParseFloat(fmt.Sprintf("%f", lat), 64) + lonFloat, _ := strconv.ParseFloat(fmt.Sprintf("%f", lon), 64) + + results[index-1] = []interface{}{lonFloat, latFloat} + } + + return &EvalResponse{ + Result: results, + Error: nil, + } +} + func evalTouch(args []string, store *dstore.Store) *EvalResponse { if len(args) != 1 { return makeEvalError(diceerrors.ErrWrongArgumentCount("TOUCH")) diff --git a/internal/iothread/cmd_meta.go b/internal/iothread/cmd_meta.go index ce7c35b7b..c740bae25 100644 --- a/internal/iothread/cmd_meta.go +++ b/internal/iothread/cmd_meta.go @@ -112,6 +112,7 @@ const ( CmdRestore = "RESTORE" CmdGeoAdd = "GEOADD" CmdGeoDist = "GEODIST" + CmdGeoPos = "GEOPOS" CmdClient = "CLIENT" CmdLatency = "LATENCY" CmdDel = "DEL" @@ -517,6 +518,9 @@ var CommandsMeta = map[string]CmdMeta{ CmdGeoDist: { CmdType: SingleShard, }, + CmdGeoPos: { + CmdType: SingleShard, + }, CmdClient: { CmdType: SingleShard, },