2

So I serialize and then use JsonConvert.PopulateObject to populate a Json back into a ScriptableObject, and that system works fine. It works fine with lists, arrays, anything that's on the first level.

This is my class (ManagedSubData inherits from ScriptableObject):

public class BuildableData : ManagedSubData
    {
        [SerializeField, JsonIgnore] private BuildableControllerV2 prefab;
        [SerializeField, JsonProperty] private float heightMove = 2f;
        [SerializeField, JsonProperty] private bool isStatic;
        [SerializeField, JsonProperty] private int initialQuantity = 1;
        [SerializeField, JsonProperty] private bool hasMinigame = false;
        [SerializeField, JsonProperty] private UpgradeLevel[] upgradeLevels;
    }

So basicly all of this works fine, I can use JsonIgnore, JsonProperty, If I need to use unity classes like Vector3, I have custom serializers. All works according to plan.. Except for the last property, UpgradeLevel, which is a custom serializable class :

[Serializable]
    public class UpgradeLevel
    {
        [SerializeField] private GameObject visual;
        [SerializeField, JsonIgnore] private int cost;
        [SerializeField] private Vector3 test;
    }

All 3 of these parameters, because they are either tagged with JsonIgnore or not tagged at all, once I populate, will default to their default. So, visual will become null, cost will become 0, and test will become 0,0,0.

With test and cost, it isn't an immediate problem, as if I tag them with JsonProperty it correctly gets that value. But visual is a GameObject reference, I can't and don't want to handle it, so it becomes null everytime and I have no alternative.

So the question is : Why would my custom UpgradeLevel class have this different behaviour for its fields? A setting somewhere perhaps? Contract resolver ?

BTW : I don't want to have to code a converter for every custom class like UpgradeLevel, we need to use this tool widely in the project and that would be inefficient

EDIT: These are my settings :

