Search code examples
c#dictionarytupleskeydeserialization

Deserializing Dictionary property with Tuple of enums as key


I'm working with a class (not of my creation) that has a property of type Dictionary, the key type of which is a Tuple of two enumerated types, and the value type of which is another class. I am having difficulty JSON-deserializing the class from the text produced by serialization. Let's suppose these are my enum types:

public enum Color {
  Red,
  Yellow,
  Blue,
  Black
}

public enum Shape {
  Round,
  Rectangular,
  Triangular,
  Hexagonal
}

The serialized class and the value type are defined thusly:

[DataContract]
public class ShelvingUnit {
  public Dictionary<Tuple<Color, Shape>, Stuff> _bins;

  [DataMember]
  public Dictionary<Tuple<Color, Shape>, Stuff> Bins { get { return _bins; } set { _bins = value; } }

  [DataMember]
  public List<Tuple<Color, Shape>> Classifiers { get; set; }

  public ShelvingUnit() {
    _bins = new Dictionary<Tuple<Color, Shape>, Stuff>();
    Classifiers = new List<Tuple<Color, Shape>>();
  }
}

public class Stuff {
  public string Purpose { get; set; }
  public int NumberOfThings { get; set; }
}

Come runtime, we'll instantiate the class and add three items to its Dictionary and two to its List like so…

    var shelving = new ShelvingUnit();
    shelving.Bins.Add(new Tuple<Color, Shape>(Color.Red, Shape.Round),
      new Stuff { NumberOfThings = 4, Purpose = "Let car move" });
    shelving.Bins.Add(new Tuple<Color, Shape>(Color.Blue, Shape.Rectangular),
      new Stuff { NumberOfThings = 500, Purpose = "Share contact info" });
    shelving.Bins.Add(new Tuple<Color, Shape>(Color.Yellow, Shape.Hexagonal),
      new Stuff { NumberOfThings = 370, Purpose = "Grow bees" });
    shelving.Classifiers.Add(new Tuple<Color, Shape>(Color.Black, Shape.Triangular));
    shelving.Classifiers.Add(new Tuple<Color, Shape>(Color.Blue, Shape.Round));
    string json = JsonConvert.SerializeObject(shelving);

…and then serializing it per that last statement produces a json value of:

