1

When I deserialize some JSON into this F# class, the property setter for the CountResults property is not working as expected.

I have this class:

type CounterResponse () =
       
    let mutable countsList:List<CountResult> = List<CountResult>()
    let mutable countResultsLookup:IReadOnlyDictionary<string,int> = readOnlyDict [("", 0)]
    
    member this.CountResults
    with get() = countsList
    and set(value:List<CountResult>) =
        countResultsLookup <- value |> Seq.map(fun c -> c.Name, c.Count) |> readOnlyDict
        countsList <- value
    ...

If I create an instance of this class with F# using:

let countResults = seq {
    {Name = "Count01"; Count = count}
    {Name = "Count02"; Count = count}
    {Name = "Count03"; Count = count}
}
let response = CountersResponse(CountResults = List<CountResult>(countResults))

Then the CountResults property setter runs and sets the values of the private backing fields countsList and countResultsLookup.

However, when I create an instance of this class with NewtonSoft.Json only the countsList backing field is set and I can't get the breakpoint in the setter code to stop. It appears that NewtonSoft is bypassing my property setter code, finding the countsList backing field, and setting it directly.

let response = JsonConvert.DeserializeObject<CounterResponse>(
    """
    {
        "countResults": [
            { "name": "Count01","count": 4 },
            { "name": "Count02", "count": 2 },
            { "name": "Count3", "count": 1 },
        ],
        "version": "6.0.0.0",
        "errorSection": {
            "validationErrors": [],
            "code": "200",
            "message": "Success"
        }
    }
    """
)

In the above example the backing field countResultsLookup is set to the default value readOnlyDict [("", 0)] and countList contains the 3 counts in the JSON.

Is there an option to tell NewtonSoft to use the property setter so that I can get the expected results in both cases?

Matthew MacFarland
  • 2,413
  • 3
  • 26
  • 34

1 Answers1

2

Your problem here is the same as the problem from Why are all the collections in my POCO are null when deserializing some valid json with the .NET Newtonsoft.Json component. When Json.NET deserializes a member whose value is a mutable list, it calls the getter to see if the list has already been constructed. If so, it populates the returned list as-is, and never sets it back. Thus, since your CountResults property is in fact pre-allocated, the logic to build the countResultsLookup dictionary is never invoked.

To work around the problem, you could adopt one of the solutions from that question such as marking the CountResults property with [<JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)>]:

[<JsonProperty(ObjectCreationHandling = ObjectCreationHandling.Replace)>]
member this.CountResults
    with get() = countsList
    and set(value:List<CountResult>) =
        countResultsLookup <- value |> Seq.map(fun c -> c.Name, c.Count) |> readOnlyDict
        countsList <- value

This requires Json.NET to construct a fresh list and set it back after it is fully deserialized.

Demo fiddle #1 here.

That being said, I can't really recommend this design. Your type CounterResponse contains a mutable list property and a read-only dictionary that provides lookups into the list -- but makes no effort to keep these two collections synchronized. If you need mutability for your CountResults, consider creating a custom collection that inherits from KeyedCollection<TKey,TItem> and provides the necessary lookup facility.

Assuming that CountResult looks like this:

type CountResult = { Name : string; Count : int }

You can define CounterResponse as follows:

type CountResultCollection() = 
    inherit System.Collections.ObjectModel.KeyedCollection<string,CountResult>()
    override this.GetKeyForItem i = i.Name

type CounterResponse (l : CountResult seq) =
    let countList = CountResultCollection()
    do
        for c in l do countList.Add(c)
    new() = CounterResponse(Seq.empty)
    member this.CountResults = countList

And now the methods CountResults.Item(s : string) and CountResults.TryGetValue() will be usable to locate count results by name.

Demo fiddle #2 here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • This is amazing. Everything needed to solve this problem on a silver platter! The addition of the attribute worked perfectly. It's tempting to just leave it at that, but your feedback about the issues with the class design are solid. I'm going to work through that next. – Matthew MacFarland Dec 28 '22 at 13:40
  • I updated the class to use the keyed collection as you suggested. That's a way better approach than what I was doing before. Very nice work on this answer. I learned a lot! – Matthew MacFarland Dec 28 '22 at 14:47