Search code examples
c#jsonsystem.text.json

Can I force System.Text.Json.JsonSerializer to work non-recursively?


I basically have a graph-like structure. Something like this:

public class Whole {
    public List<Node> allPossibleNodes = new List<Node>();
}

public class Node {
    public List<Node> neighbors = new List<Node>();
}

Now I want to serialize this using following code:

public class WriteTest {
    static JsonSerializerOptions options = new () {
        WriteIndented = true,
        IncludeFields = true,
        IgnoreReadOnlyProperties = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        ReferenceHandler = ReferenceHandler.Preserve,
    };

    public static void write() {
        var w = new Whole();
        Node? p = null; //previous
        for (int i = 0; i < 3; i++) {
            var n = new Node();
            w.allPossibleNodes.Add(n);
            if (p != null) {
                n.neighbors.Add(p);
                p.neighbors.Add(n);
            }
            p = n;
        }


        var json = JsonSerializer.Serialize(w, options);
        File.WriteAllText("test.json", json);
    }

    public static void read() {
        var json = File.ReadAllText("test2.json");
        var w = JsonSerializer.Deserialize<Whole>(json, options);
    }
}

It produces following output:

{
  "$id": "1",
  "allPossibleNodes": {
    "$id": "2",
    "$values": [
      {
        "$id": "3",
        "neighbors": {
          "$id": "4",
          "$values": [
            {
              "$id": "5",
              "neighbors": {
                "$id": "6",
                "$values": [
                  {
                    "$ref": "3"
                  },
                  {
                    "$id": "7",
                    "neighbors": {
                      "$id": "8",
                      "$values": [
                        {
                          "$ref": "5"
                        }
                      ]
                    }
                  }
                ]
              }
            }
          ]
        }
      },
      {
        "$ref": "5"
      },
      {
        "$ref": "7"
      }
    ]
  }
}

Which is problematic in that:

  1. It will eventually throw me an exception:

"JsonException: 'A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64."

or if I set options.MaxDepth = Int.MaxValue; it could potentially overflow the stack.

  1. I't just ugly, human-non-readable garbage.

What I would like to have is something like this:

{
  "$id": "1",
  "allPossibleNodes": {
    "$id": "2",
    "$values": [
      {
        "$id": "3",
        "neighbors": {
          "$id": "6",
          "$values": [
            {
                "$ref": "4"
            }
          ]
        }
      },
      {
        "$id": "4",
        "neighbors": {
          "$id": "7",
          "$values": [
            {
                "$ref": "3"
            },
            {
                "$ref": "5"
            },
          ]
        }
      },
      {
        "$id": "5",
        "neighbors": {
          "$id": "8",
          "$values": [
            {
                "$ref": "4"
            }
          ]
        }
      }
    ]
  }
}

But unfortunately the serializer can't even read that, crying with "'Reference '4' was not found..."

Is there an option to make this work?


Solution

  • There is no way to do exactly what you want. System.Text.Json is a single pass serializer so it cannot look forward to resolve a reference.

    As a workaround, you could disable direct serialization of Node.neighbors and replaces Whole with a DTO that serializes the allPossibleNodes list and a table of neighbors as two separate, sequential properties. Doing so would limit the recursion depth to something manageable.

    To do this, modify Whole and Node as follows, introducing a custom converter for Whole:

    [JsonConverter(typeof(WholeConverter))]
    public class Whole {
        public List<Node> allPossibleNodes = new List<Node>();
    }
    
    public class Node {
        [JsonIgnore]
        public List<Node> neighbors = new List<Node>();
    }
    
    class WholeConverter : JsonConverter<Whole>
    {
        struct WholeDto
        {
            [JsonPropertyOrder(1)]
            public List<Node>? allPossibleNodes { get; set; }
            [JsonPropertyOrder(2)]
            public IEnumerable<NeighborItem>? neighborTable { get; set; }
        }
        
        record struct NeighborItem(Node node, List<Node> neighbors);
    
        public override Whole? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var dto = JsonSerializer.Deserialize<WholeDto>(ref reader, options);
            foreach (var item in dto.neighborTable ?? Enumerable.Empty<NeighborItem>().Where(i => i.node != null))
                item.node.neighbors = item.neighbors;
            var whole = new Whole();
            if (dto.allPossibleNodes != null)
                whole.allPossibleNodes = dto.allPossibleNodes;
            return whole;
        }
    
        public override void Write(Utf8JsonWriter writer, Whole value, JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer,
                new WholeDto 
                { 
                    allPossibleNodes = value.allPossibleNodes,
                    neighborTable = value.allPossibleNodes.Where(n => n.neighbors.Count > 0).Select(n => new NeighborItem(n, n.neighbors)),
                },
                options);
    }
    

    The resulting JSON will look like:

    {
      "allPossibleNodes": {
        "$id": "1",
        "$values": [
          {
            "$id": "2"
          },
          {
            "$id": "3"
          },
          {
            "$id": "4"
          }
        ]
      },
      "neighborTable": {
        "$id": "5",
        "$values": [
          {
            "node": {
              "$ref": "2"
            },
            "neighbors": {
              "$id": "6",
              "$values": [
                {
                  "$ref": "3"
                }
              ]
            }
          },
          {
            "node": {
              "$ref": "3"
            },
            "neighbors": {
              "$id": "7",
              "$values": [
                {
                  "$ref": "2"
                },
                {
                  "$ref": "4"
                }
              ]
            }
          },
          {
            "node": {
              "$ref": "4"
            },
            "neighbors": {
              "$id": "8",
              "$values": [
                {
                  "$ref": "3"
                }
              ]
            }
          }
        ]
      }
    }
    

    It's not quite as clean as your desired JSON, but it's still manageable.

    Notes:

    • The code assumes that a Node will only ever be serialized from with a Whole.allPossibleNodes list, and that this list does in fact contain all reachable nodes.

      If these assumptions are not correct, marking neighbors with [JsonIgnore] may cause problems.

    • If file size is a problem, be sure to set WriteIndented = false instead of true.

    • Setting options.MaxDepth = Int.MaxValue is no longer required. Nodes will not be serialized recursively, so serialization depth will be capped.

    Demo fiddle here.