diff --git a/Microsoft.Azure.Cosmos/src/Spatial/BoundingBox.cs b/Microsoft.Azure.Cosmos/src/Spatial/BoundingBox.cs index b80232a691..2219811f4c 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/BoundingBox.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/BoundingBox.cs @@ -7,6 +7,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System; using System.Runtime.Serialization; using Microsoft.Azure.Cosmos.Spatial.Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -14,6 +15,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(BoundingBoxJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(BoundingBoxSTJConverter))] public sealed class BoundingBox : IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/CrsJsonConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/CrsJsonConverter.cs index 5b1879532b..9ec73e7654 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Converters/CrsJsonConverter.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/CrsJsonConverter.cs @@ -122,7 +122,7 @@ public override object ReadJson( throw new JsonSerializationException(RMResources.SpatialFailedToDeserializeCrs); } - return new LinkedCrs(crsHref.Value(), crsHrefType.Value()); + return new LinkedCrs(crsHref.Value(), crsHrefType?.Value()); default: throw new JsonSerializationException(RMResources.SpatialFailedToDeserializeCrs); diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/BoundingBoxSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/BoundingBoxSTJConverter.cs new file mode 100644 index 0000000000..3413b08b7c --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/BoundingBoxSTJConverter.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Globalization; + using System.Linq; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of BoundingBox. + /// + internal sealed class BoundingBoxSTJConverter : JsonConverter + { + /// + /// Deserializes a BoundingBox from a JSON array of coordinates. + /// The array contains min coordinates followed by max coordinates. + /// Example: [minLon, minLat, maxLon, maxLat] for 2D or [minLon, minLat, minAlt, maxLon, maxLat, maxAlt] for 3D. + /// + public override BoundingBox Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + double[] coordinates = JsonSerializer.Deserialize(ref reader, options); + if (coordinates == null) + { + return null; + } + + if (coordinates.Length % 2 != 0 || coordinates.Length < 4) + { + throw new JsonException(RMResources.SpatialBoundingBoxInvalidCoordinates); + } + + return new BoundingBox( + new Position(coordinates.Take(coordinates.Length / 2).ToList()), + new Position(coordinates.Skip(coordinates.Length / 2).ToList())); + } + + /// + /// Serializes a BoundingBox to a JSON array. + /// Outputs min coordinates followed by max coordinates. + /// + public override void Write(Utf8JsonWriter writer, BoundingBox box, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (double coordinate in box.Min.Coordinates.Concat(box.Max.Coordinates)) + { + // Check if the number is effectively an integer. + if (coordinate == Math.Truncate(coordinate)) + { + // If so, write it with one decimal place to match Newtonsoft's [x.0] format. + writer.WriteRawValue(coordinate.ToString("0.0", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteRawValue(coordinate.ToString("R", CultureInfo.InvariantCulture)); + } + } + writer.WriteEndArray(); + } + } +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/CrsSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/CrsSTJConverter.cs new file mode 100644 index 0000000000..62f4411672 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/CrsSTJConverter.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of Crs (Coordinate Reference System). + /// Handles NamedCrs, LinkedCrs, and Unspecified CRS types. + /// Ensures output format matches Newtonsoft.Json exactly. + /// + internal sealed class CrsSTJConverter : JsonConverter + { + public override bool HandleNull => true; + + /// + /// Deserializes a CRS from JSON. + /// Supports named CRS (e.g., EPSG:4326) and linked CRS with optional type. + /// Returns Crs.Unspecified for null values. + /// + public override Crs Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return Crs.Unspecified; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(RMResources.JsonUnexpectedToken); + } + + JsonElement rootElement = JsonDocument.ParseValue(ref reader).RootElement; + if (!rootElement.TryGetProperty(STJMetaDataFields.Properties, out JsonElement properties) || (properties.ValueKind != JsonValueKind.Object)) + { + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + + if (!rootElement.TryGetProperty(STJMetaDataFields.Type, out JsonElement crsType) || crsType.ValueKind != JsonValueKind.String) + { + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + + switch (crsType.GetString()) + { + case "name": + if (!properties.TryGetProperty(STJMetaDataFields.Name, out JsonElement crsName) || crsName.ValueKind != JsonValueKind.String) + { + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + return new NamedCrs(crsName.GetString()); + + case "link": + if (!properties.TryGetProperty(STJMetaDataFields.Href, out JsonElement crsHref) || crsHref.ValueKind != JsonValueKind.String) + { + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + + if (properties.TryGetProperty(STJMetaDataFields.Type, out JsonElement crsHrefType)) + { + if (crsHrefType.ValueKind != JsonValueKind.String) + { + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + return new LinkedCrs(crsHref.GetString(), crsHrefType.GetString()); + } + return new LinkedCrs(crsHref.GetString()); + + default: + throw new JsonException(RMResources.SpatialFailedToDeserializeCrs); + } + + } + /// + /// Serializes a CRS to JSON. + /// Outputs different JSON structures based on CRS type (Named, Linked, or Unspecified). + /// + public override void Write(Utf8JsonWriter writer, Crs crs, JsonSerializerOptions options) + { + if (crs == null) + { + writer.WriteNullValue(); + return; + } + + switch (crs.Type) + { + case CrsType.Linked: + { + writer.WriteStartObject(); + LinkedCrs linkedCrs = (LinkedCrs)crs; + writer.WriteString(STJMetaDataFields.Type, "link"); + writer.WritePropertyName(STJMetaDataFields.Properties); + writer.WriteStartObject(); + writer.WriteString(STJMetaDataFields.Href, linkedCrs.Href); + if (linkedCrs.HrefType != null) + { + writer.WriteString(STJMetaDataFields.Type, linkedCrs.HrefType); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + break; + } + case CrsType.Named: + { + writer.WriteStartObject(); + NamedCrs namedCrs = (NamedCrs)crs; + writer.WriteString(STJMetaDataFields.Type, "name"); + writer.WritePropertyName(STJMetaDataFields.Properties); + writer.WriteStartObject(); + writer.WriteString(STJMetaDataFields.Name, namedCrs.Name); + writer.WriteEndObject(); + writer.WriteEndObject(); + break; + } + case CrsType.Unspecified: + writer.WriteNullValue(); + break; + } + + } + } +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/GeometrySTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/GeometrySTJConverter.cs new file mode 100644 index 0000000000..13254b1af1 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/GeometrySTJConverter.cs @@ -0,0 +1,217 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Documents; + + /// + /// A factory converter for all Geometry types including Point, LineString, Polygon, + /// MultiPoint, MultiLineString, MultiPolygon, and GeometryCollection. + /// + internal sealed class GeometrySTJConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) + { + return typeof(Geometry).IsAssignableFrom(typeToConvert); + } + + public override Geometry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonElement rootElement = JsonDocument.ParseValue(ref reader).RootElement; + (string typeName, GeometryParams geometryParams) = this.ReadGeometryProperties(rootElement, options); + return this.CreateGeometry(typeName, geometryParams, rootElement, options); + } + + public override void Write(Utf8JsonWriter writer, Geometry value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteString(STJMetaDataFields.Type, value.Type.ToString()); + this.WriteCoordinates(writer, value, options); + this.WriteOptionalProperties(writer, value, options); + + writer.WriteEndObject(); + } + + /// + /// Writes the coordinates or geometries property based on the geometry type. + /// GeometryCollection uses 'geometries' property instead of 'coordinates'. + /// + private void WriteCoordinates(Utf8JsonWriter writer, Geometry value, JsonSerializerOptions options) + { + switch (value.Type) + { + case GeometryType.Point: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((Point)value).Position, options); + break; + case GeometryType.LineString: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((LineString)value).Positions, options); + break; + case GeometryType.Polygon: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((Polygon)value).Rings, options); + break; + case GeometryType.MultiPoint: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((MultiPoint)value).Points, options); + break; + case GeometryType.MultiLineString: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((MultiLineString)value).LineStrings, options); + break; + case GeometryType.MultiPolygon: + writer.WritePropertyName(STJMetaDataFields.Coordinates); + JsonSerializer.Serialize(writer, ((MultiPolygon)value).Polygons, options); + break; + case GeometryType.GeometryCollection: + writer.WritePropertyName(STJMetaDataFields.Geometries); + JsonSerializer.Serialize(writer, ((GeometryCollection)value).Geometries, options); + break; + default: + throw new ArgumentOutOfRangeException(nameof(value.Type), "Unsupported geometry type."); + } + } + + private void WriteOptionalProperties(Utf8JsonWriter writer, Geometry value, JsonSerializerOptions options) + { + // Match Newtonsoft Order property: Crs (2) then BoundingBox (3) + if (value.Crs != null && !value.Crs.Equals(Crs.Default)) + { + writer.WritePropertyName(STJMetaDataFields.Crs); + JsonSerializer.Serialize(writer, value.Crs, options); + } + + if (value.BoundingBox != null) + { + writer.WritePropertyName(STJMetaDataFields.Bbox); + JsonSerializer.Serialize(writer, value.BoundingBox, options); + } + + if (value.AdditionalProperties != null) + { + foreach (KeyValuePair property in value.AdditionalProperties) + { + writer.WritePropertyName(property.Key); + JsonSerializer.Serialize(writer, property.Value, options); + } + } + } + + private (string typeName, GeometryParams geometryParams) ReadGeometryProperties(JsonElement rootElement, JsonSerializerOptions options) + { + if (!rootElement.TryGetProperty(STJMetaDataFields.Type, out JsonElement typeElement) || typeElement.ValueKind != JsonValueKind.String) + { + throw new JsonException(RMResources.SpatialInvalidGeometryType); + } + + string typeName = typeElement.GetString(); + + BoundingBox boundingBox = null; + if (rootElement.TryGetProperty(STJMetaDataFields.Bbox, out JsonElement bboxElement)) + { + boundingBox = bboxElement.Deserialize(options); + } + + Crs crs = null; + if (rootElement.TryGetProperty(STJMetaDataFields.Crs, out JsonElement crsElement)) + { + crs = crsElement.Deserialize(options); + } + + if (crs != null && crs.Equals(Crs.Unspecified)) + { + crs = null; + } + + IDictionary additionalProperties = null; + foreach (JsonProperty property in rootElement.EnumerateObject()) + { + if (property.Name != STJMetaDataFields.Type && property.Name != STJMetaDataFields.Coordinates && property.Name != STJMetaDataFields.Bbox && property.Name != STJMetaDataFields.Crs && property.Name != STJMetaDataFields.Geometries) + { + additionalProperties ??= new Dictionary(); + additionalProperties[property.Name] = this.ReadValue(property.Value); + } + } + + return (typeName, new GeometryParams { BoundingBox = boundingBox, Crs = crs, AdditionalProperties = additionalProperties }); + } + + /// + /// Creates the appropriate Geometry subclass based on the type name. + /// Supports all geometry types including GeometryCollection which contains nested geometries. + /// + private Geometry CreateGeometry(string typeName, GeometryParams geometryParams, JsonElement rootElement, JsonSerializerOptions options) + { + switch (typeName) + { + case "Point": + Position pointCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize(options); + return new Point(pointCoordinates, geometryParams); + case "MultiPoint": + List multiPointCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize>(options); + return new MultiPoint(multiPointCoordinates, geometryParams); + case "LineString": + List lineStringCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize>(options); + return new LineString(lineStringCoordinates, geometryParams); + case "MultiLineString": + List multiLineStringCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize>(options); + return new MultiLineString(multiLineStringCoordinates, geometryParams); + case "Polygon": + List polygonCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize>(options); + return new Polygon(polygonCoordinates, geometryParams); + case "MultiPolygon": + List multiPolygonCoordinates = rootElement.GetProperty(STJMetaDataFields.Coordinates).Deserialize>(options); + return new MultiPolygon(multiPolygonCoordinates, geometryParams); + case "GeometryCollection": + List geometries = rootElement.GetProperty(STJMetaDataFields.Geometries).Deserialize>(options); + return new GeometryCollection(geometries, geometryParams); + default: + throw new JsonException(RMResources.SpatialInvalidGeometryType); + } + } + + /// + /// Reads a JSON value and converts it to the appropriate .NET type. + /// Handles primitive types, objects, and arrays for additional properties. + /// Matches Newtonsoft.Json behavior by returning Int32 for numbers that fit in 32 bits. + /// + private object ReadValue(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + // Match Newtonsoft behavior: try Int32 first, then Int64, then Double + if (element.TryGetInt32(out int i)) + { + return i; + } + if (element.TryGetInt64(out long l)) + { + return l; + } + return element.GetDouble(); + case JsonValueKind.Null: + return null; + case JsonValueKind.Object: + case JsonValueKind.Array: + default: + return element.Clone(); + } + } + } +} \ No newline at end of file diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LineStringCoordinatesSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LineStringCoordinatesSTJConverter.cs new file mode 100644 index 0000000000..c7eb50590d --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LineStringCoordinatesSTJConverter.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Cosmos.Spatial; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of LineStringCoordinates. + /// LineStringCoordinates represents an array of positions forming a line string. + /// Used within MultiLineString geometries. + /// + internal sealed class LineStringCoordinatesSTJConverter : JsonConverter + { + /// + /// + /// Deserializes LineStringCoordinates from a JSON array of Position objects. + /// + public override LineStringCoordinates Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + // A LineStringCoordinate is just an array of positions. + // System.Text.Json cannot deserialize into a ReadOnlyCollection, so we deserialize into a List first. + List positions = JsonSerializer.Deserialize>(ref reader, options); + return new LineStringCoordinates(positions); + } + + /// + /// + /// Serializes LineStringCoordinates to a JSON array of positions. + /// + public override void Write( + Utf8JsonWriter writer, + LineStringCoordinates value, + JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Positions, options); + } + + } + +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LinearRingSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LinearRingSTJConverter.cs new file mode 100644 index 0000000000..669ed17b39 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/LinearRingSTJConverter.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Cosmos.Spatial; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of LinearRing. + /// A LinearRing is a closed LineString (first and last positions must be identical). + /// + internal sealed class LinearRingSTJConverter : JsonConverter + { + /// + /// Deserializes a LinearRing from a JSON array of Position objects. + /// The ring must be closed (first position equals last position). + /// + public override LinearRing Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException(RMResources.JsonUnexpectedToken); + } + + IList positions = JsonSerializer.Deserialize>(ref reader, options); + return new LinearRing(positions); + } + + /// + /// Serializes a LinearRing to a JSON array of positions. + /// + public override void Write(Utf8JsonWriter writer, LinearRing linearRing, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, linearRing.Positions, options); + } + + } + +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PolygonCoordinatesSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PolygonCoordinatesSTJConverter.cs new file mode 100644 index 0000000000..66a14d2ff2 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PolygonCoordinatesSTJConverter.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Cosmos.Spatial; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of PolygonCoordinates. + /// PolygonCoordinates represents an array of LinearRings forming a polygon. + /// The first ring is the exterior boundary, subsequent rings are holes. + /// Used within MultiPolygon geometries. + /// + internal sealed class PolygonCoordinatesSTJConverter : JsonConverter + { + /// + /// + /// Deserializes PolygonCoordinates from a JSON array of LinearRing objects. + /// + public override PolygonCoordinates Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + // A PolygonCoordinate is just an array of LinearRings. + // System.Text.Json cannot deserialize into a ReadOnlyCollection, so we deserialize into a List first. + List rings = JsonSerializer.Deserialize>(ref reader, options); + return new PolygonCoordinates(rings); + } + + /// + /// + /// Serializes PolygonCoordinates to a JSON array of LinearRings. + /// + public override void Write( + Utf8JsonWriter writer, + PolygonCoordinates value, + JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Rings, options); + } + + } + +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PositionSTJConverter.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PositionSTJConverter.cs new file mode 100644 index 0000000000..c1de2d42b1 --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/PositionSTJConverter.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Globalization; + using System.Text.Json; + using System.Text.Json.Serialization; + using Microsoft.Azure.Documents; + /// + /// Converter used to support System.Text.Json serialization/deserialization of Position. + /// Handles 2D positions (longitude, latitude) and 3D positions (longitude, latitude, altitude). + /// Ensures output format matches Newtonsoft.Json exactly. + /// + internal sealed class PositionSTJConverter : JsonConverter + { + /// + /// Deserializes a Position from a JSON array of coordinates. + /// Requires at least 2 coordinates (longitude, latitude). Altitude is optional. + /// + public override Position Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException(RMResources.JsonUnexpectedToken); + } + + IList coordinates = JsonSerializer.Deserialize>(ref reader, options); + if (coordinates == null || coordinates.Count < 2) + { + throw new JsonException(RMResources.SpatialInvalidPosition); + } + + return new Position(coordinates); + } + /// + /// Serializes a Position to a JSON array. + /// Integer coordinates are formatted with .0 decimal to match Newtonsoft.Json output. + /// Uses "R" format for non-integer values to preserve full precision. + /// + public override void Write(Utf8JsonWriter writer, Position position, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (double coordinate in position.Coordinates) + { + // Check if the number is effectively an integer. + if (coordinate == Math.Truncate(coordinate)) + { + // If so, write it with one decimal place to match Newtonsoft's [x.0] format. + writer.WriteRawValue(coordinate.ToString("0.0", CultureInfo.InvariantCulture)); + } + else + { + writer.WriteRawValue(coordinate.ToString("R", CultureInfo.InvariantCulture)); + } + } + writer.WriteEndArray(); + } + + } +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/STJMetaDataFields.cs b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/STJMetaDataFields.cs new file mode 100644 index 0000000000..ba41f769cc --- /dev/null +++ b/Microsoft.Azure.Cosmos/src/Spatial/Converters/STJConverters/STJMetaDataFields.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters +{ + using System; + using System.Collections.Generic; + using System.Text; + + /// + /// Constants for JSON property names used in spatial geometry serialization. + /// These field names are part of the GeoJSON specification and must match exactly. + /// + internal static class STJMetaDataFields + { + /// + /// Position property name. + /// + public const string Position = "position"; + + /// + /// Additional properties field name. + /// + public const string AdditionalProperties = "additionalProperties"; + + /// + /// Coordinate Reference System property name. + /// + public const string Crs = "crs"; + + /// + /// Bounding box property name. + /// + public const string BoundingBox = "boundingBox"; + + /// + /// Geometry type property name. + /// + public const string Type = "type"; + + /// + /// Points array property name (used in MultiPoint). + /// + public const string Points = "points"; + + /// + /// Positions array property name (used in LineString). + /// + public const string Positions = "positions"; + + /// + /// LineStrings array property name (used in MultiLineString). + /// + public const string LineStrings = "lineStrings"; + + /// + /// Rings array property name (used in Polygon). + /// + public const string Rings = "rings"; + + /// + /// Polygons array property name (used in MultiPolygon). + /// + public const string Polygons = "polygons"; + + /// + /// Maximum coordinates in bounding box. + /// + public const string Max = "max"; + + /// + /// Minimum coordinates in bounding box. + /// + public const string Min = "min"; + + /// + /// CRS properties object. + /// + public const string Properties = "properties"; + + /// + /// Name property (used in NamedCrs). + /// + public const string Name = "name"; + + /// + /// Link type property (used in LinkedCrs). + /// + public const string Link = "link"; + + /// + /// Href property (used in LinkedCrs). + /// + public const string Href = "href"; + + /// + /// Geometries array property name (used in GeometryCollection). + /// + public const string Geometries = "geometries"; + + /// + /// Longitude coordinate. + /// + public const string Longitude = "longitude"; + + /// + /// Altitude coordinate. + /// + public const string Altitude = "altitude"; + + /// + /// Latitude coordinate. + /// + public const string Latitude = "latitude"; + + /// + /// Coordinates array property name. + /// + public const string Coordinates = "coordinates"; + + /// + /// Bounding box property name in GeoJSON format. + /// + public const string Bbox = "bbox"; + + } +} diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Crs.cs b/Microsoft.Azure.Cosmos/src/Spatial/Crs.cs index 94427b701b..5b31fbf628 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Crs.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Crs.cs @@ -6,6 +6,7 @@ namespace Microsoft.Azure.Cosmos.Spatial { using System.Runtime.Serialization; using Microsoft.Azure.Cosmos.Spatial.Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -13,6 +14,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(CrsJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(CrsSTJConverter))] public abstract class Crs { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Geometry.cs b/Microsoft.Azure.Cosmos/src/Spatial/Geometry.cs index e3ab8e2768..b6431c6415 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Geometry.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Geometry.cs @@ -9,6 +9,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Linq; using System.Runtime.Serialization; using Microsoft.Azure.Cosmos.Spatial.Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Microsoft.Azure.Documents; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -19,6 +20,7 @@ namespace Microsoft.Azure.Cosmos.Spatial [DataContract] [JsonObject(MemberSerialization.OptIn)] [JsonConverter(typeof(GeometryJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] public abstract class Geometry { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/GeometryCollection.cs b/Microsoft.Azure.Cosmos/src/Spatial/GeometryCollection.cs index f14a753f05..516aa2f486 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/GeometryCollection.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/GeometryCollection.cs @@ -9,12 +9,14 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// /// Represents a geometry consisting of other geometries. /// [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] internal sealed class GeometryCollection : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/LineString.cs b/Microsoft.Azure.Cosmos/src/Spatial/LineString.cs index 00b2c077ac..72ed73319e 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/LineString.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/LineString.cs @@ -9,12 +9,14 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// /// Represents a geometry consisting of connected line segments. /// [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] public sealed class LineString : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/LineStringCoordinates.cs b/Microsoft.Azure.Cosmos/src/Spatial/LineStringCoordinates.cs index e231d78f5f..0e9258500f 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/LineStringCoordinates.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/LineStringCoordinates.cs @@ -10,6 +10,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Linq; using System.Runtime.Serialization; using Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -18,6 +19,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(LineStringCoordinatesJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(LineStringCoordinatesSTJConverter))] internal sealed class LineStringCoordinates : IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/LinearRing.cs b/Microsoft.Azure.Cosmos/src/Spatial/LinearRing.cs index 9437d9e623..5fc60a1112 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/LinearRing.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/LinearRing.cs @@ -10,6 +10,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Linq; using System.Runtime.Serialization; using Microsoft.Azure.Cosmos.Spatial.Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -20,6 +21,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(LinearRingJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(LinearRingSTJConverter))] public sealed class LinearRing : IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/MultiLineString.cs b/Microsoft.Azure.Cosmos/src/Spatial/MultiLineString.cs index 0ad888187b..27ab1ee578 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/MultiLineString.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/MultiLineString.cs @@ -9,6 +9,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -16,6 +17,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// /// . [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] internal sealed class MultiLineString : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/MultiPoint.cs b/Microsoft.Azure.Cosmos/src/Spatial/MultiPoint.cs index c4ed9e68c9..3c1971788c 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/MultiPoint.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/MultiPoint.cs @@ -9,6 +9,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -16,6 +17,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// /// . [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] internal sealed class MultiPoint : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/MultiPolygon.cs b/Microsoft.Azure.Cosmos/src/Spatial/MultiPolygon.cs index 83646dd1cd..3cc7333557 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/MultiPolygon.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/MultiPolygon.cs @@ -9,6 +9,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -16,6 +17,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// /// [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] public sealed class MultiPolygon : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Point.cs b/Microsoft.Azure.Cosmos/src/Spatial/Point.cs index 640710f0c0..1d948d96c9 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Point.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Point.cs @@ -6,12 +6,14 @@ namespace Microsoft.Azure.Cosmos.Spatial { using System; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// /// Point geometry class in the Azure Cosmos DB service. /// [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] public sealed class Point : Geometry, IEquatable { /// @@ -57,6 +59,7 @@ public Point(Position position, GeometryParams geometryParams) } this.Position = position; + } /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Polygon.cs b/Microsoft.Azure.Cosmos/src/Spatial/Polygon.cs index be22052cfa..1c67cd4f12 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Polygon.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Polygon.cs @@ -9,6 +9,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Collections.ObjectModel; using System.Linq; using System.Runtime.Serialization; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -58,6 +59,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// /// [DataContract] + [System.Text.Json.Serialization.JsonConverter(typeof(GeometrySTJConverter))] public sealed class Polygon : Geometry, IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/PolygonCoordinates.cs b/Microsoft.Azure.Cosmos/src/Spatial/PolygonCoordinates.cs index 5c7f3b65eb..3eced645bf 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/PolygonCoordinates.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/PolygonCoordinates.cs @@ -10,6 +10,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Linq; using System.Runtime.Serialization; using Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -18,6 +19,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(PolygonCoordinatesJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(PolygonCoordinatesSTJConverter))] public sealed class PolygonCoordinates : IEquatable { /// diff --git a/Microsoft.Azure.Cosmos/src/Spatial/Position.cs b/Microsoft.Azure.Cosmos/src/Spatial/Position.cs index de44b8895b..131e0639f7 100644 --- a/Microsoft.Azure.Cosmos/src/Spatial/Position.cs +++ b/Microsoft.Azure.Cosmos/src/Spatial/Position.cs @@ -10,6 +10,7 @@ namespace Microsoft.Azure.Cosmos.Spatial using System.Linq; using System.Runtime.Serialization; using Converters; + using Microsoft.Azure.Cosmos.Spatial.Converters.STJConverters; using Newtonsoft.Json; /// @@ -23,6 +24,7 @@ namespace Microsoft.Azure.Cosmos.Spatial /// [DataContract] [JsonConverter(typeof(PositionJsonConverter))] + [System.Text.Json.Serialization.JsonConverter(typeof(PositionSTJConverter))] public sealed class Position : IEquatable { /// @@ -61,6 +63,7 @@ public Position(double longitude, double latitude, double? altitude) { this.Coordinates = new ReadOnlyCollection(new[] { longitude, latitude }); } + } /// diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/ClientTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/ClientTests.cs index bd086a3434..96d10decc9 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/ClientTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/ClientTests.cs @@ -17,12 +17,14 @@ namespace Microsoft.Azure.Cosmos.SDK.EmulatorTests using System.Runtime.Serialization.Json; using System.Security.Cryptography.X509Certificates; using System.Text; + using System.Text.Json; using System.Threading; using System.Threading.Tasks; using global::Azure; using Microsoft.Azure.Cosmos.FaultInjection; using Microsoft.Azure.Cosmos.Query.Core; using Microsoft.Azure.Cosmos.Services.Management.Tests.LinqProviderTests; + using Microsoft.Azure.Cosmos.Spatial; using Microsoft.Azure.Cosmos.Telemetry; using Microsoft.Azure.Cosmos.Utils; using Microsoft.Azure.Documents; @@ -1135,6 +1137,124 @@ public async Task CreateItemDuringTimeoutTest() if (db != null) await db.DeleteAsync(); } } + + [TestMethod] + public async Task ValidateSpatialPointSTJSerialization() + { + string authKey = ConfigurationManager.AppSettings["MasterKey"]; + string endpoint = ConfigurationManager.AppSettings["GatewayEndpoint"]; + + using (CosmosClient cosmosClient = new CosmosClient(endpoint, authKey, + new CosmosClientOptions() + { + // this makes it use STJ library for serialization/de-serialization + UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + } + } + )) + { + + string GUID = Guid.NewGuid().ToString(); + Cosmos.Database database = await cosmosClient.CreateDatabaseIfNotExistsAsync("AzureCosmosSpatialSerialization"); + Container container = await database.CreateContainerIfNotExistsAsync("spatial-items", "/id"); + + Point point = new Point( + new Position(20, 30), + new GeometryParams + { + AdditionalProperties = new Dictionary + { + ["one"] = "a large one", + ["two"] = "a new two" + }, + Crs = Crs.Linked("http://foo.com", "link") + }); + + SpatialItem inputItem = new SpatialItem() + { + Id = GUID, + Name = "Spatial Point", + Location = point + }; + + SpatialItem result = await container.CreateItemAsync(inputItem); + SpatialItem readItem = await container.ReadItemAsync(GUID, new Cosmos.PartitionKey(GUID)); + + Assert.AreEqual(readItem, inputItem); + Assert.AreEqual(result, inputItem); + + Point updatedPoint = new Point(new Position(40, 50)); + SpatialItem patchedItem = await container.PatchItemAsync( + GUID, + new Cosmos.PartitionKey(GUID), + patchOperations: new[] + { + PatchOperation.Set("/location", updatedPoint) + }); + + Assert.AreEqual(updatedPoint, patchedItem.Location); + + SpatialItem readAfterPatch = await container.ReadItemAsync(GUID, new Cosmos.PartitionKey(GUID)); + Assert.AreEqual(updatedPoint, readAfterPatch.Location); + } + + } + [TestMethod] + public async Task ValidateSpatialPointNewtonSoftSerialization() + { + string authKey = ConfigurationManager.AppSettings["MasterKey"]; + string endpoint = ConfigurationManager.AppSettings["GatewayEndpoint"]; + // default serialization uses NewtonSoft + using (CosmosClient cosmosClient = new CosmosClient(endpoint, authKey)) + { + + string GUID = Guid.NewGuid().ToString(); + Cosmos.Database database = await cosmosClient.CreateDatabaseIfNotExistsAsync("AzureCosmosSpatialSerialization"); + Container container = await database.CreateContainerIfNotExistsAsync("spatial-items", "/id"); + + Point point = new Point( + new Position(20, 30), + new GeometryParams + { + AdditionalProperties = new Dictionary + { + ["one"] = "a large one", + ["two"] = "a new two" + }, + Crs = Crs.Linked("http://foo.com", "link") + }); + + SpatialItem inputItem = new SpatialItem() + { + Id = GUID, + Name = "Spatial Point", + Location = point + }; + + SpatialItem result = await container.CreateItemAsync(inputItem); + SpatialItem readItem = await container.ReadItemAsync(GUID, new Cosmos.PartitionKey(GUID)); + + Assert.AreEqual(readItem, inputItem); + Assert.AreEqual(result, inputItem); + + Point updatedPoint = new Point(new Position(40, 50)); + SpatialItem patchedItem = await container.PatchItemAsync( + GUID, + new Cosmos.PartitionKey(GUID), + patchOperations: new[] + { + PatchOperation.Set("/Location", updatedPoint) + }); + + Assert.AreEqual(updatedPoint, patchedItem.Location); + + SpatialItem readAfterPatch = await container.ReadItemAsync(GUID, new Cosmos.PartitionKey(GUID)); + Assert.AreEqual(updatedPoint, readAfterPatch.Location); + } + + } public static IReadOnlyList GetActiveConnections() { string testPid = Process.GetCurrentProcess().Id.ToString(); @@ -1270,6 +1390,13 @@ internal static string EscapeForSQL(this string input) return input.Replace("'", "\\'").Replace("\"", "\\\""); } } + internal record SpatialItem + { + [JsonProperty("id")] + public string Id { get; set; } + public string Name { get; set; } + public Point Location { get; set; } + } internal class TestWebProxy : IWebProxy { diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Spatial/STJSpatialTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Spatial/STJSpatialTest.cs new file mode 100644 index 0000000000..938414feef --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Spatial/STJSpatialTest.cs @@ -0,0 +1,780 @@ +namespace Microsoft.Azure.Cosmos.Test.Spatial +{ + using System.Collections.Generic; + using Microsoft.Azure.Cosmos.Spatial; + using Microsoft.VisualStudio.TestTools.UnitTesting; + using Newtonsoft.Json; + using Point = Cosmos.Spatial.Point; + + /// + /// Spatial STJ serialization/deserialization tests + /// + [TestClass] + public class STJSpatialTest + { + + /// + /// Tests serialization/deserialization of BoundingBox class. + /// + [TestMethod] + [DynamicData(nameof(BoundingBoxData))] + public void TestBoundingBoxSerialization(BoundingBox input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + BoundingBox newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + BoundingBox stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(stjJson, newtonsoftJson); + } + + /// + /// Tests serialization/deserialization of Position class. + /// + [TestMethod] + [DynamicData(nameof(PositionData))] + public void TestPositionSerialization(Position input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + Position newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + Position stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of Crs class and its variants. + /// + [TestMethod] + [DynamicData(nameof(CrsData))] + public void TestCrsSerialization(Crs input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + Crs newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + Crs stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of LinearRing class. + /// + [TestMethod] + [DynamicData(nameof(LinearRingData))] + public void TestLinearRingSerialization(LinearRing input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + LinearRing newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + LinearRing stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of Point class. + /// + [TestMethod] + [DynamicData(nameof(PointData))] + public void TestPointSerialization(Point input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + Point newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + Point stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + + /// + /// Tests serialization/deserialization of MultiPoint class. + /// + [TestMethod] + public void TestMultiPointSerialization() + { + + MultiPoint input = new MultiPoint( + new[] { new Position(20, 30), new Position(30, 40) }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + Crs = Crs.Named("SomeCrs") + }); + + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + MultiPoint newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + MultiPoint stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of LineString class. + /// + [TestMethod] + [DynamicData(nameof(LineStringData))] + public void TestLineStringSerialization(LineString input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + LineString newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + LineString stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of MultiLineString class. + /// + [TestMethod] + public void TestMultiLineStringSerialization() + { + MultiLineString input = new MultiLineString( + new[] + { + new LineStringCoordinates(new[] { new Position(20, 30), new Position(30, 40) }), + new LineStringCoordinates(new[] { new Position(40, 50), new Position(60, 60) }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + Crs = Crs.Named("SomeCrs") + }); + + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + MultiLineString newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + MultiLineString stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of Polygon class. + /// + [TestMethod] + [DynamicData(nameof(PolygonData))] + public void TestPolygonSerialization(Polygon input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + Polygon newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + Polygon stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of MultiPolygon class. + /// + [TestMethod] + [DynamicData(nameof(MultiPolygonData))] + public void TestMultiPolygonSerialization(MultiPolygon input) + { + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + MultiPolygon newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + MultiPolygon stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of a GeometryCollection with mixed types. + /// + [TestMethod] + public void TestGeometricCollectionMixedTypeSerialization() + { + GeometryCollection input = new GeometryCollection( + new Geometry[] + { + new Point(20, 30), + new Polygon(new[] + { + new LinearRing(new[] + { + new Position(40, 40), + new Position(45, 40), + new Position(45, 45), + new Position(40, 45), + new Position(40, 40) + }) + }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(50, 50)), + Crs = Crs.Named("SomeCrs"), + AdditionalProperties = new Dictionary + { + ["one"] = "a large one", + ["two"] = "a new two" + }, + }); + + + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of GeometryCollection class with Point geometries. + /// + [TestMethod] + public void TestGeometricCollectionPointSerialization() + { + GeometryCollection input = new GeometryCollection( + new[] { new Point(20, 30), new Point(30, 40) }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + Crs = Crs.Named("SomeCrs"), + AdditionalProperties = new Dictionary + { + ["one"] = "a large one", + ["two"] = "a new two" + }, + }); + + + // Newtonsoft + string newtonsoftJson = JsonConvert.SerializeObject(input); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult); + + // STJ + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult); + + Assert.AreEqual(newtonsoftJson, stjJson); + } + + /// + /// Tests serialization/deserialization of GeometryCollection containing all geometry types. + /// Ensures comprehensive coverage of GeometryCollection with Point, LineString, Polygon, + /// MultiPoint, MultiLineString, and MultiPolygon. + /// + [TestMethod] + public void TestGeometryCollectionWithAllGeometryTypes() + { + // Create a comprehensive GeometryCollection with all supported geometry types + GeometryCollection input = new GeometryCollection( + new Geometry[] + { + // Point + new Point(new Position(10, 20)), + + // LineString + new LineString(new[] + { + new Position(20, 30), + new Position(30, 40) + }), + + // Polygon + new Polygon(new[] + { + new LinearRing(new[] + { + new Position(0, 0), + new Position(0, 10), + new Position(10, 10), + new Position(10, 0), + new Position(0, 0) + }) + }), + + // MultiPoint + new MultiPoint(new[] + { + new Position(5, 5), + new Position(15, 15) + }), + + // MultiLineString + new MultiLineString(new[] + { + new LineStringCoordinates(new[] + { + new Position(25, 25), + new Position(35, 35) + }) + }), + + // MultiPolygon + new MultiPolygon(new[] + { + new PolygonCoordinates(new[] + { + new LinearRing(new[] + { + new Position(40, 40), + new Position(40, 50), + new Position(50, 50), + new Position(50, 40), + new Position(40, 40) + }) + }) + }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(50, 50)), + Crs = Crs.Named("EPSG:4326") + }); + + // Newtonsoft serialization/deserialization + string newtonsoftJson = JsonConvert.SerializeObject(input); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + + // Verify Newtonsoft round-trip by comparing JSON strings + string newtonsoftJson2 = JsonConvert.SerializeObject(newtonsoftResult); + Assert.AreEqual(newtonsoftJson, newtonsoftJson2, "Newtonsoft round-trip should produce identical JSON"); + + // STJ serialization/deserialization + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + + // Verify STJ round-trip by comparing JSON strings + string stjJson2 = System.Text.Json.JsonSerializer.Serialize(stjResult); + Assert.AreEqual(stjJson, stjJson2, "STJ round-trip should produce identical JSON"); + + // Ensure both serializers produce identical JSON output + Assert.AreEqual(newtonsoftJson, stjJson, "STJ and Newtonsoft should produce identical JSON"); + } + + /// + /// Tests serialization/deserialization of nested GeometryCollections. + /// Ensures that GeometryCollection can contain other GeometryCollections. + /// + [TestMethod] + public void TestNestedGeometryCollections() + { + // Create nested GeometryCollections + GeometryCollection innerCollection1 = new GeometryCollection( + new Geometry[] + { + new Point(10, 20), + new Point(30, 40) + }); + + GeometryCollection innerCollection2 = new GeometryCollection( + new Geometry[] + { + new LineString(new[] + { + new Position(50, 60), + new Position(70, 80) + }) + }); + + GeometryCollection outerCollection = new GeometryCollection( + new Geometry[] + { + innerCollection1, + innerCollection2, + new Point(90, 100) + }, + new GeometryParams + { + Crs = Crs.Named("SomeCrs") + }); + + // Newtonsoft serialization/deserialization + string newtonsoftJson = JsonConvert.SerializeObject(outerCollection); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(outerCollection, newtonsoftResult, "Newtonsoft deserialized nested collection should match input"); + + // STJ serialization/deserialization + string stjJson = System.Text.Json.JsonSerializer.Serialize(outerCollection); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(outerCollection, stjResult, "STJ deserialized nested collection should match input"); + + // Ensure both serializers produce identical JSON output + Assert.AreEqual(newtonsoftJson, stjJson, "STJ and Newtonsoft should produce identical JSON for nested collections"); + } + + /// + /// Tests serialization/deserialization of GeometryCollection with complex coordinates. + /// Ensures proper handling of floating-point precision and 3D coordinates. + /// + [TestMethod] + public void TestGeometryCollectionWithComplexCoordinates() + { + GeometryCollection input = new GeometryCollection( + new Geometry[] + { + new Point(new Position(20.123456, 30.654321, 100.5)), + new LineString(new[] + { + new Position(-122.419, 37.775, 0), + new Position(-122.420, 37.776, 50.25) + }), + new Polygon(new[] + { + new LinearRing(new[] + { + new Position(0.1, 0.2), + new Position(0.1, 0.3), + new Position(0.2, 0.3), + new Position(0.2, 0.2), + new Position(0.1, 0.2) + }) + }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox( + new Position(-122.420, 37.775, 0), + new Position(20.123456, 37.776, 100.5)), + AdditionalProperties = new Dictionary + { + ["precision"] = "high", + ["elevation"] = true + } + }); + + // Newtonsoft serialization/deserialization + string newtonsoftJson = JsonConvert.SerializeObject(input); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult, "Newtonsoft should handle complex coordinates"); + + // STJ serialization/deserialization + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult, "STJ should handle complex coordinates"); + + // Ensure both serializers produce identical JSON output + Assert.AreEqual(newtonsoftJson, stjJson, "Both serializers should produce identical JSON for complex coordinates"); + } + + /// + /// Tests serialization/deserialization of GeometryCollection without optional properties. + /// Ensures proper handling when BoundingBox, Crs, and AdditionalProperties are null. + /// + [TestMethod] + public void TestGeometryCollectionWithoutOptionalProperties() + { + // Create a minimal GeometryCollection without optional properties + GeometryCollection input = new GeometryCollection( + new Geometry[] + { + new Point(10, 20), + new LineString(new[] { new Position(30, 40), new Position(50, 60) }) + }); + + // Newtonsoft serialization/deserialization + string newtonsoftJson = JsonConvert.SerializeObject(input); + GeometryCollection newtonsoftResult = JsonConvert.DeserializeObject(newtonsoftJson); + Assert.AreEqual(input, newtonsoftResult, "Newtonsoft should handle GeometryCollection without optional properties"); + + // STJ serialization/deserialization + string stjJson = System.Text.Json.JsonSerializer.Serialize(input); + GeometryCollection stjResult = System.Text.Json.JsonSerializer.Deserialize(stjJson); + Assert.AreEqual(input, stjResult, "STJ should handle GeometryCollection without optional properties"); + + // Ensure both serializers produce identical JSON output + Assert.AreEqual(newtonsoftJson, stjJson, "Both serializers should produce identical JSON without optional properties"); + } + + public static IEnumerable PointData => new[] + { + new object[] { + new Point(new Position(20.4, 30.6)) + }, + new object[] { + new Point( + new Position(20.2, 30.9), + new GeometryParams + { + AdditionalProperties = new Dictionary { + ["one"] = 1.2, + ["two"] = 3.4 + }, + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + }) + }, + new object[] { + new Point( + new Position(20, 30), + new GeometryParams + { + AdditionalProperties = new Dictionary { + ["one"] = "a large one", + ["two"] = "a new two" + }, + Crs = Crs.Named("EPSG:4326") + }) + } + }; + + public static IEnumerable LineStringData => new[] + { + new object[] { + new LineString( + new[] { new Position(20.30, 30), new Position(30, 40.0) } + ) + }, + new object[] { + new LineString( + new[] { new Position(20, 30), new Position(30, 40) }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 41)), + AdditionalProperties = new Dictionary { + ["hello"] = 1.2, + ["world"] = 3.4 + }, + }) + }, + new object[] { + new LineString( + new[] { new Position(20, 30), new Position(30, 40) }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 41)), + Crs = Crs.Linked("http://foo.com", "link") + }) + }, + + }; + + public static IEnumerable PolygonData => new[] + { + new object[] { + new Polygon( + new[]{ + new LinearRing( + new[]{ + new Position(20, 20), + new Position(20, 21), + new Position(21, 21), + new Position(21, 20), + new Position(22, 20) + }) + }) + }, + new object[] { + new Polygon( + new[]{ + new LinearRing( + new[]{ + new Position(20, 20), + new Position(20, 21), + new Position(21, 21), + new Position(21, 20), + new Position(22, 20) + }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + Crs = Crs.Named("SomeCrs") + }) + }, + new object[] { + new Polygon( + new[]{ + new LinearRing( + new[]{ + new Position(20, 20), + new Position(20, 21), + new Position(21, 21), + new Position(21, 20), + new Position(22, 20) + }) + }, + + new GeometryParams + { + Crs = Crs.Named("SomeCrs"), + AdditionalProperties = new Dictionary { + ["hello"] = 1.2, + ["world"] = 3.4 + }, + }) + }, + }; + public static IEnumerable BoundingBoxData => new[] + { + new object[] + { + new BoundingBox(new Position(0, 0), new Position(40, 40)) + }, + new object[] + { + new BoundingBox(new Position(0.43, 0.4), new Position(40.53, 40.5)) + }, + new object[] + { + new BoundingBox(new Position(-10, 20.5), new Position(30.5, -40)) + }, + }; + public static IEnumerable PositionData => new[] + { + new object[] + { + new Position(10, 20) + }, + new object[] + { + new Position(15.5, 25.5) + }, + new object[] + { + new Position(30, 40, 50) + }, + new object[] + { + new Position(35.5, 45.5, 55.5) + }, + new object[] + { + new Position(-10.1, -20.2, -30.3) + }, + }; + + public static IEnumerable MultiPolygonData => new[] + { + new object[] { + new MultiPolygon( + new[]{ + new PolygonCoordinates( + new[]{ + new LinearRing( + new[] + { + new Position(20, 20), new Position(20, 21), new Position(21, 21), + new Position(21, 20), new Position(20, 20) + }) + }) + }) + }, + + new object[] { + new MultiPolygon( + new[] + { + new PolygonCoordinates( + new[] + { + new LinearRing( + new[] + { + new Position(20, 21), new Position(20, 21), new Position(21, 21), + new Position(21, 20), new Position(22, 20) + }) + }) + }, + new GeometryParams + { + BoundingBox = new BoundingBox(new Position(0, 0), new Position(40, 40)), + Crs = Crs.Named("SomeCrs") + }) + }, + + }; + + public static IEnumerable LinearRingData => new[] + { + new object[] + { + new LinearRing(new[] + { + new Position(10, 20), + new Position(20, 30), + new Position(30, 20), + new Position(10, 20) + }) + }, + new object[] + { + new LinearRing(new[] + { + new Position(10.1, 20.2), + new Position(20.2, 30.3, 5.0), + new Position(30.3, 20.2), + new Position(10.1, 20.2) + }) + }, + }; + + public static IEnumerable CrsData => new[] + { + new object[] { Crs.Default }, + new object[] { Crs.Unspecified }, + new object[] { Crs.Named("EPSG:4326") }, + new object[] { Crs.Linked("http://example.com/crs/1") }, + new object[] { Crs.Linked("http://example.com/crs/2", "custom-type") }, + }; + + + + + + } +}