diff --git a/src/Nest/QueryDsl/Geo/BoundingBox/BoundingBox.cs b/src/Nest/QueryDsl/Geo/BoundingBox/BoundingBox.cs index 4cb99b917ed..79a2e530f73 100644 --- a/src/Nest/QueryDsl/Geo/BoundingBox/BoundingBox.cs +++ b/src/Nest/QueryDsl/Geo/BoundingBox/BoundingBox.cs @@ -10,26 +10,30 @@ public interface IBoundingBox [JsonProperty("bottom_right")] GeoLocation BottomRight { get; set; } + + [JsonProperty("wkt")] + string WellKnownText { get; set; } } public class BoundingBox : IBoundingBox { public GeoLocation TopLeft { get; set; } public GeoLocation BottomRight { get; set; } + public string WellKnownText { get; set; } } public class BoundingBoxDescriptor : DescriptorBase, IBoundingBox { GeoLocation IBoundingBox.TopLeft { get; set; } GeoLocation IBoundingBox.BottomRight { get; set; } + string IBoundingBox.WellKnownText { get; set; } - public BoundingBoxDescriptor TopLeft(GeoLocation topLeft) => Assign(a => a.TopLeft = topLeft); public BoundingBoxDescriptor TopLeft(double lat, double lon) => Assign(a => a.TopLeft = new GeoLocation(lat,lon)); public BoundingBoxDescriptor BottomRight(GeoLocation bottomRight) => Assign(a => a.BottomRight = bottomRight); public BoundingBoxDescriptor BottomRight(double lat, double lon) => Assign(a => a.BottomRight = new GeoLocation(lat, lon)); - + public BoundingBoxDescriptor WellKnownText(string wkt)=> Assign(a => a.WellKnownText = wkt); } -} \ No newline at end of file +} diff --git a/src/Nest/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQuery.cs b/src/Nest/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQuery.cs index 34cd44e5163..3303dd1a366 100644 --- a/src/Nest/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQuery.cs +++ b/src/Nest/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQuery.cs @@ -29,7 +29,7 @@ public class GeoBoundingBoxQuery : FieldNameQueryBase, IGeoBoundingBoxQuery internal override void InternalWrapInContainer(IQueryContainer c) => c.GeoBoundingBox = this; internal static bool IsConditionless(IGeoBoundingBoxQuery q) => - q.Field.IsConditionless() || q.BoundingBox?.BottomRight == null || q.BoundingBox?.TopLeft == null; + q.Field.IsConditionless() || (q.BoundingBox?.BottomRight == null && q.BoundingBox?.TopLeft == null && q.BoundingBox?.WellKnownText == null); } public class GeoBoundingBoxQueryDescriptor @@ -47,6 +47,9 @@ public GeoBoundingBoxQueryDescriptor BoundingBox(double topLeftLat, double to public GeoBoundingBoxQueryDescriptor BoundingBox(GeoLocation topLeft, GeoLocation bottomRight) => BoundingBox(f=>f.TopLeft(topLeft).BottomRight(bottomRight)); + public GeoBoundingBoxQueryDescriptor BoundingBox(string wkt) => + BoundingBox(f=>f.WellKnownText(wkt)); + public GeoBoundingBoxQueryDescriptor BoundingBox(Func boundingBoxSelector) => Assign(a => a.BoundingBox = boundingBoxSelector?.Invoke(new BoundingBoxDescriptor())); diff --git a/src/Nest/QueryDsl/Geo/GeoCoordinateJsonConverter.cs b/src/Nest/QueryDsl/Geo/GeoCoordinateJsonConverter.cs index a85b2a5d7b3..5931559903e 100644 --- a/src/Nest/QueryDsl/Geo/GeoCoordinateJsonConverter.cs +++ b/src/Nest/QueryDsl/Geo/GeoCoordinateJsonConverter.cs @@ -17,6 +17,8 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WriteStartArray(); serializer.Serialize(writer, p.Longitude); serializer.Serialize(writer, p.Latitude); + if (p.Z.HasValue) + serializer.Serialize(writer, p.Z.Value); writer.WriteEndArray(); } @@ -24,8 +26,15 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist { if (reader.TokenType != JsonToken.StartArray) return null; var doubles = serializer.Deserialize(reader); - if (doubles.Length != 2) return null; - return new GeoCoordinate(doubles[1], doubles[0]); + switch (doubles.Length) + { + case 2: + return new GeoCoordinate(doubles[1], doubles[0]); + case 3: + return new GeoCoordinate(doubles[1], doubles[0], doubles[2]); + default: + return null; + } } } -} \ No newline at end of file +} diff --git a/src/Nest/QueryDsl/Geo/GeoLocation.cs b/src/Nest/QueryDsl/Geo/GeoLocation.cs index 4047d8b89bd..5c1b22741fd 100644 --- a/src/Nest/QueryDsl/Geo/GeoLocation.cs +++ b/src/Nest/QueryDsl/Geo/GeoLocation.cs @@ -123,7 +123,8 @@ public static implicit operator GeoLocation(double[] lonLat) } /// - /// Represents a Latitude/Longitude as a 2 dimensional point that gets serialized as new [] { lon, lat } + /// Represents a Latitude/Longitude and optional Z value as a 2 or 3 dimensional point + /// that gets serialized as new [] { lon, lat, [z] } /// [JsonConverter(typeof(GeoCoordinateJsonConverter))] public class GeoCoordinate : GeoLocation @@ -134,17 +135,35 @@ public class GeoCoordinate : GeoLocation public GeoCoordinate(double latitude, double longitude) : base(latitude, longitude) { } /// - /// Creates a new instance of from a pair of coordinates - /// in the order Latitude then Longitude. + /// Creates a new instance of + /// + public GeoCoordinate(double latitude, double longitude, double z) : base(latitude, longitude) => + Z = z; + + /// + /// Gets or sets the Z value + /// + public double? Z { get; set; } + + /// + /// Creates a new instance of from an array + /// of 2 or 3 doubles, in the order Latitude, Longitude, and optional Z value. /// public static implicit operator GeoCoordinate(double[] coordinates) { - if (coordinates == null || coordinates.Length != 2) - throw new ArgumentOutOfRangeException( - nameof(coordinates), - $"Can not create a {nameof(GeoCoordinate)} from an array that does not have two doubles"); - - return new GeoCoordinate(coordinates[0], coordinates[1]); + if (coordinates == null) return null; + + switch (coordinates.Length) + { + case 2: + return new GeoCoordinate(coordinates[0], coordinates[1]); + case 3: + return new GeoCoordinate(coordinates[0], coordinates[1], coordinates[2]); + } + + throw new ArgumentOutOfRangeException( + nameof(coordinates), + $"Cannot create a {nameof(GeoCoordinate)} from an array that does not contain 2 or 3 values"); } } } diff --git a/src/Nest/QueryDsl/Geo/Shape/GeoShapeBase.cs b/src/Nest/QueryDsl/Geo/Shape/GeoShapeBase.cs index da5dd78a9fe..bfc57f76c7a 100644 --- a/src/Nest/QueryDsl/Geo/Shape/GeoShapeBase.cs +++ b/src/Nest/QueryDsl/Geo/Shape/GeoShapeBase.cs @@ -16,11 +16,34 @@ public interface IGeoShape string Type { get; } } + internal enum GeoShapeFormat + { + GeoJson, + WellKnownText + } + + internal static class GeoShapeType + { + public const string Point = "POINT"; + public const string MultiPoint = "MULTIPOINT"; + public const string LineString = "LINESTRING"; + public const string MultiLineString = "MULTILINESTRING"; + public const string Polygon = "POLYGON"; + public const string MultiPolygon = "MULTIPOLYGON"; + public const string Circle = "CIRCLE"; + public const string Envelope = "ENVELOPE"; + public const string GeometryCollection = "GEOMETRYCOLLECTION"; + + // WKT uses BBOX for envelope geo shape + public const string BoundingBox = "BBOX"; + } + /// /// Base type for geo shapes /// public abstract class GeoShapeBase : IGeoShape { + internal GeoShapeFormat Format { get; set; } protected GeoShapeBase(string type) => this.Type = type; /// @@ -29,42 +52,112 @@ public abstract class GeoShapeBase : IGeoShape internal class GeoShapeConverter : JsonConverter { - public override bool CanWrite => false; + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => - throw new NotSupportedException(); + if (value is IGeoShape shape) + { + if (value is GeoShapeBase shapeBase && shapeBase.Format == GeoShapeFormat.WellKnownText) + { + writer.WriteValue(GeoWKTWriter.Write(shapeBase)); + return; + } + + writer.WriteStartObject(); + writer.WritePropertyName("type"); + writer.WriteValue(shape.Type); + + switch (shape) + { + case IPointGeoShape point: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, point.Coordinates); + break; + case IMultiPointGeoShape multiPoint: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, multiPoint.Coordinates); + break; + case ILineStringGeoShape lineString: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, lineString.Coordinates); + break; + case IMultiLineStringGeoShape multiLineString: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, multiLineString.Coordinates); + break; + case IPolygonGeoShape polygon: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, polygon.Coordinates); + break; + case IMultiPolygonGeoShape multiPolygon: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, multiPolygon.Coordinates); + break; + case IEnvelopeGeoShape envelope: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, envelope.Coordinates); + break; + case ICircleGeoShape circle: + writer.WritePropertyName("coordinates"); + serializer.Serialize(writer, circle.Coordinates); + writer.WritePropertyName("radius"); + writer.WriteValue(circle.Radius); + break; + case IGeometryCollection collection: + writer.WritePropertyName("geometries"); + serializer.Serialize(writer, collection.Geometries); + break; + } + + writer.WriteEndObject(); + } + else + { + throw new NotSupportedException($"{value.GetType()} is not a supported {nameof(IGeoShape)}"); + } + } public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { - if (reader.TokenType == JsonToken.Null) - return null; - - var shape = JObject.Load(reader); - return ReadJToken(shape, serializer); + switch (reader.TokenType) + { + case JsonToken.Null: + return null; + case JsonToken.String: + return GeoWKTReader.Read((string)reader.Value); + default: + var shape = JObject.Load(reader); + return ReadJToken(shape, serializer); + } } internal static IGeoShape ReadJToken(JToken shape, JsonSerializer serializer) { - var typeName = shape["type"]?.Value(); + var typeName = shape["type"]?.Value().ToUpperInvariant(); switch (typeName) { - case "circle": + case GeoShapeType.Circle: return ParseCircleGeoShape(shape, serializer); - case "envelope": + case GeoShapeType.Envelope: return ParseEnvelopeGeoShape(shape, serializer); - case "linestring": + case GeoShapeType.LineString: return ParseLineStringGeoShape(shape, serializer); - case "multilinestring": + case GeoShapeType.MultiLineString: return ParseMultiLineStringGeoShape(shape, serializer); - case "point": + case GeoShapeType.Point: return ParsePointGeoShape(shape, serializer); - case "multipoint": + case GeoShapeType.MultiPoint: return ParseMultiPointGeoShape(shape, serializer); - case "polygon": + case GeoShapeType.Polygon: return ParsePolygonGeoShape(shape, serializer); - case "multipolygon": + case GeoShapeType.MultiPolygon: return ParseMultiPolygonGeoShape(shape, serializer); - case "geometrycollection": + case GeoShapeType.GeometryCollection: return ParseGeometryCollection(shape, serializer); default: return null; diff --git a/src/Nest/QueryDsl/Geo/Shape/GeoShapeQueryJsonConverter.cs b/src/Nest/QueryDsl/Geo/Shape/GeoShapeQueryJsonConverter.cs index e8434041292..d835c7b8b16 100644 --- a/src/Nest/QueryDsl/Geo/Shape/GeoShapeQueryJsonConverter.cs +++ b/src/Nest/QueryDsl/Geo/Shape/GeoShapeQueryJsonConverter.cs @@ -95,7 +95,7 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } private static IGeoShapeQuery ParseIndexedShapeQuery(JToken indexedShape) => - new GeoShapeQuery { IndexedShape = indexedShape.ToObject()}; + new GeoShapeQuery { IndexedShape = indexedShape.ToObject() }; private static IGeoShapeQuery ParseShapeQuery(JToken shape, JsonSerializer serializer) { diff --git a/src/Nest/QueryDsl/Geo/WKT/GeoWKTException.cs b/src/Nest/QueryDsl/Geo/WKT/GeoWKTException.cs new file mode 100644 index 00000000000..8c5dbf737da --- /dev/null +++ b/src/Nest/QueryDsl/Geo/WKT/GeoWKTException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Nest +{ + /// + /// An exception when handling in Well-Known Text format + /// + public class GeoWKTException : Exception + { + public GeoWKTException(string message) + : base(message) + { + } + + public GeoWKTException(string message, int lineNumber, int position) + : base($"{message} at line {lineNumber}, position {position}") + { + } + } +} diff --git a/src/Nest/QueryDsl/Geo/WKT/GeoWKTReader.cs b/src/Nest/QueryDsl/Geo/WKT/GeoWKTReader.cs new file mode 100644 index 00000000000..19e1542109e --- /dev/null +++ b/src/Nest/QueryDsl/Geo/WKT/GeoWKTReader.cs @@ -0,0 +1,585 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace Nest +{ + /// + /// Reads Well-Known Text (WKT) into types + /// + public class GeoWKTReader + { + /// + /// Reads Well-Known Text (WKT) into a new instance of + /// + public static IGeoShape Read(string wellKnownText) + { + using (var tokenizer = new WellKnownTextTokenizer(new StringReader(wellKnownText))) + return Read(tokenizer, null); + } + + private static IGeoShape Read(WellKnownTextTokenizer tokenizer, string shapeType) + { + var token = tokenizer.NextToken(); + + if (token != TokenType.Word) + throw new GeoWKTException( + $"Expected word but found {tokenizer.TokenString()}", tokenizer.LineNumber, tokenizer.Position); + + var type = tokenizer.TokenValue.ToUpperInvariant(); + + if (shapeType != null && shapeType != GeoShapeType.GeometryCollection && type != shapeType) + throw new GeoWKTException($"Expected geometry type {shapeType} but found {type}"); + + switch (type) + { + case GeoShapeType.Point: + var point = ParsePoint(tokenizer); + point.Format = GeoShapeFormat.WellKnownText; + return point; + case GeoShapeType.MultiPoint: + var multiPoint = ParseMultiPoint(tokenizer); + multiPoint.Format = GeoShapeFormat.WellKnownText; + return multiPoint; + case GeoShapeType.LineString: + var lineString = ParseLineString(tokenizer); + lineString.Format = GeoShapeFormat.WellKnownText; + return lineString; + case GeoShapeType.MultiLineString: + var multiLineString = ParseMultiLineString(tokenizer); + multiLineString.Format = GeoShapeFormat.WellKnownText; + return multiLineString; + case GeoShapeType.Polygon: + var polygon = ParsePolygon(tokenizer); + polygon.Format = GeoShapeFormat.WellKnownText; + return polygon; + case GeoShapeType.MultiPolygon: + var multiPolygon = ParseMultiPolygon(tokenizer); + multiPolygon.Format = GeoShapeFormat.WellKnownText; + return multiPolygon; + case GeoShapeType.BoundingBox: + var envelope = ParseBoundingBox(tokenizer); + envelope.Format = GeoShapeFormat.WellKnownText; + return envelope; + case GeoShapeType.GeometryCollection: + var geometryCollection = ParseGeometryCollection(tokenizer); + geometryCollection.Format = GeoShapeFormat.WellKnownText; + return geometryCollection; + default: + throw new GeoWKTException($"Unknown geometry type: {type}"); + } + } + + private static PointGeoShape ParsePoint(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var point = new PointGeoShape(ParseCoordinate(tokenizer)); + NextCloser(tokenizer); + + return point; + } + + private static MultiPointGeoShape ParseMultiPoint(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var coordinates = ParseCoordinates(tokenizer); + return new MultiPointGeoShape(coordinates); + } + + private static LineStringGeoShape ParseLineString(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var coordinates = ParseCoordinates(tokenizer); + return new LineStringGeoShape(coordinates); + } + + private static MultiLineStringGeoShape ParseMultiLineString(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var coordinates = ParseCoordinateLists(tokenizer); + return new MultiLineStringGeoShape(coordinates); + } + + private static PolygonGeoShape ParsePolygon(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var coordinates = ParseCoordinateLists(tokenizer); + return new PolygonGeoShape(coordinates); + } + + private static MultiPolygonGeoShape ParseMultiPolygon(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var coordinates = new List>> + { + ParseCoordinateLists(tokenizer) + }; + + while (NextCloserOrComma(tokenizer) == TokenType.Comma) + coordinates.Add(ParseCoordinateLists(tokenizer)); + + return new MultiPolygonGeoShape(coordinates); + } + + private static EnvelopeGeoShape ParseBoundingBox(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var minLon = NextNumber(tokenizer); + NextComma(tokenizer); + var maxLon = NextNumber(tokenizer); + NextComma(tokenizer); + var maxLat = NextNumber(tokenizer); + NextComma(tokenizer); + var minLat = NextNumber(tokenizer); + NextCloser(tokenizer); + return new EnvelopeGeoShape(new [] { new GeoCoordinate(maxLat, minLon), new GeoCoordinate(minLat, maxLon) }); + } + + private static GeometryCollection ParseGeometryCollection(WellKnownTextTokenizer tokenizer) + { + if (NextEmptyOrOpen(tokenizer) == TokenType.Word) + return null; + + var geometries = new List + { + Read(tokenizer, GeoShapeType.GeometryCollection) + }; + + while (NextCloserOrComma(tokenizer) == TokenType.Comma) + geometries.Add(Read(tokenizer, null)); + + return new GeometryCollection { Geometries = geometries }; + } + + private static List> ParseCoordinateLists(WellKnownTextTokenizer tokenizer) + { + var coordinates = new List>(); + + NextEmptyOrOpen(tokenizer); + coordinates.Add(ParseCoordinates(tokenizer)); + + while (NextCloserOrComma(tokenizer) == TokenType.Comma) + { + NextEmptyOrOpen(tokenizer); + coordinates.Add(ParseCoordinates(tokenizer)); + } + + return coordinates; + } + + private static List ParseCoordinates(WellKnownTextTokenizer tokenizer) + { + var coordinates = new List(); + + if (IsNumberNext(tokenizer) || (tokenizer.NextToken() == TokenType.LParen)) + coordinates.Add(ParseCoordinate(tokenizer)); + + while (NextCloserOrComma(tokenizer) == TokenType.Comma) + { + var isOpenParen = false; + + if (IsNumberNext(tokenizer) || (isOpenParen = tokenizer.NextToken() == TokenType.LParen)) + coordinates.Add(ParseCoordinate(tokenizer)); + + if (isOpenParen) + NextCloser(tokenizer); + } + + return coordinates; + } + + private static GeoCoordinate ParseCoordinate(WellKnownTextTokenizer tokenizer) + { + var lon = NextNumber(tokenizer); + var lat = NextNumber(tokenizer); + double? z = null; + + if (IsNumberNext(tokenizer)) + z = NextNumber(tokenizer); + + return z == null + ? new GeoCoordinate(lat, lon) + : new GeoCoordinate(lat, lon, z.Value); + } + + private static void NextCloser(WellKnownTextTokenizer tokenizer) + { + if (tokenizer.NextToken() != TokenType.RParen) + throw new GeoWKTException( + $"Expected {(char)WellKnownTextTokenizer.RParen} " + + $"but found: {tokenizer.TokenString()}", tokenizer.LineNumber, tokenizer.Position); + } + + private static void NextComma(WellKnownTextTokenizer tokenizer) + { + if (tokenizer.NextToken() != TokenType.Comma) + throw new GeoWKTException( + $"Expected {(char)WellKnownTextTokenizer.Comma} but found: {tokenizer.TokenString()}", + tokenizer.LineNumber, + tokenizer.Position); + } + + private static TokenType NextEmptyOrOpen(WellKnownTextTokenizer tokenizer) + { + var token = tokenizer.NextToken(); + if (token == TokenType.LParen || + token == TokenType.Word && tokenizer.TokenValue.Equals(WellKnownTextTokenizer.Empty, StringComparison.OrdinalIgnoreCase)) + return token; + + throw new GeoWKTException( + $"Expected {WellKnownTextTokenizer.Empty} or {(char)WellKnownTextTokenizer.LParen} " + + $"but found: {tokenizer.TokenString()}", tokenizer.LineNumber, tokenizer.Position); + } + + private static TokenType NextCloserOrComma(WellKnownTextTokenizer tokenizer) + { + var token = tokenizer.NextToken(); + if (token == TokenType.Comma || token == TokenType.RParen) + return token; + + throw new GeoWKTException( + $"Expected {(char)WellKnownTextTokenizer.Comma} or {(char)WellKnownTextTokenizer.RParen} " + + $"but found: {tokenizer.TokenString()}", tokenizer.LineNumber, tokenizer.Position); + } + + private static double NextNumber(WellKnownTextTokenizer tokenizer) + { + if (tokenizer.NextToken() == TokenType.Number) + { + if (string.Equals(tokenizer.TokenValue, WellKnownTextTokenizer.NaN, StringComparison.OrdinalIgnoreCase)) + return double.NaN; + + if (double.TryParse( + tokenizer.TokenValue, + NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, out var d)) + return d; + } + + throw new GeoWKTException( + $"Expected number but found: {tokenizer.TokenString()}", tokenizer.LineNumber, tokenizer.Position); + } + + private static bool IsNumberNext(WellKnownTextTokenizer tokenizer) + { + var token = tokenizer.PeekToken(); + return token == TokenType.Number; + } + } + + /// + /// Character types when parsing Well-Known Text + /// + internal enum CharacterType : byte + { + Whitespace, + Digit, + Alpha, + Comment + } + + /// + /// Well-Known Text token types + /// + internal enum TokenType : byte + { + None, + Word, + Number, + LParen, + RParen, + Comma + } + + /// + /// Tokenizes a sequence of characters into Well-Known Text + /// (WKT) + /// + internal class WellKnownTextTokenizer : IDisposable + { + private const int NeedChar = int.MaxValue; + private const int CharacterTypesLength = 256; + + public const int Linefeed = '\n'; + public const int CarriageReturn = '\r'; + public const int LParen = '('; + public const int RParen = ')'; + public const int Comma = ','; + public const int Comment = '#'; + public const int Dot = '.'; + public const int Plus = '+'; + public const int Minus = '-'; + public const string NaN = "NAN"; + public const string Empty = "EMPTY"; + + private static readonly CharacterType[] CharacterTypes = new CharacterType[CharacterTypesLength]; + + private static void Chars(int low, int high, CharacterType type) + { + if (low < 0) + low = 0; + + if (high >= CharacterTypesLength) + high = CharacterTypesLength - 1; + + while (low <= high) + CharacterTypes[low++] = type; + } + + static WellKnownTextTokenizer() + { + // build a map of ASCII chars and their types + // Any unmapped ASCII will be considered whitespace + // and anything > 0 outside of ASCII will be considered alpha. + // Treat + - and . as digit characters to make parsing numbers easier. + Chars('a', 'z', CharacterType.Alpha); + Chars('A', 'Z', CharacterType.Alpha); + Chars(128 + 32, 255, CharacterType.Alpha); + Chars('0', '9', CharacterType.Digit); + Chars(LParen, RParen, CharacterType.Alpha); + Chars(Plus, Plus, CharacterType.Digit); + Chars(Comma, Comma, CharacterType.Alpha); + Chars(Minus, Dot, CharacterType.Digit); + Chars(Comment, Comment, CharacterType.Comment); + } + + private readonly TextReader _reader; + private readonly List _buffer = new List(); + private bool _pushed; + private int _peekChar = NeedChar; + + // TODO: use ReadOnlySpan in future + public WellKnownTextTokenizer(TextReader reader) => + _reader = reader ?? throw new ArgumentNullException(nameof(reader)); + + /// + /// Gets the current position + /// + public int Position { get; private set; } + + /// + /// Gets the current line number + /// + public int LineNumber { get; private set; } = 1; + + /// + /// Gets the current token value + /// + public string TokenValue { get; private set; } + + /// + /// Gets the current token type + /// + public TokenType TokenType { get; private set; } = TokenType.None; + + /// + /// A user friendly string for the current token + /// + public string TokenString() + { + switch (TokenType) + { + case TokenType.Word: + case TokenType.Number: + return TokenValue; + case TokenType.None: + return "END-OF-STREAM"; + case TokenType.LParen: + return "("; + case TokenType.RParen: + return ")"; + case TokenType.Comma: + return ","; + default: + return $"\'{(char)_peekChar}\'"; + } + } + + private int Read() + { + Position++; + return _reader.Read(); + } + + /// + /// Peeks at the next token without changing the state + /// of the reader + /// + public TokenType PeekToken() + { + var position = Position; + var token = NextToken(); + Position = position; + _pushed = true; + return token; + } + + /// + /// Gets the next token, advancing the position + /// + public TokenType NextToken() + { + if (_pushed) + { + _pushed = false; + + // Add the length of peeked token + Position += !string.IsNullOrEmpty(TokenValue) + ? 1 + TokenValue.Length + : 1; + + return TokenType; + } + + TokenValue = null; + + var c = _peekChar; + if (c < 0) + c = NeedChar; + + if (c == NeedChar) + { + c = Read(); + if (c < 0) + return TokenType = TokenType.None; + } + + // reset the peek character for next token + _peekChar = NeedChar; + + var characterType = c < CharacterTypesLength + ? CharacterTypes[c] + : CharacterType.Alpha; + + // consume all whitespace + while (characterType == CharacterType.Whitespace) + { + if (c == CarriageReturn) + { + LineNumber++; + Position = 0; + c = Read(); + if (c == Linefeed) + c = Read(); + } + else + { + if (c == Linefeed) + { + LineNumber++; + Position = 0; + } + + c = Read(); + } + + if (c < 0) + return TokenType = TokenType.None; + + characterType = c < CharacterTypesLength + ? CharacterTypes[c] + : CharacterType.Alpha; + } + + switch (c) + { + case LParen: + return TokenType = TokenType.LParen; + case RParen: + return TokenType = TokenType.RParen; + case Comma: + return TokenType = TokenType.Comma; + } + + if (characterType == CharacterType.Alpha) + { + var i = 0; + + do + { + _buffer.Insert(i++, (char)c); + c = Read(); + + if (c < 0) + characterType = CharacterType.Whitespace; + else if (c < CharacterTypesLength) + characterType = CharacterTypes[c]; + else + characterType = CharacterType.Alpha; + + } while (characterType == CharacterType.Alpha); + + _peekChar = c; + TokenValue = new string(_buffer.ToArray(), 0, i); + + // special case for NaN + if (string.Equals(TokenValue, NaN, StringComparison.OrdinalIgnoreCase)) + return TokenType = TokenType.Number; + + return TokenType = TokenType.Word; + } + + if (characterType == CharacterType.Digit) + { + var i = 0; + var dots = 0; + do + { + _buffer.Insert(i++, (char)c); + c = Read(); + + if (c < 0) + characterType = CharacterType.Whitespace; + else if (c < CharacterTypesLength) + { + characterType = CharacterTypes[c]; + if (c == Dot) + dots++; + } + else + characterType = CharacterType.Alpha; + } while (characterType == CharacterType.Digit); + + _peekChar = c; + TokenValue = new string(_buffer.ToArray(), 0, i); + + return dots > 1 + ? TokenType = TokenType.Word + : TokenType = TokenType.Number; + } + + if (characterType == CharacterType.Comment) + { + // consume all characters on comment line + while ((c = Read()) != Linefeed && c != CarriageReturn && c >= 0) + { + } + + _peekChar = c; + return NextToken(); + } + + return TokenType = TokenType.None; + } + + /// + /// Disposes of the reader from which characters are read + /// + public void Dispose() => _reader?.Dispose(); + } +} diff --git a/src/Nest/QueryDsl/Geo/WKT/GeoWKTWriter.cs b/src/Nest/QueryDsl/Geo/WKT/GeoWKTWriter.cs new file mode 100644 index 00000000000..038c21ccd31 --- /dev/null +++ b/src/Nest/QueryDsl/Geo/WKT/GeoWKTWriter.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Nest +{ + /// + /// Writes types to Well-Known Text (WKT) + /// + public class GeoWKTWriter + { + /// + /// Writes a to Well-Known Text (WKT) + /// + public static string Write(IGeoShape shape) => + shape == null ? null : Write(shape, new StringBuilder()); + + private static string Write(IGeoShape shape, StringBuilder builder) + { + switch (shape) + { + case IPointGeoShape point: + WritePoint(point, builder); + break; + case IMultiPointGeoShape multiPoint: + WriteMultiPoint(multiPoint, builder); + break; + case ILineStringGeoShape lineString: + WriteLineString(lineString, builder); + break; + case IMultiLineStringGeoShape multiLineString: + WriteMultiLineString(multiLineString, builder); + break; + case IPolygonGeoShape polygon: + WritePolygon(polygon, builder); + break; + case IMultiPolygonGeoShape multiPolygon: + WriteMultiPolygon(multiPolygon, builder); + break; + case IGeometryCollection geometryCollection: + WriteGeometryCollection(geometryCollection, builder); + break; + case IEnvelopeGeoShape envelope: + WriteEnvelope(envelope, builder); + break; + default: + throw new GeoWKTException($"Unknown geometry type: {shape.GetType().Name}"); + } + + return builder.ToString(); + } + + private static void WritePoint(IPointGeoShape point, StringBuilder builder) + { + builder.Append(GeoShapeType.Point).Append(" ("); + WriteCoordinate(point.Coordinates, builder); + builder.Append(")"); + } + + private static void WriteMultiPoint(IMultiPointGeoShape multiPoint, StringBuilder builder) + { + builder.Append(GeoShapeType.MultiPoint).Append(" ("); + WriteCoordinates(multiPoint.Coordinates, builder); + builder.Append(")"); + } + + private static void WriteLineString(ILineStringGeoShape lineString, StringBuilder builder) + { + builder.Append(GeoShapeType.LineString).Append(" ("); + WriteCoordinates(lineString.Coordinates, builder); + builder.Append(")"); + } + + private static void WriteMultiLineString(IMultiLineStringGeoShape multiLineString, StringBuilder builder) + { + builder.Append(GeoShapeType.MultiLineString).Append(" "); + WriteCoordinatesList(multiLineString.Coordinates, builder); + } + + private static void WritePolygon(IPolygonGeoShape polygon, StringBuilder builder) + { + builder.Append(GeoShapeType.Polygon).Append(" "); + WriteCoordinatesList(polygon.Coordinates, builder); + } + + private static void WriteMultiPolygon(IMultiPolygonGeoShape multiPolygon, StringBuilder builder) + { + builder.Append(GeoShapeType.MultiPolygon).Append(" ("); + var i = 0; + foreach (var polygon in multiPolygon.Coordinates) + { + if (i > 0) + builder.Append(", "); + + WriteCoordinatesList(polygon, builder); + i++; + } + builder.Append(")"); + } + + private static void WriteGeometryCollection(IGeometryCollection geometryCollection, StringBuilder builder) + { + builder.Append(GeoShapeType.GeometryCollection).Append(" ("); + var i = 0; + foreach (var shape in geometryCollection.Geometries) + { + if (i > 0) + builder.Append(", "); + + Write(shape, builder); + i++; + } + builder.Append(")"); + } + + private static void WriteEnvelope(IEnvelopeGeoShape envelope, StringBuilder builder) + { + builder.Append(GeoShapeType.BoundingBox).Append(" ("); + var topLeft = envelope.Coordinates.ElementAt(0); + var bottomRight = envelope.Coordinates.ElementAt(1); + + // WKT specification expects the following order: minLon, maxLon, maxLat, minLat. + // envelope is top_left (minLon, maxLat), bottom_right (maxLon, minLat) + builder.Append(topLeft.Longitude) + .Append(", ") + .Append(bottomRight.Longitude) + .Append(", ") + .Append(topLeft.Latitude) + .Append(", ") + .Append(bottomRight.Latitude) + .Append(")"); + } + + private static void WriteCoordinatesList(IEnumerable> coordinates, StringBuilder builder) + { + builder.Append("("); + var i = 0; + foreach (var coordinateGroup in coordinates) + { + if (i > 0) + builder.Append(", "); + + builder.Append("("); + WriteCoordinates(coordinateGroup, builder); + builder.Append(")"); + i++; + } + builder.Append(")"); + } + + private static void WriteCoordinates(IEnumerable coordinates, StringBuilder builder) + { + var i = 0; + foreach (var coordinate in coordinates) + { + if (i > 0) + builder.Append(", "); + WriteCoordinate(coordinate, builder); + i++; + } + } + + private static void WriteCoordinate(GeoCoordinate coordinate, StringBuilder builder) + { + builder.Append(coordinate.Longitude) + .Append(" ") + .Append(coordinate.Latitude); + + if (coordinate.Z.HasValue) + builder.Append(" ").Append(coordinate.Z.Value); + } + } +} diff --git a/src/Tests/Tests/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQueryUsageTests.cs b/src/Tests/Tests/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQueryUsageTests.cs index aca7cfa4a47..43f17991f90 100644 --- a/src/Tests/Tests/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQueryUsageTests.cs +++ b/src/Tests/Tests/QueryDsl/Geo/BoundingBox/GeoBoundingBoxQueryUsageTests.cs @@ -68,4 +68,56 @@ protected override QueryContainer QueryFluent(QueryContainerDescriptor q => q.Field = null }; } + + public class GeoBoundingBoxWKTQueryUsageTests : QueryDslUsageTestsBase + { + public GeoBoundingBoxWKTQueryUsageTests(ReadOnlyCluster i, EndpointUsage usage) : base(i, usage) { } + + protected override object QueryJson => new + { + geo_bounding_box = new + { + type = "indexed", + validation_method = "strict", + _name = "named_query", + boost = 1.1, + location = new + { + wkt = "BBOX (34, -34, -34, 34)" + } + } + }; + + protected override QueryContainer QueryInitializer => new GeoBoundingBoxQuery + { + Boost = 1.1, + Name = "named_query", + Field = Infer.Field(p => p.Location), + BoundingBox = new Nest.BoundingBox + { + WellKnownText = "BBOX (34, -34, -34, 34)" + }, + Type = GeoExecution.Indexed, + ValidationMethod = GeoValidationMethod.Strict + }; + + protected override QueryContainer QueryFluent(QueryContainerDescriptor q) => q + .GeoBoundingBox(g=>g + .Boost(1.1) + .Name("named_query") + .Field(p=>p.Location) + .BoundingBox(b=>b + .WellKnownText("BBOX (34, -34, -34, 34)") + ) + .ValidationMethod(GeoValidationMethod.Strict) + .Type(GeoExecution.Indexed) + ); + + protected override ConditionlessWhen ConditionlessWhen => new ConditionlessWhen(a => a.GeoBoundingBox) + { + q => q.BoundingBox = null, + q => q.BoundingBox = new Nest.BoundingBox { } , + q => q.Field = null + }; + } } diff --git a/src/Tests/Tests/QueryDsl/Geo/Shape/GeoShapeSerializationTests.cs b/src/Tests/Tests/QueryDsl/Geo/Shape/GeoShapeSerializationTests.cs index c21bec367d1..18778fa8a52 100644 --- a/src/Tests/Tests/QueryDsl/Geo/Shape/GeoShapeSerializationTests.cs +++ b/src/Tests/Tests/QueryDsl/Geo/Shape/GeoShapeSerializationTests.cs @@ -1,67 +1,34 @@ using System; using System.Collections.Generic; +using System.Drawing; +using System.IO; using System.Linq; +using System.Text; +using Elastic.Xunit.XunitPlumbing; using Elasticsearch.Net; using FluentAssertions; using Nest; +using Newtonsoft.Json.Linq; using Tests.Core.Extensions; using Tests.Core.ManagedElasticsearch.Clusters; using Tests.Framework; using Tests.Framework.Integration; using Tests.Framework.ManagedElasticsearch.Clusters; +using Tests.Domain; -namespace Tests.QueryDsl.Geo.Shape +namespace Tests.QueryDsl.Geo { - public class GeoShapeSerializationTests : + public abstract class GeoShapeSerializationTestsBase : ApiIntegrationTestBase, ISearchRequest, SearchDescriptor, SearchRequest> { - public GeoShapeSerializationTests(IntrusiveOperationCluster cluster, EndpointUsage usage) + protected GeoShapeSerializationTestsBase(IntrusiveOperationCluster cluster, EndpointUsage usage) : base(cluster, usage) { } - private const string Index = "shapes"; - - protected override void IntegrationSetup(IElasticClient client, CallUniqueValues values) - { - if (client.IndexExists(Index).Exists) - return; - - var createIndexResponse = client.CreateIndex(Index, c => c - .Settings(s => s - .NumberOfShards(1) - ) - .Mappings(m => m - .Map(mm => mm - .AutoMap() - .Properties(p => p - .GeoShape(g => g - .Name(n => n.GeometryCollection) - ) - .GeoShape(g => g - .Name(n => n.Envelope) - ) - .GeoShape(g => g - .Name(n => n.Circle) - ) - ) - ) - ) - ); - - if (!createIndexResponse.IsValid) - throw new Exception($"Error creating index for integration test: {createIndexResponse.DebugInformation}"); - - var bulkResponse = this.Client.Bulk(b => b - .IndexMany(Domain.Shape.Shapes) - .Refresh(Refresh.WaitFor) - ); - - if (!bulkResponse.IsValid) - throw new Exception($"Error indexing shapes for integration test: {bulkResponse.DebugInformation}"); - } + protected abstract string Index { get; } protected override LazyResponses ClientUsage() => Calls( fluent: (client, f) => client.Search(f), @@ -97,10 +64,10 @@ protected override LazyResponses ClientUsage() => Calls( protected override int ExpectStatusCode => 200; protected override bool ExpectIsValid => true; - protected override string UrlPath => $"/shapes/doc/_search"; + protected override string UrlPath => $"/{Index}/doc/_search"; protected override HttpMethod HttpMethod => HttpMethod.POST; - protected override SearchRequest Initializer => new SearchRequest + protected override SearchRequest Initializer => new SearchRequest(Index) { Query = new GeoShapeQuery { @@ -109,11 +76,12 @@ protected override LazyResponses ClientUsage() => Calls( Field = Infer.Field(p => p.Envelope), Shape = new EnvelopeGeoShape(this._coordinates), Relation = GeoShapeRelation.Intersects, - IgnoreUnmapped = true + IgnoreUnmapped = true, } }; protected override Func, ISearchRequest> Fluent => s => s + .Index(Index) .Query(q => q .GeoShape(c => c .Name("named_query") @@ -129,8 +97,160 @@ protected override LazyResponses ClientUsage() => Calls( protected override void ExpectResponse(ISearchResponse response) { - response.ShouldBeValid(); + response.IsValid.Should().BeTrue(); response.Documents.Count.Should().Be(10); } } + + public class GeoShapeSerializationTests : GeoShapeSerializationTestsBase + { + public GeoShapeSerializationTests(IntrusiveOperationCluster cluster, EndpointUsage usage) + : base(cluster, usage) { } + + protected override void IntegrationSetup(IElasticClient client, CallUniqueValues values) + { + if (client.IndexExists(Index).Exists) + return; + + var createIndexResponse = client.CreateIndex(Index, c => c + .Settings(s => s + .NumberOfShards(1) + .NumberOfReplicas(0) + ) + .Mappings(m => m + .Map(mm => mm + .AutoMap() + .Properties(p => p + .GeoShape(g => g + .Name(n => n.GeometryCollection) + ) + .GeoShape(g => g + .Name(n => n.Envelope) + ) + .GeoShape(g => g + .Name(n => n.Circle) + ) + ) + ) + ) + ); + + if (!createIndexResponse.IsValid) + throw new Exception($"Error creating index for integration test: {createIndexResponse.DebugInformation}"); + + var bulkResponse = this.Client.Bulk(b => b + .Index(Index) + .IndexMany(Domain.Shape.Shapes) + .Refresh(Refresh.WaitFor) + ); + + if (!bulkResponse.IsValid) + throw new Exception($"Error indexing shapes for integration test: {bulkResponse.DebugInformation}"); + } + + protected override string Index => "geoshapes"; + } + + [SkipVersion("<6.2.0", "Support for WKT in Elasticsearch 6.2.0+")] + public class GeoShapeWKTSerializationTests : GeoShapeSerializationTestsBase + { + public GeoShapeWKTSerializationTests(IntrusiveOperationCluster cluster, EndpointUsage usage) + : base(cluster, usage) { } + + protected override void IntegrationSetup(IElasticClient client, CallUniqueValues values) + { + if (client.IndexExists(Index).Exists) + return; + + var createIndexResponse = client.CreateIndex(Index, c => c + .Settings(s => s + .NumberOfShards(1) + .NumberOfReplicas(0) + ) + .Mappings(m => m + .Map(mm => mm + .AutoMap() + .Properties(p => p + .GeoShape(g => g + .Name(n => n.GeometryCollection) + ) + .GeoShape(g => g + .Name(n => n.Envelope) + ) + .GeoShape(g => g + .Name(n => n.Circle) + ) + ) + ) + ) + ); + + if (!createIndexResponse.IsValid) + throw new Exception($"Error creating index for integration test: {createIndexResponse.DebugInformation}"); + + var bulk = new List(); + + // use the low level client to force WKT + var typeName = this.Client.Infer.TypeName(); + foreach (var shape in Domain.Shape.Shapes) + { + bulk.Add(new { index = new { _index = Index, _type = typeName, _id = shape.Id } }); + bulk.Add(new + { + id = shape.Id, + geometryCollection = GeoWKTWriter.Write(shape.GeometryCollection), + envelope = GeoWKTWriter.Write(shape.Envelope), + circle = shape.Circle + }); + } + + var bulkResponse = this.Client.LowLevel.Bulk( + PostData.MultiJson(bulk), + new BulkRequestParameters{ Refresh = Refresh.WaitFor } + ); + + if (!bulkResponse.IsValid) + throw new Exception($"Error indexing shapes for integration test: {bulkResponse.DebugInformation}"); + } + + protected override string Index => "wkt-geoshapes"; + + protected override void ExpectResponse(ISearchResponse response) + { + base.ExpectResponse(response); + + // index shapes again + var bulkResponse = this.Client.Bulk(b => b + .Index(Index) + .IndexMany(response.Documents) + .Refresh(Refresh.WaitFor) + .RequestConfiguration(r => r + .DisableDirectStreaming() + ) + ); + + bulkResponse.IsValid.Should().BeTrue(); + + // ensure they were indexed as WKT + var request = Encoding.UTF8.GetString(bulkResponse.ApiCall.RequestBodyInBytes); + using (var reader = new StringReader(request)) + { + string line; + var i = 0; + while ((line = reader.ReadLine()) != null) + { + i++; + if (i % 2 != 0) + continue; + + var jObject = JObject.Parse(line); + var jValue = (JValue)jObject["geometryCollection"]; + jValue.Value.Should().BeOfType(); + jValue = (JValue)jObject["envelope"]; + jValue.Value.Should().BeOfType(); + jObject["circle"].Should().BeOfType(); + } + } + } + } } diff --git a/src/Tests/Tests/QueryDsl/Geo/Shape/GeoWKTTests.cs b/src/Tests/Tests/QueryDsl/Geo/Shape/GeoWKTTests.cs new file mode 100644 index 00000000000..6ac22a27493 --- /dev/null +++ b/src/Tests/Tests/QueryDsl/Geo/Shape/GeoWKTTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Linq; +using Elastic.Xunit.XunitPlumbing; +using FluentAssertions; +using Nest; +using Tests.Framework; + +namespace Tests.QueryDsl.Geo +{ + public class GeoWKTTests + { + [U] + public void ReadAndWritePoint() + { + var wkt = "POINT (-77.03653 38.897676)"; + var shape = GeoWKTReader.Read(wkt); + + shape.Should().BeOfType(); + var point = (PointGeoShape)shape; + + point.Coordinates.Latitude.Should().Be(38.897676); + point.Coordinates.Longitude.Should().Be(-77.03653); + + GeoWKTWriter.Write(point).Should().Be(wkt); + } + + [U] + public void ReadAndWriteMultiPoint() + { + var wkt = "MULTIPOINT (102.0 2.0, 103.0 2.0)"; + var shape = GeoWKTReader.Read(wkt); + + var multiPoint = shape as MultiPointGeoShape; + + multiPoint.Should().NotBeNull(); + + var firstPoint = multiPoint.Coordinates.First(); + firstPoint.Latitude.Should().Be(2); + firstPoint.Longitude.Should().Be(102); + + var lastPoint = multiPoint.Coordinates.Last(); + lastPoint.Latitude.Should().Be(2); + lastPoint.Longitude.Should().Be(103); + + GeoWKTWriter.Write(multiPoint).Should().Be("MULTIPOINT (102 2, 103 2)"); + } + + [U] + public void ReadAndWriteLineString() + { + var wkt = "LINESTRING (-77.03653 38.897676, -77.009051 38.889939)"; + + var shape = GeoWKTReader.Read(wkt); + + var lineString = shape as LineStringGeoShape; + + lineString.Should().NotBeNull(); + lineString.Coordinates.First().Latitude.Should().Be(38.897676); + lineString.Coordinates.First().Longitude.Should().Be(-77.03653); + + lineString.Coordinates.Last().Latitude.Should().Be(38.889939); + lineString.Coordinates.Last().Longitude.Should().Be(-77.009051); + + GeoWKTWriter.Write(lineString).Should().Be(wkt); + } + + [U] + public void ReadMultiLineString() + { + var wkt = @"MULTILINESTRING ((102.0 2.0, 103.0 2.0, 103.0 3.0, 102.0 3.0), + (100.0 0.0, 101.0 0.0, 101.0 1.0, 100.0 1.0), + (100.2 0.2, 100.8 0.2, 100.8 0.8, 100.2 0.8))"; + + var shape = GeoWKTReader.Read(wkt); + + var multiLineString = shape as MultiLineStringGeoShape; + + multiLineString.Should().NotBeNull(); + + foreach (var lineString in multiLineString.Coordinates) + foreach (var coordinate in lineString) + { + coordinate.Latitude.Should().BeGreaterOrEqualTo(0).And.BeLessOrEqualTo(3); + coordinate.Longitude.Should().BeGreaterOrEqualTo(100).And.BeLessOrEqualTo(103); + } + } + + [U] + public void WriteMultiLineString() + { + var multLineString = new MultiLineStringGeoShape(new [] + { + new [] + { + new GeoCoordinate(2, 102), + new GeoCoordinate(2, 103), + new GeoCoordinate(3, 103), + new GeoCoordinate(3, 102), + }, + new [] + { + new GeoCoordinate(0, 100), + new GeoCoordinate(0, 101), + new GeoCoordinate(1, 101), + new GeoCoordinate(1, 100), + } + }); + + var wkt = GeoWKTWriter.Write(multLineString); + wkt.Should().Be("MULTILINESTRING ((102 2, 103 2, 103 3, 102 3), (100 0, 101 0, 101 1, 100 1))"); + } + + [U] + public void ReadPolygon() + { + var wkt = @"POLYGON ((100.0 0.0, 101.0 0.0, 101.0 1.0, 100.0 1.0, 100.0 0.0), + (100.2 0.2, 100.8 0.2, 100.8 0.8, 100.2 0.8, 100.2 0.2))"; + + var shape = GeoWKTReader.Read(wkt); + + var polygon = shape as PolygonGeoShape; + + polygon.Should().NotBeNull(); + + foreach (var ring in polygon.Coordinates) + foreach (var coordinate in ring) + { + coordinate.Latitude.Should().BeLessOrEqualTo(1.0); + coordinate.Longitude.Should().BeGreaterOrEqualTo(100.0); + } + } + + [U] + public void WritePolygon() + { + var polygon = new PolygonGeoShape(new [] + { + new [] + { + new GeoCoordinate(2, 102), + new GeoCoordinate(2, 103), + new GeoCoordinate(3, 103), + new GeoCoordinate(3, 102), + }, + new [] + { + new GeoCoordinate(0, 100), + new GeoCoordinate(0, 101), + new GeoCoordinate(1, 101), + new GeoCoordinate(1, 100), + } + }); + + var wkt = GeoWKTWriter.Write(polygon); + wkt.Should().Be("POLYGON ((102 2, 103 2, 103 3, 102 3), (100 0, 101 0, 101 1, 100 1))"); + } + + [U] + public void ReadMultiPolygon() + { + var wkt = @"MULTIPOLYGON ( + ((102.0 2.0, 103.0 2.0, 103.0 3.0, 102.0 3.0, 102.0 2.0)), + ((100.0 0.0, 101.0 0.0, 101.0 1.0, 100.0 1.0, 100.0 0.0), + (100.2 0.2, 100.8 0.2, 100.8 0.8, 100.2 0.8, 100.2 0.2)))"; + + var shape = GeoWKTReader.Read(wkt); + + var multiPolygon = shape as MultiPolygonGeoShape; + + multiPolygon.Should().NotBeNull(); + multiPolygon.Coordinates.Should().HaveCount(2); + + foreach (var polygon in multiPolygon.Coordinates) + foreach (var ring in polygon) + { + ring.Should().HaveCount(5); + foreach (var coordinate in ring) + { + coordinate.Latitude.Should().BeLessOrEqualTo(3.0).And.BeGreaterOrEqualTo(0); + coordinate.Longitude.Should().BeGreaterOrEqualTo(100.0).And.BeLessOrEqualTo(103.0); + } + } + } + + [U] + public void WriteMultiPolygon() + { + var multiPolygon = new MultiPolygonGeoShape(new [] + { + new [] + { + new [] + { + new GeoCoordinate(2, 102), + new GeoCoordinate(2, 103), + new GeoCoordinate(3, 103), + new GeoCoordinate(3, 102), + new GeoCoordinate(2, 102), + } + }, + new [] + { + new [] + { + new GeoCoordinate(0, 100), + new GeoCoordinate(0, 101), + new GeoCoordinate(1, 101), + new GeoCoordinate(1, 100), + new GeoCoordinate(0, 100), + }, + new [] + { + new GeoCoordinate(0.2, 100.2), + new GeoCoordinate(0.2, 100.8), + new GeoCoordinate(0.8, 100.8), + new GeoCoordinate(0.8, 100.2), + new GeoCoordinate(0.2, 100.2), + } + } + }); + + GeoWKTWriter.Write(multiPolygon).Should().Be("MULTIPOLYGON (((102 2, 103 2, 103 3, 102 3, 102 2)), ((100 0, 101 0, 101 1, 100 1, 100 0), (100.2 0.2, 100.8 0.2, 100.8 0.8, 100.2 0.8, 100.2 0.2)))"); + } + + [U] + public void ReadAndWriteEnvelope() + { + var wkt = "BBOX (-74.1, -71.12, 40.73, 40.01)"; + var shape = GeoWKTReader.Read(wkt); + var envelope = shape as EnvelopeGeoShape; + + envelope.Should().NotBeNull(); + envelope.Coordinates.First().Latitude.Should().Be(40.73); + envelope.Coordinates.First().Longitude.Should().Be(-74.1); + envelope.Coordinates.Last().Latitude.Should().Be(40.01); + envelope.Coordinates.Last().Longitude.Should().Be(-71.12); + + GeoWKTWriter.Write(shape).Should().Be(wkt); + } + + [U] + public void WriteEnvelopeIgnoresZValues() + { + var envelope = new EnvelopeGeoShape(new [] + { + new GeoCoordinate(40.73, -74.1, 3), + new GeoCoordinate(40.01, -71.12, 2) + }); + + GeoWKTWriter.Write(envelope).Should().Be("BBOX (-74.1, -71.12, 40.73, 40.01)"); + } + + [U] + public void ReadAndWriteGeometryCollection() + { + var wkt = "GEOMETRYCOLLECTION (POINT (100 0), LINESTRING (101 0, 102 1))"; + var shape = GeoWKTReader.Read(wkt); + var geometryCollection = shape as IGeometryCollection; + + geometryCollection.Should().NotBeNull(); + geometryCollection.Geometries.Should().HaveCount(2); + geometryCollection.Geometries.First().Should().BeOfType(); + geometryCollection.Geometries.Last().Should().BeOfType(); + + GeoWKTWriter.Write(geometryCollection).Should().Be(wkt); + } + + [U] + public void UnknownGeometryThrowsGeoWKTException() + { + var wkt = "UNKNOWN (100 0)"; + Action action = () => GeoWKTReader.Read(wkt); + action.ShouldThrow().Which.Message.Should().Be("Unknown geometry type: UNKNOWN"); + } + + [U] + public void MalformedPolygonThrowsGeoWKTException() + { + var wkt = "POLYGON ((100, 5) (100, 10) (90, 10), (90, 5), (100, 5)"; + Action action = () => GeoWKTReader.Read(wkt); + action.ShouldThrow().Which.Message.Should().Be("Expected number but found: , at line 1, position 14"); + } + + [U] + public void GeoWKTExceptionReturnsCorrectLineNumberAndPosition() + { + var wkt = "POLYGON (\n(100, 5) (100, 10) (90, 10), (90, 5), (100, 5)"; + Action action = () => GeoWKTReader.Read(wkt); + action.ShouldThrow().Which.Message.Should().Be("Expected number but found: , at line 2, position 5"); + } + + } +}