0

I have a simple type containing a property that is IDictionary<decimal, int>:

    public class CountEvent
{
    [JsonProperty(PropertyName = "_ts")]
    public DateTime Timestamp { get; set; }
    public String Pair { get; set; }
    [JsonProperty(PropertyName = "dir")]
    public String Direction { get; set; }
    public IDictionary<Decimal, Int32> Data { get; set; }

    public RateCountEvent()
    {
        Data = new Dictionary<Decimal, Int32>();
    }
}

I used IDictionary on purpose, since I provide either Dictionary or SortedDictionary instance at runtime. Changing property type to Dictionary class doesn't affect the behaviour.
I would like to provide custom serialization logic for Decimal (namely, remove trailing zeroes), and I've written a class for that:

    public class DecimalWithoutTrailingZerosConverter: JsonConverter
{
    private readonly IFormatProvider formatProvider;

    public DecimalWithoutTrailingZerosConverter(IFormatProvider formatProvider)
    {
        this.formatProvider = formatProvider;
    }

    public override Boolean CanConvert(Type objectType)
    {
        return objectType == typeof(Decimal);
    }

    public override Object ReadJson(JsonReader reader, Type objectType, Object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType != JsonToken.String)
            throw new Exception("Wrong Token Type");

        return Convert.ToDecimal(reader.Value, formatProvider);
    }

    public override void WriteJson(JsonWriter writer, Object value, JsonSerializer serializer)
    {
        String val;

        if (value is decimal)
        {
            val = ((Decimal) value).ToString("G29", formatProvider);
        }
        else
        {
            throw new Exception("Expected date object value.");
        }
        writer.WriteValue(val);
    }
}

Yep, I know JSON serialization is typically culture-invariant. Not my point here.
The thing is, when I add my converter to serializer settings, it is considered (CanConvert method is hit by debugger), but Decimal type never shows in there as objectType parameter, while all the other types do (my type CountEvent, DateTime, String, Dictionary (I think it consideres run-time type) and Int32).
I've looked at the library code and it seems there's a custom logic in KeyValuePairConverter class that serializes dictionaries (I assume).
The question is - is behaviour I'm seeing right? Can it be overriden?
P.S. I'm not saying the library is wrong (after all, thousands of people use it every day), I'm just trying to find a way to make it work for this scenario.

10
  • The ctor for your class CountEvent is incorrect. Commented Sep 6, 2016 at 10:54
  • also you're never sending decimal , a dictionary instead. Commented Sep 6, 2016 at 11:01
  • @AmitKumarGhosh how so? ctor should be empty? Commented Sep 6, 2016 at 11:01
  • about never sending a decimal - I understand that, but Int32 type is considered by CanConvert method. shouldn't Decimal be too? Commented Sep 6, 2016 at 11:02
  • apparantly the Int32 isn't related to the Dictionary<decimal, int>. All the property getters are getting called for their types, but not for that last Int32 consideration. Commented Sep 6, 2016 at 11:07

1 Answer 1

1

The reason your DecimalWithoutTrailingZerosConverter is not used for dictionary keys is that Json.NET does not serialize the keys - it merely converts them to strings. From the docs:

When serializing a dictionary, the keys of the dictionary are converted to strings and used as the JSON object property names. The string written for a key can be customized by either overriding ToString() for the key type or by implementing a TypeConverter. A TypeConverter will also support converting a custom string back again when deserializing a dictionary.

Thus, to get the output you need, you could override the system TypeConverter for decimal as explained here -- but I wouldn't really recommend it since this will change the converter used for decimal everywhere in your application, with various unforseeable consequences.

The alternative would be to write a custom JsonConverter for all dictionaries implementing IDictionary<decimal, TValue> for some TValue:

public class DecimalDictionaryWithoutTrailingZerosConverter : DecimalWithoutTrailingZerosConverterBase
{
    public DecimalDictionaryWithoutTrailingZerosConverter(IFormatProvider formatProvider) : base(formatProvider) { }

    public override Boolean CanConvert(Type objectType)
    {
        var types = objectType.GetDictionaryKeyValueTypes().ToList();
        return types.Count == 1 && types[0].Key == typeof(Decimal);
    }

    object ReadJsonGeneric<TValue>(JsonReader reader, Type objectType, IDictionary<decimal, TValue> existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
            return null;
        if (reader.TokenType != JsonToken.StartObject)
            throw new JsonSerializationException("Invalid object type " + reader.TokenType);
        if (existingValue == null)
        {
            var contract = serializer.ContractResolver.ResolveContract(objectType);
            existingValue = (IDictionary<decimal, TValue>)contract.DefaultCreator();
        }
        while (reader.Read())
        {
            switch (reader.TokenType)
            {
                case JsonToken.Comment:
                    break;
                case JsonToken.PropertyName:
                    {
                        var name = reader.Value.ToString();
                        var key = TokenToDecimal(JsonToken.String, name);
                        if (!reader.Read())
                            throw new JsonSerializationException(string.Format("Missing value at path: {0}", reader.Path));
                        var value = serializer.Deserialize<TValue>(reader);
                        existingValue.Add(key, value);
                    }
                    break;
                case JsonToken.EndObject:
                    return existingValue;
                default:
                    throw new JsonSerializationException(string.Format("Unknown token {0} at path: {1} ", reader.TokenType, reader.Path));
            }
        }
        throw new JsonSerializationException(string.Format("Unclosed object at path: {0}", reader.Path));
    }