public static JsonSerializerSettings defaultSettings = new JsonSerializerSettings(){
            Converters = CreateConverters(),
            Formatting = Formatting.None,
            PreserveReferencesHandling = PreserveReferencesHandling.None,
            MissingMemberHandling = MissingMemberHandling.Ignore,
            ObjectCreationHandling = ObjectCreationHandling.Replace,
            TypeNameHandling = TypeNameHandling.All,
            ContractResolver = new NoPropertiesContractResolver()
        };
  • Are you sure that you want to use private fields and not public properties (even if they have a private setter)? – ChrisBD May 15 '23 at 12:56
  • I tried to set all fields to Public instead of private, doesn't change anything. Can't do properties, they need to be visible in the unity inspector. – Steven Goulet May 15 '23 at 13:06
  • 1
    Why it should... It is array and you don't set default value so it's null – Selvin May 15 '23 at 13:17
  • Are you hoping that `JsonConvert.PopulateObject()` will populate the existing array items from the JSON, matching them by index? If so, then unfortunately it doesn't do that, it replaces the old entries. But you could try applying `[[JsonConverter(typeof(ArrayMergeConverter))]` to `private UpgradeLevel[] upgradeLevels;` where `ArrayMergeConverter` comes from [this answer](https://stackoverflow.com/a/40432055/3744182) to [JsonSerializer.CreateDefault().Populate(..) resets my values](https://stackoverflow.com/q/40422136/3744182), I think that should handle the merge for you. – dbc May 15 '23 at 14:23
  • Forget that upgradeLevels is an array ; if it's just a single UpgradeLevel (not a collection), it still does the behaviour described above – Steven Goulet May 15 '23 at 14:35
  • @StevenGoulet - Adding `[JsonConverter(typeof(ArrayMergeConverter))]` to `[SerializeField, JsonProperty] private UpgradeLevel[] upgradeLevels;` seems to do the trick, see the mockup fiddle here: https://dotnetfiddle.net/vVuUXH. Note that the mockup is just a console app not a Unity app so you will need to try it yourself. – dbc May 15 '23 at 14:48
  • I also had to add `[JsonProperty]` to `UpgradeLevel.cost` and `UpgradeLevel.test`. – dbc May 15 '23 at 14:49
  • I *Forget that upgradeLevels is an array ; if it's just a single UpgradeLevel (not a collection), it still does the behaviour described above* -- I believe that is because you are using `ObjectCreationHandling.Replace`, which will discard and reallocate all pre-existing property values when deserializing or populating. I tried the converter with your settings here: https://dotnetfiddle.net/pH6gNT. I had to add `[JsonProperty(TypeNameHandling = TypeNameHandling.None)]` to `upgradeLevels`. – dbc May 15 '23 at 14:57
  • `TypeNameHandling.All` has security risks by the way, do you really need to use it? See [TypeNameHandling caution in Newtonsoft Json](https://stackoverflow.com/q/39565954/3744182) for details. – dbc May 15 '23 at 14:57
  • Ok, first,thanks for the answer! First, TypeNameHandling is not necessary, no, removed it. We're making progress, here are my observations : If I just do the last thing you suggested, I put ObjectCreationHandling.Replace back to ObjectCreationgHandling.Auto. The reason I put it to replace, is because arrays and lists get fucked up if its not .Replace. Basicly, it duplicates the content. If I have 3 instances in my list, it becomes 6 on populate. So that's first issue. .Auto works for the single class of Upgrade (cool!) but still resets my data when they are inside a collection – Steven Goulet May 15 '23 at 15:25
  • Note that I didn't try the first thing you did yet with ArrayMergeConverter, do you think it might have something to do with it? – Steven Goulet May 15 '23 at 15:25
  • `ObjectCreationgHandling.Auto` will cause incoming JSON to be added to `List` lists, arrays will always be replaced because they cannot be resized. Anyway, try adding `[JsonConverter(typeof(ArrayMergeConverter)), JsonProperty(TypeNameHandling = TypeNameHandling.None)]` to upgradeLevels as shown in https://dotnetfiddle.net/pH6gNT and see if it works for you. – dbc May 15 '23 at 15:37
  • Also, if you want to clear every `List` before populating it, rather than using `ObjectCreationHandling.Replace`, since you are using a custom contract resolver anyway, add the logic from `CollectionClearingContractResolver` from [this answer](https://stackoverflow.com/a/35483868) by Zoltán Tamási to [Clear collections before adding items when populating existing objects](https://stackoverflow.com/q/35482896). This won't **merge** the lists, but it will clear them. – dbc May 15 '23 at 15:41
  • in general `[SerializeField] private GameObject visual;` it makes little sense to serialize a `UnityEngine.Object` reference to JSON tbh. What is your goal by doing so? -> when deserializing this json where should you get the according GameObject from? You probably do not want to create a new instance of `GameObject` ... and could you show us the JSON you used? – derHugo May 15 '23 at 16:02
  • dbc - So I added the CollectionClearingContractResolver logic and that part worked. I tried using the ArrayMergeConverter - I got this because some of the code isn't implemented NotImplementedException: The method or operation is not implemented. In the CanConvert function – Steven Goulet May 15 '23 at 16:38
  • derHugo - I want my GameObject link there but ignored by the serializer. But that's not what it's doing, despite JsonIgnore, it is clearing all the values. – Steven Goulet May 15 '23 at 16:39
  • dbc - Ok I got it to work by just returning false in CanConvert. However, 2 problems I still have : -It doesn't work with Lists -You have to manually define the converter as a variable attribute, which is very error prone in a large project with a bunch of devs – Steven Goulet May 15 '23 at 17:03
  • It's surprising you would need to modify `CanConvert()` -- it's not called when the converter is applied via attributes. Anyway, the reason that `ArrayMergeConverter` has to be applied via attributes is that there's no obvious way to know precisely which item types can be populated, which is a prerequisite to adding it to `settings.Converters`. `string` cannot be populated, immutable records cannot be populated, types with converters can't be populated. Can you characterize which item types can be populated? If so it would be possible to use that to write your own `CanConvert()`. – dbc May 15 '23 at 17:58
  • *It doesn't work with Lists* -- that is correct, it is because arrays have a fixed size but `List` is resizable. Your question doesn't show any `List` collections, only arrays, which is why I suggested `ArrayMergeConverter`. If you also need a converter for `List` collections I would suggesting asking a second question, since the suggested format for questions on stack overflow is [one question per post](https://meta.stackexchange.com/q/222735). – dbc May 15 '23 at 18:00
  • Would it be possible to have the ArrayMergeConverter target a base class (say JsonArrayBase), which UpgradeLevel would inherit from, and somehow add that explicit converter to the list of converters? – Steven Goulet May 15 '23 at 19:26
  • @StevenGoulet - You can't subclass or othewise customize array types, they are built in. Instead you could add some attribute to the item type, and trigger the array converter based on that. I could add an answer based on that, if you want. – dbc May 15 '23 at 19:38
  • Yes. Also, you are awesome ! If I could add the attribute to the class definition instead (hopefully works with derived types) it would be easier for the other devs. I am working on a List converter in the meantime! – Steven Goulet May 15 '23 at 19:53

2 Answers2

1

You are trying to populate an array member from JSON and recursively populate each item in the array from the corresponding JSON array, matching items by index. Unfortunately, as explained in this answer to JsonSerializer.CreateDefault().Populate(..) resets my values, this isn't implemented. Instead, when populating collections, Json.NET

  • Replaces the contents of read-only collections and arrays.

  • Concatenates the contents of resizable collections such as List<T>.

You also have an additional problem that you are setting ObjectCreationHandling = ObjectCreationHandling.Replace which always replaces inner objects with fresh instances, thereby disabling recursive population entirely. You need to remove this setting.

The converter in the linked answer, ArrayMergeConverter, is designed to only be applied via attributes or for specific item types T because there is no guaranteed way to determine whether any given POCO can be populated. E.g.:

  • A string cannot be populated.

  • An class with one or more immutable properties cannot be fully populated.

  • A struct with entirely mutable properties, however, can be populated.

Since you want to create a general purpose ArrayMergeConverter for all item types, I would suggest the following:

  1. Add some heuristics to determine whether a given item type can probably be populated.

  2. Introduce an attribute that allows for that to be overridden, and apply it to types as required.

With that in mind, create the following attribute and converter:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, AllowMultiple = false, Inherited = true)]
public sealed class JsonCanPopulateAttribute : System.Attribute
{
    public JsonCanPopulateAttribute(bool canPopulate) => this.CanPopulate = canPopulate;
    public bool CanPopulate { get; init; }
}

public class AutomaticArrayMergeConverter : ArrayMergeConverter
{
    static Lazy<IContractResolver> DefaultResolver { get; } = new (() => JsonSerializer.Create().ContractResolver );
    
    readonly IContractResolver resolver;
    
    public AutomaticArrayMergeConverter() : this(DefaultResolver.Value) { }
    public AutomaticArrayMergeConverter(IContractResolver resolver) => this.resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
    
    public override bool CanConvert(Type objectType) =>
        objectType.IsArray && objectType.GetArrayRank() == 1 && resolver.CanPopulateType(objectType.GetElementType()!);
}

// Adapted from this answer https://stackoverflow.com/a/40432055/3744182
// To https://stackoverflow.com/questions/40422136/jsonserializer-createdefault-populate-resets-my-values
public class ArrayMergeConverter : JsonConverter
{
    public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
    {
        if (!objectType.IsArray)
            throw new JsonSerializationException(string.Format("Non-array type {0} not supported.", objectType));
        var contract = (JsonArrayContract)serializer.ContractResolver.ResolveContract(objectType);
        if (contract.IsMultidimensionalArray)
            throw new JsonSerializationException("Multidimensional arrays not supported.");
        if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
            return null;
        else if (reader.TokenType != JsonToken.StartArray)
            throw new JsonSerializationException(string.Format("Invalid start token: {0}", reader.TokenType));
        var existingArray = existingValue as System.Array;
        IList list = new List<object>();
        while (reader.ReadToContentAndAssert().TokenType != JsonToken.EndArray)
        {
            switch (reader.TokenType)
            {
                case JsonToken.Null:
                    list.Add(null);
                    break;
                default:
                    // Add item to list
                    var existingItem = existingArray != null && list.Count < existingArray.Length ? existingArray.GetValue(list.Count) : null;
                    if (existingItem == null)
                        existingItem = serializer.Deserialize(reader, contract.CollectionItemType);
                    else
                        serializer.Populate(reader, existingItem);
                    list.Add(existingItem);
                    break;
            }
        }

        var array = (existingArray != null && existingArray.Length == list.Count ? existingArray : Array.CreateInstance(contract.CollectionItemType!, list.Count));
        list.CopyTo(array, 0);
        return array;
    }

    public override bool CanWrite => false;
    public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) => throw new NotImplementedException();
    public override bool CanConvert(Type objectType) => throw new NotImplementedException("This converter is meant to be applied via attributes only.");
}

