Using JSON Patch to add values to a dictionary
Since there doesn't seem to be any official way to do it, I've come up with a Temporary Solution™ (read: a solution that works well enough so I'll probably keep it forever).
In order to make it seem like JSON Patch handles dictionary-like operations, I created a class called DynamicDeserialisationStore
which inherits from DynamicObject
and makes use of JSON Patch's support for dynamic objects.
More specifically, this class overrides methods like TrySetMember
, TrySetIndex
, TryGetMember
, etc. to essentially act like a dictionary, except that it delegates all these operations to callbacks provided to its constructor.
Implementation
The code below provides the implementation of DynamicDeserialisationStore
. It implements IDictionary<string, object>
(which is the signature JSON Patch requires to work with dynamic objects) but I only implement the bare minimum of the methods I require.
The problem with JSON Patch's support for dynamic objects is that it will set properties to JObject
instances i.e. it won't automatically perform deserialisation like it would when setting static properties, as it can't infer the type. DynamicDeserialisationStore
is parameterised on the type of object that it will try to automatically try to deserialise these JObject
instances to when they're set.
The class accepts callbacks to handle basic dictionary operations instead of maintaining an internal dictionary itself, because in my "real" system model code I don't actually use a dictionary (for various reasons) - I just make it appear that way to clients.
internal sealed class DynamicDeserialisationStore<T> : DynamicObject, IDictionary<string, object> where T : class
{
private readonly Action<string, T> storeValue;
private readonly Func<string, bool> removeValue;
private readonly Func<string, T> retrieveValue;
private readonly Func<IEnumerable<string>> retrieveKeys;
public DynamicDeserialisationStore(
Action<string, T> storeValue,
Func<string, bool> removeValue,
Func<string, T> retrieveValue,
Func<IEnumerable<string>> retrieveKeys)
{
this.storeValue = storeValue;
this.removeValue = removeValue;
this.retrieveValue = retrieveValue;
this.retrieveKeys = retrieveKeys;
}
public int Count
{
get
{
return this.retrieveKeys().Count();
}
}
private IReadOnlyDictionary<string, T> AsDict
{
get
{
return (from key in this.retrieveKeys()
let value = this.retrieveValue(key)
select new { key, value })
.ToDictionary(it => it.key, it => it.value);
}
}
public override bool TrySetIndex(SetIndexBinder binder, object[] indexes, object value)
{
if (indexes.Length == 1 && indexes[0] is string && value is JObject)
{
return this.TryUpdateValue(indexes[0] as string, value);
}
return base.TrySetIndex(binder, indexes, value);
}
public override bool TryGetIndex(GetIndexBinder binder, object[] indexes, out object result)
{
if (indexes.Length == 1 && indexes[0] is string)
{
try
{
result = this.retrieveValue(indexes[0] as string);
return true;
}
catch (KeyNotFoundException)
{
// Pass through.
}
}
return base.TryGetIndex(binder, indexes, out result);
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
return this.TryUpdateValue(binder.Name, value);
}
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
try
{
result = this.retrieveValue(binder.Name);
return true;
}
catch (KeyNotFoundException)
{
return base.TryGetMember(binder, out result);
}
}
private bool TryUpdateValue(string name, object value)
{
JObject jObject = value as JObject;
T tObject = value as T;
if (jObject != null)
{
this.storeValue(name, jObject.ToObject<T>());
return true;
}
else if (tObject != null)
{
this.storeValue(name, tObject);
return true;
}
return false;
}
object IDictionary<string, object>.this[string key]
{
get
{
return this.retrieveValue(key);
}
set
{
this.TryUpdateValue(key, value);
}
}
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
return this.AsDict.ToDictionary(it => it.Key, it => it.Value as object).GetEnumerator();
}
public void Add(string key, object value)
{
this.TryUpdateValue(key, value);
}
public bool Remove(string key)
{
return this.removeValue(key);
}
#region Unused methods
bool ICollection<KeyValuePair<string, object>>.IsReadOnly
{
get
{
throw new NotImplementedException();
}
}
ICollection<string> IDictionary<string, object>.Keys
{
get
{
throw new NotImplementedException();
}
}
ICollection<object> IDictionary<string, object>.Values
{
get
{
throw new NotImplementedException();
}
}
void ICollection<KeyValuePair<string, object>>.Add(KeyValuePair<string, object> item)
{
throw new NotImplementedException();
}
void ICollection<KeyValuePair<string, object>>.Clear()
{
throw new NotImplementedException();
}
bool ICollection<KeyValuePair<string, object>>.Contains(KeyValuePair<string, object> item)
{
throw new NotImplementedException();
}
bool IDictionary<string, object>.ContainsKey(string key)
{
throw new NotImplementedException();
}
void ICollection<KeyValuePair<string, object>>.CopyTo(KeyValuePair<string, object>[] array, int arrayIndex)
{
throw new NotImplementedException();
}
IEnumerator IEnumerable.GetEnumerator()
{
throw new NotImplementedException();
}
bool ICollection<KeyValuePair<string, object>>.Remove(KeyValuePair<string, object> item)
{
throw new NotImplementedException();
}
bool IDictionary<string, object>.TryGetValue(string key, out object value)
{
throw new NotImplementedException();
}
#endregion
}
Tests
The tests for this class are provided below. I create a mock system model (see image) and perform various JSON Patch operations on it.
Here's the code:
public class DynamicDeserialisationStoreTests
{
private readonly FooSystemModel fooSystem;
public DynamicDeserialisationStoreTests()
{
this.fooSystem = new FooSystemModel();
}
[Fact]
public void Store_Should_Handle_Adding_Keyed_Model()
{
// GIVEN the foo system currently contains no foos.
this.fooSystem.Foos.ShouldBeEmpty();
// GIVEN a patch document to store a foo called "test".
var request = "{\"op\":\"add\",\"path\":\"/foos/test\",\"value\":{\"number\":3,\"bazzed\":true}}";
var operation = JsonConvert.DeserializeObject<Operation<FooSystemModel>>(request);
var patchDocument = new JsonPatchDocument<FooSystemModel>(
new[] { operation }.ToList(),
new CamelCasePropertyNamesContractResolver());
// WHEN we apply this patch document to the foo system model.
patchDocument.ApplyTo(this.fooSystem);
// THEN the system model should now contain a new foo called "test" with the expected properties.
this.fooSystem.Foos.ShouldHaveSingleItem();
FooModel foo = this.fooSystem.Foos["test"] as FooModel;
foo.Number.ShouldBe(3);
foo.IsBazzed.ShouldBeTrue();
}
[Fact]
public void Store_Should_Handle_Removing_Keyed_Model()
{
// GIVEN the foo system currently contains a foo.
var testFoo = new FooModel { Number = 3, IsBazzed = true };
this.fooSystem.Foos["test"] = testFoo;
// GIVEN a patch document to remove a foo called "test".
var request = "{\"op\":\"remove\",\"path\":\"/foos/test\"}";
var operation = JsonConvert.DeserializeObject<Operation<FooSystemModel>>(request);
var patchDocument = new JsonPatchDocument<FooSystemModel>(
new[] { operation }.ToList(),
new CamelCasePropertyNamesContractResolver());
// WHEN we apply this patch document to the foo system model.
patchDocument.ApplyTo(this.fooSystem);
// THEN the system model should be empty.
this.fooSystem.Foos.ShouldBeEmpty();
}
[Fact]
public void Store_Should_Handle_Modifying_Keyed_Model()
{
// GIVEN the foo system currently contains a foo.
var originalFoo = new FooModel { Number = 3, IsBazzed = true };
this.fooSystem.Foos["test"] = originalFoo;
// GIVEN a patch document to modify a foo called "test".
var request = "{\"op\":\"replace\",\"path\":\"/foos/test\", \"value\":{\"number\":6,\"bazzed\":false}}";
var operation = JsonConvert.DeserializeObject<Operation<FooSystemModel>>(request);
var patchDocument = new JsonPatchDocument<FooSystemModel>(
new[] { operation }.ToList(),
new CamelCasePropertyNamesContractResolver());
// WHEN we apply this patch document to the foo system model.
patchDocument.ApplyTo(this.fooSystem);
// THEN the system model should contain a modified "test" foo.
this.fooSystem.Foos.ShouldHaveSingleItem();
FooModel foo = this.fooSystem.Foos["test"] as FooModel;
foo.Number.ShouldBe(6);
foo.IsBazzed.ShouldBeFalse();
}
#region Mock Models
private class FooModel
{
[JsonProperty(PropertyName = "number")]
public int Number { get; set; }
[JsonProperty(PropertyName = "bazzed")]
public bool IsBazzed { get; set; }
}
private class FooSystemModel
{
private readonly IDictionary<string, FooModel> foos;
public FooSystemModel()
{
this.foos = new Dictionary<string, FooModel>();
this.Foos = new DynamicDeserialisationStore<FooModel>(
storeValue: (name, foo) => this.foos[name] = foo,
removeValue: name => this.foos.Remove(name),
retrieveValue: name => this.foos[name],
retrieveKeys: () => this.foos.Keys);
}
[JsonProperty(PropertyName = "foos")]
public IDictionary<string, object> Foos { get; }
}
#endregion
}