From 04bf448efcc24c53536ffe31509b7dd7014b48b7 Mon Sep 17 00:00:00 2001 From: prvyk Date: Thu, 27 Feb 2025 13:17:55 +0200 Subject: [PATCH 01/10] Implement GEOSEARCH BYRADIUS, ASC and DESC ordering and FROMLATLONG. Implement GEORADIUS_RO and GEORADIUSBYMEMBER_RO. Implement most of GEORADIUS and GEORADIUSBYMEMBER. Add tests. --- libs/resources/RespCommandsDocs.json | 522 +++++++++++++++++ libs/resources/RespCommandsInfo.json | 156 +++++ .../Objects/SortedSet/SortedSetObject.cs | 26 +- libs/server/Objects/SortedSetGeo/GeoHash.cs | 14 + .../SortedSetGeo/SortedSetGeoObjectImpl.cs | 546 +++++++++++++----- libs/server/Resp/CmdStrings.cs | 2 + .../Resp/Objects/SortedSetGeoCommands.cs | 66 ++- libs/server/Resp/Parser/RespCommand.cs | 22 + libs/server/Resp/RespServerSession.cs | 4 + .../CommandInfoUpdater/SupportedCommand.cs | 4 + test/Garnet.test/Resp/ACL/RespCommandTests.cs | 60 ++ test/Garnet.test/RespSortedSetGeoTests.cs | 119 +++- website/docs/commands/api-compatibility.md | 8 +- website/docs/commands/data-structures.md | 107 ++++ 14 files changed, 1471 insertions(+), 185 deletions(-) diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index f42b5bcccc..ce86e2ad85 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -2448,6 +2448,528 @@ } ] }, + { + "Command": "GEORADIUS", + "Name": "GEORADIUS", + "Summary": "Query a sorted set representing a geospatial index to fetch members matching a given maximum distance from a point", + "Group": "Geo", + "Complexity": "O(N\u002Blog(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "DocFlags": "Deprecated", + "ReplacedBy": "\u0060GEOSEARCH\u0060 and \u0060GEOSEARCHSTORE\u0060 with the \u0060BYRADIUS\u0060 argument", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LONGITUDE", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LATITUDE", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "RADIUS", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "UNIT", + "Type": "OneOf", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "M", + "Type": "PureToken", + "Token": "M" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "KM", + "Type": "PureToken", + "Token": "KM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FT", + "Type": "PureToken", + "Token": "FT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MI", + "Type": "PureToken", + "Token": "MI" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHCOORD", + "Type": "PureToken", + "Token": "WITHCOORD", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHDIST", + "Type": "PureToken", + "Token": "WITHDIST", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHHASH", + "Type": "PureToken", + "Token": "WITHHASH", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "COUNT", + "Type": "Block", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "Type": "Integer", + "Token": "COUNT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ANY", + "Type": "PureToken", + "Token": "ANY", + "ArgumentFlags": "Optional" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "ORDER", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ASC", + "Type": "PureToken", + "Token": "ASC" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "DESC", + "Type": "PureToken", + "Token": "DESC" + } + ] + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "Token": "STORE", + "ArgumentFlags": "Optional", + "KeySpecIndex": 1 + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "Token": "STOREDIST", + "ArgumentFlags": "Optional", + "KeySpecIndex": 2 + } + ] + }, + { + "Command": "GEORADIUS_RO", + "Name": "GEORADIUS_RO", + "Summary": "A read-only variant for GEORADIUS", + "Group": "Geo", + "Complexity": "O(N\u002Blog(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "DocFlags": "Deprecated", + "ReplacedBy": "\u0060GEOSEARCH\u0060 with the \u0060BYRADIUS\u0060 argument", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LONGITUDE", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "LATITUDE", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "RADIUS", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "UNIT", + "Type": "OneOf", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "M", + "Type": "PureToken", + "Token": "M" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "KM", + "Type": "PureToken", + "Token": "KM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FT", + "Type": "PureToken", + "Token": "FT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MI", + "Type": "PureToken", + "Token": "MI" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHCOORD", + "Type": "PureToken", + "Token": "WITHCOORD", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHDIST", + "Type": "PureToken", + "Token": "WITHDIST", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHHASH", + "Type": "PureToken", + "Token": "WITHHASH", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "COUNT", + "Type": "Block", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "Type": "Integer", + "Token": "COUNT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ANY", + "Type": "PureToken", + "Token": "ANY", + "ArgumentFlags": "Optional" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "ORDER", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ASC", + "Type": "PureToken", + "Token": "ASC" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "DESC", + "Type": "PureToken", + "Token": "DESC" + } + ] + } + ] + }, + { + "Command": "GEORADIUSBYMEMBER", + "Name": "GEORADIUSBYMEMBER", + "Summary": "Query a sorted set representing a geospatial index to fetch members matching a given maximum distance from a member", + "Group": "Geo", + "Complexity": "O(N\u002Blog(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "DocFlags": "Deprecated", + "ReplacedBy": "\u0060GEOSEARCH\u0060 and \u0060GEOSEARCHSTORE\u0060 with the \u0060BYRADIUS\u0060 and \u0060FROMMEMBER\u0060 arguments", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "Type": "String" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "RADIUS", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "UNIT", + "Type": "OneOf", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "M", + "Type": "PureToken", + "Token": "M" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "KM", + "Type": "PureToken", + "Token": "KM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FT", + "Type": "PureToken", + "Token": "FT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MI", + "Type": "PureToken", + "Token": "MI" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHCOORD", + "Type": "PureToken", + "Token": "WITHCOORD", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHDIST", + "Type": "PureToken", + "Token": "WITHDIST", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHHASH", + "Type": "PureToken", + "Token": "WITHHASH", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "COUNT", + "Type": "Block", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "Type": "Integer", + "Token": "COUNT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ANY", + "Type": "PureToken", + "Token": "ANY", + "ArgumentFlags": "Optional" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "ORDER", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ASC", + "Type": "PureToken", + "Token": "ASC" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "DESC", + "Type": "PureToken", + "Token": "DESC" + } + ] + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "Token": "STORE", + "ArgumentFlags": "Optional", + "KeySpecIndex": 1 + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "Token": "STOREDIST", + "ArgumentFlags": "Optional", + "KeySpecIndex": 2 + } + ] + }, + { + "Command": "GEORADIUSBYMEMBER_RO", + "Name": "GEORADIUSBYMEMBER_RO", + "Summary": "A read-only variant for GEORADIUSBYMEMBER", + "Group": "Geo", + "Complexity": "O(N\u002Blog(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "DocFlags": "Deprecated", + "ReplacedBy": "\u0060GEOSEARCH\u0060 with the \u0060BYRADIUS\u0060 and \u0060FROMMEMBER\u0060 arguments", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "KEY", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MEMBER", + "Type": "String" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "RADIUS", + "Type": "Double" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "UNIT", + "Type": "OneOf", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "M", + "Type": "PureToken", + "Token": "M" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "KM", + "Type": "PureToken", + "Token": "KM" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "FT", + "Type": "PureToken", + "Token": "FT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MI", + "Type": "PureToken", + "Token": "MI" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHCOORD", + "Type": "PureToken", + "Token": "WITHCOORD", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHDIST", + "Type": "PureToken", + "Token": "WITHDIST", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "WITHHASH", + "Type": "PureToken", + "Token": "WITHHASH", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "COUNT", + "Type": "Block", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "Type": "Integer", + "Token": "COUNT" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ANY", + "Type": "PureToken", + "Token": "ANY", + "ArgumentFlags": "Optional" + } + ] + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "ORDER", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "ASC", + "Type": "PureToken", + "Token": "ASC" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "DESC", + "Type": "PureToken", + "Token": "DESC" + } + ] + } + ] + }, { "Command": "GEOSEARCH", "Name": "GEOSEARCH", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index dd1499f05d..ec77f63be7 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -1528,6 +1528,162 @@ } ] }, + { + "Command": "GEORADIUS", + "Name": "GEORADIUS", + "Arity": -6, + "Flags": "DenyOom, MovableKeys, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Geo, Slow, Write", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchKeyword", + "Keyword": "STORE", + "StartFrom": 6 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchKeyword", + "Keyword": "STOREDIST", + "StartFrom": 6 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + } + ] + }, + { + "Command": "GEORADIUS_RO", + "Name": "GEORADIUS_RO", + "Arity": -6, + "Flags": "ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Geo, Read, Slow", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, + { + "Command": "GEORADIUSBYMEMBER", + "Name": "GEORADIUSBYMEMBER", + "Arity": -5, + "Flags": "DenyOom, MovableKeys, Write", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Geo, Slow, Write", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchKeyword", + "Keyword": "STORE", + "StartFrom": 5 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchKeyword", + "Keyword": "STOREDIST", + "StartFrom": 5 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + } + ] + }, + { + "Command": "GEORADIUSBYMEMBER_RO", + "Name": "GEORADIUSBYMEMBER_RO", + "Arity": -5, + "Flags": "ReadOnly", + "FirstKey": 1, + "LastKey": 1, + "Step": 1, + "AclCategories": "Geo, Read, Slow", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "GEOSEARCH", "Name": "GEOSEARCH", diff --git a/libs/server/Objects/SortedSet/SortedSetObject.cs b/libs/server/Objects/SortedSet/SortedSetObject.cs index aa0e520f46..817aef9437 100644 --- a/libs/server/Objects/SortedSet/SortedSetObject.cs +++ b/libs/server/Objects/SortedSet/SortedSetObject.cs @@ -32,7 +32,6 @@ public enum SortedSetOperation : byte GEODIST, GEOPOS, GEOSEARCH, - GEOSEARCHSTORE, ZREVRANK, ZREMRANGEBYLEX, ZREMRANGEBYRANK, @@ -77,6 +76,30 @@ public enum SortedSetRangeOpts : byte WithScores = 1 << 4 } + /// + /// Options for specifying the range in sorted set operations. + /// + [Flags] + public enum SortedSetGeoOpts : byte + { + /// + /// No options specified. + /// + None = 0, + /// + /// ReadOnly operation. + /// + ReadOnly = 1, + /// + /// Operate by radius. + /// + ByRadius = 1 << 1, + /// + /// Get lonlat from member. + /// + ByMember = 1 << 2 + } + [Flags] public enum SortedSetAddOption { @@ -298,7 +321,6 @@ public override unsafe bool Operate(ref ObjectInput input, ref GarnetObjectStore GeoPosition(ref input, ref output.SpanByteAndMemory); break; case SortedSetOperation.GEOSEARCH: - case SortedSetOperation.GEOSEARCHSTORE: GeoSearch(ref input, ref output.SpanByteAndMemory); break; case SortedSetOperation.ZRANGE: diff --git a/libs/server/Objects/SortedSetGeo/GeoHash.cs b/libs/server/Objects/SortedSetGeo/GeoHash.cs index 2cdedb5c96..cfa4a6b3f0 100644 --- a/libs/server/Objects/SortedSetGeo/GeoHash.cs +++ b/libs/server/Objects/SortedSetGeo/GeoHash.cs @@ -263,6 +263,20 @@ public static double Distance(double sourceLat, double sourceLon, double targetL return 2 * Math.Asin(Math.Sqrt(latHaversine + (tmp * lonHaversine))) * EarthRadiusInMeters; } + /// + /// Find if a point is in the circle. + /// + public static bool GetDistanceWhenInCircle(double radius, double latCenterPoint, double lonCenterPoint, double lat2, double lon2, ref double distance) + { + distance = Distance(latCenterPoint, lonCenterPoint, lat2, lon2); + if (distance > radius) + { + return false; + } + + return true; + } + /// /// Find if a point is in the axis-aligned rectangle. /// when the distance between the searched point and the center point is less than or equal to diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index 6d7ee6a77a..023560296a 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -5,6 +5,7 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Text; using Garnet.common; using Tsavorite.core; @@ -27,22 +28,99 @@ private struct GeoSearchData public (double Latitude, double Longitude) Coordinates; } + /// + /// Type of GeoSearch + /// + internal enum GeoSearchType + { + /// + /// No defined order. + /// + Undefined, + + /// + /// Search inside circular area + /// + ByRadius, + + /// + /// Search inside an axis-aligned rectangle + /// + ByBox, + } + + /// + /// The direction in which to sequence elements. + /// + internal enum GeoOrder + { + /// + /// No defined order. + /// + None, + + /// + /// Order from low values to high values. + /// + Ascending, + + /// + /// Order from high values to low values. + /// + Descending, + } + + internal enum GeoOriginType + { + /// + /// Not defined. + /// + Undefined, + + /// + /// From explicit lon lat coordinates. + /// + FromLonLat, + + /// + /// From member key + /// + FromMember + } + /// /// Small struct to store options for GEOSEARCH command /// - private struct GeoSearchOptions + private ref struct GeoSearchOptions { - public bool FromMember { get; set; } - public bool FromLonLat { get; set; } - public bool ByRadius { get; set; } - public bool ByBox { get; set; } - public bool SortDescending { get; set; } - public bool WithCount { get; set; } - public int WithCountValue { get; set; } - public bool WithCountAny { get; set; } - public bool WithCoord { get; set; } - public bool WithDist { get; set; } - public bool WithHash { get; set; } + internal GeoSearchType searchType; + internal ReadOnlySpan unit; + internal double radius; + internal double boxWidth; + internal double boxHeight + { + get + { + return radius; + } + set + { + radius = value; + } + } + + internal int countValue; + + internal GeoOriginType origin; + + internal byte[] fromMember; + internal double lon, lat; + + internal bool withCoord; + internal bool withHash; + //internal bool withCountAny; + internal bool withDist; + internal GeoOrder sort; } private void GeoAdd(ref ObjectInput input, ref SpanByteAndMemory output) @@ -284,6 +362,10 @@ private void GeoPosition(ref ObjectInput input, ref SpanByteAndMemory output) private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) { + var cmtOpt = (SortedSetGeoOpts)input.arg2; + var byRadiusCmd = (cmtOpt & SortedSetGeoOpts.ByRadius) != 0; + var readOnlyCmd = (cmtOpt & SortedSetGeoOpts.ReadOnly) != 0; + var isMemory = false; MemoryHandle ptrHandle = default; var ptr = output.SpanByte.ToPointer(); @@ -294,122 +376,258 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) ObjectOutputHeader _output = default; try { - var opts = new GeoSearchOptions(); - byte[] fromMember = null; - var byBoxUnits = "M"u8; - double width = 0, height = 0; - var countValue = 0; + var opts = new GeoSearchOptions() + { + unit = "M"u8 + }; ReadOnlySpan errorMessage = default; var argNumError = false; - var currTokenIdx = 0; + if (byRadiusCmd) + { + // Read coordinates, note we already checked the number of arguments earlier. + if ((cmtOpt & SortedSetGeoOpts.ByMember) != 0) + { + // From Member + opts.fromMember = input.parseState.GetArgSliceByRef(currTokenIdx++).SpanByte.ToByteArray(); + opts.origin = GeoOriginType.FromMember; + } + else + { + if (!input.parseState.TryGetDouble(currTokenIdx++, out var lon) || + !input.parseState.TryGetDouble(currTokenIdx++, out var lat) || + (Math.Abs(lon) > 180) || (Math.Abs(lat) > 90)) + { + errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + + while (!RespWriteUtils.TryWriteError(errorMessage, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + + opts.origin = GeoOriginType.FromLonLat; + opts.lon = lon; + opts.lat = lat; + } + + // Radius + if (!input.parseState.TryGetDouble(currTokenIdx++, out var Radius) || (Radius < 0)) + { + errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + + while (!RespWriteUtils.TryWriteError(errorMessage, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + return; + } + + opts.searchType = GeoSearchType.ByRadius; + opts.radius = Radius; + opts.unit = input.parseState.GetArgSliceByRef(currTokenIdx++).ReadOnlySpan; + } + // Read the options while (currTokenIdx < input.parseState.Count) { // Read token var tokenBytes = input.parseState.GetArgSliceByRef(currTokenIdx++).ReadOnlySpan; - if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("FROMMEMBER"u8)) + if (!byRadiusCmd) { - if (input.parseState.Count - currTokenIdx == 0) + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("FROMMEMBER"u8)) { - argNumError = true; - break; + if (opts.origin != GeoOriginType.Undefined) + { + errorMessage = CmdStrings.RESP_SYNTAX_ERROR; + break; + } + + if (input.parseState.Count - currTokenIdx == 0) + { + argNumError = true; + break; + } + + opts.fromMember = input.parseState.GetArgSliceByRef(currTokenIdx++).SpanByte.ToByteArray(); + opts.origin = GeoOriginType.FromMember; + continue; } - fromMember = input.parseState.GetArgSliceByRef(currTokenIdx++).SpanByte.ToByteArray(); - opts.FromMember = true; - } - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("FROMLONLAT"u8)) - { - if (input.parseState.Count - currTokenIdx < 2) + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("FROMLONLAT"u8)) { - argNumError = true; - break; + if (opts.origin != GeoOriginType.Undefined) + { + errorMessage = CmdStrings.RESP_SYNTAX_ERROR; + break; + } + + if (input.parseState.Count - currTokenIdx < 2) + { + argNumError = true; + break; + } + + // Read coordinates + if (!input.parseState.TryGetDouble(currTokenIdx++, out var lon) || + !input.parseState.TryGetDouble(currTokenIdx++, out var lat) || + (Math.Abs(lon) > 180) || (Math.Abs(lat) > 90)) + { + errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + break; + } + + opts.origin = GeoOriginType.FromLonLat; + opts.lon = lon; + opts.lat = lat; + continue; } - // Read coordinates - if (!input.parseState.TryGetDouble(currTokenIdx++, out _) || - !input.parseState.TryGetDouble(currTokenIdx++, out _)) + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("BYRADIUS"u8)) { - errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; - break; + if (opts.searchType != GeoSearchType.Undefined) + { + errorMessage = CmdStrings.RESP_SYNTAX_ERROR; + break; + } + + if (input.parseState.Count - currTokenIdx < 2) + { + argNumError = true; + break; + } + + // Read radius and units + if (!input.parseState.TryGetDouble(currTokenIdx++, out opts.radius)) + { + errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + break; + } + + if (opts.radius < 0) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_OUT_OF_RANGE; + break; + } + + opts.searchType = GeoSearchType.ByRadius; + opts.unit = input.parseState.GetArgSliceByRef(currTokenIdx++).ReadOnlySpan; + continue; + } + + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("BYBOX"u8)) + { + if (opts.searchType != GeoSearchType.Undefined) + { + errorMessage = CmdStrings.RESP_SYNTAX_ERROR; + break; + } + + if (input.parseState.Count - currTokenIdx < 3) + { + argNumError = true; + break; + } + + // Read width, height + if (!input.parseState.TryGetDouble(currTokenIdx++, out opts.boxWidth) || + !input.parseState.TryGetDouble(currTokenIdx++, out var height)) + { + errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + break; + } + opts.boxHeight = height; + + if (opts.boxWidth < 0 || opts.boxHeight < 0) + { + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_OUT_OF_RANGE; + break; + } + + // Read units + opts.searchType = GeoSearchType.ByBox; + opts.unit = input.parseState.GetArgSliceByRef(currTokenIdx++).ReadOnlySpan; + continue; } + } - opts.FromLonLat = true; + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ASC"u8)) + { + opts.sort = GeoOrder.Ascending; + continue; } - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("BYRADIUS"u8)) + + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("DESC"u8)) { - if (input.parseState.Count - currTokenIdx < 2) + opts.sort = GeoOrder.Descending; + continue; + } + + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.COUNT)) + { + if (input.parseState.Count - currTokenIdx == 0) { argNumError = true; break; } - // Read radius and units - if (!input.parseState.TryGetDouble(currTokenIdx++, out _)) + if (!input.parseState.TryGetInt(currTokenIdx++, out var countValue)) { - errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER; break; } - opts.ByRadius = true; - } - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("BYBOX"u8)) - { - if (input.parseState.Count - currTokenIdx < 3) + if (countValue <= 0) { - argNumError = true; + errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_OUT_OF_RANGE; break; } - // Read width, height - if (!input.parseState.TryGetDouble(currTokenIdx++, out width) || - !input.parseState.TryGetDouble(currTokenIdx++, out height)) - { - errorMessage = CmdStrings.RESP_ERR_NOT_VALID_FLOAT; - break; - } + opts.countValue = countValue; + continue; + } - // Read units - byBoxUnits = input.parseState.GetArgSliceByRef(currTokenIdx++).ReadOnlySpan; + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("WITHCOORD"u8)) + { + opts.withCoord = true; + continue; + } - opts.ByBox = true; + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ANY"u8)) + { + //geoSearchOpt.withCountAny = true; + continue; } - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ASC"u8)) opts.SortDescending = false; - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("DESC"u8)) opts.SortDescending = true; - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("COUNT"u8)) + + // When GEORADIUS STORE support is added we'll need to account for it. + // For now we'll act as if all radius commands are readonly. + if (readOnlyCmd || byRadiusCmd) { - if (input.parseState.Count - currTokenIdx == 0) + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHDIST)) { - argNumError = true; - break; + opts.withDist = true; + continue; } - if (!input.parseState.TryGetInt(currTokenIdx++, out countValue)) + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHHASH)) { - errorMessage = CmdStrings.RESP_ERR_GENERIC_VALUE_IS_NOT_INTEGER; - break; + opts.withHash = true; + continue; } - - opts.WithCount = true; } - else if (input.header.SortedSetOp == SortedSetOperation.GEOSEARCH && tokenBytes.EqualsUpperCaseSpanIgnoringCase("WITHCOORD"u8)) opts.WithCoord = true; - else if ((input.header.SortedSetOp == SortedSetOperation.GEOSEARCH && tokenBytes.EqualsUpperCaseSpanIgnoringCase("WITHDIST"u8)) || - (input.header.SortedSetOp == SortedSetOperation.GEOSEARCHSTORE && tokenBytes.EqualsUpperCaseSpanIgnoringCase("STOREDIST"u8))) opts.WithDist = true; - else if (input.header.SortedSetOp == SortedSetOperation.GEOSEARCH && tokenBytes.EqualsUpperCaseSpanIgnoringCase("WITHHASH"u8)) opts.WithHash = true; - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ANY"u8)) opts.WithCountAny = true; - else + // GEOSEARCHSTORE + else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STOREDIST)) { - errorMessage = CmdStrings.RESP_SYNTAX_ERROR; - break; + opts.withDist = true; + continue; } + + errorMessage = CmdStrings.RESP_SYNTAX_ERROR; + break; } // Check that we have the mandatory options - if (errorMessage == default && !((opts.FromMember || opts.FromLonLat) && (opts.ByRadius || opts.ByBox))) + if (errorMessage == default && ((opts.origin == 0) || (opts.searchType == 0))) argNumError = true; // Check if we have a wrong number of arguments @@ -427,120 +645,138 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) return; } + #region act // Not supported options in Garnet: WITHHASH - if (opts.WithHash) + if (opts.withHash) { while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_GENERIC_UNK_CMD, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); return; } - // Get the results + // FROMLONLAT + bool hasLonLat = opts.origin == GeoOriginType.FromLonLat; // FROMMEMBER - if (opts.FromMember && sortedSetDict.TryGetValue(fromMember, out var centerPointScore)) + if (opts.origin == GeoOriginType.FromMember && sortedSetDict.TryGetValue(opts.fromMember, out var centerPointScore)) { - var (lat, lon) = server.GeoHash.GetCoordinatesFromLong((long)centerPointScore); + (opts.lat, opts.lon) = server.GeoHash.GetCoordinatesFromLong((long)centerPointScore); + hasLonLat = true; + } - if (opts.ByRadius) + // Get the results + if (hasLonLat) + { + var responseData = new List(); + foreach (var point in sortedSet) { - // Not supported in Garnet: ByRadius - while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_GENERIC_UNK_CMD, ref curr, end)) + var coorInItem = server.GeoHash.GetCoordinatesFromLong((long)point.Score); + double distance = 0; + + if (opts.searchType == GeoSearchType.ByBox) + { + if (!server.GeoHash.GetDistanceWhenInRectangle( + server.GeoHash.ConvertValueToMeters(opts.boxWidth, opts.unit), + server.GeoHash.ConvertValueToMeters(opts.boxHeight, opts.unit), + opts.lat, opts.lon, coorInItem.Latitude, coorInItem.Longitude, ref distance)) + { + continue; + } + } + else /* byRadius == true */ + { + if (!server.GeoHash.GetDistanceWhenInCircle( + server.GeoHash.ConvertValueToMeters(opts.radius, opts.unit), + opts.lat, opts.lon, coorInItem.Latitude, coorInItem.Longitude, ref distance)) + { + continue; + } + } + + // The item is inside the shape + responseData.Add(new GeoSearchData() + { + Member = point.Element, + Distance = distance, + GeoHashCode = server.GeoHash.GetGeoHashCode((long)point.Score), + Coordinates = server.GeoHash.GetCoordinatesFromLong((long)point.Score) + }); + + if (responseData.Count == opts.countValue) + break; + } + + if (responseData.Count == 0) + { + while (!RespWriteUtils.TryWriteInt32(0, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } else { - var responseData = new List(); - foreach (var point in sortedSet) + var innerArrayLength = 1; + if (opts.withDist) { - var coorInItem = server.GeoHash.GetCoordinatesFromLong((long)point.Item1); - double distance = 0; - if (opts.ByBox) - { - if (server.GeoHash.GetDistanceWhenInRectangle(server.GeoHash.ConvertValueToMeters(width, byBoxUnits), server.GeoHash.ConvertValueToMeters(height, byBoxUnits), lat, lon, coorInItem.Item1, coorInItem.Item2, ref distance)) - { - // The item is inside the shape - responseData.Add(new GeoSearchData() - { - Member = point.Item2, - Distance = distance, - GeoHashCode = server.GeoHash.GetGeoHashCode((long)point.Item1), - Coordinates = server.GeoHash.GetCoordinatesFromLong((long)point.Item1) - }); - - if (opts.WithCount && responseData.Count == countValue) - break; - } - } + innerArrayLength++; } - - if (responseData.Count == 0) + if (opts.withHash) { - while (!RespWriteUtils.TryWriteInt32(0, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + innerArrayLength++; } - else + if (opts.withCoord) { - var innerArrayLength = 1; - if (opts.WithDist) - { - innerArrayLength++; - } - if (opts.WithHash) - { - innerArrayLength++; - } - if (opts.WithCoord) + innerArrayLength++; + } + + // Write results + while (!RespWriteUtils.TryWriteArrayLength(responseData.Count, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + + var q = responseData.AsQueryable(); + if (opts.sort == GeoOrder.Ascending) + q = q.OrderBy(i => i.Distance); + else if (opts.sort == GeoOrder.Descending) + q = q.OrderByDescending(i => i.Distance); + + foreach (var item in q) + { + if (innerArrayLength > 1) { - innerArrayLength++; + while (!RespWriteUtils.TryWriteArrayLength(innerArrayLength, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } - // Write results - while (!RespWriteUtils.TryWriteArrayLength(responseData.Count, ref curr, end)) + while (!RespWriteUtils.TryWriteBulkString(item.Member, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - foreach (var item in responseData) + if (opts.withDist) { - if (innerArrayLength > 1) + var distanceValue = opts.searchType switch { - while (!RespWriteUtils.TryWriteArrayLength(innerArrayLength, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - } + GeoSearchType.ByBox => server.GeoHash.ConvertMetersToUnits(item.Distance, opts.unit), - while (!RespWriteUtils.TryWriteBulkString(item.Member, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + // byRadius + _ => server.GeoHash.ConvertMetersToUnits(item.Distance, opts.unit), + }; - if (opts.WithDist) - { - var distanceValue = (byBoxUnits.Length == 1 && (byBoxUnits[0] == (int)'M' || byBoxUnits[0] == (int)'m')) ? item.Distance - : server.GeoHash.ConvertMetersToUnits(item.Distance, byBoxUnits); - - while (!RespWriteUtils.TryWriteDoubleBulkString(distanceValue, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - } + while (!RespWriteUtils.TryWriteDoubleBulkString(distanceValue, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } - if (opts.WithCoord) - { - // Write array of 2 values - while (!RespWriteUtils.TryWriteArrayLength(2, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + if (opts.withCoord) + { + // Write array of 2 values + while (!RespWriteUtils.TryWriteArrayLength(2, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - while (!RespWriteUtils.TryWriteDoubleBulkString(item.Coordinates.Longitude, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + while (!RespWriteUtils.TryWriteDoubleBulkString(item.Coordinates.Longitude, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - while (!RespWriteUtils.TryWriteDoubleBulkString(item.Coordinates.Latitude, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - } + while (!RespWriteUtils.TryWriteDoubleBulkString(item.Coordinates.Latitude, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } } } } - - // Not supported options in Garnet: FROMLONLAT BYBOX BYRADIUS - if (opts.FromLonLat) - { - while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_GENERIC_UNK_CMD, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - } + #endregion } finally { diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index d5b135bce0..0e311f4b87 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -23,6 +23,8 @@ static partial class CmdStrings public static ReadOnlySpan get => "get"u8; public static ReadOnlySpan SET => "SET"u8; public static ReadOnlySpan set => "set"u8; + public static ReadOnlySpan GEORADIUSBYMEMBER_RO => "GEORADIUSBYMEMBER_RO"u8; + public static ReadOnlySpan REWRITE => "REWRITE"u8; public static ReadOnlySpan rewrite => "rewrite"u8; public static ReadOnlySpan CONFIG => "CONFIG"u8; diff --git a/libs/server/Resp/Objects/SortedSetGeoCommands.cs b/libs/server/Resp/Objects/SortedSetGeoCommands.cs index 9c4c5de1f6..b508c2e292 100644 --- a/libs/server/Resp/Objects/SortedSetGeoCommands.cs +++ b/libs/server/Resp/Objects/SortedSetGeoCommands.cs @@ -79,6 +79,14 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi paramsRequiredInCommand = 1; break; case RespCommand.GEOSEARCH: + paramsRequiredInCommand = 6; + break; + case RespCommand.GEORADIUS: + case RespCommand.GEORADIUS_RO: + paramsRequiredInCommand = 4; + break; + case RespCommand.GEORADIUSBYMEMBER: + case RespCommand.GEORADIUSBYMEMBER_RO: paramsRequiredInCommand = 3; break; } @@ -92,20 +100,47 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi var sbKey = parseState.GetArgSliceByRef(0).SpanByte; var keyBytes = sbKey.ToByteArray(); - var op = - command switch - { - RespCommand.GEOHASH => SortedSetOperation.GEOHASH, - RespCommand.GEODIST => SortedSetOperation.GEODIST, - RespCommand.GEOPOS => SortedSetOperation.GEOPOS, - RespCommand.GEOSEARCH => SortedSetOperation.GEOSEARCH, - _ => throw new Exception($"Unexpected {nameof(SortedSetOperation)}: {command}") - }; + SortedSetOperation op; + SortedSetGeoOpts opts = 0; + switch (command) + { + case RespCommand.GEOHASH: + op = SortedSetOperation.GEOHASH; + break; + case RespCommand.GEODIST: + op = SortedSetOperation.GEODIST; + break; + case RespCommand.GEOPOS: + op = SortedSetOperation.GEOPOS; + break; + case RespCommand.GEORADIUS: + op = SortedSetOperation.GEOSEARCH; + opts = SortedSetGeoOpts.ByRadius; + break; + case RespCommand.GEORADIUS_RO: + op = SortedSetOperation.GEOSEARCH; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ReadOnly; + break; + case RespCommand.GEORADIUSBYMEMBER: + op = SortedSetOperation.GEOSEARCH; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ByMember; + break; + case RespCommand.GEORADIUSBYMEMBER_RO: + op = SortedSetOperation.GEOSEARCH; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ReadOnly | SortedSetGeoOpts.ByMember; + break; + case RespCommand.GEOSEARCH: + op = SortedSetOperation.GEOSEARCH; + opts = SortedSetGeoOpts.ReadOnly; + break; + default: + throw new Exception($"Unexpected {nameof(SortedSetOperation)}: {command}"); + } // Prepare input var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = op }; - var input = new ObjectInput(header, ref parseState, startIdx: 1); + var input = new ObjectInput(header, ref parseState, startIdx: 1, arg2: (int)opts); var outputFooter = new GarnetObjectStoreOutput { SpanByteAndMemory = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)) }; @@ -123,6 +158,10 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi while (!RespWriteUtils.TryWriteDirect(CmdStrings.RESP_ERRNOTFOUND, ref dcurr, dend)) SendAndReset(); break; + case SortedSetOperation.GEOSEARCH: + while (!RespWriteUtils.TryWriteEmptyArray(ref dcurr, dend)) + SendAndReset(); + break; default: var inputCount = parseState.Count - 1; while (!RespWriteUtils.TryWriteArrayLength(inputCount, ref dcurr, dend)) @@ -134,8 +173,8 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi } break; } - break; + case GarnetStatus.WRONGTYPE: while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) SendAndReset(); @@ -154,7 +193,8 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi private unsafe bool GeoSearchStore(ref TGarnetApi storageApi) where TGarnetApi : IGarnetApi { - if (parseState.Count < 4) + // GEOSEARCHSTORE dst src FROMEMBER key BYRADIUS 0 m + if (parseState.Count < 7) { return AbortWithWrongNumberOfArguments(nameof(RespCommand.GEOSEARCHSTORE)); } @@ -165,7 +205,7 @@ private unsafe bool GeoSearchStore(ref TGarnetApi storageApi) var input = new ObjectInput(new RespInputHeader { type = GarnetObjectType.SortedSet, - SortedSetOp = SortedSetOperation.GEOSEARCHSTORE + SortedSetOp = SortedSetOperation.GEOSEARCH }, ref parseState, startIdx: 2); var output = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)); diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index 1c74fd8ac1..f8bb9f8266 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -31,6 +31,8 @@ public enum RespCommand : ushort GEODIST, GEOHASH, GEOPOS, + GEORADIUS_RO, + GEORADIUSBYMEMBER_RO, GEOSEARCH, GET, GETBIT, @@ -116,6 +118,8 @@ public enum RespCommand : ushort FLUSHALL, FLUSHDB, GEOADD, + GEORADIUS, + GEORADIUSBYMEMBER, GEOSEARCHSTORE, GETDEL, GETEX, @@ -1473,6 +1477,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HEXPIREAT; } + else if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("GEORADIU"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("US\r\n"u8)) + { + return RespCommand.GEORADIUS; + } break; case 10: if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("SSUBSCRI"u8) && *(uint*)(ptr + 11) == MemoryMarshal.Read("BE\r\n"u8)) @@ -1617,6 +1625,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.HPEXPIRETIME; } + else if (*(ulong*)(ptr + 3) == MemoryMarshal.Read("\r\nGEORAD"u8) && *(ulong*)(ptr + 11) == MemoryMarshal.Read("IUS_RO\r\n"u8)) + { + return RespCommand.GEORADIUS_RO; + } break; case 13: @@ -1662,6 +1674,12 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan return RespCommand.ZREVRANGEBYSCORE; } break; + case 17: + if (*(ulong*)(ptr + 4) == MemoryMarshal.Read("\nGEORADI"u8) && *(ulong*)(ptr + 12) == MemoryMarshal.Read("USBYMEMB"u8) && *(uint*)(ptr + 20) == MemoryMarshal.Read("ER\r\n"u8)) + { + return RespCommand.GEORADIUSBYMEMBER; + } + break; } // Reset optimistically changed state, if no matching command was found @@ -1755,6 +1773,10 @@ private RespCommand SlowParseCommand(ref int count, ref ReadOnlySpan speci { return RespCommand.ECHO; } + else if (command.SequenceEqual(CmdStrings.GEORADIUSBYMEMBER_RO)) + { + return RespCommand.GEORADIUSBYMEMBER_RO; + } else if (command.SequenceEqual(CmdStrings.REPLICAOF)) { return RespCommand.REPLICAOF; diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index b401f75282..30fd246dc7 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -726,6 +726,10 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.GEOHASH => GeoCommands(cmd, ref storageApi), RespCommand.GEODIST => GeoCommands(cmd, ref storageApi), RespCommand.GEOPOS => GeoCommands(cmd, ref storageApi), + RespCommand.GEORADIUS_RO or + RespCommand.GEORADIUSBYMEMBER_RO => GeoCommands(cmd, ref storageApi), + RespCommand.GEORADIUS or + RespCommand.GEORADIUSBYMEMBER => GeoCommands(cmd, ref storageApi), RespCommand.GEOSEARCH => GeoCommands(cmd, ref storageApi), RespCommand.GEOSEARCHSTORE => GeoSearchStore(ref storageApi), //HLL Commands diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index 2d59d8be80..4d937bdd71 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -140,6 +140,10 @@ public class SupportedCommand new("GEODIST", RespCommand.GEODIST), new("GEOHASH", RespCommand.GEOHASH), new("GEOPOS", RespCommand.GEOPOS), + new("GEORADIUS", RespCommand.GEORADIUS), + new("GEORADIUS_RO", RespCommand.GEORADIUS_RO), + new("GEORADIUSBYMEMBER", RespCommand.GEORADIUSBYMEMBER), + new("GEORADIUSBYMEMBER_RO", RespCommand.GEORADIUSBYMEMBER_RO), new("GEOSEARCH", RespCommand.GEOSEARCH), new("GEOSEARCHSTORE", RespCommand.GEOSEARCHSTORE), new("GET", RespCommand.GET), diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index d721f9103b..4dd66adb9b 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -6285,6 +6285,66 @@ static async Task DoGeoPosMultiAsync(GarnetClient client) } } + [Test] + public async Task GeoRadiusACLsAsync() + { + await CheckCommandsAsync( + "GEORADIUS", + [DoGeoRadiusAsync], + skipPermitted: true + ); + + static async Task DoGeoRadiusAsync(GarnetClient client) + { + await client.ExecuteForStringResultAsync("GEORADIUS", ["foo", "0", "85", "10", "km"]); + } + } + + [Test] + public async Task GeoRadiusROACLsAsync() + { + await CheckCommandsAsync( + "GEORADIUS_RO", + [DoGeoRadiusROAsync], + skipPermitted: true + ); + + static async Task DoGeoRadiusROAsync(GarnetClient client) + { + await client.ExecuteForStringResultAsync("GEORADIUS_RO", ["foo", "0", "85", "10", "km"]); + } + } + + [Test] + public async Task GeoRadiusByMemberACLsAsync() + { + await CheckCommandsAsync( + "GEORADIUSBYMEMBER", + [DoGeoRadiusByMemberAsync], + skipPermitted: true + ); + + static async Task DoGeoRadiusByMemberAsync(GarnetClient client) + { + await client.ExecuteForStringResultAsync("GEORADIUSBYMEMBER", ["foo", "bar", "10", "km"]); + } + } + + [Test] + public async Task GeoRadiusByMemberROACLsAsync() + { + await CheckCommandsAsync( + "GEORADIUSBYMEMBER_RO", + [DoGeoRadiusByMemberROAsync], + skipPermitted: true + ); + + static async Task DoGeoRadiusByMemberROAsync(GarnetClient client) + { + await client.ExecuteForStringResultAsync("GEORADIUSBYMEMBER_RO", ["foo", "bar", "10", "km"]); + } + } + [Test] public async Task GeoSearchACLsAsync() { diff --git a/test/Garnet.test/RespSortedSetGeoTests.cs b/test/Garnet.test/RespSortedSetGeoTests.cs index b7c6d1cc88..a4d286a04c 100644 --- a/test/Garnet.test/RespSortedSetGeoTests.cs +++ b/test/Garnet.test/RespSortedSetGeoTests.cs @@ -14,6 +14,7 @@ namespace Garnet.test { [TestFixture] + [Category("GEOTESTS")] public class RespSortedSetGeoTests { GarnetServer server; @@ -235,7 +236,7 @@ public void CanValidateUnknownWithNotSupportedOptions() var key = new RedisKey("Sicily"); db.GeoAdd(key, 13.361389, 38.115556, new RedisValue("Palermo"), CommandFlags.None); var box = new GeoSearchBox(500, 500, GeoUnit.Kilometers); - Assert.Throws(() => db.GeoSearch(key, 73.9262, 40.8296, box, count: 2)); + Assert.Throws(() => db.GeoSearch(key, 73.9262, 40.8296, box, count: 2, options: GeoRadiusOptions.WithGeoHash)); } [Test] @@ -270,6 +271,8 @@ public void CheckGeoSortedSetOperationsOnWrongTypeObjectSE() RespTestsUtils.CheckCommandOnWrongTypeObjectSE(() => db.GeoDistance(keys[0], values[0][1], values[0][1])); // GEOPOS RespTestsUtils.CheckCommandOnWrongTypeObjectSE(() => db.GeoPosition(keys[0], values[0])); + // GEORADIUS + RespTestsUtils.CheckCommandOnWrongTypeObjectSE(() => db.GeoRadius(keys[0], values[0][1], 800, GeoUnit.Kilometers)); // GEOSEARCH RespTestsUtils.CheckCommandOnWrongTypeObjectSE(() => db.GeoSearch(keys[0], values[0][1], new GeoSearchBox(800, 800, GeoUnit.Kilometers))); } @@ -282,7 +285,7 @@ public void CanUseGeoSearch() var entries = new GeoEntry[cities.GetLength(0)]; var key = new RedisKey("cities"); var destinationKey = new RedisKey("newCities"); - for (int j = 0; j < cities.GetLength(0); j++) + for (var j = 0; j < cities.GetLength(0); j++) { entries[j] = new GeoEntry( double.Parse(cities[j, 0], CultureInfo.InvariantCulture), @@ -291,7 +294,8 @@ public void CanUseGeoSearch() } var response = db.GeoAdd(key, entries, CommandFlags.None); - var res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), options: GeoRadiusOptions.None); + var res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), + order: Order.Ascending, options: GeoRadiusOptions.None); ClassicAssert.AreEqual(3, res.Length); ClassicAssert.AreEqual("Washington", (string)res[0].Member); ClassicAssert.AreEqual(res[0].Distance, null); @@ -303,7 +307,9 @@ public void CanUseGeoSearch() ClassicAssert.AreEqual(res[2].Distance, null); ClassicAssert.AreEqual(res[2].Position, null); - res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), options: GeoRadiusOptions.WithDistance); + res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), + order: Order.Ascending, + options: GeoRadiusOptions.WithDistance); ClassicAssert.AreEqual(3, res.Length); ClassicAssert.AreEqual("Washington", (string)res[0].Member); Assert.That(res[0].Distance, Is.EqualTo(0).Within(1.0 / Math.Pow(10, 6))); @@ -315,7 +321,9 @@ public void CanUseGeoSearch() Assert.That(res[2].Distance, Is.EqualTo(327.676458633557).Within(1.0 / Math.Pow(10, 6))); ClassicAssert.AreEqual(res[2].Position, null); - res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), options: GeoRadiusOptions.WithCoordinates); + res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), + order: Order.Ascending, + options: GeoRadiusOptions.WithCoordinates); ClassicAssert.AreEqual(3, res.Length); ClassicAssert.AreEqual("Washington", (string)res[0].Member); ClassicAssert.AreEqual(res[0].Distance, null); @@ -330,7 +338,9 @@ public void CanUseGeoSearch() Assert.That(res[2].Position.Value.Longitude, Is.EqualTo(-74.00594205).Within(1.0 / Math.Pow(10, 6))); Assert.That(res[2].Position.Value.Latitude, Is.EqualTo(40.71278259).Within(1.0 / Math.Pow(10, 6))); - res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), options: GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithCoordinates); + res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), + order: Order.Ascending, + options: GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithCoordinates); ClassicAssert.AreEqual(3, res.Length); ClassicAssert.AreEqual("Washington", (string)res[0].Member); Assert.That(res[0].Distance, Is.EqualTo(0).Within(1.0 / Math.Pow(10, 6))); @@ -344,6 +354,31 @@ public void CanUseGeoSearch() Assert.That(res[2].Distance, Is.EqualTo(327.676458633557).Within(1.0 / Math.Pow(10, 6))); Assert.That(res[2].Position.Value.Longitude, Is.EqualTo(-74.00594205).Within(1.0 / Math.Pow(10, 6))); Assert.That(res[2].Position.Value.Latitude, Is.EqualTo(40.71278259).Within(1.0 / Math.Pow(10, 6))); + + res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchCircle(530, GeoUnit.Kilometers), + order: Order.Descending, + options: GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithCoordinates); + ClassicAssert.AreEqual(4, res.Length); + ClassicAssert.AreEqual("Columbus", (string)res[0].Member); + Assert.That(res[0].Distance, Is.EqualTo(525.298997019908).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[0].Position.Value.Longitude, Is.EqualTo(-82.99879419999999).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[0].Position.Value.Latitude, Is.EqualTo(39.9611766).Within(1.0 / Math.Pow(10, 6))); + ClassicAssert.AreEqual("New York", (string)res[1].Member); + Assert.That(res[1].Distance, Is.EqualTo(327.676458633557).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[1].Position.Value.Longitude, Is.EqualTo(-74.00594205).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[1].Position.Value.Latitude, Is.EqualTo(40.71278259).Within(1.0 / Math.Pow(10, 6))); + ClassicAssert.AreEqual("Philadelphia", (string)res[2].Member); + Assert.That(res[2].Distance, Is.EqualTo(198.424300439725).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[2].Position.Value.Longitude, Is.EqualTo(-75.1652196).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[2].Position.Value.Latitude, Is.EqualTo(39.95258287).Within(1.0 / Math.Pow(10, 6))); + ClassicAssert.AreEqual("Washington", (string)res[3].Member); + Assert.That(res[3].Distance, Is.EqualTo(0).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[3].Position.Value.Longitude, Is.EqualTo(-77.03687042).Within(1.0 / Math.Pow(10, 6))); + Assert.That(res[3].Position.Value.Latitude, Is.EqualTo(38.9071919).Within(1.0 / Math.Pow(10, 6))); + + res = db.GeoSearch(key, new RedisValue("Washington"), new GeoSearchCircle(530, GeoUnit.Kilometers), + options: GeoRadiusOptions.WithDistance | GeoRadiusOptions.WithCoordinates); + ClassicAssert.AreEqual(4, res.Length); } [Test] @@ -410,8 +445,17 @@ public void CanUseGeoSearchWithCities(int bytesSent) using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); var db = redis.GetDatabase(0); var entries = new GeoEntry[cities.GetLength(0)]; - for (int j = 0; j < cities.GetLength(0); j++) + + string lat = "0", lon = "0"; + for (var j = 0; j < cities.GetLength(0); j++) { + if (string.Compare(cities[j, 2], "Washington", + CultureInfo.InvariantCulture, CompareOptions.IgnoreCase) == 0) + { + lon = cities[j, 0].TrimEnd(); + lat = cities[j, 1].TrimEnd(); + } + entries[j] = new GeoEntry( double.Parse(cities[j, 0], CultureInfo.InvariantCulture), double.Parse(cities[j, 1], CultureInfo.InvariantCulture), @@ -423,15 +467,37 @@ public void CanUseGeoSearchWithCities(int bytesSent) //TODO: Assert values for latitude and longitude //TODO: Review precision to use for all framework versions using var lightClientRequest = TestUtils.CreateRequest(); - var responseBuf = lightClientRequest.SendCommands("GEOSEARCH cities FROMMEMBER Washington BYBOX 800 800 km WITHCOORD WITHDIST", "PING"); + var responseBuf = lightClientRequest.SendCommands("GEOSEARCH cities FROMMEMBER Washington BYBOX 800 800 km WITHCOORD WITHDIST ASC", "PING"); var expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; - var actualValue = Encoding.ASCII.GetString(responseBuf).Substring(0, expectedResponse.Length); + var actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); //Send command in chunks - responseBuf = lightClientRequest.SendCommandChunks("GEOSEARCH cities FROMMEMBER Washington BYBOX 800 800 km COUNT 3 ANY WITHCOORD WITHDIST", bytesSent, 16); + responseBuf = lightClientRequest.SendCommandChunks("GEOSEARCH cities FROMMEMBER Washington BYBOX 800 800 km COUNT 3 ANY WITHCOORD WITHDIST ASC", bytesSent, 16); + expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; + actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); + ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); + + using var lightClientRequest2 = TestUtils.CreateRequest(); + responseBuf = lightClientRequest2.SendCommands("GEOSEARCH cities FROMMEMBER Washington BYRADIUS 500 km WITHCOORD WITHDIST ASC", "PING"); + expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; + actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); + ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); + + responseBuf = lightClientRequest2.SendCommandChunks($"GEOSEARCH cities FROMLONLAT {lon} {lat} BYRADIUS 500 km COUNT 3 ANY WITHCOORD WITHDIST ASC", bytesSent, 16); + expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; + actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); + ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); + + using var lightClientRequest3 = TestUtils.CreateRequest(); + responseBuf = lightClientRequest3.SendCommands("GEORADIUSBYMEMBER_RO cities Washington 500 km WITHCOORD WITHDIST ASC", "PING"); + expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; + actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); + ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); + + responseBuf = lightClientRequest3.SendCommandChunks($"GEORADIUS_RO cities FROMLONLAT {lon} {lat} 500 km COUNT 3 ANY WITHCOORD WITHDIST ASC", bytesSent, 16); expectedResponse = "*3\r\n*3\r\n$10\r\nWashington\r\n$1\r\n0\r\n*2\r\n$17\r\n-77.0368704199791\r\n$17\r\n38.90719190239906\r\n*3\r\n$12\r\nPhiladelphia\r\n$17\r\n198.4242996738795\r\n*2\r\n$18\r\n-75.16521960496902\r\n$18\r\n39.952582865953445\r\n*3\r\n$8\r\nNew York\r\n$18\r\n327.67645879712575\r\n*2\r\n$17\r\n-74.0059420466423\r\n$18\r\n40.712782591581345\r\n+PONG\r\n"; - actualValue = Encoding.ASCII.GetString(responseBuf).Substring(0, expectedResponse.Length); + actualValue = Encoding.ASCII.GetString(responseBuf, 0, expectedResponse.Length); ClassicAssert.IsTrue(actualValue.IndexOf("Washington") != -1); } @@ -619,6 +685,37 @@ public void CanContinueWhenNotEnoughParametersInGeoAdd(int bytesSent) actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); ClassicAssert.AreEqual(expectedResponse, actualValue); } + + [Test] + public void InvalidGeoSearches() + { + using var lightClientRequest = TestUtils.CreateRequest(); + + var response = lightClientRequest.SendCommand("GEOADD Sicily NX 13.361389 38.115556 Palermo 15.087269 37.502669 Catania"); + var expectedResponse = ":2\r\n"; + var actualValue = Encoding.ASCII.GetString(response, 0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + response = lightClientRequest.SendCommand("GEOSEARCH Sicily FROMLONLAT 15 37 FROMMEMBER a BYRADIUS 0 km"); + expectedResponse = "-ERR syntax error\r\n"; + actualValue = Encoding.ASCII.GetString(response, 0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + response = lightClientRequest.SendCommand("GEOSEARCH Sicily FROMLONLAT 15 37 BYRADIUS 100 km BYBOX 400 400 km"); + expectedResponse = "-ERR syntax error\r\n"; + actualValue = Encoding.ASCII.GetString(response, 0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + response = lightClientRequest.SendCommand("GEOSEARCH Sicily FROMLONLAT 15 37 BYBOX 400 400 km COUNT 0 ANY"); + expectedResponse = "-ERR value is out of range, must be positive.\r\n"; + actualValue = Encoding.ASCII.GetString(response, 0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + + response = lightClientRequest.SendCommand("GEOSEARCH foo FROMMEMBER bar BYRADIUS 0 m"); + expectedResponse = "*0\r\n"; + actualValue = Encoding.ASCII.GetString(response, 0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } #endregion } } \ No newline at end of file diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 118d0122b4..1004eae221 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -155,10 +155,10 @@ Note that this list is subject to change as we continue to expand our API comman | | [GEODIST](data-structures.md#geodist) | ➕ | | | | [GEOHASH](data-structures.md#geohash) | ➕ | | | | [GEOPOS](data-structures.md#geopos) | ➕ | | -| | GEORADIUS | ➖ | (Deprecated) | -| | GEORADIUS_RO | ➖ | (Deprecated) | -| | GEORADIUSBYMEMBER | ➖ | (Deprecated) | -| | GEORADIUSBYMEMBER_RO | ➖ | (Deprecated) | +| | [GEORADIUS](data-structures.md#georadius) | ➕ | (Deprecated) Partially Implemented | +| | [GEORADIUS_RO](data-structures.md#georadius_ro) | ➕ | (Deprecated) | +| | [GEORADIUSBYMEMBER](data-structures.md#georadiusbymember) | ➕ | (Deprecated) Partially Implemented | +| | [GEORADIUSBYMEMBER_RO](data-structures.md#georadiusbymember_ro) | ➕ | (Deprecated) | | | [GEOSEARCH](data-structures.md#geosearch) | ➕ | Partially Implemented | | | [GEOSEARCHSTORE](data-structures.md#geosearchstore) | ➕ | Partially Implemented | | **HASH** | [HDEL](data-structures.md#hdel) | ➕ | | diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 3992413920..6ee9c4b14d 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -1611,6 +1611,113 @@ The command can accept a variable number of arguments so it always returns an ar --- +### GEORADIUS + +#### Syntax + +```bash +GEORADIUS_RO key longitude latitude radius + [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC | DESC] + [STORE key | STOREDIST key] +``` + +Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified with the center location and the maximum distance from the center (the radius). + +The common use case for this command is to retrieve geospatial items near a specified point not farther than a given amount of meters (or other units). This allows, for example, to suggest mobile users of an application nearby places. + +The radius is specified in one of the following units: + + m for meters. + km for kilometers. + mi for miles. + ft for feet. + +The command optionally returns additional information using the following options: + + WITHDIST: Also return the distance of the returned items from the specified center. The distance is returned in the same unit as the unit specified as the radius argument of the command. + WITHCOORD: Also return the longitude,latitude coordinates 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. + +The command default is to return unsorted items. Two different sorting methods can be invoked using the following two options: + + ASC: Sort returned items from the nearest to the farthest, relative to the center. + DESC: Sort returned items from the farthest to the nearest, relative to the center. + +By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. + +By default the command returns the items to the client. It is possible to store the results with one of these options: + + STORE: Store the items in a sorted set populated with their geospatial information. + STOREDIST: Store the items in a sorted set populated with their distance from the center as a floating point number, in the same unit specified in the radius. + +--- + +### GEORADIUS_RO + +#### Syntax + +```bash +GEORADIUS_RO key longitude latitude radius + [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC | DESC] +``` + +Return the members of a sorted set populated with geospatial information using GEOADD, which are within the borders of the area specified with the center location and the maximum distance from the center (the radius). + +The common use case for this command is to retrieve geospatial items near a specified point not farther than a given amount of meters (or other units). This allows, for example, to suggest mobile users of an application nearby places. + +The radius is specified in one of the following units: + + m for meters. + km for kilometers. + mi for miles. + ft for feet. + +The command optionally returns additional information using the following options: + + WITHDIST: Also return the distance of the returned items from the specified center. The distance is returned in the same unit as the unit specified as the radius argument of the command. + WITHCOORD: Also return the longitude,latitude coordinates 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. + +The command default is to return unsorted items. Two different sorting methods can be invoked using the following two options: + + ASC: Sort returned items from the nearest to the farthest, relative to the center. + DESC: Sort returned items from the farthest to the nearest, relative to the center. + +By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. When ANY is provided the command will return as soon as enough matches are found, so the results may not be the ones closest to the specified point, but on the other hand, the effort invested by the server is significantly lower. 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. + +--- + +### GEORADIUSBYMEMBER + +#### Syntax + +```bash +GEORADIUSBYMEMBER_RO key member radius + [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC | DESC] + [STORE key | STOREDIST key] +``` + +This command is exactly like [GEORADIUS](#georadius) with the sole difference that instead of taking, as the center of the area to query, a longitude and latitude value, it takes the name of a member already existing inside the geospatial index represented by the sorted set. + +The position of the specified member is used as the center of the query. + +--- + +### GEORADIUSBYMEMBER_RO + +#### Syntax + +```bash +GEORADIUSBYMEMBER_RO key member radius + [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC | DESC] +``` + +This command is exactly like [GEORADIUS_RO](#georadius_ro) with the sole difference that instead of taking, as the center of the area to query, a longitude and latitude value, it takes the name of a member already existing inside the geospatial index represented by the sorted set. + +The position of the specified member is used as the center of the query. + +--- + ### GEOSEARCH #### Syntax From cc5b892119d05bd3979c94b59f8f00707bed37cf Mon Sep 17 00:00:00 2001 From: prvyk Date: Fri, 28 Feb 2025 02:41:20 +0200 Subject: [PATCH 02/10] Fix website. --- website/docs/commands/data-structures.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 6ee9c4b14d..3cdd9ade43 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -1643,7 +1643,7 @@ The command default is to return unsorted items. Two different sorting methods c ASC: Sort returned items from the nearest to the farthest, relative to the center. DESC: Sort returned items from the farthest to the nearest, relative to the center. -By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. +By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. By default the command returns the items to the client. It is possible to store the results with one of these options: @@ -1683,7 +1683,7 @@ The command default is to return unsorted items. Two different sorting methods c ASC: Sort returned items from the nearest to the farthest, relative to the center. DESC: Sort returned items from the farthest to the nearest, relative to the center. -By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. When ANY is provided the command will return as soon as enough matches are found, so the results may not be the ones closest to the specified point, but on the other hand, the effort invested by the server is significantly lower. 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. +By default all the matching items are returned. It is possible to limit the results to the first N matching items by using the COUNT option. When ANY is provided the command will return as soon as enough matches are found, so the results may not be the ones closest to the specified point, but on the other hand, the effort invested by the server is significantly lower. 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. --- From 92e37e9adf47d90d39820887fa91e39d0de697af Mon Sep 17 00:00:00 2001 From: prvyk Date: Fri, 28 Feb 2025 05:02:06 +0200 Subject: [PATCH 03/10] Fix comment --- libs/server/Objects/SortedSet/SortedSetObject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server/Objects/SortedSet/SortedSetObject.cs b/libs/server/Objects/SortedSet/SortedSetObject.cs index 817aef9437..5e20d9766b 100644 --- a/libs/server/Objects/SortedSet/SortedSetObject.cs +++ b/libs/server/Objects/SortedSet/SortedSetObject.cs @@ -77,7 +77,7 @@ public enum SortedSetRangeOpts : byte } /// - /// Options for specifying the range in sorted set operations. + /// Options for specifying command type in sorted set geo operations. /// [Flags] public enum SortedSetGeoOpts : byte From 4e3914c14dbebb03ba61513485b11bd986f4a531 Mon Sep 17 00:00:00 2001 From: prvyk Date: Mon, 3 Mar 2025 22:58:28 +0200 Subject: [PATCH 04/10] Match spec and behaviour more closely. --- .../SortedSetGeo/SortedSetGeoObjectImpl.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index 023560296a..85d4e1d29b 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -118,7 +118,7 @@ internal double boxHeight internal bool withCoord; internal bool withHash; - //internal bool withCountAny; + internal bool withCountAny; internal bool withDist; internal GeoOrder sort; } @@ -595,7 +595,7 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ANY"u8)) { - //geoSearchOpt.withCountAny = true; + opts.withCountAny = true; continue; } @@ -701,13 +701,13 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) Coordinates = server.GeoHash.GetCoordinatesFromLong((long)point.Score) }); - if (responseData.Count == opts.countValue) + if (opts.withCountAny && (responseData.Count == opts.countValue)) break; } if (responseData.Count == 0) { - while (!RespWriteUtils.TryWriteInt32(0, ref curr, end)) + while (!RespWriteUtils.TryWriteEmptyArray(ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } else @@ -731,10 +731,22 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); var q = responseData.AsQueryable(); - if (opts.sort == GeoOrder.Ascending) - q = q.OrderBy(i => i.Distance); - else if (opts.sort == GeoOrder.Descending) - q = q.OrderByDescending(i => i.Distance); + switch (opts.sort) + { + case GeoOrder.Descending: + q = q.OrderByDescending(i => i.Distance); ; + break; + case GeoOrder.Ascending: + q = q.OrderBy(i => i.Distance); + break; + case GeoOrder.None: + if (opts.countValue > 0) + q = q.OrderBy(i => i.Distance); + break; + } + + if (opts.countValue > 0) + q = q.Take(opts.countValue); foreach (var item in q) { From 437332c28f123a1f24302dc9eed60e0e29574bb9 Mon Sep 17 00:00:00 2001 From: prvyk Date: Mon, 3 Mar 2025 23:05:33 +0200 Subject: [PATCH 05/10] Tiny optimization --- libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index 85d4e1d29b..d875c6d26d 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -666,7 +666,8 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) // Get the results if (hasLonLat) { - var responseData = new List(); + var len = opts.withCountAny ? opts.countValue : sortedSet.Count; + var responseData = new List(len); foreach (var point in sortedSet) { var coorInItem = server.GeoHash.GetCoordinatesFromLong((long)point.Score); @@ -734,7 +735,7 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) switch (opts.sort) { case GeoOrder.Descending: - q = q.OrderByDescending(i => i.Distance); ; + q = q.OrderByDescending(i => i.Distance); break; case GeoOrder.Ascending: q = q.OrderBy(i => i.Distance); From 062f8a82f1141c197d0491b0068039e99ffd97e2 Mon Sep 17 00:00:00 2001 From: prvyk Date: Tue, 4 Mar 2025 02:18:44 +0200 Subject: [PATCH 06/10] Must adjust array length --- .../Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index d875c6d26d..8937e6f230 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -727,10 +727,6 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) innerArrayLength++; } - // Write results - while (!RespWriteUtils.TryWriteArrayLength(responseData.Count, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - var q = responseData.AsQueryable(); switch (opts.sort) { @@ -746,8 +742,18 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) break; } + // Write results if (opts.countValue > 0) + { q = q.Take(opts.countValue); + while (!RespWriteUtils.TryWriteArrayLength(opts.countValue, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + else + { + while (!RespWriteUtils.TryWriteArrayLength(responseData.Count, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } foreach (var item in q) { From 6f6da79b9836b81cbb534d18c5cc84581180b79d Mon Sep 17 00:00:00 2001 From: prvyk Date: Tue, 4 Mar 2025 09:55:09 +0200 Subject: [PATCH 07/10] Implement WITHHASH It's more intutative to check STORE instead of READONLY when the command enum is called GEOSEARCH. --- .../Objects/SortedSet/SortedSetObject.cs | 4 ++-- .../SortedSetGeo/SortedSetGeoObjectImpl.cs | 18 +++++++++--------- .../Resp/Objects/SortedSetGeoCommands.cs | 13 ++++++------- test/Garnet.test/RespSortedSetGeoTests.cs | 12 ------------ 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/libs/server/Objects/SortedSet/SortedSetObject.cs b/libs/server/Objects/SortedSet/SortedSetObject.cs index 5e20d9766b..cd80ecc41d 100644 --- a/libs/server/Objects/SortedSet/SortedSetObject.cs +++ b/libs/server/Objects/SortedSet/SortedSetObject.cs @@ -87,9 +87,9 @@ public enum SortedSetGeoOpts : byte /// None = 0, /// - /// ReadOnly operation. + /// Operation can store to database. /// - ReadOnly = 1, + Store = 1, /// /// Operate by radius. /// diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index 8937e6f230..afb43a3c97 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -24,6 +24,7 @@ private struct GeoSearchData { public Byte[] Member; public double Distance; + public long GeoHash; public string GeoHashCode; public (double Latitude, double Longitude) Coordinates; } @@ -364,7 +365,7 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) { var cmtOpt = (SortedSetGeoOpts)input.arg2; var byRadiusCmd = (cmtOpt & SortedSetGeoOpts.ByRadius) != 0; - var readOnlyCmd = (cmtOpt & SortedSetGeoOpts.ReadOnly) != 0; + var readOnlyCmd = (cmtOpt & SortedSetGeoOpts.Store) == 0; var isMemory = false; MemoryHandle ptrHandle = default; @@ -646,14 +647,6 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) } #region act - // Not supported options in Garnet: WITHHASH - if (opts.withHash) - { - while (!RespWriteUtils.TryWriteError(CmdStrings.RESP_ERR_GENERIC_UNK_CMD, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); - return; - } - // FROMLONLAT bool hasLonLat = opts.origin == GeoOriginType.FromLonLat; // FROMMEMBER @@ -698,6 +691,7 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) { Member = point.Element, Distance = distance, + GeoHash = (long)point.Score, GeoHashCode = server.GeoHash.GetGeoHashCode((long)point.Score), Coordinates = server.GeoHash.GetCoordinatesFromLong((long)point.Score) }); @@ -780,6 +774,12 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } + if (opts.withHash) + { + while (!RespWriteUtils.TryWriteInt64(item.GeoHash, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + if (opts.withCoord) { // Write array of 2 values diff --git a/libs/server/Resp/Objects/SortedSetGeoCommands.cs b/libs/server/Resp/Objects/SortedSetGeoCommands.cs index b508c2e292..14e9a89090 100644 --- a/libs/server/Resp/Objects/SortedSetGeoCommands.cs +++ b/libs/server/Resp/Objects/SortedSetGeoCommands.cs @@ -115,23 +115,22 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi break; case RespCommand.GEORADIUS: op = SortedSetOperation.GEOSEARCH; - opts = SortedSetGeoOpts.ByRadius; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.Store; break; case RespCommand.GEORADIUS_RO: op = SortedSetOperation.GEOSEARCH; - opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ReadOnly; + opts = SortedSetGeoOpts.ByRadius; break; case RespCommand.GEORADIUSBYMEMBER: op = SortedSetOperation.GEOSEARCH; - opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ByMember; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.Store | SortedSetGeoOpts.ByMember; break; case RespCommand.GEORADIUSBYMEMBER_RO: op = SortedSetOperation.GEOSEARCH; - opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ReadOnly | SortedSetGeoOpts.ByMember; + opts = SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ByMember; break; case RespCommand.GEOSEARCH: op = SortedSetOperation.GEOSEARCH; - opts = SortedSetGeoOpts.ReadOnly; break; default: throw new Exception($"Unexpected {nameof(SortedSetOperation)}: {command}"); @@ -205,8 +204,8 @@ private unsafe bool GeoSearchStore(ref TGarnetApi storageApi) var input = new ObjectInput(new RespInputHeader { type = GarnetObjectType.SortedSet, - SortedSetOp = SortedSetOperation.GEOSEARCH - }, ref parseState, startIdx: 2); + SortedSetOp = SortedSetOperation.GEOSEARCH, + }, ref parseState, startIdx: 2, arg2: (int)SortedSetGeoOpts.Store); var output = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)); var status = storageApi.GeoSearchStore(sourceKey, destinationKey, ref input, ref output); diff --git a/test/Garnet.test/RespSortedSetGeoTests.cs b/test/Garnet.test/RespSortedSetGeoTests.cs index a4d286a04c..3240cdb35e 100644 --- a/test/Garnet.test/RespSortedSetGeoTests.cs +++ b/test/Garnet.test/RespSortedSetGeoTests.cs @@ -228,17 +228,6 @@ public void CanUseGeoPos() ClassicAssert.AreEqual(expectedResponse, actualValue); } - [Test] - public void CanValidateUnknownWithNotSupportedOptions() - { - using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); - var db = redis.GetDatabase(0); - var key = new RedisKey("Sicily"); - db.GeoAdd(key, 13.361389, 38.115556, new RedisValue("Palermo"), CommandFlags.None); - var box = new GeoSearchBox(500, 500, GeoUnit.Kilometers); - Assert.Throws(() => db.GeoSearch(key, 73.9262, 40.8296, box, count: 2, options: GeoRadiusOptions.WithGeoHash)); - } - [Test] public void CheckGeoSortedSetOperationsOnWrongTypeObjectSE() { @@ -534,7 +523,6 @@ public void CanDoGeoAddWhenInvalidPairLC(int bytesSent) ClassicAssert.AreEqual(expectedResponse, actualValue); } - [Test] [TestCase(10)] [TestCase(50)] From 984f3647119abdeaf6c0484c00a7c2c4277cc47f Mon Sep 17 00:00:00 2001 From: prvyk Date: Tue, 4 Mar 2025 15:48:25 +0200 Subject: [PATCH 08/10] GEORADIUS/GEORADIUSBYMEMBER STORE/STOREDIST Fix default GEOSEARCHSTORE which apparently never worked since hash wasn't used as score. --- .../SortedSetGeo/SortedSetGeoObjectImpl.cs | 47 +++++++---- libs/server/Resp/CmdStrings.cs | 2 + .../Resp/Objects/SortedSetGeoCommands.cs | 79 ++++++++++++++++--- libs/server/Resp/RespServerSession.cs | 8 +- test/Garnet.test/RespSortedSetGeoTests.cs | 9 +++ website/docs/commands/api-compatibility.md | 8 +- 6 files changed, 119 insertions(+), 34 deletions(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index afb43a3c97..271faec861 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using Garnet.common; using Tsavorite.core; @@ -588,22 +589,36 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) continue; } - if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("WITHCOORD"u8)) - { - opts.withCoord = true; - continue; - } - if (tokenBytes.EqualsUpperCaseSpanIgnoringCase("ANY"u8)) { opts.withCountAny = true; continue; } - // When GEORADIUS STORE support is added we'll need to account for it. - // For now we'll act as if all radius commands are readonly. - if (readOnlyCmd || byRadiusCmd) + if (!readOnlyCmd) + { + if (byRadiusCmd && tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STORE)) + { + if (byRadiusCmd) + currTokenIdx++; + continue; + } + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STOREDIST)) + { + if (byRadiusCmd) + currTokenIdx++; + opts.withDist = true; + continue; + } + } + else { + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHCOORD)) + { + opts.withCoord = true; + continue; + } + if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.WITHDIST)) { opts.withDist = true; @@ -616,12 +631,6 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) continue; } } - // GEOSEARCHSTORE - else if (tokenBytes.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STOREDIST)) - { - opts.withDist = true; - continue; - } errorMessage = CmdStrings.RESP_SYNTAX_ERROR; break; @@ -646,6 +655,12 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) return; } + // On storing to ZSET, we need to use either dist or hash as score. + if (!readOnlyCmd && !opts.withDist && !opts.withHash) + { + opts.withHash = true; + } + #region act // FROMLONLAT bool hasLonLat = opts.origin == GeoOriginType.FromLonLat; @@ -776,7 +791,7 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) if (opts.withHash) { - while (!RespWriteUtils.TryWriteInt64(item.GeoHash, ref curr, end)) + while (!RespWriteUtils.TryWriteArrayItem(item.GeoHash, ref curr, end)) ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); } diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs index 0e311f4b87..e95b1b7285 100644 --- a/libs/server/Resp/CmdStrings.cs +++ b/libs/server/Resp/CmdStrings.cs @@ -122,7 +122,9 @@ static partial class CmdStrings public static ReadOnlySpan CHANNELS => "CHANNELS"u8; public static ReadOnlySpan NUMPAT => "NUMPAT"u8; public static ReadOnlySpan NUMSUB => "NUMSUB"u8; + public static ReadOnlySpan STORE => "STORE"u8; public static ReadOnlySpan STOREDIST => "STOREDIST"u8; + public static ReadOnlySpan WITHCOORD => "WITHCOORD"u8; public static ReadOnlySpan WITHDIST => "WITHDIST"u8; public static ReadOnlySpan WITHHASH => "WITHHASH"u8; public static ReadOnlySpan LIB_NAME => "LIB-NAME"u8; diff --git a/libs/server/Resp/Objects/SortedSetGeoCommands.cs b/libs/server/Resp/Objects/SortedSetGeoCommands.cs index 14e9a89090..af3788b4bf 100644 --- a/libs/server/Resp/Objects/SortedSetGeoCommands.cs +++ b/libs/server/Resp/Objects/SortedSetGeoCommands.cs @@ -61,7 +61,7 @@ private unsafe bool GeoAdd(ref TGarnetApi storageApi) /// /// /// - private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi storageApi) + private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi storageApi, bool @readonly = true) where TGarnetApi : IGarnetApi { var paramsRequiredInCommand = 0; @@ -83,11 +83,11 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi break; case RespCommand.GEORADIUS: case RespCommand.GEORADIUS_RO: - paramsRequiredInCommand = 4; + paramsRequiredInCommand = 5; break; case RespCommand.GEORADIUSBYMEMBER: case RespCommand.GEORADIUSBYMEMBER_RO: - paramsRequiredInCommand = 3; + paramsRequiredInCommand = 4; break; } @@ -136,6 +136,9 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi throw new Exception($"Unexpected {nameof(SortedSetOperation)}: {command}"); } + if (@readonly) + opts &= ~SortedSetGeoOpts.Store; + // Prepare input var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = op }; @@ -187,25 +190,81 @@ private unsafe bool GeoCommands(RespCommand command, ref TGarnetApi /// GEOSEARCHSTORE: Store the the members of a sorted set populated with geospatial data, which are within the borders of the area specified by a given shape. /// /// + /// /// /// - private unsafe bool GeoSearchStore(ref TGarnetApi storageApi) + private unsafe bool GeoSearchStore(RespCommand cmd, ref TGarnetApi storageApi) where TGarnetApi : IGarnetApi { - // GEOSEARCHSTORE dst src FROMEMBER key BYRADIUS 0 m - if (parseState.Count < 7) + int GetDestIdx() + { + var idx = 1; + while (true) + { + if (idx >= parseState.Count - 1) + break; + + var argSpan = parseState.GetArgSliceByRef(idx++).ReadOnlySpan; + + if (argSpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STORE) || + argSpan.EqualsUpperCaseSpanIgnoringCase(CmdStrings.STOREDIST)) + { + return idx; + } + } + + return -1; + } + + SortedSetGeoOpts opt; + int destIdx, sourceIdx; + + switch (cmd) { - return AbortWithWrongNumberOfArguments(nameof(RespCommand.GEOSEARCHSTORE)); + case RespCommand.GEORADIUS: + // GERADIUS src lon lat 0 km + if (parseState.Count < 5) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.GEORADIUS)); + } + opt = SortedSetGeoOpts.Store | SortedSetGeoOpts.ByRadius; + sourceIdx = 0; + destIdx = GetDestIdx(); + break; + case RespCommand.GEORADIUSBYMEMBER: + // GERADIUSBYMEMBER src member 0 km + if (parseState.Count < 4) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.GEORADIUSBYMEMBER)); + } + opt = SortedSetGeoOpts.Store | SortedSetGeoOpts.ByRadius | SortedSetGeoOpts.ByMember; + sourceIdx = 0; + destIdx = GetDestIdx(); + break; + case RespCommand.GEOSEARCHSTORE: + default: + // GEOSEARCHSTORE dst src FROMEMBER key BYRADIUS 0 m + if (parseState.Count < 7) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.GEOSEARCHSTORE)); + } + opt = SortedSetGeoOpts.Store; + destIdx = 0; + sourceIdx = 1; + break; } - var destinationKey = parseState.GetArgSliceByRef(0); - var sourceKey = parseState.GetArgSliceByRef(1); + if (destIdx == -1) + return GeoCommands(cmd, ref storageApi, true); + + var destinationKey = parseState.GetArgSliceByRef(destIdx); + var sourceKey = parseState.GetArgSliceByRef(sourceIdx); var input = new ObjectInput(new RespInputHeader { type = GarnetObjectType.SortedSet, SortedSetOp = SortedSetOperation.GEOSEARCH, - }, ref parseState, startIdx: 2, arg2: (int)SortedSetGeoOpts.Store); + }, ref parseState, startIdx: sourceIdx + 1, arg2: (int)opt); var output = new SpanByteAndMemory(dcurr, (int)(dend - dcurr)); var status = storageApi.GeoSearchStore(sourceKey, destinationKey, ref input, ref output); diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index 30fd246dc7..b0b2996fb7 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -726,12 +726,12 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.GEOHASH => GeoCommands(cmd, ref storageApi), RespCommand.GEODIST => GeoCommands(cmd, ref storageApi), RespCommand.GEOPOS => GeoCommands(cmd, ref storageApi), - RespCommand.GEORADIUS_RO or + RespCommand.GEORADIUS => GeoSearchStore(cmd, ref storageApi), + RespCommand.GEORADIUS_RO => GeoCommands(cmd, ref storageApi), + RespCommand.GEORADIUSBYMEMBER => GeoSearchStore(cmd, ref storageApi), RespCommand.GEORADIUSBYMEMBER_RO => GeoCommands(cmd, ref storageApi), - RespCommand.GEORADIUS or - RespCommand.GEORADIUSBYMEMBER => GeoCommands(cmd, ref storageApi), RespCommand.GEOSEARCH => GeoCommands(cmd, ref storageApi), - RespCommand.GEOSEARCHSTORE => GeoSearchStore(ref storageApi), + RespCommand.GEOSEARCHSTORE => GeoSearchStore(cmd, ref storageApi), //HLL Commands RespCommand.PFADD => HyperLogLogAdd(ref storageApi), RespCommand.PFMERGE => HyperLogLogMerge(ref storageApi), diff --git a/test/Garnet.test/RespSortedSetGeoTests.cs b/test/Garnet.test/RespSortedSetGeoTests.cs index 3240cdb35e..9d2e5d8303 100644 --- a/test/Garnet.test/RespSortedSetGeoTests.cs +++ b/test/Garnet.test/RespSortedSetGeoTests.cs @@ -400,6 +400,9 @@ public void CanUseGeoSearchStore() Assert.That(actualValues[1].Score, Is.EqualTo(198.424300439725).Within(1.0 / Math.Pow(10, 6))); ClassicAssert.AreEqual("New York", (string)actualValues[2].Element); Assert.That(actualValues[2].Score, Is.EqualTo(327.676458633557).Within(1.0 / Math.Pow(10, 6))); + + actualCount = db.GeoSearchAndStore(key, destinationKey, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers), storeDistances: true); + ClassicAssert.AreEqual(3, actualCount); } [Test] @@ -418,6 +421,12 @@ public void CanUseGeoSearchStoreWithDeleteKeyWhenSourceNotFound() var actualValues = db.SortedSetRangeByScoreWithScores(destinationKey); ClassicAssert.AreEqual(0, actualValues.Length); + + actualCount = db.GeoSearchAndStore(key, destinationKey, new RedisValue("Washington"), new GeoSearchBox(800, 800, GeoUnit.Kilometers)); + ClassicAssert.AreEqual(0, actualCount); + + actualValues = db.SortedSetRangeByScoreWithScores(destinationKey); + ClassicAssert.AreEqual(0, actualValues.Length); } //end region of SE tests diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 1004eae221..c4031caee8 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -155,12 +155,12 @@ Note that this list is subject to change as we continue to expand our API comman | | [GEODIST](data-structures.md#geodist) | ➕ | | | | [GEOHASH](data-structures.md#geohash) | ➕ | | | | [GEOPOS](data-structures.md#geopos) | ➕ | | -| | [GEORADIUS](data-structures.md#georadius) | ➕ | (Deprecated) Partially Implemented | +| | [GEORADIUS](data-structures.md#georadius) | ➕ | (Deprecated) | | | [GEORADIUS_RO](data-structures.md#georadius_ro) | ➕ | (Deprecated) | -| | [GEORADIUSBYMEMBER](data-structures.md#georadiusbymember) | ➕ | (Deprecated) Partially Implemented | +| | [GEORADIUSBYMEMBER](data-structures.md#georadiusbymember) | ➕ | (Deprecated) | | | [GEORADIUSBYMEMBER_RO](data-structures.md#georadiusbymember_ro) | ➕ | (Deprecated) | -| | [GEOSEARCH](data-structures.md#geosearch) | ➕ | Partially Implemented | -| | [GEOSEARCHSTORE](data-structures.md#geosearchstore) | ➕ | Partially Implemented | +| | [GEOSEARCH](data-structures.md#geosearch) | ➕ | | +| | [GEOSEARCHSTORE](data-structures.md#geosearchstore) | ➕ | | | **HASH** | [HDEL](data-structures.md#hdel) | ➕ | | | | [HEXISTS](data-structures.md#hexists) | ➕ | | | | [HEXPIRE](data-structures.md#hexpire) | ➕ | | From 8b167c875ab66d0ace2c15fb097f490b5e73f238 Mon Sep 17 00:00:00 2001 From: prvyk Date: Tue, 4 Mar 2025 16:24:42 +0200 Subject: [PATCH 09/10] Withhash normal output is integer except when stored when we should use arrayItem. --- .../Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index 271faec861..fc80bb929a 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -791,8 +791,16 @@ private void GeoSearch(ref ObjectInput input, ref SpanByteAndMemory output) if (opts.withHash) { - while (!RespWriteUtils.TryWriteArrayItem(item.GeoHash, ref curr, end)) - ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + if (readOnlyCmd) + { + while (!RespWriteUtils.TryWriteInt64(item.GeoHash, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } + else + { + while (!RespWriteUtils.TryWriteArrayItem(item.GeoHash, ref curr, end)) + ObjectUtils.ReallocateOutput(ref output, ref isMemory, ref ptr, ref ptrHandle, ref curr, ref end); + } } if (opts.withCoord) From c72a471049aa2e738060948fadddee61e0d6890a Mon Sep 17 00:00:00 2001 From: prvyk Date: Sat, 8 Mar 2025 03:33:03 +0200 Subject: [PATCH 10/10] Remove unnecessary using. --- libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs index fc80bb929a..bddcda2890 100644 --- a/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs +++ b/libs/server/Objects/SortedSetGeo/SortedSetGeoObjectImpl.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using Garnet.common; using Tsavorite.core;