diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d790b35..e97987f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Represents the **NuGet** versions. - *Enhancement*: A new `Abstractions.ServiceBusMessageActions` has been created to encapsulate either a `Microsoft.Azure.WebJobs.ServiceBus.ServiceBusMessageActions` (existing [_in-process_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) function support) or `Microsoft.Azure.Functions.Worker.ServiceBusMessageActions` (new [_isolated_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) function support) and used internally. Implicit conversion is enabled to simplify usage; existing projects will need to be recompiled. The latter capability does not support `RenewAsync` and as such this capability is no longer leveraged for consistency; review documented [`PeekLock`](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cextensionv5&pivots=programming-language-csharp#peeklock-behavior) behavior to get desired outcome. - *Enhancement*: The `Result`, `Result`, `PagingArgs` and `PagingResult` have had `IEquatable` added to enable equality comparisons. - *Enhancement*: Upgraded `UnitTestEx` dependency to `4.0.2` to enable _isolated_ function testing. +- *Enhancement*: Enabled `IJsonSerializer` support for `CompositeKey` JSON serialization/deserialization. +- *Enhancement*: Added `IEventDataFormatter` which when implemented by the value set as the `EventData.Value` allows additional formatting to be applied by the `EventDataFormatter`. +- *Fixed*: `EventDataFormatter` and `CloudEventSerializerBase` updated to correctly set the `Key` where applicable. - *Internal:* Upgraded `NUnit` dependency to `4.0.1` for all `CoreEx` unit test; also, all unit tests now leverage the [_NUnit constraint model_](https://docs.nunit.org/articles/nunit/writing-tests/assertions/assertion-models/constraint.html) testing approach. ## v3.8.1 diff --git a/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs b/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs new file mode 100644 index 00000000..0b42b9e9 --- /dev/null +++ b/src/CoreEx.Newtonsoft/Json/CompositeKeyJsonConverter.cs @@ -0,0 +1,147 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CoreEx.Newtonsoft.Json +{ + /// + /// Performs JSON value conversion for values. + /// + public class CompositeKeyJsonConverter : JsonConverter + { + /// + public override bool CanConvert(Type objectType) => objectType == typeof(CompositeKey); + + /// + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, global::Newtonsoft.Json.JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return CompositeKey.Empty; + + if (reader.TokenType != JsonToken.StartArray) + { + var jtr = (JsonTextReader)reader; + throw new JsonSerializationException($"Expected {nameof(JsonToken.StartArray)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); + } + + var depth = reader.Depth; + var args = new List(); + + reader.Read(); + while (reader.Depth > depth) + { + if (reader.TokenType == JsonToken.Null) + { + args.Add(null); + reader.Read(); + continue; + } + + if (reader.TokenType != JsonToken.StartObject) + { + var jtr = (JsonTextReader)reader; + throw new JsonSerializationException($"Expected {nameof(JsonToken.StartObject)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); + } + + var objDepth = reader.Depth; + reader.Read(); + while (reader.Depth > objDepth) + { + if (reader.TokenType != JsonToken.PropertyName) + { + var jtr = (JsonTextReader)reader; + throw new JsonSerializationException($"Expected {nameof(JsonToken.PropertyName)} for a {nameof(CompositeKey)}; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); + } + + var name = reader.Value; + + switch (name) + { + case "string": args.Add(reader.ReadAsString()); break; + case "char": args.Add(reader.ReadAsString()?.ToCharArray().FirstOrDefault()); break; + case "short": args.Add((short?)reader.ReadAsInt32()); break; + case "int": args.Add(reader.ReadAsInt32()); break; + case "long": args.Add((long?)reader.ReadAsDecimal()); break; + case "guid": args.Add(reader.ReadAsString() is string s && Guid.TryParse(s, out var g) ? g : null); break; + case "datetime": args.Add(reader.ReadAsDateTime()); break; + case "datetimeoffset": args.Add(reader.ReadAsDateTimeOffset()); break; + case "ushort": args.Add((ushort?)reader.ReadAsInt32()); break; + case "uint": args.Add((uint?)reader.ReadAsDecimal()); break; + case "ulong": args.Add((ulong?)reader.ReadAsDecimal()); break; + default: + var jtr = (JsonTextReader)reader; + throw new JsonSerializationException($"Unsupported {nameof(CompositeKey)} type '{name}'.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); + } + + reader.Read(); + if (reader.TokenType != JsonToken.EndObject) + { + var jtr = (JsonTextReader)reader; + throw new JsonSerializationException($"Expected {nameof(JsonToken.EndObject)} for a {nameof(CompositeKey)} argument; found {reader.TokenType}.", jtr.Path, jtr.LineNumber, jtr.LinePosition, null); + } + } + + reader.Read(); + } + + return new CompositeKey([.. args]); + } + + /// + public override void WriteJson(JsonWriter writer, object? value, global::Newtonsoft.Json.JsonSerializer serializer) + { + if (value is not CompositeKey key || key.Args.Length == 0) + { + writer.WriteNull(); + return; + } + + writer.WriteStartArray(); + + foreach (var arg in key.Args) + { + if (arg is null) + { + writer.WriteNull(); + continue; + } + + writer.WriteStartObject(); + + _ = arg switch + { + string str => JsonWrite(writer, "string", () => writer.WriteValue(str)), + char c => JsonWrite(writer, "char", () => writer.WriteValue(c.ToString())), + short s => JsonWrite(writer, "short", () => writer.WriteValue(s)), + int i => JsonWrite(writer, "int", () => writer.WriteValue(i)), + long l => JsonWrite(writer, "long", () => writer.WriteValue(l)), + Guid g => JsonWrite(writer, "guid", () => writer.WriteValue(g)), + DateTime d => JsonWrite(writer, "datetime", () => writer.WriteValue(d)), + DateTimeOffset o => JsonWrite(writer, "datetimeoffset", () => writer.WriteValue(o)), + ushort us => JsonWrite(writer, "ushort", () => writer.WriteValue(us)), + uint ui => JsonWrite(writer, "uint", () => writer.WriteValue(ui)), + ulong ul => JsonWrite(writer, "ulong", () => writer.WriteValue(ul)), + _ => throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{arg.GetType().Name}'.") + }; + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + /// + /// Provides a simple means to write a JSON property name and value. + /// + private static bool JsonWrite(JsonWriter writer, string name, Action action) + { + writer.WritePropertyName(name); + action(); + return true; + } + } +} \ No newline at end of file diff --git a/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs b/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs index bc751c85..971cabfd 100644 --- a/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs +++ b/src/CoreEx.Newtonsoft/Json/JsonSerializer.cs @@ -1,7 +1,6 @@ // Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx using CoreEx.Json; -using CoreEx.RefData; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; @@ -28,7 +27,8 @@ public class JsonSerializer : IJsonSerializer /// = . /// = . /// = . - /// = , , . + /// = , , + /// and . /// /// public static JsonSerializerSettings DefaultSettings { get; set; } = new JsonSerializerSettings @@ -37,7 +37,7 @@ public class JsonSerializer : IJsonSerializer NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.None, ContractResolver = ContractResolver.Default, - Converters = { new Nsj.Converters.StringEnumConverter(), new ReferenceDataJsonConverter(), new CollectionResultJsonConverter() } + Converters = { new Nsj.Converters.StringEnumConverter(), new ReferenceDataJsonConverter(), new CollectionResultJsonConverter(), new CompositeKeyJsonConverter() } }; /// diff --git a/src/CoreEx/Entities/CompositeKey.cs b/src/CoreEx/Entities/CompositeKey.cs index b6982954..f6059685 100644 --- a/src/CoreEx/Entities/CompositeKey.cs +++ b/src/CoreEx/Entities/CompositeKey.cs @@ -12,9 +12,24 @@ namespace CoreEx.Entities /// Represents an immutable composite key. /// /// May contain zero or more that represent the composite key. A subset of the the .NET built-in types - /// are supported: , , , , , , , , , (converted to a ) and . + /// are supported: , , , , , , , , , (converted to a ) and . + /// A is not generally intended to be a first-class JSON-serialized property type, although is supported (see ); but, to be used in a read-only non-serialized manner to group (encapsulate) other properties + /// into a single value. The is also used within the , and .Example as follows: + /// + /// public class SalesOrderItem + /// { + /// [JsonPropertyName("order")] + /// public string? OrderNumber { get; set; } + /// + /// [JsonPropertyName("item")] + /// public int ItemNumber { get; set; } + /// + /// [JsonIgnore()] + /// public CompositeKey SalesOrderItemKey => CompositeKey.Create(SalesOrderNumber, SalesOrderItemNumber); + /// } + /// [System.Diagnostics.DebuggerStepThrough] - [System.Diagnostics.DebuggerDisplay("Key = {ToString()}")] + [System.Diagnostics.DebuggerDisplay("Args = {ToString()}")] public readonly struct CompositeKey : IEquatable { private readonly ImmutableArray _args; @@ -22,7 +37,7 @@ namespace CoreEx.Entities /// /// Represents an empty . /// - public static readonly CompositeKey Empty; + public static readonly CompositeKey Empty = new(); /// /// Creates a new from the argument values, @@ -48,10 +63,10 @@ public CompositeKey(params object?[] args) return; } - var newArgs = new object?[args.Length]; + object? temp; for (int idx = 0; idx < args.Length; idx++) { - newArgs[idx] = args[idx] == null ? null : args[idx] switch + temp = args[idx] == null ? null : args[idx] switch { string str => str, char c => c, @@ -69,7 +84,7 @@ public CompositeKey(params object?[] args) }; } - _args = newArgs.ToImmutableArray(); + _args = args.ToImmutableArray(); } /// @@ -147,7 +162,7 @@ public bool IsInitial /// /// The composite key as a . /// Each value is JSON-formatted to ensure consistency and portability. - public override string ToString() => ToString(','); + public override string? ToString() => ToString(','); /// /// Returns the as a with the separated by the . @@ -155,10 +170,10 @@ public bool IsInitial /// The seperator character. /// The composite key as a . /// Each value is JSON-formatted to ensure consistency and portability. - public string ToString(char separator) + public string? ToString(char separator) { if (Args.Length == 0) - return string.Empty; + return null; var index = 0; var sb = new StringBuilder(); @@ -173,7 +188,7 @@ public string ToString(char separator) if (index > 0) sb.Append(separator); - bool isString = JsonWrite(ujw, false, arg); + bool isString = JsonWrite(ujw, arg); ujw.Flush(); if (abw.WrittenMemory.Length > 0 && !(isString && abw.WrittenMemory.Length <= 2)) { @@ -191,130 +206,48 @@ public string ToString(char separator) return sb.ToString(); } - /// - /// Returns the as a JSON . - /// - /// The composite key as a JSON . - public string ToJsonString() - { - if (Args.Length == 0) - return "null"; - - var abw = new ArrayBufferWriter(); - using var ujw = new Utf8JsonWriter(abw); - ujw.WriteStartArray(); - - foreach (var arg in Args) - { - ujw.WriteStartObject(); - JsonWrite(ujw, true, arg); - ujw.WriteEndObject(); - } - - ujw.WriteEndArray(); - ujw.Flush(); - return new BinaryData(abw.WrittenMemory).ToString(); - } - /// /// Writes the JSON name and argument value pair. /// - private static bool JsonWrite(Utf8JsonWriter ujw, bool includeName, object? arg) => arg switch + private static bool JsonWrite(Utf8JsonWriter ujw, object? arg) => arg switch { - string str => JsonWrite(ujw, includeName ? "string" : null, () => ujw.WriteStringValue(str), true), - char c => JsonWrite(ujw, includeName ? "char" : null, () => ujw.WriteStringValue(c.ToString()), true), - short s => JsonWrite(ujw, includeName ? "short" : null, () => ujw.WriteNumberValue(s), false), - int i => JsonWrite(ujw, includeName ? "int" : null, () => ujw.WriteNumberValue(i), false), - long l => JsonWrite(ujw, includeName ? "long" : null, () => ujw.WriteNumberValue(l), false), - Guid g => JsonWrite(ujw, includeName ? "guid" : null, () => ujw.WriteStringValue(g), true), - DateTime d => JsonWrite(ujw, includeName ? "date" : null, () => ujw.WriteStringValue(d), true), - DateTimeOffset o => JsonWrite(ujw, includeName? "offset" : null, () => ujw.WriteStringValue(o), true), - ushort us => JsonWrite(ujw, includeName ? "ushort" : null, () => ujw.WriteNumberValue(us), false), - uint ui => JsonWrite(ujw, includeName ? "uint" : null, () => ujw.WriteNumberValue(ui), false), - ulong ul => JsonWrite(ujw, includeName ? "ulong" : null, () => ujw.WriteNumberValue(ul), false), + string str => JsonWrite(() => ujw.WriteStringValue(str), true), + char c => JsonWrite(() => ujw.WriteStringValue(c.ToString()), true), + short s => JsonWrite(() => ujw.WriteNumberValue(s), false), + int i => JsonWrite(() => ujw.WriteNumberValue(i), false), + long l => JsonWrite(() => ujw.WriteNumberValue(l), false), + Guid g => JsonWrite(() => ujw.WriteStringValue(g), true), + DateTime d => JsonWrite(() => ujw.WriteStringValue(d), true), + DateTimeOffset o => JsonWrite(() => ujw.WriteStringValue(o), true), + ushort us => JsonWrite(() => ujw.WriteNumberValue(us), false), + uint ui => JsonWrite(() => ujw.WriteNumberValue(ui), false), + ulong ul => JsonWrite(() => ujw.WriteNumberValue(ul), false), _ => false }; /// /// Writes the JSON name and invokes action to write argument value. /// - private static bool JsonWrite(Utf8JsonWriter ujw, string? name, Action action, bool isString) + private static bool JsonWrite(Action action, bool isString) { - if (name != null) - ujw.WritePropertyName(name); - action(); return isString; } /// - /// Creates a new from serialized (see ); + /// Returns the as a JSON . /// - /// The JSON string. - /// The . - public static CompositeKey CreateFromJson(string json) - { - if (string.IsNullOrEmpty(json) || json == "null") - return new CompositeKey(); - - using var jd = JsonDocument.Parse(json); - var (key, error) = Deserialize(jd); - if (key.HasValue) - return key.Value; - - throw new ArgumentException($"The JSON document is incorrectly formatted, or contains invalid data: {error}"); - } + /// The composite key as a JSON . + /// Uses the internally. + public string ToJsonString() => Json.JsonSerializer.Default.Serialize(this); /// - /// Deserialize the document. + /// Creates a new from serialized (see ); /// - private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) - { - if (jd.RootElement.ValueKind != JsonValueKind.Array) - return (null, "Root element must be an array."); - - var args = new object?[jd.RootElement.GetArrayLength()]; - int i = 0; - - foreach (var jo in jd.RootElement.EnumerateArray()) - { - if (jo.ValueKind != JsonValueKind.Object) - return (null, "Root element array must only contains objects."); - - foreach (var jp in jo.EnumerateObject()) - { - if (jp.Value.ValueKind != JsonValueKind.String && jp.Value.ValueKind != JsonValueKind.Number) - return (null, "Array element must be either a String or a Number."); - - try - { - switch (jp.Name) - { - case "string": args[i] = jp.Value.GetString(); break; - case "char": args[i] = Convert.ToChar(jp.Value.GetString() ?? string.Empty); break; - case "short": args[i] = jp.Value.GetInt16(); break; - case "int": args[i] = jp.Value.GetInt32(); break; - case "long": args[i] = jp.Value.GetInt64(); break; - case "guid": args[i] = jp.Value.GetGuid(); break; - case "date": args[i] = jp.Value.GetDateTime(); break; - case "offset": args[i] = jp.Value.GetDateTimeOffset(); break; - case "ushort": args[i] = jp.Value.GetUInt16(); break; - case "uint": args[i] = jp.Value.GetUInt32(); break; - case "ulong": args[i] = jp.Value.GetUInt64(); break; - default: return (null, $"Property '{jp.Name}' is not supported."); - } - } - catch (Exception ex) - { - return (null, $"Property '{jp.Name}' value is invalid: {ex.Message}"); - } - } - - i++; - } - - return (new CompositeKey(args), null); - } + /// The JSON string. + /// The . + /// Uses the internally. + public static CompositeKey CreateFromJson(string json) => (string.IsNullOrEmpty(json) || json == "null") ? new CompositeKey() : Json.JsonSerializer.Default.Deserialize(json); /// /// Creates a new from a string-based () where the key is of the specified. @@ -324,7 +257,7 @@ private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) /// The seperator character. /// The . /// The types specified must represent exact match of underlying parts. - public static CompositeKey CreateFromString(string key, char separator = ',') => CreateFromString(key, separator, new Type[] { typeof(T) }); + public static CompositeKey CreateFromString(string? key, char separator = ',') => CreateFromString(key, separator, [typeof(T)]); /// /// Creates a new from a string-based () where each underlying part is of the specified. @@ -335,7 +268,7 @@ private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) /// The seperator character. /// The . /// The types specified must represent exact match of underlying parts. - public static CompositeKey CreateFromString(string key, char separator = ',') => CreateFromString(key, separator, new Type[] { typeof(T1), typeof(T2) }); + public static CompositeKey CreateFromString(string? key, char separator = ',') => CreateFromString(key, separator, [typeof(T1), typeof(T2)]); /// /// Creates a new from a string-based () where each underlying part is of the specified. @@ -347,7 +280,7 @@ private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) /// The seperator character. /// The . /// The types specified must represent exact match of underlying parts. - public static CompositeKey CreateFromString(string key, char separator = ',') => CreateFromString(key, separator, new Type[] { typeof(T1), typeof(T2), typeof(T3) }); + public static CompositeKey CreateFromString(string? key, char separator = ',') => CreateFromString(key, separator, [typeof(T1), typeof(T2), typeof(T3)]); /// /// Creates a new from a string-based representation () where each underlying part is of the specified. @@ -356,7 +289,7 @@ private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) /// The array. /// The . /// The types specified must represent exact match of underlying parts. - public static CompositeKey CreateFromString(string key, params Type[] types) => CreateFromString(key, ',', types); + public static CompositeKey CreateFromString(string? key, params Type[] types) => CreateFromString(key, ',', types); /// /// Creates a new from a string-based representation () where each underlying part is of the specified. @@ -366,9 +299,12 @@ private static (CompositeKey? key, string? error) Deserialize(JsonDocument jd) /// The array. /// The . /// The types specified must represent exact match of underlying parts. - public static CompositeKey CreateFromString(string key, char separator, params Type[] types) + public static CompositeKey CreateFromString(string? key, char separator, params Type[] types) { - var parts = (key ?? throw new ArgumentNullException(nameof(key))).Split(separator, StringSplitOptions.None); + if (key is null) + return Empty; + + var parts = key.Split(separator, StringSplitOptions.None); if (types.Length == 0 && parts.Length == 1 && string.IsNullOrEmpty(parts[0])) return new CompositeKey(); diff --git a/src/CoreEx/Events/CloudEventSerializerBase.cs b/src/CoreEx/Events/CloudEventSerializerBase.cs index 320865e8..faa9a98f 100644 --- a/src/CoreEx/Events/CloudEventSerializerBase.cs +++ b/src/CoreEx/Events/CloudEventSerializerBase.cs @@ -23,13 +23,14 @@ public abstract class CloudEventSerializerBase : IEventSerializer private const string PartitionKeyName = "partitionkey"; private const string TenantIdName = "tenantid"; private const string ETagName = "etag"; + private const string KeyName = "key"; /// /// Gets the list of reserved attribute names. /// - /// The reserved names are as follows: 'id', 'time', 'type', 'source', 'subject', 'action', 'correlationid', 'tenantid', 'etag', 'partitionkey'. Also, + /// The reserved names are as follows: 'id', 'time', 'type', 'source', 'subject', 'action', 'correlationid', 'tenantid', 'etag', 'partitionkey', 'key'. Also, /// an attribute name must consist of lowercase letters and digits only; any that contain other characters will be ignored. - public static string[] ReservedNames { get; } = new string[] { "id", "time", "type", "source", SubjectName, ActionName, CorrelationIdName, TenantIdName, ETagName, PartitionKeyName }; + public static string[] ReservedNames { get; } = new string[] { "id", "time", "type", "source", SubjectName, ActionName, CorrelationIdName, TenantIdName, ETagName, PartitionKeyName, KeyName }; /// /// Initializes a new instance of the class. @@ -142,6 +143,9 @@ private void DeserializeFromCloudEvent(CloudEvent cloudEvent, EventData @event) if (TryGetExtensionAttribute(cloudEvent, ETagName, out val)) @event.ETag = val; + if (TryGetExtensionAttribute(cloudEvent, KeyName, out val)) + @event.Key = val; + foreach (var att in cloudEvent.ExtensionAttributes) { if (!ReservedNames.Contains(att.Name) && TryGetExtensionAttribute(cloudEvent, att.Name, out val)) @@ -211,6 +215,7 @@ private async Task SerializeToCloudEventAsync(EventData @event, Canc SetExtensionAttribute(ce, PartitionKeyName, @event.PartitionKey); SetExtensionAttribute(ce, TenantIdName, @event.TenantId); SetExtensionAttribute(ce, ETagName, @event.ETag); + SetExtensionAttribute(ce, KeyName, @event.Key); if (@event.Attributes != null) { diff --git a/src/CoreEx/Events/EventDataBase.cs b/src/CoreEx/Events/EventDataBase.cs index b9045a90..ab83b3ed 100644 --- a/src/CoreEx/Events/EventDataBase.cs +++ b/src/CoreEx/Events/EventDataBase.cs @@ -13,7 +13,7 @@ namespace CoreEx.Events /// public abstract class EventDataBase : IIdentifier, ITenantId, IPartitionKey, IETag { - private IDictionary? _internal; + private Dictionary? _internal; /// /// Initializes a new instance of the class. @@ -134,7 +134,7 @@ public bool TryGetAttribute(string key, [NotNullWhen(true)] out string? value) /// /// It is recommened to use the for the purposes of publishing and sending of additional data. [JsonIgnore()] - public IDictionary Internal => _internal ??= new Dictionary(); + public IDictionary Internal => _internal ??= []; /// /// Indicates whether there are any items within the dictionary. diff --git a/src/CoreEx/Events/EventDataFormatter.cs b/src/CoreEx/Events/EventDataFormatter.cs index 196ba02c..8ef1cb7c 100644 --- a/src/CoreEx/Events/EventDataFormatter.cs +++ b/src/CoreEx/Events/EventDataFormatter.cs @@ -154,6 +154,12 @@ public virtual void Format(EventData @event) @event.Id ??= Guid.NewGuid().ToString(); @event.Timestamp ??= DateTimeOffset.UtcNow; + if (PropertySelection.HasFlag(EventDataProperty.Key)) + { + if (@event.Key is null && value is not null && value is IEntityKey ek) + @event.Key = ek.EntityKey.ToString(KeySeparatorCharacter); + } + if (PropertySelection.HasFlag(EventDataProperty.Subject)) { if (@event.Subject == null && SubjectDefaultToValueTypeName && value != null) @@ -239,6 +245,9 @@ public virtual void Format(EventData @event) if (!PropertySelection.HasFlag(EventDataProperty.Key)) @event.Key = null; + + if (@event.Value is not null && @event.Value is IEventDataFormatter formattable) + formattable.Format(@event); } /// diff --git a/src/CoreEx/Events/IEventDataFormatter.cs b/src/CoreEx/Events/IEventDataFormatter.cs new file mode 100644 index 00000000..9a877a9f --- /dev/null +++ b/src/CoreEx/Events/IEventDataFormatter.cs @@ -0,0 +1,17 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +namespace CoreEx.Events +{ + /// + /// Enables additional formatting of an by the . + /// + /// Invoked by the where formatting an and the corresponding value implements. + public interface IEventDataFormatter + { + /// + /// Format the . + /// + /// The being formatted. + void Format(EventData eventData); + } +} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs b/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs new file mode 100644 index 00000000..1bba9c2c --- /dev/null +++ b/src/CoreEx/Text/Json/CompositeKeyConverterFactory.cs @@ -0,0 +1,148 @@ +// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx + +using CoreEx.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CoreEx.Text.Json +{ + /// + /// Performs JSON value conversion for values. + /// + public class CompositeKeyConverterFactory : JsonConverterFactory + { + /// + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(CompositeKey); + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => new CompositeKeyConverter(); + + /// + /// Performs the "actual" JSON value conversion for a . + /// + private class CompositeKeyConverter : JsonConverter + { + /// + public override bool HandleNull => true; + + /// + public override CompositeKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return CompositeKey.Empty; + + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException($"Expected {nameof(JsonTokenType.StartArray)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); + + var depth = reader.CurrentDepth; + var args = new List(); + + reader.Read(); + while (reader.CurrentDepth > depth) + { + if (reader.TokenType == JsonTokenType.Null) + { + args.Add(null); + reader.Read(); + continue; + } + + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException($"Expected {nameof(JsonTokenType.StartObject)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); + + var objDepth = reader.CurrentDepth; + reader.Read(); + while (reader.CurrentDepth > objDepth) + { + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException($"Expected {nameof(JsonTokenType.PropertyName)} for a {nameof(CompositeKey)}; found {reader.TokenType}."); + + var name = reader.GetString(); + reader.Read(); + + switch (name) + { + case "string": args.Add(reader.GetString()); break; + case "char": args.Add(reader.GetString()?.ToCharArray().FirstOrDefault()); break; + case "short": args.Add(reader.GetInt16()); break; + case "int": args.Add(reader.GetInt32()); break; + case "long": args.Add(reader.GetInt64()); break; + case "guid": args.Add(reader.GetGuid()); break; + case "datetime": args.Add(reader.GetDateTime()); break; + case "datetimeoffset": args.Add(reader.GetDateTimeOffset()); break; + case "ushort": args.Add(reader.GetUInt16()); break; + case "uint": args.Add(reader.GetUInt32()); break; + case "ulong": args.Add(reader.GetUInt64()); break; + default: + throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{name}'."); + } + + reader.Read(); + if (reader.TokenType != JsonTokenType.EndObject) + throw new JsonException($"Expected {nameof(JsonTokenType.EndObject)} for a {nameof(CompositeKey)} argument; found {reader.TokenType}."); + } + + reader.Read(); + } + + return new CompositeKey([.. args]); + } + + /// + public override void Write(Utf8JsonWriter writer, CompositeKey value, JsonSerializerOptions options) + { + if (value.Args.Length == 0) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + + foreach (var arg in value.Args) + { + if (arg is null) + { + writer.WriteNullValue(); + continue; + } + + writer.WriteStartObject(); + + _ = arg switch + { + string str => JsonWrite(writer, "string", () => writer.WriteStringValue(str)), + char c => JsonWrite(writer, "char", () => writer.WriteStringValue(c.ToString())), + short s => JsonWrite(writer, "short", () => writer.WriteNumberValue(s)), + int i => JsonWrite(writer, "int", () => writer.WriteNumberValue(i)), + long l => JsonWrite(writer, "long", () => writer.WriteNumberValue(l)), + Guid g => JsonWrite(writer, "guid", () => writer.WriteStringValue(g)), + DateTime d => JsonWrite(writer, "datetime", () => writer.WriteStringValue(d)), + DateTimeOffset o => JsonWrite(writer, "datetimeoffset", () => writer.WriteStringValue(o)), + ushort us => JsonWrite(writer, "ushort", () => writer.WriteNumberValue(us)), + uint ui => JsonWrite(writer, "uint", () => writer.WriteNumberValue(ui)), + ulong ul => JsonWrite(writer, "ulong", () => writer.WriteNumberValue(ul)), + _ => throw new JsonException($"Unsupported {nameof(CompositeKey)} type '{arg.GetType().Name}'.") + }; + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + /// + /// Provides a simple means to write a JSON property name and value. + /// + private static bool JsonWrite(Utf8JsonWriter writer, string name, Action action) + { + writer.WritePropertyName(name); + action(); + return true; + } + } + } +} \ No newline at end of file diff --git a/src/CoreEx/Text/Json/JsonSerializer.cs b/src/CoreEx/Text/Json/JsonSerializer.cs index bd366928..a023f63b 100644 --- a/src/CoreEx/Text/Json/JsonSerializer.cs +++ b/src/CoreEx/Text/Json/JsonSerializer.cs @@ -25,7 +25,8 @@ public class JsonSerializer : IJsonSerializer /// = false. /// = . /// = . - /// = , , and . + /// = , , , + /// , and . /// /// public static Stj.JsonSerializerOptions DefaultOptions { get; set; } = new Stj.JsonSerializerOptions(Stj.JsonSerializerDefaults.Web) @@ -34,7 +35,7 @@ public class JsonSerializer : IJsonSerializer WriteIndented = false, DictionaryKeyPolicy = SubstituteNamingPolicy.Substitute, PropertyNamingPolicy = SubstituteNamingPolicy.Substitute, - Converters = { new JsonStringEnumConverter(), new ExceptionConverterFactory(), new ReferenceDataConverterFactory(), new CollectionResultConverterFactory(), new ResultConverterFactory() } + Converters = { new JsonStringEnumConverter(), new ExceptionConverterFactory(), new ReferenceDataConverterFactory(), new CollectionResultConverterFactory(), new ResultConverterFactory(), new CompositeKeyConverterFactory() } }; /// diff --git a/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs b/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs index 9bb6d98b..a1adda17 100644 --- a/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs +++ b/tests/CoreEx.Test/Framework/Entities/CompositeKeyTest.cs @@ -80,7 +80,7 @@ public void KeyCopy() public void KeyToString_And_CreateFromString() { var ck = new CompositeKey(); - Assert.That(ck.ToString(), Is.EqualTo(string.Empty)); + Assert.That(ck.ToString(), Is.Null); ck = CompositeKey.CreateFromString(ck.ToString()); Assert.That(ck.Args, Has.Length.EqualTo(0)); @@ -170,6 +170,17 @@ public void KeyToString_And_CreateFromString() Assert.That(ck.Args[1], Is.EqualTo("b")); Assert.That(ck.Args[2], Is.EqualTo("c")); }); + + ck = new CompositeKey("a,a", "b,b", "c,c"); + Assert.That(ck.ToString(), Is.EqualTo("a\\u002ca,b\\u002cb,c\\u002cc")); + ck = CompositeKey.CreateFromString(ck.ToString()); + Assert.That(ck.Args, Has.Length.EqualTo(3)); + Assert.Multiple(() => + { + Assert.That(ck.Args[0], Is.EqualTo("a,a")); + Assert.That(ck.Args[1], Is.EqualTo("b,b")); + Assert.That(ck.Args[2], Is.EqualTo("c,c")); + }); } [Test] @@ -198,7 +209,7 @@ public void KeySerializeDeserialize() Assert.That(ck, Is.EqualTo(new CompositeKey(88))); ck = new CompositeKey((int?)null); - Assert.That(ck.ToJsonString(), Is.EqualTo("[{}]")); + Assert.That(ck.ToJsonString(), Is.EqualTo("[null]")); ck = CompositeKey.CreateFromJson(ck.ToJsonString()); Assert.That(ck, Is.EqualTo(new CompositeKey((int?)null))); @@ -210,7 +221,7 @@ public void KeySerializeDeserialize() ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); - Assert.That(ck.ToJsonString(), Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"date\":\"1970-01-22T00:00:00\"},{\"date\":\"2000-01-22T20:59:43Z\"},{\"offset\":\"2000-01-22T20:59:43-08:00\"}]")); + Assert.That(ck.ToJsonString(), Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); var ck2 = ck; ck = CompositeKey.CreateFromJson(ck.ToJsonString()); @@ -220,10 +231,10 @@ public void KeySerializeDeserialize() [Test] public void KeyDeserializeErrors() { - Assert.Throws(() => CompositeKey.CreateFromJson("{}")); - Assert.Throws(() => CompositeKey.CreateFromJson("[[]]")); - Assert.Throws(() => CompositeKey.CreateFromJson("[{\"xxx\":1}]")); - Assert.Throws(() => CompositeKey.CreateFromJson("[{\"int\":\"x\"}]")); + Assert.Throws(() => CompositeKey.CreateFromJson("{}")); + Assert.Throws(() => CompositeKey.CreateFromJson("[[]]")); + Assert.Throws(() => CompositeKey.CreateFromJson("[{\"xxx\":1}]")); + Assert.Throws(() => CompositeKey.CreateFromJson("[{\"int\":\"x\"}]")); } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs index 86c5e484..51bd9471 100644 --- a/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs +++ b/tests/CoreEx.Test/Framework/Events/CloudEventSerializerTest.cs @@ -133,7 +133,8 @@ public async Task NewtonsoftJson_Serialize_Deserialize2() PartitionKey = "pid", ETag = "etag", Attributes = new Dictionary { { "fruit", "bananas" } }, - Value = new Product { Id = "A", Name = "B", Price = 1.99m } + Value = new Product { Id = "A", Name = "B", Price = 1.99m }, + Key = "A" }; internal static EventData CreateProductEvent2() => new() @@ -141,14 +142,15 @@ public async Task NewtonsoftJson_Serialize_Deserialize2() Id = "id", Timestamp = new DateTime(2022, 02, 22, 22, 02, 22, DateTimeKind.Utc), Value = new Product { Id = "A", Name = "B", Price = 1.99m }, - CorrelationId = "cid" + CorrelationId = "cid", + Key = "A" }; - private const string CloudEvent1 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; + private const string CloudEvent1 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"key\":\"A\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; - private const string CloudEvent2 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"correlationid\":\"cid\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; + private const string CloudEvent2 = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"correlationid\":\"cid\",\"key\":\"A\",\"datacontenttype\":\"application/json\",\"data\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99}}"; - private const string CloudEvent1Attachement = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"}}"; + private const string CloudEvent1Attachement = "{\"specversion\":\"1.0\",\"id\":\"id\",\"time\":\"2022-02-22T22:02:22Z\",\"type\":\"product.created\",\"source\":\"product/a\",\"subject\":\"product\",\"action\":\"created\",\"correlationid\":\"cid\",\"partitionkey\":\"pid\",\"tenantid\":\"tid\",\"etag\":\"etag\",\"key\":\"A\",\"fruit\":\"bananas\",\"datacontenttype\":\"application/json\",\"data\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"}}"; internal static EventStorage CreateEventStorage(string? data = null, int? max = null) => new(data) { MaxDataSize = max ?? 100000 }; diff --git a/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs index e41b18d3..4fbc4503 100644 --- a/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs +++ b/tests/CoreEx.Test/Framework/Events/EventDataFormatterTest.cs @@ -3,6 +3,7 @@ using CoreEx.TestFunction.Models; using NUnit.Framework; using System; +using System.Text.Json.Serialization; using UnitTestEx.NUnit; namespace CoreEx.Test.Framework.Events @@ -178,11 +179,44 @@ public void ETagDefaultGenerated() Assert.That(ed.ETag, Is.EqualTo("0rk/Eu4Si62XCw/qDYxqLh9fhNR/4rrAijmAigS0NDM=")); } + [Test] + public void FormattableValue() + { + var ed = new EventData { Value = new SalesOrderItem { OrderNo = "X400", ItemNo = 10, ProductId = "abc" } }; + var ef = new EventDataFormatter(); + ef.Format(ed); + + Assert.Multiple(() => + { + Assert.That(ed.Key, Is.EqualTo("X400,10")); + Assert.That(ed.HasAttributes, Is.True); + Assert.That(ed.Attributes!["_SessionId"], Is.EqualTo("abc")); + }); + } + internal class Person : IETag { public string? Name { get; set; } public string? ETag { get; set; } } + + internal class SalesOrderItem : IPrimaryKey, IEventDataFormatter + { + public string? OrderNo { get; set; } + + public int ItemNo { get; set; } + + public string? ProductId { get; set; } + + [JsonIgnore] + public CompositeKey PrimaryKey => new CompositeKey(OrderNo, ItemNo); + + void IEventDataFormatter.Format(EventData eventData) + { + var p = (SalesOrderItem)eventData.Value!; + eventData.AddAttribute("_SessionId", p.ProductId!); + } + } } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs b/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs index 9b99892a..51294c56 100644 --- a/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs +++ b/tests/CoreEx.Test/Framework/Events/EventDataSerializerTest.cs @@ -268,11 +268,11 @@ public async Task NewtonsoftText_Serialize_Deserialize_ValueOnly2() ObjectComparer.Assert(new EventData(), ed2); } - private const string CloudEvent1 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; + private const string CloudEvent1 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; - private const string CloudEvent2 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\"}"; + private const string CloudEvent2 = "{\"value\":{\"id\":\"A\",\"name\":\"B\",\"price\":1.99},\"id\":\"id\",\"type\":\"coreex.testfunction.models.product\",\"source\":\"null\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\"}"; - private const string CloudEvent1Attachment = "{\"value\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; + private const string CloudEvent1Attachment = "{\"value\":{\"contentType\":\"application/json\",\"attachment\":\"bananas.json\"},\"id\":\"id\",\"subject\":\"product\",\"action\":\"created\",\"type\":\"product.created\",\"source\":\"product/a\",\"timestamp\":\"2022-02-22T22:02:22+00:00\",\"correlationId\":\"cid\",\"key\":\"A\",\"tenantId\":\"tid\",\"partitionKey\":\"pid\",\"etag\":\"etag\",\"attributes\":{\"fruit\":\"bananas\"}}"; } } \ No newline at end of file diff --git a/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs b/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs index 6bb49451..2fc6a463 100644 --- a/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs +++ b/tests/CoreEx.Test/Framework/Json/JsonSerializerTest.cs @@ -297,6 +297,45 @@ public void SystemTextJson_Serialize_CollectionResult() }); } + [Test] + public void SystemTextJson_Serialize_CompositeKey() + { + var js = new CoreEx.Text.Json.JsonSerializer() as IJsonSerializer; + + var ck = CompositeKey.Empty; + var json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("null")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey((int?)null); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[null]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey(88); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[{\"int\":88}]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), + new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + } + #endregion #region NewtonsoftJson @@ -612,6 +651,45 @@ public void NewtonsoftJson_Serialize_CollectionResult() }); } + [Test] + public void Newtonsoft_Serialize_CompositeKey() + { + var js = new CoreEx.Newtonsoft.Json.JsonSerializer() as IJsonSerializer; + + var ck = CompositeKey.Empty; + var json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("null")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey((int?)null); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[null]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey(88); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[{\"int\":88}]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + + ck = new CompositeKey("text", 'x', short.MinValue, int.MinValue, long.MinValue, ushort.MaxValue, uint.MaxValue, long.MaxValue, Guid.Parse("8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc"), + new DateTime(1970, 01, 22, 0, 0, 0, DateTimeKind.Unspecified), new DateTime(2000, 01, 22, 20, 59, 43, DateTimeKind.Utc), new DateTimeOffset(2000, 01, 22, 20, 59, 43, TimeSpan.FromHours(-8))); + json = js.Serialize(ck); + Assert.Multiple(() => + { + Assert.That(json, Is.EqualTo("[{\"string\":\"text\"},{\"char\":\"x\"},{\"short\":-32768},{\"int\":-2147483648},{\"long\":-9223372036854775808},{\"ushort\":65535},{\"uint\":4294967295},{\"long\":9223372036854775807},{\"guid\":\"8bd5f616-ed6b-4fc5-9cb8-4472cc8955fc\"},{\"datetime\":\"1970-01-22T00:00:00\"},{\"datetime\":\"2000-01-22T20:59:43Z\"},{\"datetimeoffset\":\"2000-01-22T20:59:43-08:00\"}]")); + Assert.That(js.Deserialize(json), Is.EqualTo(ck)); + }); + } + #endregion public class Person