'Is this custom Composite Key type sound and performant?

I need to model a composite key type that could have any number of key / value pairs.

I need the key to be convertible into a hashed string value that can be stored in a database for querying. As part of this, I need the key to be able to be recreated at any time, with the possibility the key value pairs will be added in a different order. In my implementation I've figured using a SortedDictionary<string, string> will prevent issues with hashing and ordering.

I also need the type to have sound equality comparison in code.

I don't often need something like this, so I've created the below as a quick approach that could work.

I'm not sure if I'm overcooking this, and whether there's a simpler and/or more performant approach?

public class KeyDictionary
{
    public KeyDictionary()
    {
        Items = new SortedDictionary<string, string>();
    }

    public SortedDictionary<string, string> Items { get; init; }
}


class CompositeKey
{
    private readonly KeyDictionary keyDictionary;
    public CompositeKey()
    {
        keyDictionary = new KeyDictionary();
    }

    public CompositeKey(string base64)
    {
        var raw = Base64Decode(base64);
        var deserialized = JsonSerializer.Deserialize<KeyDictionary>(raw);
        
        if (deserialized == null)
        {
            throw new InvalidOperationException($"Attempted to deserialize a key into a sorted dictionary and got null. Key in base64 is {base64}");
        }

        keyDictionary = deserialized;
    }


    public void Add(string key, string value)
    {
        if (keyDictionary.Items.ContainsKey(key))
        {
            throw new InvalidOperationException($"Key {key} cannot be added more than once");
        }

        keyDictionary.Items.Add(key, value);
    }

    private static string Base64Decode(string base64EncodedData)
    {
        var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);
        return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
    }

    private static string Base64Encode(string plainText)
    {
        var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
        return Convert.ToBase64String(plainTextBytes);
    }

    public override string ToString()
    {
        return Base64Encode(JsonSerializer.Serialize(keyDictionary));
    }

    public override bool Equals(object? obj)
    {
        if (obj is not CompositeKey item)
        {
            return false;
        }

        return item.ToString() == ToString();
    }

    public override int GetHashCode()
    {
        return ToString().GetHashCode();
    }
}

In Use

// Creation of a key
var originalKey = new CompositeKey();
originalKey.Add("seasonId", "45045054");
originalKey.Add("matchId", "66724565");

// Hashed value of the key
var hasOfOriginalKey = originalKey.ToString();
Console.WriteLine(hasOfOriginalKey); // eyJJdGVtcyI6eyJhZHZhbnRhZ2VJZCI6IjY2NzI0NTY1Iiwic2Vhc29uSWQiOiI0NTA0NTA1NCJ9fQ==

// De hashing of the key
var dehashedKey = new CompositeKey(hasOfOriginalKey);
Console.WriteLine(originalKey.Equals(dehashedKey)); // True

// Creating the same key independently (with keys in different order).
var remadeKey = new CompositeKey();
remadeKey.Add("matchId", "66724565");
remadeKey.Add("seasonId", "45045054");
Console.WriteLine(originalKey.Equals(remadeKey)); // True


// This should not evaluate as equal to the original key
var differentKey = new CompositeKey();
differentKey.Add("matchId", "7767676");
differentKey.Add("seasonId", "45045054");
Console.WriteLine(originalKey.Equals(differentKey)); // False


Solution 1:[1]

I think you've overcooked it. I think you could just replace everything with a regular SortedDictionary<string, string> and some utility code that can do a few things like:

  1. Determine if two SortedDictionary<string, string> are equal
  2. Serialize/deserialize SortedDictionary<string, string> <-> JSON
  3. Encode/decode JSON <-> Base64

Significantly, #2 and #3 are already done for you with the JsonSerializer and Convert classes respectively.

So the only thing left for you to do is compare two SortedDictionary<string, string> for equality. And that can be done much faster than serializing the whole shebang into JSON/Base64 strings. For example:

static bool AreEqual(IReadOnlyDictionary<string, string> one, IReadOnlyDictionary<string, string> two)
{
  if (one.Count != two.Count)
    return false;
  return Enumerable
    .Zip(
      one,
      two,
      (kvp1, kvp2) => kvp1.Key == kvp2.Key && kvp1.Value == kvp2.Value
    )
    .All(x => x);
}

With (a working form of) the above function, your code becomes something like this:

string Serialize(object obj)
{
  var json = JsonSerializer.Serialize(obj);
  var utf8Bytes = System.Text.Encoding.UTF8.GetBytes(json);
  return Convert.ToBase64String(utf8Bytes);
}

SortedDictionary<string, string> Deserialize(string base64)
{
  var utf8Bytes = Convert.FromBase64String(base64);
  var json = System.Text.Encoding.UTF8.GetString(utf8Bytes);
  return JsonSerializer.Deserialize<SortedDictionary<string, string>>(json);
}

// Creation of a key
var originalKey = new SortedDictionary<string, string>();
originalKey["seasonId"] = "45045054";
originalKey["matchId"] = "66724565";

// Hashed value of the key
var hashOfOriginalKey = Serialize(originalKey);
Console.WriteLine(hashOfOriginalKey); // eyJJdGVtcyI6eyJhZHZhbnRhZ2VJZCI6IjY2NzI0NTY1Iiwic2Vhc29uSWQiOiI0NTA0NTA1NCJ9fQ==

// De hashing of the key
var dehashedKey = Deserialize(hashOfOriginalKey);
Console.WriteLine(AreEqual(dehashedKey, originalKey)); // True

// Creating the same key independently (with keys in different order).
var remadeKey = new SortedDictionary<string, string>();
remadeKey["matchId"] = "66724565";
remadeKey["seasonId"] = "45045054";
Console.WriteLine(AreEqual(originalKey, remadeKey)); // True


// This should not evaluate as equal to the original key
var differentKey = new SortedDictionary<string, string>();
differentKey["matchId"] = "7767676";
differentKey["seasonId"] = "45045054";
Console.WriteLine(AreEqual(originalKey, differentKey)); // False

Note: I'm aware that there will be a difference in the JSON produced by this code versus your code. Your code will produce JSON like this:

{
  "Items": {
    "key": "value"
  }
}

...but my code will produce JSON like this:

{
  "key": "value"
}

You can make that difference go away with sufficient hand-waving over my Serialize and Deserialize methods.


The above is just inspirational pseudocode written off the cuff. So don't be surprised if it needs some tweaks to compile ;)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Matt Thomas