You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
10 KiB
288 lines
10 KiB
using System; |
|
using System.Linq; |
|
using System.Reflection; |
|
using System.Text.Json; |
|
using System.Text.Json.Nodes; |
|
using System.Text.Json.Serialization; |
|
using System.Windows.Input; |
|
using CommunityToolkit.Mvvm.Input; |
|
using NLog; |
|
using StabilityMatrix.Avalonia.Models; |
|
|
|
namespace StabilityMatrix.Avalonia.ViewModels.Base; |
|
|
|
public abstract class LoadableViewModelBase : ViewModelBase, IJsonLoadableState |
|
{ |
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
|
|
|
private static readonly Type[] SerializerIgnoredTypes = |
|
{ |
|
typeof(ICommand), |
|
typeof(IRelayCommand) |
|
}; |
|
|
|
private static readonly string[] SerializerIgnoredNames = { nameof(HasErrors), }; |
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = |
|
new() { IgnoreReadOnlyProperties = true, }; |
|
|
|
private static bool ShouldIgnoreProperty(PropertyInfo property) |
|
{ |
|
// Skip if read-only and not IJsonLoadableState |
|
if ( |
|
property.SetMethod is null |
|
&& !typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType) |
|
) |
|
{ |
|
Logger.Trace("Skipping {Property} - read-only", property.Name); |
|
return true; |
|
} |
|
// Check not JsonIgnore |
|
if (property.GetCustomAttributes(typeof(JsonIgnoreAttribute), true).Length > 0) |
|
{ |
|
Logger.Trace("Skipping {Property} - has [JsonIgnore]", property.Name); |
|
return true; |
|
} |
|
// Check not excluded type |
|
if (SerializerIgnoredTypes.Contains(property.PropertyType)) |
|
{ |
|
Logger.Trace( |
|
"Skipping {Property} - serializer ignored type {Type}", |
|
property.Name, |
|
property.PropertyType |
|
); |
|
return true; |
|
} |
|
// Check not ignored name |
|
if (SerializerIgnoredNames.Contains(property.Name, StringComparer.Ordinal)) |
|
{ |
|
Logger.Trace("Skipping {Property} - serializer ignored name", property.Name); |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/// <summary> |
|
/// True if we should include property without checking exclusions |
|
/// </summary> |
|
private static bool ShouldIncludeProperty(PropertyInfo property) |
|
{ |
|
// Has JsonIncludeAttribute |
|
if (property.GetCustomAttributes(typeof(JsonIncludeAttribute), true).Length > 0) |
|
{ |
|
Logger.Trace("Including {Property} - has [JsonInclude]", property.Name); |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/// <summary> |
|
/// Load the state of this view model from a JSON object. |
|
/// The default implementation is a mirror of <see cref="SaveStateToJsonObject"/>. |
|
/// For the following properties on this class, we will try to set from the JSON object: |
|
/// <list type="bullet"> |
|
/// <item>Public</item> |
|
/// <item>Not read-only</item> |
|
/// <item>Not marked with [JsonIgnore]</item> |
|
/// <item>Not a type within the SerializerIgnoredTypes</item> |
|
/// <item>Not a name within the SerializerIgnoredNames</item> |
|
/// </list> |
|
/// </summary> |
|
public virtual void LoadStateFromJsonObject(JsonObject state) |
|
{ |
|
// Get all of our properties using reflection |
|
var properties = GetType().GetProperties(); |
|
Logger.Trace("Serializing {Type} with {Count} properties", GetType(), properties.Length); |
|
|
|
foreach (var property in properties) |
|
{ |
|
var name = property.Name; |
|
|
|
// If JsonPropertyName provided, use that as the key |
|
if ( |
|
property |
|
.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true) |
|
.FirstOrDefault() |
|
is JsonPropertyNameAttribute jsonPropertyName |
|
) |
|
{ |
|
Logger.Trace( |
|
"Deserializing {Property} ({Type}) with JsonPropertyName {JsonPropertyName}", |
|
property.Name, |
|
property.PropertyType, |
|
jsonPropertyName.Name |
|
); |
|
if (property.GetValue(this) is string jsonPropertyNameValue) |
|
{ |
|
name = jsonPropertyNameValue; |
|
} |
|
} |
|
|
|
// Check if property is in the JSON object |
|
if (!state.TryGetPropertyValue(name, out var value)) |
|
{ |
|
Logger.Trace("Skipping {Property} - not in JSON object", property.Name); |
|
continue; |
|
} |
|
|
|
// Check if we should ignore this property |
|
if (!ShouldIncludeProperty(property) && ShouldIgnoreProperty(property)) |
|
{ |
|
continue; |
|
} |
|
|
|
// For types that also implement IJsonLoadableState, defer to their load implementation |
|
if (typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType)) |
|
{ |
|
Logger.Trace( |
|
"Loading {Property} ({Type}) with IJsonLoadableState", |
|
property.Name, |
|
property.PropertyType |
|
); |
|
|
|
// Value must be non-null |
|
if (value is null) |
|
{ |
|
throw new InvalidOperationException( |
|
$"Property {property.Name} is IJsonLoadableState but value to be loaded is null" |
|
); |
|
} |
|
|
|
// Check if the current object at this property is null |
|
if (property.GetValue(this) is not IJsonLoadableState propertyValue) |
|
{ |
|
// If null, it must have a default constructor |
|
if ( |
|
property.PropertyType.GetConstructor(Type.EmptyTypes) |
|
is not { } constructorInfo |
|
) |
|
{ |
|
throw new InvalidOperationException( |
|
$"Property {property.Name} is IJsonLoadableState but current object is null and has no default constructor" |
|
); |
|
} |
|
|
|
// Create a new instance and set it |
|
propertyValue = (IJsonLoadableState)constructorInfo.Invoke(null); |
|
property.SetValue(this, propertyValue); |
|
} |
|
|
|
// Load the state from the JSON object |
|
propertyValue.LoadStateFromJsonObject(value.AsObject()); |
|
} |
|
else |
|
{ |
|
Logger.Trace("Loading {Property} ({Type})", property.Name, property.PropertyType); |
|
|
|
var propertyValue = value.Deserialize(property.PropertyType, SerializerOptions); |
|
property.SetValue(this, propertyValue); |
|
} |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Saves the state of this view model to a JSON object. |
|
/// The default implementation uses reflection to |
|
/// save all properties that are: |
|
/// <list type="bullet"> |
|
/// <item>Public</item> |
|
/// <item>Not read-only</item> |
|
/// <item>Not marked with [JsonIgnore]</item> |
|
/// <item>Not a type within the SerializerIgnoredTypes</item> |
|
/// <item>Not a name within the SerializerIgnoredNames</item> |
|
/// </list> |
|
/// </summary> |
|
public virtual JsonObject SaveStateToJsonObject() |
|
{ |
|
// Get all of our properties using reflection. |
|
var properties = GetType().GetProperties(); |
|
Logger.Trace("Serializing {Type} with {Count} properties", GetType(), properties.Length); |
|
|
|
// Create a JSON object to store the state. |
|
var state = new JsonObject(); |
|
|
|
// Serialize each property marked with JsonIncludeAttribute. |
|
foreach (var property in properties) |
|
{ |
|
if (!ShouldIncludeProperty(property) && ShouldIgnoreProperty(property)) |
|
{ |
|
continue; |
|
} |
|
|
|
var name = property.Name; |
|
|
|
// If JsonPropertyName provided, use that as the key. |
|
if ( |
|
property |
|
.GetCustomAttributes(typeof(JsonPropertyNameAttribute), true) |
|
.FirstOrDefault() |
|
is JsonPropertyNameAttribute jsonPropertyName |
|
) |
|
{ |
|
Logger.Trace( |
|
"Serializing {Property} ({Type}) with JsonPropertyName {JsonPropertyName}", |
|
property.Name, |
|
property.PropertyType, |
|
jsonPropertyName.Name |
|
); |
|
if (property.GetValue(this) is string value) |
|
{ |
|
name = value; |
|
} |
|
} |
|
|
|
// For types that also implement IJsonLoadableState, defer to their implementation. |
|
if (typeof(IJsonLoadableState).IsAssignableFrom(property.PropertyType)) |
|
{ |
|
Logger.Trace( |
|
"Serializing {Property} ({Type}) with IJsonLoadableState", |
|
property.Name, |
|
property.PropertyType |
|
); |
|
var value = property.GetValue(this); |
|
if (value is not null) |
|
{ |
|
var model = (IJsonLoadableState)value; |
|
var modelState = model.SaveStateToJsonObject(); |
|
state.Add(name, modelState); |
|
} |
|
} |
|
else |
|
{ |
|
Logger.Trace( |
|
"Serializing {Property} ({Type})", |
|
property.Name, |
|
property.PropertyType |
|
); |
|
var value = property.GetValue(this); |
|
if (value is not null) |
|
{ |
|
state.Add(name, JsonSerializer.SerializeToNode(value, SerializerOptions)); |
|
} |
|
} |
|
} |
|
|
|
return state; |
|
} |
|
|
|
/// <summary> |
|
/// Serialize a model to a JSON object. |
|
/// </summary> |
|
protected static JsonObject SerializeModel<T>(T model) |
|
{ |
|
var node = JsonSerializer.SerializeToNode(model); |
|
return node?.AsObject() |
|
?? throw new NullReferenceException("Failed to serialize state to JSON object."); |
|
} |
|
|
|
/// <summary> |
|
/// Deserialize a model from a JSON object. |
|
/// </summary> |
|
protected static T DeserializeModel<T>(JsonObject state) |
|
{ |
|
return state.Deserialize<T>() |
|
?? throw new NullReferenceException("Failed to deserialize state from JSON object."); |
|
} |
|
}
|
|
|