{"Bins":{"(Red, Round)":{"Purpose":"Let car move","NumberOfThings":4},"(Blue, Rectangular)":{"Purpose":"Share contact info","NumberOfThings":500},"(Yellow, Hexagonal)":{"Purpose":"Grow bees","NumberOfThings":370}},"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}]}

When I use this line of code to try to deserialize this string, it gives me the following error:

var deserd = JsonConvert.DeserializeObject<ShelvingUnit>(json);

Newtonsoft.Json.JsonSerializationException: 'Could not convert string '(Red, Round)' to dictionary key type 'System.Tuple`2[MyNamespace.Color,MyNamespace.Shape]'. Create a TypeConverter to convert from the string to the key type object. Path '_bins['(Red, Round)']', line 1, position 25.'

Notice how different the mere List<Tuple…> appears. I added a string replacement routine to format the keys of Bins the way Classifiers is:

{"Bins":{{"Item1":0,"Item2":0}:{"Purpose":"Let car move","NumberOfThings":4},{"Item1":2,"Item2":1}:{"Purpose":"Share contact info","NumberOfThings":500},{"Item1":1,"Item2":3}:{"Purpose":"Grow bees","NumberOfThings":370}},"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}]}

But deserializing this gave me the error:

Newtonsoft.Json.JsonReaderException: 'Invalid property identifier character: {. Path 'Bins', line 1, position 9.'

Thought perhaps surrounding them in quotation marks might work:

{{"Bins":{"{"Item1":0,"Item2":0}":{"Purpose":"Let car move","NumberOfThings":4},"{"Item1":2,"Item2":1}":{"Purpose":"Share contact info","NumberOfThings":500},"{"Item1":1,"Item2":3}":{"Purpose":"Grow bees","NumberOfThings":370}},"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}]}}

But this just gave me:

Newtonsoft.Json.JsonReaderException: 'Invalid character after parsing property name. Expected ':' but got: I. Path 'Bins', line 1, position 12.'

So I tried creating a TypeConverter:

public class StringToBinsTupleConverter : TypeConverter {
  public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) {
    if (sourceType == typeof(string)) {
      return true;
    }

    return base.CanConvertFrom(context, sourceType);
  }

  public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) {
    if (value is string stringValue) {
      // Get enum values on either side of the comma
      var enumMatcher = new Regex(@"\w+", RegexOptions.IgnoreCase);
      var enumVals = enumMatcher.Matches(stringValue);

      if (Enum.TryParse<Color>(enumVals[0].Value, out Color theColor)) {
        if (Enum.TryParse<Shape>(enumVals[1].Value, out Shape theShape)) {
          return new Tuple<Color, Shape>(theColor, theShape);
        }
      }

      // It does get here, but…
      throw new FormatException($"Cannot convert '{stringValue}' to Tuple<Color, Shape>.");
    }

    return base.ConvertFrom(context, culture, value);
  }

  public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) {
    if (value is Tuple<Color, Shape> camTupleValue) {
      return string.Format("({0}, {1})", camTupleValue.Item1, camTupleValue.Item2);
    }

    return base.ConvertTo(context, culture, value, destinationType);
  }
}

I outfitted ShelvingUnit with [TypeConverter(typeof(StringToBinsTupleConverter))], but stringValue evaluated to "MyNamespace.ShelvingUnit" and I got:

Newtonsoft.Json.JsonSerializationException: 'Error converting value "MyNamespace.ShelvingUnit" to type 'MyNamespace.ShelvingUnit'. Path '', line 1, position 47.'' Inner Exception ArgumentException: Could not cast or convert from System.String to MyNamespace.ShelvingUnit.

No mention of the message in the exception thrown in my code above. So, where should I go from here? Is there a way to get StringToBinsTupleConverter to handle "(Red, Round)"? Do I need to extend the class to serialize the keys and values separately and reconstruct the Dictionary post-deserialization? Thanks…

EDIT: I looked at that link in @StevePy's comment and tried adding:

  [DataMember]
  public List<KeyValuePair<Tuple<Color, Shape>, Stuff>> SerializedBins {
    get { return _bins.ToList(); }
    set { _bins = value.ToDictionary(x => x.Key, x => x.Value); }
  }

And my JSON looks more interesting:

{"SerializedBins":[{"Key":{"Item1":0,"Item2":0},"Value":{"Purpose":"Let car move","NumberOfThings":4}},{"Key":{"Item1":2,"Item2":1},"Value":{"Purpose":"Share contact info","NumberOfThings":500}},{"Key":{"Item1":1,"Item2":3},"Value":{"Purpose":"Grow bees","NumberOfThings":370}}],"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}]}

And deserializing didn't throw an exception, but Bins was empty.

EDIT 2: I added some members to the ShelvingUnit class:

  [DataMember]
  public List<Tuple<Color, Shape>> TheKeys { get { return _bins.Keys.ToList(); } set { _theKeys = value; } }
  [DataMember]
  public List<Stuff> TheValues { get { return _bins.Values.ToList(); } set { _theValues = value; } }

  public ShelvingUnit() {
    _bins = [];
    Classifiers = [];
    _theKeys = [];
    _theValues = [];
  }

  [OnDeserialized]
  public void ReconstructBins(StreamingContext ctx) {
    for (var kvp = 0; kvp < _theKeys.Count; kvp++) {
      _bins.Add(_theKeys[kvp], _theValues[kvp]);
    }

    _theKeys.Clear();
    _theValues.Clear();
  }

My serialized JSON:

{"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}],"TheKeys":[{"Item1":0,"Item2":0},{"Item1":2,"Item2":1},{"Item1":1,"Item2":3}],"TheValues":[{"Purpose":"Let car move","NumberOfThings":4},{"Purpose":"Share contact info","NumberOfThings":500},{"Purpose":"Grow bees","NumberOfThings":370}]}

But the result is the same. Inside ReconstructBins, _theKeys and _theValues are empty, and thus so is Bins.

EDIT 3: I acted on @StevePy's reminder to make sure the JsonConvert.DefaultSettings assignment is done for both deserialization and the deserialization, and that serialized to:

{"Bins":[
[{"Item1":0,"Item2":0},{"Purpose":"Let car move","NumberOfThings":4}],
[{"Item1":2,"Item2":1},{"Purpose":"Share contact info","NumberOfThings":500}],
[{"Item1":1,"Item2":3},{"Purpose":"Grow bees","NumberOfThings":370}]
],
"Classifiers":[{"Item1":3,"Item2":2},{"Item1":2,"Item2":0}]}

...which in turn finally deserialized correctly! Unfortunately, the code this post was simplified from still gives me something like this:

Cannot deserialize the current JSON array (e.g. [1,2,3]) into type 'System.Collections.Generic.Dictionary'2[System.Tuple'2[MyNamespace.Color,MyNamespace.Shape],MyNamespace.Stuff]' because the type requires a JSON object (e.g. {"name":"value"}) to deserialize correctly. To fix this error either change the JSON to a JSON object (e.g. {"name":"value"}) or change the deserialized type to an array or a type that implements a collection interface (e.g. ICollection, IList) like List that can be deserialized from a JSON array. JsonArrayAttribute can also be added to the type to force it to deserialize from a JSON array. Path 'Bins'...

But, since his answer solved the question as stated, I will mark it appropriately.


Solution

  • Sometimes the most popular answer at the time isn't necessarily the best current option. I had a look through the answers on (Not ableTo Serialize Dictionary with Complex key using Json.net) and the answer from @osexpert and @Tal Aloni looks to have nailed it, where osexpert's solution with the ValueConverter deserialized the simpler class. Using that value converter set up as a global handler for Dictionary you can simply use:

    [DataMember]
    public Dictionary<Tuple<Color, Shape>, Stuff> Bins { get; set; } = [];
    

    With the converter wired up by default:

    JsonConvert.DefaultSettings = () => new JsonSerializerSettings
    {
        ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
        Converters = new JsonConverter[] { new DictionaryAsArrayJsonConverter() }.ToList()
    };
    

    And it looks to have worked just fine, the dictionary was deserialized without any need for an extra property. (Definitely upvote osexpert's answer if this solved the issue) Unsure about the performance / memory usage if you want to serialize particularly large sets of data, but indexing large sets via a Tuple is likely a bigger concern there. :)