I have a class in which I had to change te type of a property from a simple List<string>
to a complex List<CustomObject>
.
My problem is that for some period of time, I will have people using the old and the new version of the software. Up until now, when I had contract changes, I simply used the UnknownElement
event to map the old member to the new one since it was private files and it works perfectly for backward compatibility but broke the old version since it didn't write the old format back.
But this time, it is a shared file and it made me realise that I missed upward compatibility and that people using the old version will remove the new member. I read about XmlAnyElementAttribute
to keep unknown elements and have them serialized back to the file. This fixes upward compatibility.
I now have every pieces of the puzzle but I can't find how to have them work together since adding XmlAnyElementAttribute
seems to end in UnknownElement
not being triggered.
I also thought of simply reading back the XmlAnyElementAttributeproperty once the deserialization is done but this time, it is the
XmlSerializer` that lacks an event for Deserialized.
Here is a sample of both files: Old format:
<OptionsSerializable xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ListeCategories>
<string>SX00</string>
<string>SX01</string>
</ListeCategories>
</OptionsSerializable>
New Format:
<OptionsSerializable xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ListeCategoriesExt>
<CategoryInfo Name="SX00" Type="Principal" Persistence="Global">
<ToolTip>SX00</ToolTip>
<SearchTerm>SX00</SearchTerm>
</CategoryInfo>
<CategoryInfo Name="SX01" Type="Principal" Persistence="Global">
<ToolTip>SX01</ToolTip>
<SearchTerm>SX01</SearchTerm>
</CategoryInfo>
</ListeCategoriesExt>
</OptionsSerializable>
Needed:
<OptionsSerializable xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<ListeCategories>
<string>SX00</string>
<string>SX01</string>
</ListeCategories>
<ListeCategoriesExt>
<CategoryInfo Name="SX00" Type="Principal" Persistence="Global">
<ToolTip>SX00</ToolTip>
<SearchTerm>SX00</SearchTerm>
</CategoryInfo>
<CategoryInfo Name="SX01" Type="Principal" Persistence="Global">
<ToolTip>SX01</ToolTip>
<SearchTerm>SX01</SearchTerm>
</CategoryInfo>
</ListeCategoriesExt>
</OptionsSerializable>
According to the docs:
XmlSerializer.UnknownElement
... Occurs when the XmlSerializer encounters an XML element of unknown type during deserialization.
If your <ListeCategories>
elements are getting bound to an [XmlAnyElement]
property, then they aren't of unknown type, and so no event is raised.
Now, if you have some other unknown elements besides <ListeCategories>
(not shown in your question) that you want to post-process using UnknownElement
, you can do that by restricting the names of elements bound by using [XmlAnyElementAttribute(string name)]
:
Initializes a new instance of the
XmlAnyElementAttribute
class and specifies the XML element name generated in the XML document.
I.e.:
public class OptionsSerializable
{
[XmlAnyElement("ListeCategories")]
public XmlElement [] ListeCategories { get; set; }
Now other unknown elements, e.g. <SomeOtherObsoleteNodeToPostprocess />
, will still raise the event. Demo fiddle #1 here. But you still won't get an event callback for <ListeCategories>
.
So, what are your options?
Firstly, you could do your postprocessing in the setter for the XmlElement []
array, as shown in this answer to Better IXmlSerializable format?:
[XmlRoot(ElementName="OptionsSerializable")]
public class OptionsSerializable
{
[XmlAnyElement("ListeCategories")]
public XmlElement [] ListeCategories
{
get
{
// Convert the ListeCategoriesExt items property to an array of XmlElement
}
set
{
// Convert array of XmlElement back to ListeCategoriesExt items.
}
}
The original UnknownElement
event logic could also be partly preserved by using this:
XmlElement[] _unsupported;
[XmlAnyElement()]
public XmlElement[] Unsupported {
get {
return _unsupported;
}
set {
_unsupported = value;
if ((value.Count > 0)) {
foreach (element in value) {
OnUnknownElementFound(this, new XmlElementEventArgs(){Element=element});
}
}
}
}
However, if the postprocessing is to be done by the OptionsSerializable
object itself, it makes more sense to think of ListeCategories
as a deprecated, filtered view of the ListeCategoriesExt
property. Here's how I would do it:
[XmlRoot(ElementName="OptionsSerializable")]
public class OptionsSerializable
{
[XmlArray("ListeCategories"), XmlArrayItem("string")]
public string [] XmlListeCategories
{
//Can't use [Obsolete] because doing so will cause XmlSerializer to not serialize the property, see https://stackoverflow.com/a/331038
get
{
// Since it seems <CategoryInfo Name="VerifierCoherence" Type="Principal" Persistence="Global"> should not be written back,
// you will need to add a .Where clause excluding those CategoryInfo items you don't want to appear in the old list of strings.
return ListeCategoriesExt?.Select(c => c.Name)?.ToArray();
}
set
{
// Merge in the deserialization results. Note this algorithm assumes that there are no duplicate names.
// Convert array of XmlElement back to ListeCategoriesExt items.
foreach (var name in value)
{
if (ListeCategoriesExt.FindIndex(c => c.Name == name) < 0)
{
ListeCategoriesExt.Add(new CategoryInfo
{
Name = name, Type = "Principal", Persistence = "Global",
ToolTip = name,
SearchTerm = name,
});
}
}
}
}
[XmlArray("ListeCategoriesExt"), XmlArrayItem("CategoryInfo")]
public CategoryInfo [] XmlListeCategoriesExt
{
get
{
return ListeCategoriesExt?.ToArray();
}
set
{
// Merge in the deserialization results. Note this algorithm assumes that there are no duplicate names.
foreach (var category in value)
{
var index = ListeCategoriesExt.FindIndex(c => c.Name == category.Name);
if (index < 0)
{
ListeCategoriesExt.Add(category);
}
else
{
// Overwrite the item added during XmlListeCategories deserialization.
ListeCategoriesExt[index] = category;
}
}
}
}
[XmlIgnore]
public List<CategoryInfo> ListeCategoriesExt { get; set; } = new List<CategoryInfo>();
}
[XmlRoot(ElementName="CategoryInfo")]
public class CategoryInfo
{
[XmlElement(ElementName="ToolTip")]
public string ToolTip { get; set; }
[XmlElement(ElementName="SearchTerm")]
public string SearchTerm { get; set; }
[XmlAttribute(AttributeName="Name")]
public string Name { get; set; }
[XmlAttribute(AttributeName="Type")]
public string Type { get; set; }
[XmlAttribute(AttributeName="Persistence")]
public string Persistence { get; set; }
}
Notes:
As <ListeCategories>
appears before <ListeCategoriesExt>
in your XML, it is necessary to merge the new items into the previously-deserialized obsolete items in the setter for XmlListeCategoriesExt
.
This would not be necessary if you were to set XmlArrayAttribute.Order
for both requiring that <ListeCategories>
come last.
Because of the necessity for the merge, the deserialization algorithm does not support multiple CategoryInfo
objects with identical names.
If you must have identical names in your CategoryInfo
list, merging of the old and new representations becomes more complex.
Unfortunately it is not possible to merge the old and new category lists in an OnDeserialized
event because, annoyingly, XmlSerializer
does not support [OnDeserialized]
.
Demo fiddle #2 here.