From d486e5bfdbd4a0f040902bbf82cd65cc3c7a9e37 Mon Sep 17 00:00:00 2001 From: Lucas Pedroza Date: Wed, 13 Dec 2023 12:28:06 +0100 Subject: [PATCH] Serialize dicts, lists, and add more testing --- .../Serialization/Serializer.Classes.Tests.cs | 138 +++++++++++++++ ...sts.cs => Serializer.Deserialize.Tests.cs} | 50 ++---- .../Serializer.Serialize.Tests.cs | 167 ++++++++++++++---- Fauna/Serialization/Serializer.Serialize.cs | 115 +++++++++--- Fauna/Serialization/Utf8FaunaWriter.cs | 18 ++ 5 files changed, 393 insertions(+), 95 deletions(-) create mode 100644 Fauna.Test/Serialization/Serializer.Classes.Tests.cs rename Fauna.Test/Serialization/{Serializer.Tests.cs => Serializer.Deserialize.Tests.cs} (75%) diff --git a/Fauna.Test/Serialization/Serializer.Classes.Tests.cs b/Fauna.Test/Serialization/Serializer.Classes.Tests.cs new file mode 100644 index 00000000..3c5d242c --- /dev/null +++ b/Fauna.Test/Serialization/Serializer.Classes.Tests.cs @@ -0,0 +1,138 @@ +using Fauna.Serialization; +using Fauna.Serialization.Attributes; + +namespace Fauna.Test.Serialization; + +public partial class SerializerTests +{ + private class Person + { + public string? FirstName { get; set; } = "Baz"; + public string? LastName { get; set; } = "Luhrmann"; + public int Age { get; set; } = 61; + } + + [FaunaObject] + private class PersonWithAttributes + { + [Field("first_name")] public string? FirstName { get; set; } = "Baz"; + [Field("last_name")] public string? LastName { get; set; } = "Luhrmann"; + [Field("age", FaunaType.Long)] public int Age { get; set; } = 61; + public string? Ignored { get; set; } + } + + private class ThingWithStringOverride + { + private const string Name = "TheThing"; + + public override string ToString() + { + return Name; + } + } + + [FaunaObject] + private class PersonWithTypeOverrides + { + // Long Conversions + [Field("short_to_long", FaunaType.Long)] public short? ShortToLong { get; set; } = 1; + [Field("int_to_long", FaunaType.Long)] public int? IntToLong { get; set; } = 2; + [Field("long_to_long", FaunaType.Long)] public long? LongToLong { get; set; } = 3L; + + // Int Conversions + [Field("short_to_int", FaunaType.Int)] public short? ShortToInt { get; set; } = 4; + [Field("int_to_int", FaunaType.Int)] public int? IntToInt { get; set; } = 5; + + // Double Conversions + [Field("short_to_double", FaunaType.Double)] public short? ShortToDouble { get; set; } = 6; + [Field("int_to_double", FaunaType.Double)] public int? IntToDouble { get; set; } = 7; + [Field("long_to_double", FaunaType.Double)] public long? LongToDouble { get; set; } = 8L; + [Field("decimal_to_double", FaunaType.Double)] public decimal? DecimalToDouble { get; set; } = 9.2M; + [Field("double_to_double", FaunaType.Double)] public double? DoubleToDouble { get; set; } = 10.1d; + + // Bool conversions + [Field("true_to_true", FaunaType.Boolean)] public bool? TrueToTrue { get; set; } = true; + [Field("false_to_false", FaunaType.Boolean)] public bool? FalseToFalse { get; set; } = false; + + // String conversions + [Field("class_to_string", FaunaType.String)] + public ThingWithStringOverride? ThingToString { get; set; } = new(); + [Field("string_to_string", FaunaType.String)] public string? StringToString { get; set; } = "aString"; + + // Date conversions + [Field("datetime_to_date", FaunaType.Date)] + public DateTime? DateTimeToDate { get; set; } = new DateTime(2023, 12, 13, 12, 12, 12, 1, 1, DateTimeKind.Utc); + [Field("dateonly_to_date", FaunaType.Date)] + public DateOnly? DateOnlyToDate { get; set; } = new DateOnly(2023, 12, 13); + [Field("datetimeoffset_to_date", FaunaType.Date)] + public DateTimeOffset? DateTimeOffsetToDate { get; set; } = new DateTimeOffset(new DateTime(2023, 12, 13, 12, 12, 12, 1, 1, DateTimeKind.Utc)); + + // Time conversions + [Field("datetime_to_time", FaunaType.Time)] + public DateTime? DateTimeToTime { get; set; } = new DateTime(2023, 12, 13, 12, 12, 12, 1, 1, DateTimeKind.Utc); + [Field("datetimeoffset_to_time", FaunaType.Time)] + public DateTimeOffset? DateTimeOffsetToTime { get; set; } = new DateTimeOffset(new DateTime(2023, 12, 13, 12, 12, 12, 1, 1, DateTimeKind.Utc)); + } + + + [FaunaObject] + private class PersonWithIntConflict + { + [Field("@int")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithLongConflict + { + [Field("@long")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithDoubleConflict + { + [Field("@double")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithModConflict + { + [Field("@mod")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithRefConflict + { + [Field("@ref")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithDocConflict + { + [Field("@doc")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithObjectConflict + { + [Field("@object")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithSetConflict + { + [Field("@set")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithTimeConflict + { + [Field("@time")] public string? Field { get; set; } = "not"; + } + + [FaunaObject] + private class PersonWithDateConflict + { + [Field("@date")] public string? Field { get; set; } = "not"; + } + +} \ No newline at end of file diff --git a/Fauna.Test/Serialization/Serializer.Tests.cs b/Fauna.Test/Serialization/Serializer.Deserialize.Tests.cs similarity index 75% rename from Fauna.Test/Serialization/Serializer.Tests.cs rename to Fauna.Test/Serialization/Serializer.Deserialize.Tests.cs index b7098fb2..688de841 100644 --- a/Fauna.Test/Serialization/Serializer.Tests.cs +++ b/Fauna.Test/Serialization/Serializer.Deserialize.Tests.cs @@ -6,29 +6,9 @@ namespace Fauna.Test.Serialization; [TestFixture] -public class SerializerTests +public partial class SerializerTests { - private class TestPerson - { - public string? FirstName { get; set; } - public string? LastName { get; set; } - public int Age { get; set; } - } - - [FaunaObject] - private class TestPersonWithAttributes - { - [Field("first_name")] - public string? FirstName { get; set; } - [Field("last_name")] - public string? LastName { get; set; } - [Field("age")] - public int Age { get; set; } - public string? Ignored { get; set; } - } - - [Test] public void DeserializeValues() { @@ -132,16 +112,16 @@ public void DeserializeIntoPoco() const string given = """ { - "FirstName": "Baz", - "LastName": "Luhrmann", - "Age": { "@int": "61" } + "FirstName": "Baz2", + "LastName": "Luhrmann2", + "Age": { "@int": "612" } } """; - var p = Serializer.Deserialize(given); - Assert.AreEqual("Baz", p.FirstName); - Assert.AreEqual("Luhrmann", p.LastName); - Assert.AreEqual(61, p.Age); + var p = Serializer.Deserialize(given); + Assert.AreEqual("Baz2", p.FirstName); + Assert.AreEqual("Luhrmann2", p.LastName); + Assert.AreEqual(612, p.Age); } [Test] @@ -149,17 +129,17 @@ public void DeserializeIntoPocoWithAttributes() { const string given = """ { - "first_name": "Baz", - "last_name": "Luhrmann", - "age": { "@int": "61" }, + "first_name": "Baz2", + "last_name": "Luhrmann2", + "age": { "@int": "612" }, "Ignored": "should be null" } """; - var p = Serializer.Deserialize(given); - Assert.AreEqual("Baz", p.FirstName); - Assert.AreEqual("Luhrmann", p.LastName); - Assert.AreEqual(61, p.Age); + var p = Serializer.Deserialize(given); + Assert.AreEqual("Baz2", p.FirstName); + Assert.AreEqual("Luhrmann2", p.LastName); + Assert.AreEqual(612, p.Age); Assert.IsNull(p.Ignored); } } \ No newline at end of file diff --git a/Fauna.Test/Serialization/Serializer.Serialize.Tests.cs b/Fauna.Test/Serialization/Serializer.Serialize.Tests.cs index 6aed2afa..fd40bcaa 100644 --- a/Fauna.Test/Serialization/Serializer.Serialize.Tests.cs +++ b/Fauna.Test/Serialization/Serializer.Serialize.Tests.cs @@ -1,77 +1,168 @@ +using System.Text.RegularExpressions; using Fauna.Serialization; using Fauna.Serialization.Attributes; +using Fauna.Types; using NUnit.Framework; namespace Fauna.Test.Serialization; [TestFixture] -public class SerializerSerializeTests +public partial class SerializerTests { - - private class Person - { - public string? FirstName { get; set; } - public string? LastName { get; set; } - public int Age { get; set; } - } - - [FaunaObject] - private class PersonWithContext - { - [Field("first_name")] - public string? FirstName { get; set; } - [Field("last_name")] - public string? LastName { get; set; } - [Field("age", FaunaType.Long)] - public int Age { get; set; } - } - [Test] public void SerializeValues() { + var dt = new DateTime(2023, 12, 13, 12, 12, 12, 1, 1, DateTimeKind.Utc); + var tests = new Dictionary { {"\"hello\"", "hello"}, - {"""{"@int":"42"}""", 42}, - {"""{"@long":"42"}""", 42L}, - {"""{"@double":"1.2"}""", 1.2d}, {"true", true}, {"false", false}, {"null", null}, + {"""{"@date":"2023-12-13"}""", new DateOnly(2023,12,13)}, + {"""{"@double":"1.2"}""", 1.2d}, + {"""{"@double":"3.14"}""", 3.14M}, + {"""{"@int":"42"}""", 42}, + {"""{"@long":"42"}""", 42L}, + {"""{"@mod":"module"}""", new Module("module")}, + {"""{"@time":"2023-12-13T12:12:12.0010010Z"}""", dt}, + // \u002 is the + character. This is expected because we do not + // enable unsafe json serialization. Fauna wire protocol supports this. + {"""{"@time":"2023-12-14T12:12:12.0010010\u002B00:00"}""", new DateTimeOffset(dt.AddDays(1))}, }; - foreach (var entry in tests) + foreach (var (expected, test) in tests) { - var result = Serializer.Serialize(entry.Value); - Assert.AreEqual(entry.Key, result); + var result = Serializer.Serialize(test); + Assert.AreEqual(expected, result); } } [Test] - public void SerializeUserDefinedClass() + public void SerializeDictionary() { - var test = new Person + var test = new Dictionary() { - FirstName = "Baz", - LastName = "Luhrmann", - Age = 61 + { "answer", 42 }, + { "foo", "bar" }, + { "list", new List()}, + { "obj", new Dictionary()} + }; var actual = Serializer.Serialize(test); - Assert.AreEqual("""{"FirstName":"Baz","LastName":"Luhrmann","Age":{"@int":"61"}}""", actual); + Assert.AreEqual("""{"answer":{"@int":"42"},"foo":"bar","list":[],"obj":{}}""", actual); + } + + [Test] + public void SerializeDictionaryWithTagConflicts() + { + var tests = new Dictionary, string>() + { + { new() { { "@date", "not" } }, """{"@object":{"@date":"not"}}""" }, + { new() { { "@doc", "not" } }, """{"@object":{"@doc":"not"}}""" }, + { new() { { "@double", "not" } }, """{"@object":{"@double":"not"}}""" }, + { new() { { "@int", "not" } }, """{"@object":{"@int":"not"}}""" }, + { new() { { "@long", "not" } }, """{"@object":{"@long":"not"}}""" }, + { new() { { "@mod", "not" } }, """{"@object":{"@mod":"not"}}""" }, + { new() { { "@object", "not" } }, """{"@object":{"@object":"not"}}""" }, + { new() { { "@ref", "not" } }, """{"@object":{"@ref":"not"}}""" }, + { new() { { "@set", "not" } }, """{"@object":{"@set":"not"}}""" }, + { new() { { "@time", "not" } }, """{"@object":{"@time":"not"}}""" } + }; + + foreach (var (test, expected) in tests) + { + var actual = Serializer.Serialize(test); + Assert.AreEqual(expected, actual); + } } [Test] - public void SerializeUserDefinedClassWithContext() + public void SerializeList() { - var test = new PersonWithContext + var test = new List() { - FirstName = "Baz", - LastName = "Luhrmann", - Age = 61 + 42, + "foo bar", + new List(), + new Dictionary() }; + var actual = Serializer.Serialize(test); + Assert.AreEqual("""[{"@int":"42"},"foo bar",[],{}]""", actual); + } + + [Test] + public void SerializeClass() + { + var test = new Person(); + var actual = Serializer.Serialize(test); + Assert.AreEqual("""{"FirstName":"Baz","LastName":"Luhrmann","Age":{"@int":"61"}}""", actual); + } + + [Test] + public void SerializeClassWithAttributes() + { + var test = new PersonWithAttributes(); var actual = Serializer.Serialize(test); Assert.AreEqual("""{"first_name":"Baz","last_name":"Luhrmann","age":{"@long":"61"}}""", actual); } -} \ No newline at end of file + + [Test] + public void SerializeClassWithTagConflicts() + { + var tests = new Dictionary() + { + { new PersonWithDateConflict(), """{"@object":{"@date":"not"}}""" }, + { new PersonWithDocConflict(), """{"@object":{"@doc":"not"}}""" }, + { new PersonWithDoubleConflict(), """{"@object":{"@double":"not"}}""" }, + { new PersonWithIntConflict(), """{"@object":{"@int":"not"}}""" }, + { new PersonWithLongConflict(), """{"@object":{"@long":"not"}}""" }, + { new PersonWithModConflict(), """{"@object":{"@mod":"not"}}""" }, + { new PersonWithObjectConflict(), """{"@object":{"@object":"not"}}""" }, + { new PersonWithRefConflict(), """{"@object":{"@ref":"not"}}""" }, + { new PersonWithSetConflict(), """{"@object":{"@set":"not"}}""" }, + { new PersonWithTimeConflict(), """{"@object":{"@time":"not"}}""" } + }; + + foreach (var (test, expected) in tests) + { + var actual = Serializer.Serialize(test); + Assert.AreEqual(expected, actual); + } + } + + [Test] + public void SerializeClassWithTypeConversions() + { + var test = new PersonWithTypeOverrides(); + var expectedWithWhitespace = """ + { + "short_to_long": {"@long": "1"}, + "int_to_long": {"@long": "2"}, + "long_to_long": {"@long": "3"}, + "short_to_int": {"@int": "4"}, + "int_to_int": {"@int": "5"}, + "short_to_double": {"@double": "6"}, + "int_to_double": {"@double": "7"}, + "long_to_double": {"@double": "8"}, + "decimal_to_double": {"@double": "9.2"}, + "double_to_double": {"@double": "10.1"}, + "true_to_true": true, + "false_to_false": false, + "class_to_string": "TheThing", + "string_to_string": "aString", + "datetime_to_date": {"@date": "2023-12-13"}, + "dateonly_to_date": {"@date": "2023-12-13"}, + "datetimeoffset_to_date": {"@date": "2023-12-13"}, + "datetime_to_time": {"@time":"2023-12-13T12:12:12.0010010Z"}, + "datetimeoffset_to_time": {"@time":"2023-12-13T12:12:12.0010010\u002B00:00"} + } + """; + var expected = Regex.Replace(expectedWithWhitespace, @"\s", string.Empty); + var actual = Serializer.Serialize(test); + Assert.AreEqual(expected, actual); + } +} diff --git a/Fauna/Serialization/Serializer.Serialize.cs b/Fauna/Serialization/Serializer.Serialize.cs index 76cce719..46062cf9 100644 --- a/Fauna/Serialization/Serializer.Serialize.cs +++ b/Fauna/Serialization/Serializer.Serialize.cs @@ -1,6 +1,4 @@ -using System.Reflection; using System.Text; -using Fauna.Serialization.Attributes; using Module = Fauna.Types.Module; namespace Fauna.Serialization; @@ -8,6 +6,11 @@ namespace Fauna.Serialization; public static partial class Serializer { + private static readonly HashSet Tags = new() + { + "@int", "@long", "@double", "@date", "@time", "@mod", "@ref", "@doc", "@set", "@object" + }; + public static string Serialize(object? obj) { using var stream = new MemoryStream(); @@ -38,44 +41,80 @@ private static void SerializeValueInternal(Utf8FaunaWriter writer, object? obj, switch (typeHint) { case FaunaType.Int: - writer.WriteIntValue((int)obj); + if (obj is short or int) + { + var int32 = Convert.ToInt32(obj); + writer.WriteIntValue(int32); + } + else + { + throw new SerializationException($"Unsupported Int conversion. Provided value must be a short or int but was a {obj.GetType()}"); + } break; case FaunaType.Long: - if (obj is int) + if (obj is short or int or long) { var int64 = Convert.ToInt64(obj); writer.WriteLongValue(int64); } else { - writer.WriteLongValue((long)obj); + throw new SerializationException($"Unsupported Long conversion. Provided value must be a short, int, or long but was a {obj.GetType()}"); } break; case FaunaType.Double: - writer.WriteDoubleValue((double)obj); + if (obj is double or decimal or short or int or long) + { + var dec = Convert.ToDecimal(obj); + writer.WriteDoubleValue(dec); + } + else + { + throw new SerializationException($"Unsupported Double conversion. Provided value must be a short, int, long, double, or decimal, but was a {obj.GetType()}"); + + } break; case FaunaType.String: writer.WriteStringValue(obj.ToString() ?? string.Empty); break; case FaunaType.Date: - if (obj is not DateTime date) + switch (obj) { - throw new SerializationException($"Unsupported Date conversion. Provided value must be a DateTime but was a {obj.GetType()}"); + case DateTime v: + writer.WriteDateValue(v); + break; + case DateOnly v: + writer.WriteDateValue(v); + break; + case DateTimeOffset v: + writer.WriteDateValue(v); + break; + default: + throw new SerializationException($"Unsupported Date conversion. Provided value must be a DateTime, DateTimeOffset, or DateOnly but was a {obj.GetType()}"); } - - writer.WriteDateValue(date); break; case FaunaType.Time: - if (obj is not DateTime time) + switch (obj) { - throw new SerializationException($"Unsupported Time conversion. Provided value must be a DateTime but was a {obj.GetType()}"); + case DateTime v: + writer.WriteTimeValue(v); + break; + case DateTimeOffset v: + writer.WriteTimeValue(v); + break; + default: + throw new SerializationException($"Unsupported Time conversion. Provided value must be a DateTime or DateTimeOffset but was a {obj.GetType()}"); } - - writer.WriteTimeValue(time); break; case FaunaType.Boolean: - - writer.WriteBooleanValue((bool)obj); + if (obj is bool b) + { + writer.WriteBooleanValue(b); + } + else + { + throw new SerializationException($"Unsupported Boolean conversion. Provided value must be a bool but was a {obj.GetType()}"); + } break; default: throw new ArgumentOutOfRangeException(nameof(typeHint), typeHint, null); @@ -88,6 +127,9 @@ private static void SerializeValueInternal(Utf8FaunaWriter writer, object? obj, case null: writer.WriteNullValue(); break; + case short v: + writer.WriteIntValue(v); + break; case int v: writer.WriteIntValue(v); break; @@ -109,6 +151,15 @@ private static void SerializeValueInternal(Utf8FaunaWriter writer, object? obj, case Module v: writer.WriteModuleValue(v); break; + case DateTime v: + writer.WriteTimeValue(v); + break; + case DateTimeOffset v: + writer.WriteTimeValue(v); + break; + case DateOnly v: + writer.WriteDateValue(v); + break; default: SerializeObjectInternal(writer, obj, context); break; @@ -120,10 +171,16 @@ private static void SerializeObjectInternal(Utf8FaunaWriter writer, object obj, { switch (obj) { - case IDictionary: - case IDictionary: + case Dictionary d: + SerializeIDictionaryInternal(writer, d, context); break; - case IEnumerable: + case List e: + writer.WriteStartArray(); + foreach (var o in e) + { + SerializeValueInternal(writer, o, context); + } + writer.WriteEndArray(); break; default: SerializeClassInternal(writer, obj, context); @@ -131,18 +188,32 @@ private static void SerializeObjectInternal(Utf8FaunaWriter writer, object obj, } } + private static void SerializeIDictionaryInternal(Utf8FaunaWriter writer, IDictionary d, + SerializationContext context) + { + var shouldEscape = Tags.Overlaps(d.Keys); + if (shouldEscape) writer.WriteStartEscapedObject(); else writer.WriteStartObject(); + foreach (var (key, value) in d) + { + writer.WriteFieldName(key); + SerializeValueInternal(writer, value, context); + } + if (shouldEscape) writer.WriteEndEscapedObject(); else writer.WriteEndObject(); + } + private static void SerializeClassInternal(Utf8FaunaWriter writer, object obj, SerializationContext context) { var t = obj.GetType(); var fieldMap = context.GetFieldMap(t); - - writer.WriteStartObject(); + var shouldEscape = Tags.Overlaps(fieldMap.Values.Select(x => x.Name)); + + if (shouldEscape) writer.WriteStartEscapedObject(); else writer.WriteStartObject(); foreach (var field in fieldMap.Values) { writer.WriteFieldName(field.Name!); var v = field.Info?.GetValue(obj); SerializeValueInternal(writer, v, context, field.Type); } - writer.WriteEndObject(); + if (shouldEscape) writer.WriteEndEscapedObject(); else writer.WriteEndObject(); } } diff --git a/Fauna/Serialization/Utf8FaunaWriter.cs b/Fauna/Serialization/Utf8FaunaWriter.cs index 12426eeb..5a4f9ca4 100644 --- a/Fauna/Serialization/Utf8FaunaWriter.cs +++ b/Fauna/Serialization/Utf8FaunaWriter.cs @@ -188,12 +188,30 @@ public void WriteDateValue(DateTime value) var str = value.ToString("yyyy-MM-dd"); WriteTaggedValue("@date", str); } + + public void WriteDateValue(DateOnly value) + { + var str = value.ToString("yyyy-MM-dd"); + WriteTaggedValue("@date", str); + } + + public void WriteDateValue(DateTimeOffset value) + { + var str = value.ToString("yyyy-MM-dd"); + WriteTaggedValue("@date", str); + } public void WriteTimeValue(DateTime value) { var str = value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); WriteTaggedValue("@time", str); } + + public void WriteTimeValue(DateTimeOffset value) + { + var str = value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); + WriteTaggedValue("@time", str); + } public void WriteBooleanValue(bool value) {