SamboyCoding / Tomlet

Zero-Dependency, model-based TOML De/Serializer for .NET
MIT License
155 stars 29 forks source link

Serialize/Deserilize derived classes #36

Closed federicomrc closed 8 months ago

federicomrc commented 1 year ago

It would be really useful to be able to serialize and deserialize derived classes. The actual behaviour is the one listed below:

class A {
    public int AA { get; set; }
}

class B : A {
    public int BB { get; set; }
}

class C : A {
    public int CC { get; set; }
}

class Test
{
    public List<A> MyList { get; set; }
}

/* Inside some function */
var test = new Test()
{
    MyList = new List<A>()
    {
        new A(){/*...*/},
        new B(){/*...*/},
        new C(){/*...*/},
    }
};

// s correctly contains all properties for each element in MyList 
// MyList = [
//         { AA = 1 },
//         { BB = 22, AA = 2 },
//         { CC = 33, AA = 3 },
// ]
var s = TomletMain.TomlStringFrom(test); 

// myDeserializedTest contains only properties contained in A, as if MyList 
// contained only instances of A
// MyList = [
//         { AA = 1 },
//         { AA = 2 },
//         { AA = 3 },
// ]
var myDeserializedTest = TomletMain.To<Test>(s);

I think it would be very useful if deserialization returned the correct types (or the subtypes "casted" to the base class), i.e.

// MyList = [
//  { AA = 1 },             ----> Type A
//  { BB = 22, AA = 2 },    ----> Type B
//  { CC = 33, AA = 3 },    ----> Type C 
// ]
var myDeserializedTest = TomletMain.To<Test>(s);

EDIT: In the first sentence I meant "deserialize derived classes", not "deserialize base classes"

ThatCoolCoder commented 9 months ago

It's a bit tricky but it is somewhat possible to achieve this behavior manually, by storing a property for your base class that also persists the class name into TOML (eg an extra field TypeName = "B"). Then in the deserializer you can look up this class and use a bit of reflection find the derived class and call TomletMain.To() on that.

Creating user-defined classes at runtime is a bit of a security risk so make sure to limit what classes can be loaded.

An implementation might look something like this:

class A {
    public string TypeName { get { return GetType().Name; } } // you could also manually define a TypeName property in A, B, and C, but this saves a bit of work
}

...

TomletMain.RegisterMapper<A>(
    null,
    tomlValue =>
    {
        var typeName = tomlTable.GetString("TypeName");
        if (typeName == null) throw new Exception("Error loading A: TypeName not given"); // alternately you could assume the absence of a TypeName indicates it is the base class

        var cls = allowableLoadedTypes.FirstOrDefault(cls => cls.Name == typeName);
        if (cls == null) throw new Exception($"Error loading A: Not allowed to parse a {typeName}");

        return (A) TomletMain.To(cls, tomlValue);
    }
);

The only catch is that this method probably won't work if you have instances of AA and not just base classes, since the custom deserializer for A will end up calling TomletMain.To(A), which will call the deserializer again, and so on.

EDIT: it looks like TomlCompositeSerializer.For(A)/TomlCompositeDeserializer.For(A) could be used to avoid that problem by manually getting the ordinary serializer, however these classes are internal so they're not accessible to consumers of the library.

SamboyCoding commented 9 months ago

I can definitely post a release with those composite serializer/deserializer methods exposed via some sort of API, for this use case.