public static partial class JsonExtensions
{
    public static bool CanPopulateType(this IContractResolver resolver, Type type)
    {
        if (type.GetCustomAttribute<JsonCanPopulateAttribute>() is {} attr)
            return attr.CanPopulate;
        // No attribute, so apply some heuristics.
        var elementContract = resolver.ResolveContract(type);
        if (elementContract.Converter != null)
            return false;
        if (elementContract is JsonObjectContract c)
            // Apply some heuristics to see whether all properties are equally readable and writable.
            return c.Properties.All(p => p.Readable == p.Writable);
        return false;
    }
    
    public static JsonReader AssertTokenType(this JsonReader reader, JsonToken tokenType) => 
        reader.TokenType == tokenType ? reader : throw new JsonSerializationException(string.Format("Unexpected token {0}, expected {1}", reader.TokenType, tokenType));
    
    public static JsonReader ReadToContentAndAssert(this JsonReader reader) =>
        reader.ReadAndAssert().MoveToContentAndAssert();

    public static JsonReader MoveToContentAndAssert(this JsonReader reader)
    {
        if (reader == null)
            throw new ArgumentNullException();
        if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
            reader.ReadAndAssert();
        while (reader.TokenType == JsonToken.Comment) // Skip past comments.
            reader.ReadAndAssert();
        return reader;
    }

