diff --git a/go.mod b/go.mod index 0bd398776..c18f4e4e0 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da github.com/dicedb/dicedb-go v1.0.10 + github.com/emirpasic/gods v1.18.1 github.com/gobwas/glob v0.2.3 github.com/google/btree v1.1.3 github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index 2c4f13ea2..83e581b33 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 h1:y7y0Oa6UawqTFP github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dicedb/dicedb-go v1.0.10 h1:inKRSmzpXp7M4vleOfJUV4woISCUFVceDckeum461aw= github.com/dicedb/dicedb-go v1.0.10/go.mod h1:V1fiCJnPfSObKWrOJ/zrhHEGlLwT9k3pKCto3sz1oW8= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/internal/cmd/cmd_geoadd.go b/internal/cmd/cmd_geoadd.go new file mode 100644 index 000000000..f5d83f057 --- /dev/null +++ b/internal/cmd/cmd_geoadd.go @@ -0,0 +1,123 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "strconv" + + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOADD = &CommandMeta{ + Name: "GEOADD", + Syntax: "GEOADD key [NX | XX] [CH] longitude latitude member [longitude latitude member ...]", + HelpShort: "GEOADD adds all the specified GEO members with the specified longitude & latitude pair to the sorted set stored at key", + HelpLong: ` +GEOADD adds all the specified GEO members with the specified longitude & latitude pair to the sorted set stored at key +The command takes arguments in the standard format x,y so the longitude must be specified before the latitude. +There are limits to the coordinates that can be indexed: areas very near to the poles are not indexable. + +The exact limits, as specified by EPSG:900913 / EPSG:3785 / OSGEO:41001 are the following: + +- Valid longitudes are from -180 to 180 degrees. +- Valid latitudes are from -85.05112878 to 85.05112878 degrees. + +This has similar options as ZADD +- NX: Only add new elements and do not update existing elements +- XX: Only update existing elements and do not add new elements +`, + Examples: ` +localhost:7379> GEOADD Delhi NX 77.2096 28.6145 "Central Delhi" +OK 1 +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOADD Delhi NX 77.2096 280 "Central Delhi" +ERR Invalid Longitude, Latitude pair ('77.209600', '280.000000')! Check the range in Docs + `, + Eval: evalGEOADD, + Execute: executeGEOADD, +} + +func init() { + CommandRegistry.AddCommand(cGEOADD) +} + +func newGEOADDRes(count int64) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOADDRes{ + GEOADDRes: &wire.GEOADDRes{ + Count: count, + }, + }, + }, + } +} + +var ( + GEOADDResNilRes = newGEOADDRes(0) +) + +func evalGEOADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 4 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + key := c.C.Args[0] + params, nonParams := parseParams(c.C.Args[1:]) + + if len(nonParams)%3 != 0 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + gr = types.NewGeoRegistry() + } else { + if obj.Type != object.ObjTypeGeoRegistry { + return GEOADDResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + } + + GeoCoordinates, members := []*types.GeoCoordinate{}, []string{} + for i := 0; i < len(nonParams); i += 3 { + lon, errLon := strconv.ParseFloat(nonParams[i], 10) + lat, errLat := strconv.ParseFloat(nonParams[i+1], 10) + if errLon != nil || errLat != nil { + return GEOADDResNilRes, errors.ErrInvalidNumberFormat + } + coordinate, err := types.NewGeoCoordinateFromLonLat(lon, lat) + if err != nil { + return GEOADDResNilRes, err + } + GeoCoordinates = append(GeoCoordinates, coordinate) + members = append(members, nonParams[i+2]) + } + + count, err := gr.Add(GeoCoordinates, members, params) + if err != nil { + return GEOADDResNilRes, err + } + + s.Put(key, s.NewObj(gr, -1, object.ObjTypeGeoRegistry), dsstore.WithPutCmd(dsstore.ZAdd)) + return newGEOADDRes(count), nil +} + +func executeGEOADD(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 4 { + return GEOADDResNilRes, errors.ErrWrongArgumentCount("GEOADD") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOADD(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geodist.go b/internal/cmd/cmd_geodist.go new file mode 100644 index 000000000..ecdaf597a --- /dev/null +++ b/internal/cmd/cmd_geodist.go @@ -0,0 +1,103 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEODIST = &CommandMeta{ + Name: "GEODIST", + Syntax: "GEODIST key member1 member2 [M | KM | FT | MI]", + HelpShort: "GEODIST Return the distance between two members in the geospatial index represented by the sorted set.", + HelpLong: ` +GEODIST Return the distance between two members in the geospatial index represented by the sorted set. +If any of the member is null, this will return Nil Output + +The unit must be one of the following, and defaults to meters: +- m for meters. +- km for kilometers. +- mi for miles. +- ft for feet. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2096 28.6145 centralDelhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate +OK 3 +localhost:7379> GEODIST Delhi CP IndiaGate km +OK 2.416700 + `, + Eval: evalGEODIST, + Execute: executeGEODIST, +} + +func init() { + CommandRegistry.AddCommand(cGEODIST) +} + +func newGEODISTRes(distance float64) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEODISTRes{ + GEODISTRes: &wire.GEODISTRes{ + Distance: distance, + }, + }, + }, + } +} + +var ( + GEODISTResNilRes = newGEODISTRes(0) +) + +func evalGEODIST(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 3 || len(c.C.Args) > 4 { + return GEODISTResNilRes, errors.ErrWrongArgumentCount("GEODIST") + } + + key := c.C.Args[0] + params, nonParams := parseParams(c.C.Args[1:]) + + unit := types.GetUnitTypeFromParsedParams(params) + if len(c.C.Args) == 4 && len(unit) == 0 { + return GEODISTResNilRes, errors.ErrInvalidUnit(c.C.Args[3]) + } else if len(unit) == 0 { + unit = types.M + } + + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEODISTResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEODISTResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + + dist, err := gr.GetDistanceBetweenMembers(nonParams[0], nonParams[1], unit) + + if err != nil { + return GEODISTResNilRes, err + } + + return newGEODISTRes(dist), nil + +} + +func executeGEODIST(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 3 || len(c.C.Args) > 4 { + return GEODISTResNilRes, errors.ErrWrongArgumentCount("GEODIST") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEODIST(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geohash.go b/internal/cmd/cmd_geohash.go new file mode 100644 index 000000000..c53bba1e1 --- /dev/null +++ b/internal/cmd/cmd_geohash.go @@ -0,0 +1,89 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOHASH = &CommandMeta{ + Name: "GEOHASH", + Syntax: "GEOHASH key [member [member ...]]", + HelpShort: "Returns valid Geohash strings representing the position of one or more elements in a sorted set value representing a geospatial index", + HelpLong: ` + The command returns 11 characters Geohash strings, so no precision is lost compared to the Redis internal 52 bit representation + The returned Geohashes have the following properties: + 1. They can be shortened removing characters from the right. It will lose precision but will still point to the same area. + 2. Strings with a similar prefix are nearby, but the contrary is not true, it is possible that strings with different prefixes are nearby too. + 3. It is possible to use them in geohash.org URLs such as http://geohash.org/. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOHASh Delhi CP IndiaGate +OK +0) ttnfvh5qxd0 +1) ttnfv2uf1z0 + `, + Eval: evalGEOHASH, + Execute: executeGEOHASH, +} + +func init() { + CommandRegistry.AddCommand(cGEOHASH) +} + +func newGEOHASHRes(hashArr []string) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOHASHRes{ + GEOHASHRes: &wire.GEOHASHRes{ + Hashes: hashArr, + }, + }, + }, + } +} + +var ( + GEOHASHResNilRes = newGEOHASHRes([]string{}) +) + +func evalGEOHASH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOHASHResNilRes, errors.ErrWrongArgumentCount("GEOHASH") + } + + key := c.C.Args[0] + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEOHASHResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEOHASHResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + + hashArr := gr.Get11BytesHash(c.C.Args[1:]) + + return newGEOHASHRes(hashArr), nil + +} + +func executeGEOHASH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOHASHResNilRes, errors.ErrWrongArgumentCount("GEOHASH") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOHASH(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geopos.go b/internal/cmd/cmd_geopos.go new file mode 100644 index 000000000..5ff758547 --- /dev/null +++ b/internal/cmd/cmd_geopos.go @@ -0,0 +1,94 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOPOS = &CommandMeta{ + Name: "GEOPOS", + Syntax: "GEOPOS key [member [member ...]]", + HelpShort: "Return the positions (longitude,latitude) of all the specified members of the geospatial index.", + HelpLong: ` + Given a sorted set representing a geospatial index, populated using the GEOADD command, it is often useful to obtain back the coordinates of specified member. + When the geospatial index is populated via GEOADD the coordinates are converted into a 52 bit geohash, + so the coordinates returned may not be exactly the ones used in order to add the elements, but small errors may be introduced. + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOPOS Delhi CP nonEx +OK +0) 77.216700, 28.631498 +1) (nil) + `, + Eval: evalGEOPOS, + Execute: executeGEOPOS, +} + +func init() { + CommandRegistry.AddCommand(cGEOPOS) +} + +func newGEOPOSRes(coordinates []*types.GeoCoordinate) *CmdRes { + reponseCoordinates := make([]*wire.GEOCoordinates, len(coordinates)) + for i, coord := range coordinates { + if coord == nil { + continue + } + reponseCoordinates[i] = &wire.GEOCoordinates{ + Longitude: coord.Longitude, + Latitude: coord.Latitude, + } + } + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOPOSRes{ + GEOPOSRes: &wire.GEOPOSRes{ + Coordinates: reponseCoordinates, + }, + }, + }, + } +} + +var ( + GEOPOSResNilRes = newGEOPOSRes([]*types.GeoCoordinate{}) +) + +func evalGEOPOS(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOPOSResNilRes, errors.ErrWrongArgumentCount("GEOPOS") + } + + key := c.C.Args[0] + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEOPOSResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEOPOSResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + coordinates := gr.GetCoordinates(c.C.Args[1:]) + return newGEOPOSRes(coordinates), nil +} + +func executeGEOPOS(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 2 { + return GEOPOSResNilRes, errors.ErrWrongArgumentCount("GEOPOS") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOPOS(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_geosearch.go b/internal/cmd/cmd_geosearch.go new file mode 100644 index 000000000..e52967fe1 --- /dev/null +++ b/internal/cmd/cmd_geosearch.go @@ -0,0 +1,145 @@ +// Copyright (c) 2022-present, DiceDB contributors +// All rights reserved. Licensed under the BSD 3-Clause License. See LICENSE file in the project root for full license information. + +package cmd + +import ( + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dice/internal/object" + "github.com/dicedb/dice/internal/shardmanager" + dsstore "github.com/dicedb/dice/internal/store" + "github.com/dicedb/dice/internal/types" + "github.com/dicedb/dicedb-go/wire" +) + +var cGEOSEARCH = &CommandMeta{ + Name: "GEOSEARCH", + Syntax: "GEOSEARCH key longitude latitude radius [WITHCOORD] [WITHDIST] [WITHHASH]", + HelpShort: "GEOSEARCH Returns the members within the borders of the area specified by a given shape", + HelpLong: ` +Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified by a given shape. +This command extends the GEORADIUS command, so in addition to searching within circular areas, it supports searching within rectangular areas. + +This command should be used in place of the deprecated GEORADIUS and GEORADIUSBYMEMBER commands. + +The query's center point is provided by one of these mandatory options: + +FROMMEMBER: Use the position of the given existing in the sorted set. +FROMLONLAT: Use the given and position. +The query's shape is provided by one of these mandatory options: + +BYRADIUS: Similar to GEORADIUS, search inside circular area according to given . +BYBOX: Search inside an axis-aligned rectangle, determined by and . +The command optionally returns additional information using the following options: + +WITHDIST: Also return the distance of the returned items from the specified center point. The distance is returned in the same unit as specified for the radius or height and width arguments. +WITHCOORD: Also return the longitude and latitude of the matching items. +WITHHASH: Also return the raw geohash-encoded sorted set score of the item, in the form of a 52 bit unsigned integer. This is only useful for low level hacks or debugging and is otherwise of little interest for the general user. + +Matching items are returned unsorted by default. To sort them, use one of the following two options: + +ASC: Sort returned items from the nearest to the farthest, relative to the center point. +DESC: Sort returned items from the farthest to the nearest, relative to the center point. + +All matching items are returned by default. To limit the results to the first N matching items, +use the COUNT option. When the ANY option is used, the command returns as soon as enough matches are found. +This means that the results returned may not be the ones closest to the specified point, +but the effort invested by the server to generate them is significantly less. When ANY is not provided, +the command will perform an effort that is proportional to the number of items matching the specified area and sort them, +so to query very large areas with a very small COUNT option may be slow even if just a few results are returned. + +NOTE: +1. If ANY is used with ASC or DESC then sorting is avoided. +2. IF COUNT is used without ASC or DESC then result is automatically sorted with ASC + `, + Examples: ` +localhost:7379> GEOADD Delhi 77.2167 28.6315 CP 77.2295 28.6129 IndiaGate 77.1197 28.6412 Rajouri 77.1000 28.5562 Airport 77.1900 28.6517 KarolBagh +OK 5 +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 ANY +OK +0) Airport +1) Rajouri +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 10 km COUNT 2 DESC +OK +0) Rajouri +1) Airport +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYRADIUS 20 km COUNT 2 DESC +OK +0) CP +1) IndiaGate +localhost:7379> GEOSEARCH Delhi FROMLONLAT 77.1000 28.5562 BYBOX 40 40 km COUNT 2 ASC +OK +0) Airport +1) Rajouri +localhost:7379> GEOSEARCH Delhi FROMMEMBER CP BYBOX 40 40 km COUNT 2 ASC +OK +0) IndiaGate +0) KarolBagh + `, + Eval: evalGEOSEARCH, + Execute: executeGEOSEARCH, +} + +func init() { + CommandRegistry.AddCommand(cGEOSEARCH) +} + +func newGEOSEARCHRes(elements []*wire.GEOElement) *CmdRes { + return &CmdRes{ + Rs: &wire.Result{ + Message: "OK", + Status: wire.Status_OK, + Response: &wire.Result_GEOSEARCHRes{ + GEOSEARCHRes: &wire.GEOSEARCHRes{ + Elements: elements, + }, + }, + }, + } +} + +var ( + GEOSEARCHResNilRes = newGEOSEARCHRes([]*wire.GEOElement{}) +) + +func evalGEOSEARCH(c *Cmd, s *dsstore.Store) (*CmdRes, error) { + if len(c.C.Args) < 5 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + key := c.C.Args[0] + params, nonParams := parseParams(c.C.Args[1:]) + + if len(nonParams) < 2 { + return GEOSEARCHResNilRes, errors.ErrInvalidSyntax("GEOSEARCH") + } + + // Get sorted set else return + var gr *types.GeoRegistry + obj := s.Get(key) + if obj == nil { + return GEODISTResNilRes, nil + } + if obj.Type != object.ObjTypeGeoRegistry { + return GEOSEARCHResNilRes, errors.ErrWrongTypeOperation + } + gr = obj.Value.(*types.GeoRegistry) + + geoElements, err := gr.SearchElementsWithinShape(params, nonParams) + + if err != nil { + return GEOSEARCHResNilRes, err + } + + return newGEOSEARCHRes(geoElements), nil + +} + +func executeGEOSEARCH(c *Cmd, sm *shardmanager.ShardManager) (*CmdRes, error) { + if len(c.C.Args) < 5 { + return GEOSEARCHResNilRes, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + shard := sm.GetShardForKey(c.C.Args[0]) + return evalGEOSEARCH(c, shard.Thread.Store()) +} diff --git a/internal/cmd/cmd_set.go b/internal/cmd/cmd_set.go index 0e00b298a..7a2df8649 100644 --- a/internal/cmd/cmd_set.go +++ b/internal/cmd/cmd_set.go @@ -95,6 +95,13 @@ func parseParams(args []string) (params map[types.Param]string, nonParams []stri i++ case types.XX, types.NX, types.KEEPTTL, types.LT, types.GT, types.CH, types.INCR: params[arg] = "true" + case types.M, types.KM, types.MI, types.FT, + types.WITHCOORD, types.WITHDIST, types.WITHHASH, + types.FROMLONLAT, types.FROMMEMBER, + types.BYBOX, types.BYRADIUS, + types.ASC, types.DESC, + types.COUNT, types.ANY: + params[arg] = "true" default: nonParams = append(nonParams, args[i]) } diff --git a/internal/cmd/cmd_zadd.go b/internal/cmd/cmd_zadd.go index 1abbfd62a..c1eb5dedb 100644 --- a/internal/cmd/cmd_zadd.go +++ b/internal/cmd/cmd_zadd.go @@ -98,7 +98,6 @@ func evalZADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { obj := s.Get(key) if obj == nil { ss = types.NewSortedSet() - s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) } else { if obj.Type != object.ObjTypeSortedSet { return ZADDResNilRes, errors.ErrWrongTypeOperation @@ -111,6 +110,9 @@ func evalZADD(c *Cmd, s *dsstore.Store) (*CmdRes, error) { if err != nil { return ZADDResNilRes, err } + + // Add the key after the SortedSet is updated/created successfully + s.Put(key, s.NewObj(ss, -1, object.ObjTypeSortedSet), dsstore.WithPutCmd(dsstore.ZAdd)) return newZADDRes(count), nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 9d66932ec..696192e51 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -83,6 +83,29 @@ var ( return fmt.Errorf("number of elements to peek should be a positive number less than %d", max) // Signals an invalid count for elements to peek. } + ErrInvalidUnit = func(unit string) error { + return fmt.Errorf("Unsupported unit '%s'. please use m, km, ft, mi", unit) // Signals an invalid unit + } + ErrInvalidLonLatPair = func(lon, lat float64) error { + return fmt.Errorf("Invalid Longitude, Latitude pair ('%f', '%f')! Check the range in Docs", lon, lat) + } + ErrMemberNotFoundInSortedSet = func(member string) error { + return fmt.Errorf("Member not found: %s", member) + } + ErrInvalidSetOfOptions = func(options ...string) error { + errorString := "Invalid set of options:" + for i := range options { + errorString += " '" + options[i] + "'" + } + return fmt.Errorf(errorString) + } + ErrNeedOneOfTheOptions = func(options ...string) error { + errorString := "Need one of the options:" + for i := range options { + errorString += " '" + options[i] + "'" + } + return fmt.Errorf(errorString) + } ErrGeneral = func(err string) error { return fmt.Errorf("%s", err) // General error format for various commands. } @@ -113,6 +136,10 @@ var ( ErrUnknownCmd = func(cmd string) error { return fmt.Errorf("ERROR unknown command '%v'", cmd) // Indicates that an unsupported encoding type was provided. } + + ErrUnknownOption = func(option string) error { + return fmt.Errorf("ERROR unknown option '%v'", option) // Indicates that an unsupported option. + } ) type PreProcessError struct { diff --git a/internal/object/object.go b/internal/object/object.go index b35d8472b..62cac3437 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -89,6 +89,7 @@ const ( ObjTypeSet ObjTypeSSMap ObjTypeSortedSet + ObjTypeGeoRegistry ObjTypeCountMinSketch ObjTypeBF ObjTypeDequeue diff --git a/internal/types/geo.go b/internal/types/geo.go new file mode 100644 index 000000000..6e11c69f1 --- /dev/null +++ b/internal/types/geo.go @@ -0,0 +1,655 @@ +package types + +import ( + "math" + "strconv" + + "github.com/dicedb/dice/internal/errors" + "github.com/dicedb/dicedb-go/wire" + "github.com/emirpasic/gods/queues/priorityqueue" + "github.com/mmcloughlin/geohash" +) + +// Bit precision - Same as redis (https://github.com/redis/redis/blob/5d0d64b062c160093dc287ed5d18ec7c807873cf/src/geohash_helper.c#L213) +const BIT_PRECISION = 52 + +// Earth's radius in meters +const EARTH_RADIUS float64 = 6372797.560856 + +// The maximum/minumum projected coordinate value (in meters) in the Web Mercator projection (EPSG:3857) +// Earth’s equator: ~40,075 km → half of that = ~20,037 km +// The Mercator projection transforms the globe into a square map. +// MERCATOR_MAX is the extent of that square in meters. +const MERCATOR_MAX float64 = 20037726.37 +const MERCATOR_MIN float64 = -20037726.37 + +// Limits from EPSG:900913 / EPSG:3785 / OSGEO:41001 +const LAT_MIN float64 = -85.05112878 +const LAT_MAX float64 = 85.05112878 +const LONG_MIN float64 = -180 +const LONG_MAX float64 = 180 + +// GEO Neighbors +type Neighbors struct { + North uint64 + NorthEast uint64 + East uint64 + SouthEast uint64 + South uint64 + SouthWest uint64 + West uint64 + NorthWest uint64 + Center uint64 + Precision uint +} + +func CreateNeighborsFromArray(arr []uint64) *Neighbors { + neightbors := Neighbors{} + if len(arr) < 8 { + return &neightbors + } + + neightbors.North = arr[0] + neightbors.NorthEast = arr[1] + neightbors.East = arr[2] + neightbors.SouthEast = arr[3] + neightbors.South = arr[4] + neightbors.SouthWest = arr[5] + neightbors.West = arr[6] + neightbors.NorthWest = arr[7] + neightbors.Center = arr[8] + + return &neightbors +} + +func (neightbors *Neighbors) ToArray() [9]uint64 { + arr := [9]uint64{} + + arr[0] = neightbors.North + arr[1] = neightbors.NorthEast + arr[2] = neightbors.East + arr[3] = neightbors.SouthEast + arr[4] = neightbors.South + arr[5] = neightbors.SouthWest + arr[6] = neightbors.West + arr[7] = neightbors.NorthWest + arr[8] = neightbors.Center + + return arr +} + +// Struct to save coordinates +type GeoCoordinate struct { + Hash uint64 + Longitude float64 + Latitude float64 +} + +func NewGeoCoordinateFromLonLat(longitude, latitude float64) (*GeoCoordinate, error) { + if err := ValidateLonLat(longitude, latitude); err != nil { + return nil, err + } + hash := EncodeHash(longitude, latitude) + return &GeoCoordinate{ + Hash: hash, + Longitude: longitude, + Latitude: latitude, + }, nil +} + +func NewGeoCoordinateFromHash(hash uint64) (*GeoCoordinate, error) { + longitude, latitude := DecodeHash(hash) + if err := ValidateLonLat(longitude, latitude); err != nil { + return nil, err + } + return &GeoCoordinate{ + Hash: hash, + Longitude: longitude, + Latitude: latitude, + }, nil +} + +func (coord *GeoCoordinate) GetLatDistanceFromCoordinate(otherCoord *GeoCoordinate) float64 { + return EARTH_RADIUS * math.Abs(DegToRad(otherCoord.Latitude)-DegToRad(coord.Latitude)) +} + +func (coord *GeoCoordinate) GetDistanceFromCoordinate(otherCoord *GeoCoordinate) float64 { + lon1r := DegToRad(coord.Longitude) + lat1r := DegToRad(coord.Latitude) + + lon2r := DegToRad(otherCoord.Longitude) + lat2r := DegToRad(otherCoord.Latitude) + + v := math.Sin((lon2r - lon1r) / 2) + // if v == 0 we can avoid doing expensive math when lons are practically the same + if v == 0.0 { + return coord.GetLatDistanceFromCoordinate(otherCoord) + } + + u := math.Sin((lat2r - lat1r) / 2) + + a := u*u + math.Cos(lat1r)*math.Cos(lat2r)*v*v + + return 2.0 * EARTH_RADIUS * math.Asin(math.Sqrt(a)) +} + +// This saves only the Hash of Longitude and Latitude +type GeoRegistry struct { + *SortedSet +} + +func NewGeoRegistry() *GeoRegistry { + return &GeoRegistry{ + SortedSet: NewSortedSet(), + } +} + +func (geoReg *GeoRegistry) Add(coordinates []*GeoCoordinate, members []string, params map[Param]string) (int64, error) { + + hashArr := []int64{} + for _, coord := range coordinates { + hashArr = append(hashArr, int64(coord.Hash)) + } + + // Note: Validation of the params is done in the SortedSet.ZADD method + return geoReg.ZADD(hashArr, members, params) + +} + +func (geoReg *GeoRegistry) GetDistanceBetweenMembers(member1 string, member2 string, unit Param) (float64, error) { + node1 := geoReg.GetByKey(member1) + node2 := geoReg.GetByKey(member2) + + if node1 == nil || node2 == nil { + return 0, nil + } + + hash1 := node1.Score() + hash2 := node2.Score() + + coord1, _ := NewGeoCoordinateFromHash(uint64(hash1)) + coord2, _ := NewGeoCoordinateFromHash(uint64(hash2)) + + dist, err := ConvertDistance(coord1.GetDistanceFromCoordinate(coord2), unit) + + if err != nil { + return 0, err + } + + return dist, nil + +} + +// This returns all the nodes which are in the given shape +// Refer to this link for a detailed explanation of the function - https://www.notion.so/Geo-Bounding-Box-Research-1f6a37dc1a9a80e7ac43feeeab7215bb +func (geoReg *GeoRegistry) SearchElementsWithinShape(params map[Param]string, nonParams []string) ([]*wire.GEOElement, error) { + unit := GetUnitTypeFromParsedParams(params) + if len(unit) == 0 { + return nil, errors.ErrInvalidUnit(string(unit)) + } + + // Return error if both FROMLONLAT & FROMMEMBER are set + if params[FROMLONLAT] != "" && params[FROMMEMBER] != "" { + return nil, errors.ErrInvalidSetOfOptions(string(FROMLONLAT), string(FROMMEMBER)) + } + + // Return error if none of FROMLONLAT & FROMMEMBER are set + if params[FROMLONLAT] == "" && params[FROMMEMBER] == "" { + return nil, errors.ErrNeedOneOfTheOptions(string(FROMLONLAT), string(FROMMEMBER)) + } + + // Return error if both BYBOX & BYRADIUS are set + if params[BYBOX] != "" && params[BYRADIUS] != "" { + return nil, errors.ErrInvalidSetOfOptions(string(BYBOX), string(BYRADIUS)) + } + + // Return error if none of BYBOX & BYRADIUS are set + if params[BYBOX] == "" && params[BYRADIUS] == "" { + return nil, errors.ErrNeedOneOfTheOptions(string(BYBOX), string(BYRADIUS)) + } + + // Return error if ANY is used without COUNT + if params[ANY] != "" && params[COUNT] == "" { + return nil, errors.ErrGeneral("ANY argument requires COUNT argument") + } + + // Return error if Both ASC & DESC are used + if params[ASC] != "" && params[DESC] != "" { + return nil, errors.ErrGeneral("Use one of ASC or DESC") + } + + // Fetch Longitute and Latitude based on FROMLONLAT & FROMMEMBER param + var centerCoordinate *GeoCoordinate + var err error + + // Fetch Longitute and Latitude from params + if params[FROMLONLAT] != "" { + if len(nonParams) < 2 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + lon, errLon := strconv.ParseFloat(nonParams[0], 10) + lat, errLat := strconv.ParseFloat(nonParams[1], 10) + + if errLon != nil || errLat != nil { + return nil, errors.ErrInvalidNumberFormat + } + + centerCoordinate, err = NewGeoCoordinateFromLonLat(lon, lat) + if err != nil { + return nil, err + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] + } + + // Fetch Longitute and Latitude from member + if params[FROMMEMBER] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + member := nonParams[0] + node := geoReg.GetByKey(member) + if node == nil { + return nil, errors.ErrMemberNotFoundInSortedSet(member) + } + hash := node.Score() + centerCoordinate, err = NewGeoCoordinateFromHash(uint64(hash)) + if err != nil { + return nil, err + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Create shape based on BYBOX or BYRADIUS param + var searchShape GeoShape + + // Create shape from BYBOX + if params[BYBOX] != "" { + if len(nonParams) < 2 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var width, height float64 + var errWidth, errHeight error + width, errWidth = strconv.ParseFloat(nonParams[0], 10) + height, errHeight = strconv.ParseFloat(nonParams[1], 10) + + if errWidth != nil || errHeight != nil { + return nil, errors.ErrInvalidNumberFormat + } + if height <= 0 || width <= 0 { + return nil, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") + } + + searchShape, _ = GetNewGeoShapeRectangle(width, height, centerCoordinate, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[2:] + } + + // Create shape from BYRADIUS + if params[BYRADIUS] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + + var radius float64 + var errRad error + radius, errRad = strconv.ParseFloat(nonParams[0], 10) + + if errRad != nil { + return nil, errors.ErrInvalidNumberFormat + } + if radius <= 0 { + return nil, errors.ErrGeneral("RADIUS should be > 0") + } + + searchShape, _ = GetNewGeoShapeCircle(radius, centerCoordinate, unit) + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // Get COUNT based on Params + var count int = -1 + var errCount error + + // Check for COUNT to limit the output + if params[COUNT] != "" { + if len(nonParams) < 1 { + return nil, errors.ErrWrongArgumentCount("GEOSEARCH") + } + count, errCount = strconv.Atoi(nonParams[0]) + if errCount != nil { + return nil, errors.ErrInvalidNumberFormat + } + if count <= 0 { + return nil, errors.ErrGeneral("COUNT must be > 0") + } + + // Adjust the nonParams array for further operations + nonParams = nonParams[1:] + } + + // If all the params are not used till now + // Means there're some unknown param + if len(nonParams) != 0 { + return nil, errors.ErrUnknownOption(nonParams[0]) + } + + // Check for ANY option + var anyOption bool = false + if params[ANY] != "" { + anyOption = true + } + + // Check for Sorting Key ASC or DESC (-1 = DESC, 0 = NoSort, 1 = ASC) + var sortType float64 = 0 + if params[ASC] != "" { + sortType = 1 + } + if params[DESC] != "" { + sortType = -1 + } + + // COUNT without ordering does not make much sense (we need to sort in order to return the closest N entries) + // Note that this is not needed for ANY option + if count != -1 && sortType == 0 && !anyOption { + sortType = 1 + } + + var withCoord, withDist, withHash bool = false, false, false + + if params[WITHCOORD] != "" { + withCoord = true + } + if params[WITHDIST] != "" { + withDist = true + } + if params[WITHHASH] != "" { + withHash = true + } + + // Find Neighbors from the shape + boudingBox := GetBoundingBoxForShape(searchShape) + neighbors := GetGeohashNeighborsWithinShape(searchShape, boudingBox) + neighborsArr := neighbors.ToArray() + + // HashMap of all the nodes (we are making map for deduplication) + geoElementMap := map[string]*wire.GEOElement{} + totalElements := 0 + + // Find all the elements in the neighbor and the center block + for _, neighbor := range neighborsArr { + + // Discarded neighbors + if neighbor == 0 { + continue + } + + // If ANY option is used and totalElements == count + // Break the loop and Return the current result + if anyOption && count == totalElements { + break + } + + maxHash, minHash := GetMaxAndMinHashForBoxHash(neighbor, neighbors.Precision) + + zElements := geoReg.ZRANGE(int(minHash), int(maxHash), true, false) + + for _, ele := range zElements { + + if anyOption && totalElements == count { + break + } + + eleCoord, _ := NewGeoCoordinateFromHash(uint64(ele.Score)) + dist := searchShape.GetDistanceIfWithinShape(eleCoord) + + if dist != 0 { + geoElement := wire.GEOElement{ + Member: ele.Member, + Coordinates: &wire.GEOCoordinates{ + Longitude: centerCoordinate.Longitude, + Latitude: centerCoordinate.Latitude, + }, + Distance: dist, + Hash: uint64(ele.Score), + } + geoElementMap[ele.Member] = &geoElement + totalElements++ + } + + } + } + + // Convert map to array + geoElements := []*wire.GEOElement{} + + for _, ele := range geoElementMap { + geoElements = append(geoElements, ele) + } + + // Return unsorted result if ANY is used or sortType = 0 + if anyOption || sortType == 0 { + filterDimensionsBasedOnFlags(geoElements, withCoord, withDist, withHash) + return geoElements, nil + } + + // Let count be the total elements we need + if count == -1 { + count = totalElements + } + + // Comparator function for MaxHeap + // If ASC is set -> we use MaxHeap -> To Pop out the largest element if LEN > COUNT + // If DESC is set -> we use MinHeap -> To Pop out the smallest element if LEN > COUNT + // So Reverse the final array + cmp := func(a, b interface{}) int { + distance1 := a.(*wire.GEOElement).Distance + distance2 := b.(*wire.GEOElement).Distance + if distance1*sortType < distance2*sortType { + return 1 + } else if distance1*sortType > distance2*sortType { + return -1 + } + return 0 + } + + // Create a priority Queue to store the 'COUNT' results + pq := priorityqueue.NewWith(cmp) + + for _, ele := range geoElements { + pq.Enqueue(ele) + if pq.Size() > count { + pq.Dequeue() + } + } + + // Final result Arr + resultGeoElements := []*wire.GEOElement{} + + // Transfer elements from priority Queue to Arr + for pq.Size() > 0 { + queueEle, _ := pq.Dequeue() + geoEle := queueEle.(*wire.GEOElement) + resultGeoElements = append(resultGeoElements, geoEle) + } + + // Reverse the output array Because + // If ASC is set -> we use MaxHeap -> Which will give use DESC array + // If DESC is set -> we use MinHeap -> Which will give use ASC array + // So Reverse the final array + for i, j := 0, len(resultGeoElements)-1; i < j; i, j = i+1, j-1 { + resultGeoElements[i], resultGeoElements[j] = resultGeoElements[j], resultGeoElements[i] + } + filterDimensionsBasedOnFlags(resultGeoElements, withCoord, withDist, withHash) + + return resultGeoElements, nil +} + +// This returns 11 characters geohash representation of the position of the specified elements. +func (geoReg *GeoRegistry) Get11BytesHash(members []string) []string { + + result := make([]string, len(members)) + geoAlphabet := []rune("0123456789bcdefghjkmnpqrstuvwxyz") + + for idx, member := range members { + + // Get GEOHASH of the member + hashNode := geoReg.GetByKey(member) + if hashNode == nil { + continue + } + hash := uint64(hashNode.Score()) + + // Convert the hash to 11 character string (base32) + hashRune := make([]rune, 11) + for i := 0; i < 11; i++ { + var idx uint64 + if i == 10 { + idx = 0 // pad last char due to only 52 bits + } else { + shift := 52 - ((i + 1) * 5) + idx = (hash >> shift) & 0x1F + } + hashRune[i] = geoAlphabet[idx] + } + result[idx] = string(hashRune) + + } + return result +} + +// This returns coordinates (longitute, latitude) of all the members +func (geoReg *GeoRegistry) GetCoordinates(members []string) []*GeoCoordinate { + result := make([]*GeoCoordinate, len(members)) + for i, member := range members { + // Get GEOHASH of the member + hashNode := geoReg.GetByKey(member) + if hashNode == nil { + continue + } + hash := uint64(hashNode.Score()) + coordinate, _ := NewGeoCoordinateFromHash(hash) + result[i] = coordinate + } + return result +} + +// ///////////////////////////////////////////////////// +// //////////// Utility Functions ////////////////////// +// ///////////////////////////////////////////////////// + +// Filter dimensions based on flags for GEO Search +func filterDimensionsBasedOnFlags(geoElements []*wire.GEOElement, withCoord, withDist, withHash bool) { + for _, ele := range geoElements { + if !withCoord { + ele.Coordinates = nil + } + + if !withDist { + ele.Distance = 0 + } + + if !withHash { + ele.Hash = 0 + } + } +} + +// Encode given Lon and Lat to GEOHASH +func EncodeHash(longitude, latitude float64) uint64 { + return geohash.EncodeIntWithPrecision(latitude, longitude, BIT_PRECISION) +} + +// DecodeHash returns the latitude and longitude from a geo hash +func DecodeHash(hash uint64) (lon, lat float64) { + lat, lon = geohash.DecodeIntWithPrecision(hash, BIT_PRECISION) + return lon, lat +} + +func ValidateLonLat(lon, lat float64) error { + if lat > LAT_MAX || lat < LAT_MIN || lon > LONG_MAX || lon < LONG_MIN { + return errors.ErrInvalidLonLatPair(lon, lat) + } + return nil +} + +func GetUnitTypeFromParsedParams(params map[Param]string) Param { + if params[M] != "" { + return M + } else if params[KM] != "" { + return KM + } else if params[MI] != "" { + return MI + } else if params[FT] != "" { + return FT + } else { + return "" + } +} + +func DegToRad(deg float64) float64 { + return deg * math.Pi / 180 +} + +func RadToDeg(rad float64) float64 { + return 180.0 * rad / math.Pi +} + +// ConvertDistance converts a distance from meters to the desired unit +func ConvertDistance(distance float64, unit Param) (float64, error) { + var result float64 + + switch unit { + case M: + result = distance + case KM: + result = distance / 1000 + case MI: + result = distance / 1609.34 + case FT: + result = distance / 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + + // Round to 5 decimal places + return math.Round(result*10000) / 10000, nil +} + +// ConvertToMeter converts a distance to meters from the given unit +func ConvertToMeter(distance float64, unit Param) (float64, error) { + var result float64 + + switch unit { + case M: + result = distance + case KM: + result = distance * 1000 + case MI: + result = distance * 1609.34 + case FT: + result = distance * 0.3048 + default: + return 0, errors.ErrInvalidUnit(string(unit)) + } + // Round to 5 decimal places + return math.Round(result*10000) / 10000, nil +} + +// Computes the min (inclusive) and max (exclusive) scores for a given hash box. +// Aligns the geohash bits to BIT_PRECISION score by left-shifting +func GetMaxAndMinHashForBoxHash(hash uint64, precision uint) (max, min uint64) { + shift := BIT_PRECISION - (precision) + base := hash << shift + rangeSize := uint64(1) << shift + min = base + max = base + rangeSize - 1 + + return max, min +} diff --git a/internal/types/geoShape.go b/internal/types/geoShape.go new file mode 100644 index 000000000..8743be45c --- /dev/null +++ b/internal/types/geoShape.go @@ -0,0 +1,280 @@ +package types + +import ( + "math" + + "github.com/dicedb/dice/internal/errors" + "github.com/mmcloughlin/geohash" +) + +// This represents a shape in which we have to find nodes +type GeoShape interface { + GetBoudingBoxWidhtAndHeight() (float64, float64) + GetRadius() float64 + GetCoordinate() *GeoCoordinate + GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) +} + +// GeoShapeCircle Implementation of GeoShape +type GeoShapeCircle struct { + Radius float64 + CenterCoordinate *GeoCoordinate + Unit Param +} + +func (circle *GeoShapeCircle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return circle.Radius * 2, circle.Radius * 2 +} + +func (circle *GeoShapeCircle) GetRadius() float64 { + return circle.Radius +} + +func (circle *GeoShapeCircle) GetCoordinate() *GeoCoordinate { + return circle.CenterCoordinate +} + +func (circle *GeoShapeCircle) GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) { + distance = coordinate.GetDistanceFromCoordinate(circle.CenterCoordinate) + if distance > circle.Radius { + return 0 + } + distance, _ = ConvertDistance(distance, circle.Unit) + return distance +} + +func GetNewGeoShapeCircle(radius float64, centerCoordinate *GeoCoordinate, unit Param) (*GeoShapeCircle, error) { + var convRadius float64 + var errRad error + if radius <= 0 { + return nil, errors.ErrGeneral("RADIUS should be > 0") + } + + if convRadius, errRad = ConvertToMeter(radius, unit); errRad != nil { + return nil, errRad + } + + longitude, latitude := centerCoordinate.Longitude, centerCoordinate.Latitude + + var coord *GeoCoordinate + var errCoord error + if coord, errCoord = NewGeoCoordinateFromLonLat(longitude, latitude); errCoord != nil { + return nil, errCoord + } + + return &GeoShapeCircle{ + Radius: convRadius, + CenterCoordinate: coord, + Unit: unit, + }, nil +} + +// GeoShapeRectangle Implementation of GeoShape +type GeoShapeRectangle struct { + Widht float64 + Height float64 + CenterCoordinate *GeoCoordinate + Unit Param +} + +func (rec *GeoShapeRectangle) GetBoudingBoxWidhtAndHeight() (float64, float64) { + return rec.Widht, rec.Height +} + +func (rec *GeoShapeRectangle) GetRadius() float64 { + radius := math.Sqrt((rec.Widht/2)*(rec.Widht/2) + (rec.Height/2)*(rec.Height/2)) + return radius +} + +func (rec *GeoShapeRectangle) GetCoordinate() *GeoCoordinate { + return rec.CenterCoordinate +} + +func (rec *GeoShapeRectangle) GetDistanceIfWithinShape(coordinate *GeoCoordinate) (distance float64) { + // latitude distance is less expensive to compute than longitude distance + // so we check first for the latitude condition + latDistance := coordinate.GetLatDistanceFromCoordinate(rec.CenterCoordinate) + if latDistance > rec.Height/2 { + return 0 + } + + // Creating a coordinate with same latitude, to get longitude distance + sameLatitudeCoord, _ := NewGeoCoordinateFromLonLat(rec.CenterCoordinate.Longitude, coordinate.Latitude) + lonDistance := coordinate.GetDistanceFromCoordinate(sameLatitudeCoord) + if lonDistance > rec.Widht/2 { + return 0 + } + + distance = coordinate.GetDistanceFromCoordinate(rec.CenterCoordinate) + distance, _ = ConvertDistance(distance, rec.Unit) + return distance +} + +func GetNewGeoShapeRectangle(widht float64, height float64, centerCoordinate *GeoCoordinate, unit Param) (*GeoShapeRectangle, error) { + var convWidth, convHeight float64 + var err error + + if widht <= 0 || height <= 0 { + return nil, errors.ErrGeneral("HEIGHT, WIDTH should be > 0") + } + if convWidth, err = ConvertToMeter(widht, unit); err != nil { + return nil, err + } + if convHeight, err = ConvertToMeter(height, unit); err != nil { + return nil, err + } + + longitude, latitude := centerCoordinate.Longitude, centerCoordinate.Latitude + + var coord *GeoCoordinate + var errCoord error + if coord, errCoord = NewGeoCoordinateFromLonLat(longitude, latitude); errCoord != nil { + return nil, errCoord + } + + return &GeoShapeRectangle{ + Widht: convWidth, + Height: convHeight, + CenterCoordinate: coord, + Unit: unit, + }, nil +} + +// Return the bounding box of the shape +// bounds[0] - bounds[2] is the minimum and maximum longitude +// bounds[1] - bounds[3] is the minimum and maximum latitude. +// since the higher the latitude, the shorter the arc length, the box shape is as follows +// +// \-----------------/ -------- \-----------------/ +// \ / / \ \ / +// \ (long,lat) / / (long,lat) \ \ (long,lat) / +// \ / / \ / \ +// --------- /----------------\ /---------------\ +// Northern Hemisphere Southern Hemisphere Around the equator +func GetBoundingBoxForShape(geoShape GeoShape) *geohash.Box { + + coord := geoShape.GetCoordinate() + lon, lat := coord.Longitude, coord.Latitude + width, height := geoShape.GetBoudingBoxWidhtAndHeight() + boudingBox := geohash.Box{} + + width /= 2 + height /= 2 + latDelta := RadToDeg(height / EARTH_RADIUS) + lonDeltaTop := RadToDeg(width / EARTH_RADIUS / math.Cos(DegToRad(lat+latDelta))) + lonDeltaBottom := RadToDeg(width / EARTH_RADIUS / math.Cos(DegToRad(lat-latDelta))) + + boudingBox.MinLat = lat - latDelta + boudingBox.MaxLat = lat + latDelta + + if lat < 0 { + boudingBox.MinLng = lon - lonDeltaBottom + boudingBox.MaxLng = lon + lonDeltaBottom + } else { + boudingBox.MinLng = lon - lonDeltaTop + boudingBox.MaxLng = lon + lonDeltaTop + } + + return &boudingBox + +} + +// Find the step → Precision at which 9 cells (3x3 cells) can cover the entire given shape +func EstimatePrecisionForShapeCoverage(geoShape GeoShape) uint { + + radius := geoShape.GetRadius() + coord := geoShape.GetCoordinate() + _, latitude := coord.Longitude, coord.Latitude + + if radius == 0 { + return 26 + } + + var step int = 1 + for radius < MERCATOR_MAX { + radius *= 2 + step++ + } + step -= 2 /* Make sure range is included in most of the base cases. */ + + // Wider range towards the poles + if latitude > 66 || latitude < -66 { + step-- + if latitude > 80 || latitude < -80 { + step-- + } + } + + /* Frame to valid range. */ + if step < 1 { + step = 1 + } + if step > 26 { + step = 26 + } + + // as (mmcloughlin/geohash) requires total number of bits, not steps, so we multiply by 2 + return uint(step * 2) +} + +func GetGeohashNeighborsWithinShape(geoShape GeoShape, boudingBox *geohash.Box) (neighbors *Neighbors) { + coord := geoShape.GetCoordinate() + lon, lat := coord.Longitude, coord.Latitude + precision := EstimatePrecisionForShapeCoverage(geoShape) + + centerHash := geohash.EncodeIntWithPrecision(lat, lon, precision) + centerBox := geohash.BoundingBoxIntWithPrecision(centerHash, precision) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, precision) + neighborsArr = append(neighborsArr, centerHash) + neighbors = CreateNeighborsFromArray(neighborsArr) + + // Check if the step is enough at the limits of the covered area. + // Decode each of the 8 neighbours to get max and min (lon, lat) + // If North.maxLatitude < maxLatitude(from bouding box) then we have to reduce step to increase neighbour size + // Do this for N, S, E, W + northBox := geohash.BoundingBoxIntWithPrecision(neighbors.North, precision) + eastBox := geohash.BoundingBoxIntWithPrecision(neighbors.East, precision) + southBox := geohash.BoundingBoxIntWithPrecision(neighbors.South, precision) + westBox := geohash.BoundingBoxIntWithPrecision(neighbors.West, precision) + + if northBox.MaxLat < boudingBox.MaxLat || southBox.MinLat > boudingBox.MinLat || eastBox.MaxLng < boudingBox.MaxLng || westBox.MinLng > boudingBox.MinLng { + precision -= 2 + centerHash = geohash.EncodeIntWithPrecision(lat, lon, precision) + centerBox = geohash.BoundingBoxIntWithPrecision(centerHash, precision) + neighborsArr := geohash.NeighborsIntWithPrecision(centerHash, precision) + neighborsArr = append(neighborsArr, centerHash) + neighbors = CreateNeighborsFromArray(neighborsArr) + } + + // Update the center block as well + neighbors.Center = centerHash + + // Exclude search areas that are useless + // why not at step < 4? Because geohash cells are so large that excluding neighbors could miss valid points. + if precision >= 4 { + if centerBox.MinLat < boudingBox.MinLat { + neighbors.South = 0 + neighbors.SouthWest = 0 + neighbors.SouthEast = 0 + } + if centerBox.MaxLat > boudingBox.MaxLat { + neighbors.North = 0 + neighbors.NorthEast = 0 + neighbors.NorthWest = 0 + } + if centerBox.MinLng < boudingBox.MinLng { + neighbors.West = 0 + neighbors.SouthWest = 0 + neighbors.NorthWest = 0 + } + if centerBox.MaxLng > boudingBox.MaxLng { + neighbors.East = 0 + neighbors.SouthEast = 0 + neighbors.NorthEast = 0 + } + } + + // Set Precision in neighbors + neighbors.Precision = precision + return neighbors +} diff --git a/internal/types/params.go b/internal/types/params.go index a35b1d04d..b8dfbb1ed 100644 --- a/internal/types/params.go +++ b/internal/types/params.go @@ -20,4 +20,25 @@ const ( KEEPTTL Param = "KEEPTTL" PERSIST Param = "PERSIST" + + M Param = "M" + KM Param = "KM" + MI Param = "MI" + FT Param = "FT" + + FROMMEMBER Param = "FROMMEMBER" + FROMLONLAT Param = "FROMLONLAT" + + BYRADIUS Param = "BYRADIUS" + BYBOX Param = "BYBOX" + + COUNT Param = "COUNT" + ANY Param = "ANY" + + ASC Param = "ASC" + DESC Param = "DESC" + + WITHCOORD Param = "WITHCOORD" + WITHDIST Param = "WITHDIST" + WITHHASH Param = "WITHHASH" )