diff --git a/Examples/GeoShapeQueryExample.md b/Examples/GeoShapeQueryExample.md new file mode 100644 index 00000000..5854d9f7 --- /dev/null +++ b/Examples/GeoShapeQueryExample.md @@ -0,0 +1,128 @@ +# GeoShape Fields Usage In RediSearch + +NRedisStack now supports GEOSHAPE field querying. + +Any object that serializes the [well-known text (WKT)](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry) as a `string` can be used with NRedisStack. + +Using GeoShape fields in searches with the [NetTopologySuite](https://github.com/NetTopologySuite/NetTopologySuite) library. + +## Example + +### Modules Needed + +```c# +using StackExchange.Redis; +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; +using NetTopologySuite.Geometries; +using NetTopologySuite.IO; +``` + +### Setup + +```csharp +// Connect to the Redis server: +var redis = ConnectionMultiplexer.Connect("localhost"); +var db = redis.GetDatabase(); +// Get a reference to the database and for search commands: +var ft = db.FT(); + +// Create WTKReader and GeometryFactory objects: +WKTReader reader = new WKTReader(); +GeometryFactory factory = new GeometryFactory(); + +``` + +### Create the index + +```csharp +ft.Create(index, new Schema().AddGeoShapeField("geom", GeoShapeField.CoordinateSystem.FLAT)); +``` + +### Prepare the data + +```csharp +Polygon small = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), +new Coordinate(1, 100), new Coordinate(100, 100), new Coordinate(100, 1), new Coordinate(1, 1)}); +db.HashSet("small", "geom", small.ToString()); + +Polygon large = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), +new Coordinate(1, 200), new Coordinate(200, 200), new Coordinate(200, 1), new Coordinate(1, 1)}); +db.HashSet("large", "geom", large.ToString()); +``` + +## Polygon type + +### Querying within condition + +```csharp +Polygon within = factory.CreatePolygon(new Coordinate[]{new Coordinate(0, 0), +new Coordinate(0, 150), new Coordinate(150, 150), new Coordinate(150, 0), new Coordinate(0, 0)}); + +SearchResult res = ft.Search(index, new Query("@geom:[within $poly]") + .AddParam("poly", within.ToString()) // Note serializing the argument to string + .Dialect(3)); // DIALECT 3 is required for this query +``` + +The search result from redis is: + +```bash +1) (integer) 1 +2) "small" +3) 1) "geom" + 2) "POLYGON ((1 1, 1 100, 100 100, 100 1, 1 1))" +``` + +Use the reader to get the polygon: + +```csharp +reader.Read(res.Documents[0]["geom"].ToString()); +``` + +### Querying contains condition + +```csharp +Polygon contains = factory.CreatePolygon(new Coordinate[]{new Coordinate(2, 2), +new Coordinate(2, 50), new Coordinate(50, 50), new Coordinate(50, 2), new Coordinate(2, 2)}); + +res = ft.Search(index, new Query("@geom:[contains $poly]") + .AddParam("poly", contains.ToString()) // Note serializing the argument to string + .Dialect(3)); // DIALECT 3 is required for this query + +``` + +Our search result: + +```bash +1) (integer) 2 +2) "small" +3) 1) "geom" + 2) "POLYGON ((1 1, 1 100, 100 100, 100 1, 1 1))" +4) "large" +5) 1) "geom" + 2) "POLYGON ((1 1, 1 200, 200 200, 200 1, 1 1))" +``` + +### Searching with Coordinates + +```csharp +Point point = factory.CreatePoint(new Coordinate(10, 10)); +db.HashSet("point", "geom", point.ToString()); + +res = ft.Search(index, new Query("@geom:[within $poly]") + .AddParam("poly", within.ToString()) // Note serializing the argument to string + .Dialect(3)); // DIALECT 3 is required for this query + +``` + +Our search result: + +```bash +1) (integer) 2 +2) "small" +3) 1) "geom" + 2) "POLYGON ((1 1, 1 100, 100 100, 100 1, 1 1))" +4) "point" +5) 1) "geom" + 2) "POINT (10 10)" +``` diff --git a/src/NRedisStack/NRedisStack.csproj b/src/NRedisStack/NRedisStack.csproj index 9b698a4c..11b92832 100644 --- a/src/NRedisStack/NRedisStack.csproj +++ b/src/NRedisStack/NRedisStack.csproj @@ -15,8 +15,9 @@ + - + diff --git a/src/NRedisStack/Search/Query.cs b/src/NRedisStack/Search/Query.cs index baa41598..3f77d67e 100644 --- a/src/NRedisStack/Search/Query.cs +++ b/src/NRedisStack/Search/Query.cs @@ -617,6 +617,7 @@ public Query SetSortBy(string field, bool? ascending = null) /// Parameters can be referenced in the query string by a $ , followed by the parameter name, /// e.g., $user , and each such reference in the search query to a parameter name is substituted /// by the corresponding parameter value. + /// Note: when calling this function with an externally supplied parameter, value should be a string. /// /// /// can be String, long or float diff --git a/src/NRedisStack/Search/Schema.cs b/src/NRedisStack/Search/Schema.cs index 442b2a72..8aaabd08 100644 --- a/src/NRedisStack/Search/Schema.cs +++ b/src/NRedisStack/Search/Schema.cs @@ -1,4 +1,5 @@ using NRedisStack.Search.Literals; +using static NRedisStack.Search.Schema.GeoShapeField; using static NRedisStack.Search.Schema.VectorField; namespace NRedisStack.Search @@ -13,6 +14,7 @@ public enum FieldType { Text, Geo, + GeoShape, Numeric, Tag, Vector @@ -38,6 +40,7 @@ internal void AddSchemaArgs(List args) { FieldType.Text => "TEXT", FieldType.Geo => "GEO", + FieldType.GeoShape => "GEOSHAPE", FieldType.Numeric => "NUMERIC", FieldType.Tag => "TAG", FieldType.Vector => "VECTOR", @@ -178,6 +181,37 @@ internal override void AddFieldTypeArgs(List args) } + public class GeoShapeField : Field + { + public enum CoordinateSystem + { + /// + /// For cartesian (X,Y). + /// + FLAT, + + /// + /// For geographic (lon, lat). + /// + SPHERICAL + } + private CoordinateSystem system { get; } + + internal GeoShapeField(FieldName name, CoordinateSystem system) + : base(name, FieldType.GeoShape) + { + this.system = system; + } + + internal GeoShapeField(string name, CoordinateSystem system) + : this(FieldName.Of(name), system) { } + + internal override void AddFieldTypeArgs(List args) + { + args.Add(system.ToString()); + } + } + public class NumericField : Field { public bool Sortable { get; } @@ -288,6 +322,30 @@ public Schema AddTextField(FieldName name, double weight = 1.0, bool sortable = return this; } + /// + /// Add a GeoShape field to the schema. + /// + /// The field's name. + /// The coordinate system to use. + /// The object. + public Schema AddGeoShapeField(string name, CoordinateSystem system) + { + Fields.Add(new GeoShapeField(name, system)); + return this; + } + + /// + /// Add a GeoShape field to the schema. + /// + /// The field's name. + /// The coordinate system to use. + /// The object. + public Schema AddGeoShapeField(FieldName name, CoordinateSystem system) + { + Fields.Add(new GeoShapeField(name, system)); + return this; + } + /// /// Add a Geo field to the schema. /// diff --git a/tests/Doc/Doc.csproj b/tests/Doc/Doc.csproj index 0958f541..c8fef667 100644 --- a/tests/Doc/Doc.csproj +++ b/tests/Doc/Doc.csproj @@ -16,7 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/NRedisStack.Tests/Examples/ExampleTests.cs b/tests/NRedisStack.Tests/Examples/ExampleTests.cs index be3f2ca7..adc5636f 100644 --- a/tests/NRedisStack.Tests/Examples/ExampleTests.cs +++ b/tests/NRedisStack.Tests/Examples/ExampleTests.cs @@ -1373,6 +1373,8 @@ public void AdvancedQueryOperationsTest() Assert.Equal(expectedResSet, resSet); } + // GeoShape Example Test is in SearchTests.cs, The test name is: GeoShapeFilterFlat. + private static void SortAndCompare(List expectedList, List res) { res.Sort(); diff --git a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj index aa4d38d6..e66bf0a1 100644 --- a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj +++ b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj @@ -21,11 +21,12 @@ + - + - + diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index 74703d1d..685fc0a2 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -6,6 +6,9 @@ using NRedisStack.Search.Aggregation; using NRedisStack.Search.Literals.Enums; using System.Runtime.InteropServices; +using NetTopologySuite.IO; +using NetTopologySuite.Geometries; + namespace NRedisStack.Tests.Search; @@ -2757,4 +2760,226 @@ public void Issue175() Assert.True(ft.Create("myIndex", ftParams, schema)); } -} \ No newline at end of file + + [SkipIfRedis(Is.OSSCluster, Comparison.LessThan, "7.2.1")] + public void GeoShapeFilterSpherical() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + WKTReader reader = new WKTReader(); + GeometryFactory factory = new GeometryFactory(); + + Assert.True(ft.Create(index, new Schema().AddGeoShapeField("geom", GeoShapeField.CoordinateSystem.SPHERICAL))); + + // Create polygons + Polygon small = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9001, 29.7001), + new Coordinate(34.9001, 29.7100), + new Coordinate(34.9100, 29.7100), + new Coordinate(34.9100, 29.7001), + new Coordinate(34.9001, 29.7001) + }); + db.HashSet("small", "geom", small.ToString()); + + Polygon large = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9001, 29.7001), + new Coordinate(34.9001, 29.7200), + new Coordinate(34.9200, 29.7200), + new Coordinate(34.9200, 29.7001), + new Coordinate(34.9001, 29.7001) + }); + db.HashSet("large", "geom", large.ToString()); + + Polygon within = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9000, 29.7000), + new Coordinate(34.9000, 29.7150), + new Coordinate(34.9150, 29.7150), + new Coordinate(34.9150, 29.7000), + new Coordinate(34.9000, 29.7000) + }); + + var res = ft.Search(index, new Query($"@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(1, res.TotalResults); + Assert.Single(res.Documents); + Assert.Equal(small, reader.Read(res.Documents[0]["geom"].ToString())); + + Polygon contains = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9002, 29.7002), + new Coordinate(34.9002, 29.7050), + new Coordinate(34.9050, 29.7050), + new Coordinate(34.9050, 29.7002), + new Coordinate(34.9002, 29.7002) + }); + + res = ft.Search(index, new Query($"@geom:[contains $poly]").AddParam("poly", contains.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + + // Create a point + Point point = factory.CreatePoint(new Coordinate(34.9010, 29.7010)); + db.HashSet("point", "geom", point.ToString()); + + res = ft.Search(index, new Query($"@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + } + + [SkipIfRedis(Is.OSSCluster, Comparison.LessThan, "7.2.1")] + public async Task GeoShapeFilterSphericalAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + WKTReader reader = new WKTReader(); + GeometryFactory factory = new GeometryFactory(); + + Assert.True(await ft.CreateAsync(index, new Schema().AddGeoShapeField("geom", GeoShapeField.CoordinateSystem.SPHERICAL))); + + // Create polygons + Polygon small = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9001, 29.7001), + new Coordinate(34.9001, 29.7100), + new Coordinate(34.9100, 29.7100), + new Coordinate(34.9100, 29.7001), + new Coordinate(34.9001, 29.7001) + }); + db.HashSet("small", "geom", small.ToString()); + + Polygon large = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9001, 29.7001), + new Coordinate(34.9001, 29.7200), + new Coordinate(34.9200, 29.7200), + new Coordinate(34.9200, 29.7001), + new Coordinate(34.9001, 29.7001) + }); + db.HashSet("large", "geom", large.ToString()); + + Polygon within = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9000, 29.7000), + new Coordinate(34.9000, 29.7150), + new Coordinate(34.9150, 29.7150), + new Coordinate(34.9150, 29.7000), + new Coordinate(34.9000, 29.7000) + }); + + var res = await ft.SearchAsync(index, new Query($"@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(1, res.TotalResults); + Assert.Single(res.Documents); + Assert.Equal(small, reader.Read(res.Documents[0]["geom"].ToString())); + + Polygon contains = factory.CreatePolygon(new Coordinate[] { + new Coordinate(34.9002, 29.7002), + new Coordinate(34.9002, 29.7050), + new Coordinate(34.9050, 29.7050), + new Coordinate(34.9050, 29.7002), + new Coordinate(34.9002, 29.7002) + }); + + res = await ft.SearchAsync(index, new Query($"@geom:[contains $poly]").AddParam("poly", contains.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + + // Create a point + Point point = factory.CreatePoint(new Coordinate(34.9010, 29.7010)); + db.HashSet("point", "geom", point.ToString()); + + res = await ft.SearchAsync(index, new Query($"@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + } + + [SkipIfRedis(Is.OSSCluster, Comparison.LessThan, "7.2.1")] + public void GeoShapeFilterFlat() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + WKTReader reader = new WKTReader(); + GeometryFactory factory = new GeometryFactory(); + + Assert.True(ft.Create(index, new Schema().AddGeoShapeField("geom", GeoShapeField.CoordinateSystem.FLAT))); + + // polygon type + Polygon small = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), + new Coordinate(1, 100), new Coordinate(100, 100), new Coordinate(100, 1), new Coordinate(1, 1)}); + db.HashSet("small", "geom", small.ToString()); + + Polygon large = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), + new Coordinate(1, 200), new Coordinate(200, 200), new Coordinate(200, 1), new Coordinate(1, 1)}); + db.HashSet("large", "geom", large.ToString()); + + // within condition + Polygon within = factory.CreatePolygon(new Coordinate[]{new Coordinate(0, 0), + new Coordinate(0, 150), new Coordinate(150, 150), new Coordinate(150, 0), new Coordinate(0, 0)}); + + SearchResult res = ft.Search(index, new Query("@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(1, res.TotalResults); + Assert.Single(res.Documents); + Assert.Equal(small, reader.Read(res.Documents[0]["geom"].ToString())); + + // contains condition + Polygon contains = factory.CreatePolygon(new Coordinate[]{new Coordinate(2, 2), + new Coordinate(2, 50), new Coordinate(50, 50), new Coordinate(50, 2), new Coordinate(2, 2)}); + + res = ft.Search(index, new Query("@geom:[contains $poly]").AddParam("poly", contains.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + + // point type + Point point = factory.CreatePoint(new Coordinate(10, 10)); + db.HashSet("point", "geom", point.ToString()); + + res = ft.Search(index, new Query("@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + } + + [SkipIfRedis(Is.OSSCluster, Comparison.LessThan, "7.2.1")] + public async Task GeoShapeFilterFlatAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + WKTReader reader = new WKTReader(); + GeometryFactory factory = new GeometryFactory(); + + Assert.True(await ft.CreateAsync(index, new Schema().AddGeoShapeField("geom", GeoShapeField.CoordinateSystem.FLAT))); + + // polygon type + Polygon small = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), + new Coordinate(1, 100), new Coordinate(100, 100), new Coordinate(100, 1), new Coordinate(1, 1)}); + db.HashSet("small", "geom", small.ToString()); + + Polygon large = factory.CreatePolygon(new Coordinate[]{new Coordinate(1, 1), + new Coordinate(1, 200), new Coordinate(200, 200), new Coordinate(200, 1), new Coordinate(1, 1)}); + db.HashSet("large", "geom", large.ToString()); + + // within condition + Polygon within = factory.CreatePolygon(new Coordinate[]{new Coordinate(0, 0), + new Coordinate(0, 150), new Coordinate(150, 150), new Coordinate(150, 0), new Coordinate(0, 0)}); + + SearchResult res = await ft.SearchAsync(index, new Query("@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(1, res.TotalResults); + Assert.Single(res.Documents); + Assert.Equal(small, reader.Read(res.Documents[0]["geom"].ToString())); + + // contains condition + Polygon contains = factory.CreatePolygon(new Coordinate[]{new Coordinate(2, 2), + new Coordinate(2, 50), new Coordinate(50, 50), new Coordinate(50, 2), new Coordinate(2, 2)}); + + res = await ft.SearchAsync(index, new Query("@geom:[contains $poly]").AddParam("poly", contains.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + + // point type + Point point = factory.CreatePoint(new Coordinate(10, 10)); + db.HashSet("point", "geom", point.ToString()); + + res = await ft.SearchAsync(index, new Query("@geom:[within $poly]").AddParam("poly", within.ToString()).Dialect(3)); + Assert.Equal(2, res.TotalResults); + Assert.Equal(2, res.Documents.Count); + } +}