    public static JsonReader ReadAndAssert(this JsonReader reader)
    {
        if (reader == null)
            throw new ArgumentNullException();
        if (!reader.Read())
            throw new JsonReaderException("Unexpected end of JSON stream.");
        return reader;
    }
}

Then add [JsonCanPopulate(bool)] to your types as required, e.g.:

[Serializable, JsonCanPopulate(true)]
public class UpgradeLevel
{
    public UpgradeLevel(GameObject visual, int cost, Vector3 test) =>
        (this.visual, this.cost, this.test) = (visual, cost, test);
    
    [SerializeField] private GameObject visual;
    [SerializeField, JsonProperty] private int cost;
    [SerializeField, JsonProperty] private Vector3 test;
}

And then populate e.g. as follows

var resolver = new NoPropertiesContractResolver(); // Cache statically to improve performance
var converter = new AutomaticArrayMergeConverter(resolver);

var defaultSettings =  new JsonSerializerSettings(){
    Converters = { converter },
    Formatting = Formatting.None,
    PreserveReferencesHandling = PreserveReferencesHandling.None,
    MissingMemberHandling = MissingMemberHandling.Ignore,
    //ObjectCreationHandling = ObjectCreationHandling.Replace -- Removed
    TypeNameHandling = TypeNameHandling.All, // Be sure you need this, see https://stackoverflow.com/q/39565954 for why.
    ContractResolver = resolver, 
};          

JsonConvert.PopulateObject(json, buildableData, defaultSettings);

Notes:

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 1
    dbc you're a Genius ; Thanks a LOT for taking the time making this response! I ended up making a base class "JsonDataClass" which has the attribute, so just inheriting from it is a much better option for simplicity :) – Steven Goulet May 15 '23 at 21:49
  • @StevenGoulet - you're welcome, glad to help. Given that you have a base class, you might want to remove the heuristics from `JsonExtensions.CanPopulateType()` and just check the attribute. – dbc May 15 '23 at 21:52
  • dbc - I posted another answer (trying to do the same with generic lists now) if you have an idea – Steven Goulet May 16 '23 at 02:59
0

Are you using the JsonUtility class from Unity? from my experience is better to use that instead of JsonConvert, especially for the results I had in the builds.

BTW I remember I had a similar issue, and by setting the fields to public it worked out, as stated by ChrisBD in the comments!

EDIT: If you are using custom classes, remember to set them as public, and Serializable!

Mimmo
  • 25
  • 6
  • Cannot use JsonUtility no, I need the flexibility, it would be too long to explain (and confusing ) here. And as you can see, my UpgradeLevel class is public and Serializable. I tried to set all fields to Public instead of private, doesn't change anything. – Steven Goulet May 15 '23 at 13:05
  • For what you are doing using JsonUtility seems fine to me, by the way Have you tried to set all the fields to public also in `UpgradeLevel`? is `BuildableData` set to `Serializable`? – Mimmo May 15 '23 at 13:27
  • ManagedSubData is a ScriptableObject (kind of implied it in my first sentence) so by default its serializable yes, and yes to the other things. – Steven Goulet May 15 '23 at 13:33
  • I would try also this: `JsonSerializerSettings settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All };` And use the settings as the second parameter of the serialization and deserialization, because you are using derived classes. – Mimmo May 15 '23 at 13:33
  • I just tried, doesn't work. UpgradeLevel will always corrupt everything inside it that isn't JsonProperty even though it shouln't handle those fields at all. – Steven Goulet May 15 '23 at 13:41
  • GameObject should be serializable, but I would expect it is only with JsonUtility (I am not sure about this) and Vector3 is not serializable as stated here: [link](https://forum.unity.com/threads/serialize-vector3.801885/), I would try to split the Vector3 instance in 3 floating numbers. EDIT: someone just say it is, but anyway I would check their solutions if work for you. I really suggest to use JsonUtility, if possible. – Mimmo May 15 '23 at 13:52
  • Please stop suggesting to use JsonUtility, it doesn't do what I need it to do (in my opinion, it's garbage for anything deep) -You have no way of using an attribute like in json.net to tell it to serialize it or not, its 100% based on the Unity default serializer. If you use [Serializable] it will parse it, if its not it won't. -Assets referenced in the project will be serialized as an instance ID reference, which can't be relied upon, as that changes, contrary to the GUID. EditorJsonUtility uses the actual GUID instead which works, but that's useless as it can only be used in the editor – Steven Goulet May 15 '23 at 14:21