    public override Object ReadJson(JsonReader reader, Type objectType, Object existingValue, JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
            return null;
        try
        {
            var keyValueTypes = objectType.GetDictionaryKeyValueTypes().Single(); // Throws an exception if not exactly one.
            var method = GetType().GetMethod("ReadJsonGeneric", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
            var genericMethod = method.MakeGenericMethod(new[] { keyValueTypes.Value });
            return genericMethod.Invoke(this, new object[] { reader, objectType, existingValue, serializer });
        }
        catch (Exception ex)
        {
            if (ex is JsonException)
                throw;
            // Wrap the TypeInvocationException in a JsonSerializerException
            throw new JsonSerializationException("Failed to deserialize " + objectType, ex);
        }
    }

    void WriteJsonGeneric<TValue>(JsonWriter writer, IDictionary<decimal, TValue> value, JsonSerializer serializer)
    {
        writer.WriteStartObject();
        foreach (var pair in value)
        {
            writer.WritePropertyName(DecimalToToken(pair.Key));
            serializer.Serialize(writer, pair.Value);
        }
        writer.WriteEndObject();
    }

    public override void WriteJson(JsonWriter writer, Object value, JsonSerializer serializer)
    {
        try
        {
            var keyValueTypes = value.GetType().GetDictionaryKeyValueTypes().Single(); // Throws an exception if not exactly one.
            var method = GetType().GetMethod("WriteJsonGeneric", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
            var genericMethod = method.MakeGenericMethod(new[] { keyValueTypes.Value });
            genericMethod.Invoke(this, new object[] { writer, value, serializer });
        }
        catch (Exception ex)
        {
            if (ex is JsonException)
                throw;
            // Wrap the TypeInvocationException in a JsonSerializerException
            throw new JsonSerializationException("Failed to serialize " + value, ex);
        }
    }
}

public class DecimalWithoutTrailingZerosConverter : DecimalWithoutTrailingZerosConverterBase
{
    public DecimalWithoutTrailingZerosConverter(IFormatProvider formatProvider) : base(formatProvider) { }

    public override Boolean CanConvert(Type objectType)
    {
        return objectType == typeof(Decimal);
    }

    public override Object ReadJson(JsonReader reader, Type objectType, Object existingValue, JsonSerializer serializer)
    {
        return TokenToDecimal(reader.TokenType, reader.Value);
    }

    public override void WriteJson(JsonWriter writer, Object value, JsonSerializer serializer)
    {
        writer.WriteValue(DecimalToToken(value));
    }
}

public abstract class DecimalWithoutTrailingZerosConverterBase : JsonConverter
{
    private readonly IFormatProvider formatProvider;

    public DecimalWithoutTrailingZerosConverterBase(IFormatProvider formatProvider)
    {
        this.formatProvider = formatProvider;
    }

    protected string DecimalToToken(decimal value)
    {
        return value.ToString("G29", formatProvider);
    }

    protected string DecimalToToken(object value)
    {
        if (value is decimal)
        {
            return DecimalToToken((Decimal)value);
        }
        else
        {
            throw new JsonSerializationException("Expected date object value.");
        }
    }

    protected decimal TokenToDecimal(JsonToken tokenType, object value)
    {
        if (tokenType != JsonToken.String)
            throw new JsonSerializationException("Wrong Token Type");
        return Convert.ToDecimal(value, formatProvider);
    }
}

public static class TypeExtensions
{
    /// <summary>
    /// Return all interfaces implemented by the incoming type as well as the type itself if it is an interface.
    /// </summary>
    /// <param name="type"></param>
    /// <returns></returns>
    public static IEnumerable<Type> GetInterfacesAndSelf(this Type type)
    {
        if (type == null)
            throw new ArgumentNullException();
        if (type.IsInterface)
            return new[] { type }.Concat(type.GetInterfaces());
        else
            return type.GetInterfaces();
    }

    public static IEnumerable<KeyValuePair<Type, Type>> GetDictionaryKeyValueTypes(this Type type)
    {
        foreach (Type intType in type.GetInterfacesAndSelf())
        {
            if (intType.IsGenericType
                && intType.GetGenericTypeDefinition() == typeof(IDictionary<,>))
            {
                var args = intType.GetGenericArguments();
                if (args.Length == 2)
                    yield return new KeyValuePair<Type, Type>(args[0], args[1]);
            }
        }
    }
}

Then use it like:

var culture = new CultureInfo("de-DE");
var settings = new JsonSerializerSettings
{
    Converters = new JsonConverter[] { new DecimalWithoutTrailingZerosConverter(culture), new DecimalDictionaryWithoutTrailingZerosConverter(culture) },
    Formatting = Formatting.Indented,
};
var json = JsonConvert.SerializeObject(rateCountEvent, settings);

Note the use of a single converter for all types of decimal dictionary. Note also that the converter uses the existingValue if one is present. Thus if the constructor in your RateCountEvent allocates a sorted dictionary rather than a dictionary, the sorted dictionary will be populated.

Sample fiddle.

Incidentally, you might want to extend your DecimalWithoutTrailingZerosConverter to handle decimal? as well as decimal.

Sign up to request clarification or add additional context in comments.

1 Comment

the problem was solved in a similar way with a friend's help. thanks

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.