-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #69 from dgg/feature/json-serialization
JSON serialization (plus EFCore)
- Loading branch information
Showing
43 changed files
with
4,427 additions
and
324 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Globalization; | ||
using MongoDB.Bson; | ||
using MongoDB.Bson.IO; | ||
using MongoDB.Bson.Serialization; | ||
using MongoDB.Bson.Serialization.Serializers; | ||
|
||
namespace NMoneys.Serialization.BSON; | ||
|
||
/// <summary> | ||
/// Allows customizing the serialization of monetary quantities | ||
/// </summary> | ||
public class MoneySerializer : StructSerializerBase<Money> | ||
{ | ||
// maps cannot be changed so the looked-up instance is cached | ||
private readonly Lazy<BsonClassMap<Money>> _map = new(() => | ||
(BsonClassMap<Money>)BsonClassMap.LookupClassMap(typeof(Money))); | ||
|
||
/// <inheritdoc /> | ||
public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value) | ||
{ | ||
context.Writer.WriteStartDocument(); | ||
|
||
writeAmount(context.Writer, value); | ||
|
||
writeCurrency(context, args, value); | ||
|
||
context.Writer.WriteEndDocument(); | ||
} | ||
|
||
/// <inheritdoc /> | ||
public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args) | ||
{ | ||
context.Reader.ReadStartDocument(); | ||
|
||
decimal amount = readAmount(context.Reader); | ||
CurrencyIsoCode currency = readCurrency(context, args); | ||
|
||
context.Reader.ReadEndDocument(); | ||
|
||
var deserialized = new Money(amount, currency); | ||
return deserialized; | ||
} | ||
|
||
#region write | ||
|
||
private void writeAmount(IBsonWriter writer, Money value) | ||
{ | ||
string amountName = _map.Value.GetMemberMap(m => m.Amount).ElementName; | ||
writer.WriteName(amountName); | ||
writer.WriteDecimal128(value.Amount); | ||
} | ||
|
||
private void writeCurrency(BsonSerializationContext context, BsonSerializationArgs args, Money value) | ||
{ | ||
var currencyMap = _map.Value.GetMemberMap(c => c.CurrencyCode); | ||
string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency"; | ||
context.Writer.WriteName(currencyName); | ||
currencyMap.GetSerializer().Serialize(context, args, value.CurrencyCode); | ||
} | ||
|
||
#endregion | ||
|
||
#region read | ||
|
||
private decimal readAmount(IBsonReader reader) | ||
{ | ||
var amountMap = _map.Value.GetMemberMap(m => m.Amount); | ||
reader.ReadName(amountMap.ElementName); | ||
BsonType type = reader.GetCurrentBsonType(); | ||
decimal amount; | ||
switch (type) | ||
{ | ||
// does not support floating point conversion | ||
case BsonType.String: | ||
string str = reader.ReadString(); | ||
amount = decimal.Parse(str, CultureInfo.InvariantCulture); | ||
break; | ||
case BsonType.Decimal128: | ||
Decimal128 dec = reader.ReadDecimal128(); | ||
amount = Decimal128.ToDecimal(dec); | ||
break; | ||
default: | ||
throw new NotSupportedException($"Cannot extract a monetary amount out of '{type.ToString()}'."); | ||
} | ||
|
||
return amount; | ||
} | ||
|
||
private CurrencyIsoCode readCurrency(BsonDeserializationContext context, BsonDeserializationArgs args) | ||
{ | ||
var currencyMap = _map.Value.GetMemberMap(m => m.CurrencyCode); | ||
string currencyName = char.IsLower(currencyMap.ElementName, 0) ? "currency" : "Currency"; | ||
context.Reader.ReadName(currencyName); | ||
var currency = (CurrencyIsoCode)currencyMap.GetSerializer() | ||
.Deserialize(context, args); | ||
return currency; | ||
} | ||
|
||
#endregion | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using MongoDB.Bson.Serialization; | ||
using MongoDB.Bson.Serialization.Serializers; | ||
|
||
namespace NMoneys.Serialization.BSON; | ||
|
||
public class QuantitySerializer : StructSerializerBase<Money> | ||
{ | ||
/// <inheritdoc /> | ||
public override void Serialize([NotNull]BsonSerializationContext context, BsonSerializationArgs args, Money value) | ||
{ | ||
context.Writer.WriteString(value.AsQuantity()); | ||
} | ||
|
||
/// <inheritdoc /> | ||
public override Money Deserialize([NotNull]BsonDeserializationContext context, BsonDeserializationArgs args) | ||
{ | ||
string quantity = context.Reader.ReadString(); | ||
|
||
Money deserialized = Money.Parse(quantity); | ||
return deserialized; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||
using System.Text.Json; | ||
|
||
namespace NMoneys.Serialization.EFCore; | ||
|
||
/// <summary> | ||
/// Defines conversions from an instance of <see cref="Money"/> in a model to a <see cref="string"/> in the store. | ||
/// </summary> | ||
public class JsonConverter : ValueConverter<Money, string> | ||
{ | ||
/// <summary> | ||
/// Initializes a new instance of the <see cref="JsonConverter"/> class. | ||
/// </summary> | ||
public JsonConverter(JsonSerializerOptions options) : base( | ||
m => JsonSerializer.Serialize(m, options), | ||
str => JsonSerializer.Deserialize<Money>(str, options)) { } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
using Microsoft.EntityFrameworkCore.ChangeTracking; | ||
|
||
namespace NMoneys.Serialization.EFCore; | ||
|
||
/// <summary> | ||
/// Specifies custom value snapshotting and comparison for <see cref="Money"/>. | ||
/// </summary> | ||
public class MoneyComparer : ValueComparer<Money> | ||
{ | ||
/// <summary> | ||
/// Creates a new <see cref="MoneyComparer"/>. A shallow copy will be used for the snapshot. | ||
/// </summary> | ||
public MoneyComparer() : base((x, y) => x.Equals(y), m => m.GetHashCode()) { } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | ||
|
||
namespace NMoneys.Serialization.EFCore; | ||
|
||
/// <summary> | ||
/// Defines conversions from an instance of <see cref="Money"/> in a model to a <see cref="string"/> in the store. | ||
/// </summary> | ||
public class QuantityConverter : ValueConverter<Money, string> | ||
{ | ||
/// <summary> | ||
/// Initializes a new instance of the <see cref="QuantityConverter"/> class. | ||
/// </summary> | ||
public QuantityConverter() : base( | ||
m => m.AsQuantity(), | ||
str => Money.Parse(str)) { } | ||
} |
190 changes: 190 additions & 0 deletions
190
src/NMoneys.Serialization/Json_NET/DescriptiveMoneyConverter.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Newtonsoft.Json; | ||
using Newtonsoft.Json.Serialization; | ||
|
||
namespace NMoneys.Serialization.Json_NET; | ||
|
||
/// <summary> | ||
/// Converts an monetary quantity <see cref="NMoneys.Money"/> to and from JSON. | ||
/// </summary> | ||
/// <remarks> | ||
/// <para>The serialized quantity would look something like: <c>{"Amount": 0, "Currency": "XXX"}</c>.</para> | ||
/// <para>Provides better JSON pointers for deserialization errors: better suited when not in control of serialization.</para> | ||
/// </remarks> | ||
public class DescriptiveMoneyConverter : JsonConverter<Money> | ||
{ | ||
private readonly bool _forceStringEnum; | ||
|
||
/// <param name="forceStringEnum">Ignore enum value configuration and force string representation of the | ||
/// <see cref="Money.CurrencyCode"/> when serializing.</param> | ||
public DescriptiveMoneyConverter(bool forceStringEnum = false) | ||
{ | ||
_forceStringEnum = forceStringEnum; | ||
} | ||
|
||
#region write | ||
|
||
/// <inheritdoc /> | ||
public override void WriteJson([NotNull] JsonWriter writer, Money value, [NotNull] JsonSerializer serializer) | ||
{ | ||
DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver; | ||
|
||
writer.WriteStartObject(); | ||
|
||
writeAmount(value.Amount, writer, resolver); | ||
writeCurrency(value.CurrencyCode, writer, serializer, resolver); | ||
|
||
writer.WriteEndObject(); | ||
} | ||
|
||
private static void writeAmount(decimal amount, JsonWriter writer, DefaultContractResolver? resolver) | ||
{ | ||
// non-pascal if "weird" resolver | ||
string amountName = resolver?.GetResolvedPropertyName("Amount") ?? "amount"; | ||
writer.WritePropertyName(amountName); | ||
writer.WriteValue(amount); | ||
} | ||
|
||
private void writeCurrency(CurrencyIsoCode currency, | ||
JsonWriter writer, JsonSerializer serializer, DefaultContractResolver? resolver) | ||
{ | ||
// non-pascal if "weird" resolver | ||
string currencyName = resolver?.GetResolvedPropertyName("Currency") ?? "currency"; | ||
writer.WritePropertyName(currencyName); | ||
if (_forceStringEnum) | ||
{ | ||
// ignore configured enum value convention, string it is | ||
writer.WriteValue(currency.ToString()); | ||
} | ||
else | ||
{ | ||
// follow configured enum value convention | ||
serializer.Serialize(writer, currency, typeof(CurrencyIsoCode)); | ||
} | ||
} | ||
|
||
#endregion | ||
|
||
#region read | ||
|
||
/// <inheritdoc /> | ||
public override Money ReadJson([NotNull] JsonReader reader, | ||
Type objectType, Money existingValue, | ||
bool hasExistingValue, [NotNull] JsonSerializer serializer) | ||
{ | ||
DefaultContractResolver? resolver = serializer.ContractResolver as DefaultContractResolver; | ||
|
||
// no need to read StartObject token (already read) | ||
|
||
decimal amount = readAmount(reader, resolver); | ||
CurrencyIsoCode currency = readCurrency(reader, serializer, resolver); | ||
|
||
// but EndObject needs to be read | ||
readEndObject(reader); | ||
|
||
return new Money(amount, currency); | ||
} | ||
|
||
private static decimal readAmount(JsonReader reader, DefaultContractResolver? resolver) | ||
{ | ||
readProperty(reader); | ||
ensurePropertyName("Amount", reader, resolver); | ||
decimal amount = readAmountValue(reader); | ||
return amount; | ||
} | ||
|
||
private static CurrencyIsoCode readCurrency(JsonReader reader, JsonSerializer serializer, DefaultContractResolver? resolver) | ||
{ | ||
readProperty(reader); | ||
ensurePropertyName("Currency", reader, resolver); | ||
CurrencyIsoCode currency = readCurrencyValue(reader, serializer); | ||
return currency; | ||
} | ||
|
||
private static void readEndObject(JsonReader reader) | ||
{ | ||
bool read = reader.Read(); | ||
if (!read || reader.TokenType != JsonToken.EndObject) | ||
{ | ||
throw buildException($"Expected token type '{JsonToken.EndObject}', but got '{reader.TokenType}'.", reader); | ||
} | ||
} | ||
|
||
private static JsonSerializationException buildException(string message, JsonReader reader, Exception? inner = null) | ||
{ | ||
IJsonLineInfo? info = reader as IJsonLineInfo; | ||
JsonSerializationException exception = (info == null || info.HasLineInfo()) ? | ||
new JsonSerializationException(message) : | ||
new JsonSerializationException(message, reader.Path, info.LineNumber, info.LinePosition, inner); | ||
return exception; | ||
} | ||
|
||
|
||
private static void readProperty(JsonReader reader) | ||
{ | ||
bool isRead = reader.Read(); | ||
if (!isRead || reader.TokenType != JsonToken.PropertyName) | ||
{ | ||
throw buildException($"Expected token type '{JsonToken.PropertyName}', but got '{reader.TokenType}'.", reader); | ||
} | ||
} | ||
|
||
private static void ensurePropertyName(string pascalSingleName, JsonReader reader, DefaultContractResolver? resolver) | ||
{ | ||
#pragma warning disable CA1308 | ||
string propName = resolver?.GetResolvedPropertyName(pascalSingleName) ?? pascalSingleName.ToLowerInvariant(); | ||
#pragma warning restore CA1308 | ||
bool matchAmount = StringComparer.Ordinal.Equals(reader.Value, propName); | ||
if (!matchAmount) | ||
{ | ||
throw buildException($"Expected property '{propName}', but got '{reader.Value}'.", reader); | ||
} | ||
} | ||
|
||
private static decimal readAmountValue(JsonReader reader) | ||
{ | ||
try | ||
{ | ||
var amount = reader.ReadAsDecimal(); | ||
if (!amount.HasValue) | ||
{ | ||
throw buildException("Amount should not be nullable.", reader); | ||
} | ||
|
||
return amount.Value; | ||
} | ||
catch (Exception ex) | ||
{ | ||
throw buildException("Could not read amount value.", reader, ex); | ||
} | ||
} | ||
|
||
private static CurrencyIsoCode readCurrencyValue(JsonReader reader, JsonSerializer serializer) | ||
{ | ||
bool read = reader.Read(); | ||
if (!read) | ||
{ | ||
throw buildException("Expected value token type.", reader); | ||
} | ||
|
||
CurrencyIsoCode currency = serializer.Deserialize<CurrencyIsoCode>(reader); | ||
ensureDefined(currency, reader); | ||
|
||
return currency; | ||
} | ||
|
||
private static void ensureDefined(CurrencyIsoCode maybeCurrency, JsonReader reader) | ||
{ | ||
try | ||
{ | ||
maybeCurrency.AssertDefined(); | ||
} | ||
catch (Exception ex) | ||
{ | ||
throw buildException($"Currency '{maybeCurrency}' not defined.", reader, ex); | ||
} | ||
} | ||
|
||
|
||
#endregion | ||
} |
Oops, something went wrong.