diff --git a/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs b/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs index 5213e3bb7d..3331f5155b 100644 --- a/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs +++ b/Microsoft.Azure.Cosmos.Samples/Usage/SystemTextJson/CosmosSystemTextJsonSerializer.cs @@ -47,7 +47,7 @@ public override T FromStream(Stream stream) public override Stream ToStream(T input) { MemoryStream streamPayload = new MemoryStream(); - this.systemTextJsonSerializer.Serialize(streamPayload, input, input.GetType(), default); + this.systemTextJsonSerializer.Serialize(streamPayload, input, typeof(T), default); streamPayload.Position = 0; return streamPayload; } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTestsCommon.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTestsCommon.cs index 37d2b62e6a..18f1e12fa0 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTestsCommon.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/LinqTestsCommon.cs @@ -25,8 +25,8 @@ namespace Microsoft.Azure.Cosmos.Services.Management.Tests using Microsoft.Azure.Documents; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; - using Newtonsoft.Json.Linq; - + using Newtonsoft.Json.Linq; + internal class LinqTestsCommon { /// @@ -36,18 +36,18 @@ internal class LinqTestsCommon /// /// private static bool CompareListOfAnonymousType(List queryResults, List dataResults, bool ignoreOrder) - { - if (!ignoreOrder) - { - return queryResults.SequenceEqual(dataResults); - } - - if (queryResults.Count != dataResults.Count) - { - return false; - } - - bool resultMatched = true; + { + if (!ignoreOrder) + { + return queryResults.SequenceEqual(dataResults); + } + + if (queryResults.Count != dataResults.Count) + { + return false; + } + + bool resultMatched = true; foreach (object obj in queryResults) { if (!dataResults.Any(a => a.Equals(obj))) @@ -64,8 +64,8 @@ private static bool CompareListOfAnonymousType(List queryResults, List> GenerateSimpleCosmosData(Cosmos.Database cosmosDatabase, bool useRandomData = true) + public static Func> GenerateSimpleCosmosData(Cosmos.Database cosmosDatabase, bool useRandomData = true) { const int DocumentCount = 10; PartitionKeyDefinition partitionKeyDefinition = new PartitionKeyDefinition { Paths = new System.Collections.ObjectModel.Collection(new[] { "/Pk" }), Kind = PartitionKind.Hash }; Container container = cosmosDatabase.CreateContainerAsync(new ContainerProperties { Id = Guid.NewGuid().ToString(), PartitionKey = partitionKeyDefinition }).Result; - ILinqTestDataGenerator dataGenerator = useRandomData ? new LinqTestRandomDataGenerator(DocumentCount) : new LinqTestDataGenerator(DocumentCount); - List testData = new List(dataGenerator.GenerateData()); - foreach (Data dataEntry in testData) + ILinqTestDataGenerator dataGenerator = useRandomData ? new LinqTestRandomDataGenerator(DocumentCount) : new LinqTestDataGenerator(DocumentCount); + List testData = new List(dataGenerator.GenerateData()); + foreach (Data dataEntry in testData) { Data response = container.CreateItemAsync(dataEntry, new Cosmos.PartitionKey(dataEntry.Pk)).Result; } @@ -593,32 +593,32 @@ public static LinqTestOutput ExecuteTest(LinqTestInput input, bool serializeResu } public static string BuildExceptionMessageForTest(Exception ex) - { - StringBuilder message = new StringBuilder(); + { + StringBuilder message = new StringBuilder(); do { if (ex is CosmosException cosmosException) - { - message.Append($"Status Code: {cosmosException.StatusCode}"); + { + message.Append($"Status Code: {cosmosException.StatusCode}"); } else if (ex is DocumentClientException documentClientException) { message.Append(documentClientException.RawErrorMessage); } else - { - message.Append(ex.Message); + { + message.Append(ex.Message); } - + ex = ex.InnerException; if (ex != null) { message.Append(","); } } - while (ex != null); - - return message.ToString(); + while (ex != null); + + return message.ToString(); } } @@ -675,27 +675,27 @@ public class LinqTestInput : BaselineTestInput // - unordered query since the results are not deterministics for LinQ results and actual query results // - scenarios not supported in LINQ, e.g. sequence doesn't contain element. internal bool skipVerification; - - // Ignore Ordering for AnonymousType object - internal bool ignoreOrder; - - internal bool serializeOutput; + + // Ignore Ordering for AnonymousType object + internal bool ignoreOrder; + + internal bool serializeOutput; internal LinqTestInput( string description, Expression> expr, - bool skipVerification = false, + bool skipVerification = false, bool ignoreOrder = false, string expressionStr = null, - string inputData = null, + string inputData = null, bool serializeOutput = false) : base(description) { this.Expression = expr ?? throw new ArgumentNullException($"{nameof(expr)} must not be null."); - this.skipVerification = skipVerification; + this.skipVerification = skipVerification; this.ignoreOrder = ignoreOrder; this.expressionStr = expressionStr; - this.inputData = inputData; + this.inputData = inputData; this.serializeOutput = serializeOutput; } @@ -761,7 +761,7 @@ public class LinqTestOutput : BaselineTestOutput { "WHERE", "\nWHERE" }, { "JOIN", "\nJOIN" }, { "ORDER BY", "\nORDER BY" }, - { "OFFSET", "\nOFFSET" }, + { "OFFSET", "\nOFFSET" }, { "GROUP BY", "\nGROUP BY" }, { " )", "\n)" } }; @@ -852,14 +852,14 @@ public override void SerializeAsXml(XmlWriter xmlWriter) } } - class SystemTextJsonLinqSerializer : CosmosLinqSerializer + internal class SystemTextJsonLinqSerializer : CosmosLinqSerializer { - private readonly JsonObjectSerializer systemTextJsonSerializer; + private readonly JsonObjectSerializer systemTextJsonSerializer; private readonly JsonSerializerOptions jsonSerializerOptions; public SystemTextJsonLinqSerializer(JsonSerializerOptions jsonSerializerOptions) { - this.systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions); + this.systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions); this.jsonSerializerOptions = jsonSerializerOptions; } @@ -894,66 +894,27 @@ public override Stream ToStream(T input) public override string SerializeMemberName(MemberInfo memberInfo) { - System.Text.Json.Serialization.JsonExtensionDataAttribute jsonExtensionDataAttribute = - memberInfo.GetCustomAttribute(true); - if (jsonExtensionDataAttribute != null) - { - return null; - } - - JsonPropertyNameAttribute jsonPropertyNameAttribute = memberInfo.GetCustomAttribute(true); - if (!string.IsNullOrEmpty(jsonPropertyNameAttribute?.Name)) - { - return jsonPropertyNameAttribute.Name; - } - - if (this.jsonSerializerOptions.PropertyNamingPolicy != null) - { - return this.jsonSerializerOptions.PropertyNamingPolicy.ConvertName(memberInfo.Name); - } - - // Do any additional handling of JsonSerializerOptions here. - - return memberInfo.Name; - } - } - - class SystemTextJsonSerializer : CosmosSerializer - { - private readonly JsonObjectSerializer systemTextJsonSerializer; - - public SystemTextJsonSerializer(JsonSerializerOptions jsonSerializerOptions) - { - this.systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions); - } - - public override T FromStream(Stream stream) - { - if (stream == null) - throw new ArgumentNullException(nameof(stream)); - - using (stream) + System.Text.Json.Serialization.JsonExtensionDataAttribute jsonExtensionDataAttribute = + memberInfo.GetCustomAttribute(true); + if (jsonExtensionDataAttribute != null) { - if (stream.CanSeek && stream.Length == 0) - { - return default; - } + return null; + } - if (typeof(Stream).IsAssignableFrom(typeof(T))) - { - return (T)(object)stream; - } + JsonPropertyNameAttribute jsonPropertyNameAttribute = memberInfo.GetCustomAttribute(true); + if (!string.IsNullOrEmpty(jsonPropertyNameAttribute?.Name)) + { + return jsonPropertyNameAttribute.Name; + } - return (T)this.systemTextJsonSerializer.Deserialize(stream, typeof(T), default); + if (this.jsonSerializerOptions.PropertyNamingPolicy != null) + { + return this.jsonSerializerOptions.PropertyNamingPolicy.ConvertName(memberInfo.Name); } - } - public override Stream ToStream(T input) - { - MemoryStream streamPayload = new MemoryStream(); - this.systemTextJsonSerializer.Serialize(streamPayload, input, input.GetType(), default); - streamPayload.Position = 0; - return streamPayload; + // Do any additional handling of JsonSerializerOptions here. + + return memberInfo.Name; } } } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/SystemTextJsonSerializer.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/SystemTextJsonSerializer.cs new file mode 100644 index 0000000000..ad121a1a03 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/Linq/SystemTextJsonSerializer.cs @@ -0,0 +1,51 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// +//----------------------------------------------------------------------- +namespace Microsoft.Azure.Cosmos.Services.Management.Tests +{ + using System; + using System.IO; + using System.Text.Json; + using global::Azure.Core.Serialization; + + internal class SystemTextJsonSerializer : CosmosSerializer + { + private readonly JsonObjectSerializer systemTextJsonSerializer; + + public SystemTextJsonSerializer(JsonSerializerOptions jsonSerializerOptions) + { + this.systemTextJsonSerializer = new JsonObjectSerializer(jsonSerializerOptions); + } + + public override T FromStream(Stream stream) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using (stream) + { + if (stream.CanSeek && stream.Length == 0) + { + return default; + } + + if (typeof(Stream).IsAssignableFrom(typeof(T))) + { + return (T)(object)stream; + } + + return (T)this.systemTextJsonSerializer.Deserialize(stream, typeof(T), default); + } + } + + public override Stream ToStream(T input) + { + MemoryStream streamPayload = new MemoryStream(); + this.systemTextJsonSerializer.Serialize(streamPayload, input, typeof(T), default); + streamPayload.Position = 0; + return streamPayload; + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs index 42f5b9ac65..268caef4fb 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/CosmosSystemTextJsonSerializerTest.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Reflection; + using System.Text.Json; using Microsoft.Azure.Cosmos.Tests.Poco.STJ; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -176,5 +177,54 @@ public void TestSerializeMemberName() Assert.AreEqual(member.Name, this.stjSerializer.SerializeMemberName(member)); } } + + [TestMethod] + public void TestPolymorphicSerialization_IncludesTypeDiscriminator() + { + // Arrange. + Shape circle = new Circle + { + Id = "circle", + Color = "Red", + Radius = 5.0 + }; + + // Act. + Stream serializedStream = this.stjSerializer.ToStream(circle); + using StreamReader reader = new(serializedStream); + string json = reader.ReadToEnd(); + + // Assert. + using JsonDocument jsonDocument = JsonDocument.Parse(json); + JsonElement rootElement = jsonDocument.RootElement; + + Assert.AreEqual("Circle", rootElement.GetProperty("$type").GetString()); + Assert.AreEqual(5.0, rootElement.GetProperty("radius").GetDouble()); + } + + [TestMethod] + public void TestPolymorphicSerialization_SerializeDeserialize_PreservesType() + { + // Arrange. + Shape original = new Circle + { + Id = "circle", + Color = "Green", + Radius = 7.5 + }; + + // Act. + Stream serializedStream = this.stjSerializer.ToStream(original); + Shape deserialized = this.stjSerializer.FromStream(serializedStream); + + // Assert. + Assert.IsNotNull(deserialized); + Assert.IsInstanceOfType(deserialized, typeof(Circle)); + + Circle deserializedCircle = (Circle)deserialized; + Assert.AreEqual(original.Id, deserializedCircle.Id); + Assert.AreEqual(original.Color, deserializedCircle.Color); + Assert.AreEqual(((Circle)original).Radius, deserializedCircle.Radius); + } } } \ No newline at end of file diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/SystemTextJsonSerializerTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/SystemTextJsonSerializerTests.cs new file mode 100644 index 0000000000..3019ec8d34 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Json/SystemTextJsonSerializerTests.cs @@ -0,0 +1,72 @@ +namespace Microsoft.Azure.Cosmos.Tests.Json +{ + using System.IO; + using System.Text.Json; + using Microsoft.Azure.Cosmos.Services.Management.Tests; + using Microsoft.Azure.Cosmos.Tests.Poco.STJ; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + /// + /// Tests for the serializer that uses . + /// + [TestClass] + public sealed class SystemTextJsonSerializerTests + { + private SystemTextJsonSerializer systemTextJsonSerializer; + + [TestInitialize] + public void SetUp() + { + this.systemTextJsonSerializer = new SystemTextJsonSerializer(new JsonSerializerOptions()); + } + + [TestMethod] + public void TestPolymorphicSerialization_IncludesTypeDiscriminator() + { + // Arrange. + Shape circle = new Circle + { + Id = "circle", + Color = "Red", + Radius = 5.0 + }; + + // Act. + Stream serializedStream = this.systemTextJsonSerializer.ToStream(circle); + using StreamReader reader = new(serializedStream); + string json = reader.ReadToEnd(); + + // Assert. + using JsonDocument jsonDocument = JsonDocument.Parse(json); + JsonElement rootElement = jsonDocument.RootElement; + + Assert.AreEqual("Circle", rootElement.GetProperty("$type").GetString()); + Assert.AreEqual(5.0, rootElement.GetProperty("radius").GetDouble()); + } + + [TestMethod] + public void TestPolymorphicSerialization_SerializeDeserialize_PreservesType() + { + // Arrange. + Shape original = new Circle + { + Id = "circle", + Color = "Green", + Radius = 7.5 + }; + + // Act. + Stream serializedStream = this.systemTextJsonSerializer.ToStream(original); + Shape deserialized = this.systemTextJsonSerializer.FromStream(serializedStream); + + // Assert. + Assert.IsNotNull(deserialized); + Assert.IsInstanceOfType(deserialized, typeof(Circle)); + + Circle deserializedCircle = (Circle)deserialized; + Assert.AreEqual(original.Id, deserializedCircle.Id); + Assert.AreEqual(original.Color, deserializedCircle.Color); + Assert.AreEqual(((Circle)original).Radius, deserializedCircle.Radius); + } + } +} diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Microsoft.Azure.Cosmos.Tests.csproj b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Microsoft.Azure.Cosmos.Tests.csproj index b6d581c79e..3738f69602 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Microsoft.Azure.Cosmos.Tests.csproj +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Microsoft.Azure.Cosmos.Tests.csproj @@ -46,9 +46,11 @@ + + diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Poco/STJ/PolymorphicTypes.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Poco/STJ/PolymorphicTypes.cs new file mode 100644 index 0000000000..27b4dbd0e0 --- /dev/null +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/Poco/STJ/PolymorphicTypes.cs @@ -0,0 +1,92 @@ +namespace Microsoft.Azure.Cosmos.Tests.Poco.STJ +{ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + + // Note: [JsonPolymorphic] and [JsonDerivedType] attributes require .NET 7+. + // Since this test project targets .NET 6, we use a custom JsonConverter approach instead. + // The converter is registered on the base type (Shape) and writes a "$type" discriminator, + // achieving the same polymorphic serialization behavior. + + [JsonConverter(typeof(ShapeConverter))] + public abstract class Shape + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("color")] + public string Color { get; set; } + } + + public class Circle : Shape + { + [JsonPropertyName("radius")] + public double Radius { get; set; } + } + + /// + /// Custom converter that writes a type discriminator for polymorphic serialization. + /// This converter is invoked when serializing through the base type (Shape), + /// which only happens when typeof(T) is used instead of input.GetType(). + /// + public class ShapeConverter : JsonConverter + { + public override Shape Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using JsonDocument doc = JsonDocument.ParseValue(ref reader); + JsonElement root = doc.RootElement; + + string shapeType = root.TryGetProperty("$type", out JsonElement typeElement) + ? typeElement.GetString() + : null; + + Shape result; + if (shapeType == nameof(Circle) || root.TryGetProperty("radius", out _)) + { + result = new Circle + { + Radius = root.TryGetProperty("radius", out JsonElement radiusEl) ? radiusEl.GetDouble() : 0 + }; + } + else + { + throw new JsonException("Cannot determine shape type"); + } + + result.Id = root.TryGetProperty("id", out JsonElement idEl) ? idEl.GetString() : null; + result.Color = root.TryGetProperty("color", out JsonElement colorEl) ? colorEl.GetString() : null; + + return result; + } + + public override void Write(Utf8JsonWriter writer, Shape value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + // Write type discriminator for polymorphic deserialization + if (value is Circle) + { + writer.WriteString("$type", nameof(Circle)); + } + + // Write base properties + if (value.Id != null) + { + writer.WriteString("id", value.Id); + } + if (value.Color != null) + { + writer.WriteString("color", value.Color); + } + + // Write derived properties + if (value is Circle circle) + { + writer.WriteNumber("radius", circle.Radius); + } + + writer.WriteEndObject(); + } + } +}