diff --git a/src/Tomlyn.Tests/ModelTests/ReflectionModelTests.cs b/src/Tomlyn.Tests/ModelTests/ReflectionModelTests.cs index a83aab2..11b0cb4 100644 --- a/src/Tomlyn.Tests/ModelTests/ReflectionModelTests.cs +++ b/src/Tomlyn.Tests/ModelTests/ReflectionModelTests.cs @@ -107,6 +107,64 @@ static void ValidateModel(PrimitiveModel model) } } + /// + /// Serialize back and forth all integer/float primitives with fields. + /// + [Test] + public void TestPrimitiveFields() + { + var model = new PrimitiveFieldsModel() + { + Int8Value = 1, + Int16Value = 2, + Int32Value = 3, + Int64Value = 4, + UInt8Value = 5, + UInt16Value = 6, + UInt32Value = 7, + UInt64Value = 8, + Float32Value = 2.5f, + Float64Value = 2.5, + DateTime = new DateTime(1970, 1, 1), + DateTimeOffset = new DateTimeOffset(1980, 1, 1, 0, 23, 1, TimeSpan.FromHours(-2)), + DateOnly = new DateOnly(1970, 5, 27), + TimeOnly = new TimeOnly(7, 32, 0, 999), + TomlDateTime = new TomlDateTime(new DateTimeOffset(new DateTime(1990, 11, 15)), 0, TomlDateTimeKind.LocalDateTime) + }; + + StandardTests.DisplayHeader("validation 1"); + ValidateModel(model); + + StandardTests.DisplayHeader("validation 2"); + model.Int8Value = sbyte.MinValue; + model.Int16Value = short.MinValue; + model.Int32Value = int.MinValue; + model.Int64Value = long.MinValue; + model.UInt8Value = byte.MaxValue; + model.UInt16Value = ushort.MaxValue; + model.UInt32Value = uint.MaxValue; + model.UInt64Value = ulong.MaxValue; + model.Float32Value = float.PositiveInfinity; + model.Float64Value = double.PositiveInfinity; + ValidateModel(model); + + static void ValidateModel(PrimitiveFieldsModel model) + { + var options = new TomlModelOptions(); + options.IncludeFields = true; + + var toml = Toml.FromModel(model, options: options); + StandardTests.DisplayHeader("toml from model"); + Console.WriteLine(toml); + var model2 = Toml.ToModel(toml, options: options); + + Console.WriteLine($"From Model: {model}"); + Console.WriteLine($"From Model2: {model2}"); + + Assert.AreEqual(model.ToString(), model2.ToString()); + } + } + /// /// Serialize back and forth all numeric and struct primitives as nullable value types. /// @@ -180,6 +238,82 @@ static void ValidateModel(NullableValueTypesModel model) } } + /// + /// Serialize back and forth all numeric and struct primitives as nullable value type fieldss. + /// + [Test] + public void TestNullableValueTypeFields() + { + var model = new NullableValueTypeFieldsModel() + { + Int8Value = 1, + Int16Value = 2, + Int32Value = 3, + Int64Value = 4, + UInt8Value = 5, + UInt16Value = 6, + UInt32Value = 7, + UInt64Value = 8, + Float32Value = 2.5f, + Float64Value = 2.5, + DateTime = new DateTime(1970, 1, 1), + DateTimeOffset = new DateTimeOffset(1980, 1, 1, 0, 23, 1, TimeSpan.FromHours(-2)), + DateOnly = new DateOnly(1970, 5, 27), + TimeOnly = new TimeOnly(7, 32, 0, 999), + TomlDateTime = new TomlDateTime(new DateTimeOffset(new DateTime(1990, 11, 15)), 0, TomlDateTimeKind.LocalDateTime) + }; + + StandardTests.DisplayHeader("validation 1"); + ValidateModel(model); + + StandardTests.DisplayHeader("validation 2"); + model.Int8Value = sbyte.MinValue; + model.Int16Value = short.MinValue; + model.Int32Value = int.MinValue; + model.Int64Value = long.MinValue; + model.UInt8Value = byte.MaxValue; + model.UInt16Value = ushort.MaxValue; + model.UInt32Value = uint.MaxValue; + model.UInt64Value = ulong.MaxValue; + model.Float32Value = float.PositiveInfinity; + model.Float64Value = double.PositiveInfinity; + ValidateModel(model); + + StandardTests.DisplayHeader("validation 3"); + model.Int8Value = null; + model.Int16Value = null; + model.Int32Value = null; + model.Int64Value = null; + model.UInt8Value = null; + model.UInt16Value = null; + model.UInt32Value = null; + model.UInt64Value = null; + model.Float32Value = null; + model.Float64Value = null; + model.DateTime = null; + model.DateTimeOffset = null; + model.DateOnly = null; + model.TimeOnly = null; + model.TomlDateTime = null; + ValidateModel(model); + + static void ValidateModel(NullableValueTypeFieldsModel model) + { + var options = new TomlModelOptions(); + options.IncludeFields = true; + + var toml = Toml.FromModel(model, options: options); + StandardTests.DisplayHeader("toml from model"); + Console.WriteLine(toml); + var model2 = Toml.ToModel(toml, options: options); + + Console.WriteLine($"From Model: {model}"); + Console.WriteLine($"From Model2: {model2}"); + + Assert.AreEqual(model.ToString(), model2.ToString()); + } + } + [Test] public void TestReflectionModel() { @@ -234,6 +368,63 @@ public void TestReflectionModel() Assert.AreEqual(127, result); } + [Test] + public void TestReflectionFieldsModel() + { + var input = @"name = ""this is a name"" +values = [""a"", ""b"", ""c"", 1] + +int_values = 1 +int_value = 2 +double_value = 2.5 + +[[sub]] +id = ""id1"" +publish = true + +[[sub]] +id = ""id2"" +publish = false + +[[sub]] +id = ""id3"""; + var syntax = Toml.Parse(input); + Assert.False(syntax.HasErrors, "The document should not have any errors"); + + StandardTests.Dump(input, syntax, syntax.ToString()); + + var options = new TomlModelOptions(); + options.IncludeFields = true; + + var model = syntax.ToModel(options); + + Assert.AreEqual("this is a name", model.Name); + Assert.AreEqual(new List() { "a", "b", "c", "1" }, model.Values); + Assert.AreEqual(new List() { 1 }, model.IntValues); + Assert.AreEqual(2, model.IntValue); + Assert.AreEqual(2.5, model.DoubleValue); + Assert.AreEqual(3, model.SubModels.Count); + var sub = model.SubModels[0]; + Assert.AreEqual("id1", sub.Id); + Assert.True(sub.Publish); + sub = model.SubModels[1]; + Assert.AreEqual("id2", sub.Id); + Assert.False(sub.Publish); + sub = model.SubModels[2]; + Assert.AreEqual("id3", sub.Id); + Assert.False(sub.Publish); + + model.SubModels[2].Value = 127; + + var toml = Toml.FromModel(model, options: options); + StandardTests.DisplayHeader("toml from model"); + Console.WriteLine(toml); + + var model2 = Toml.ToModel(toml, options: options); + var result = (model2["sub"] as TomlTableArray)?[2]?["value"]; + Assert.AreEqual(127, result); + } + [Test] public void TestReflectionModelWithConvertName() { @@ -291,6 +482,62 @@ public void TestReflectionModelWithConvertName() Assert.AreEqual(127, result); } + [Test] + public void TestReflectionFieldsModelWithConvertName() + { + var input = @"Name = ""this is a name"" +Values = [""a"", ""b"", ""c"", 1] + +IntValues = 1 +IntValue = 2 +DoubleValue = 2.5 + +[[sub]] +Id = ""id1"" +Publish = true + +[[sub]] +Id = ""id2"" +Publish = false + +[[sub]] +Id = ""id3"""; + var syntax = Toml.Parse(input); + Assert.False(syntax.HasErrors, "The document should not have any errors"); + + StandardTests.Dump(input, syntax, syntax.ToString()); + + var options = new TomlModelOptions() { ConvertPropertyName = name => name, ConvertFieldName = name => name, IncludeFields = true}; + + var model = syntax.ToModel(options); + + Assert.AreEqual("this is a name", model.Name); + Assert.AreEqual(new List() { "a", "b", "c", "1" }, model.Values); + Assert.AreEqual(new List() { 1 }, model.IntValues); + Assert.AreEqual(2, model.IntValue); + Assert.AreEqual(2.5, model.DoubleValue); + Assert.AreEqual(3, model.SubModels.Count); + var sub = model.SubModels[0]; + Assert.AreEqual("id1", sub.Id); + Assert.True(sub.Publish); + sub = model.SubModels[1]; + Assert.AreEqual("id2", sub.Id); + Assert.False(sub.Publish); + sub = model.SubModels[2]; + Assert.AreEqual("id3", sub.Id); + Assert.False(sub.Publish); + + model.SubModels[2].Value = 127; + + var toml = Toml.FromModel(model, options); + StandardTests.DisplayHeader("toml from model"); + Console.WriteLine(toml); + + var model2 = Toml.ToModel(toml, options: options); + var result = (model2["sub"] as TomlTableArray)?[2]?["Value"]; + Assert.AreEqual(127, result); + } + [Test] public void TestReflectionModelWithErrors() @@ -346,6 +593,63 @@ public void TestReflectionModelWithErrors() StringAssert.Contains("id3", diag.Message); } + [Test] + public void TestReflectionFieldsModelWithErrors() + { + var input = @"name = ""this is a name"" +values = [""a"", ""b"", ""c"", 1] + +int_values1 = 1 # error +int_value = 2 +double_value = 2.5 + +[[sub]] +id2 = ""id1"" # error +publish = true + +[[sub]] +id = ""id2"" +publish = false + +[[sub]] +id3 = ""id3"" # error +"; + var syntax = Toml.Parse(input); + Assert.False(syntax.HasErrors, "The document should not have any errors"); + + StandardTests.Dump(input, syntax, syntax.ToString()); + + var options = new TomlModelOptions(); + options.IncludeFields = true; + + var result = syntax.TryToModel(out var model, out var diagnostics, options: options); + + foreach (var message in diagnostics) + { + Console.WriteLine(message); + } + + Assert.False(result); + Assert.NotNull(model); + + // Expecting 3 errors + Assert.AreEqual(3, diagnostics.Count); + + Debug.Assert(model is not null); + // The model is still partially valid + Assert.AreEqual("this is a name", model.Name); + + var diag = diagnostics[0]; + Assert.AreEqual(3, diag.Span.Start.Line); + StringAssert.Contains("int_values1", diag.Message); + diag = diagnostics[1]; + Assert.AreEqual(8, diag.Span.Start.Line); + StringAssert.Contains("id2", diag.Message); + diag = diagnostics[2]; + Assert.AreEqual(16, diag.Span.Start.Line); + StringAssert.Contains("id3", diag.Message); + } + [Test] public void TestCommentRoundtripWithModel() { @@ -413,6 +717,21 @@ public void TestModelWithArray() CollectionAssert.AreEqual(new string[] { "1", "2", "3"}, model.Values); } + [Test] + public void TestModelWithArrayField() + { + var input = @"values = ['1', '2', '3']"; + + StandardTests.DisplayHeader("input"); + Console.WriteLine(input); + + var options = new TomlModelOptions(); + options.IncludeFields = true; + + var model = Toml.ToModel(input, options: options); + CollectionAssert.AreEqual(new string[] { "1", "2", "3" }, model.Values); + } + [Test] public void TestModelWithFixedList() { @@ -424,6 +743,20 @@ public void TestModelWithFixedList() CollectionAssert.AreEqual(new List { "1", "2", "3" }, model.Values); } + [Test] + public void TestModelWithFixedListField() + { + var input = @"values = ['1', '2', '3']"; + + StandardTests.DisplayHeader("input"); + Console.WriteLine(input); + + var options = new TomlModelOptions(); + options.IncludeFields = true; + var model = Toml.ToModel(input, options: options); + CollectionAssert.AreEqual(new List { "1", "2", "3" }, model.Values); + } + [Test] public void TestModelWithMissingProperties() { @@ -444,6 +777,31 @@ public void TestModelWithMissingProperties() } + [Test] + public void TestFieldsModelWithMissingProperties() + { + var input = @"values = ['1', '2', '3'] +some_thing_that_doesnt_exist = true +[object_that_doesnt_exist] +required = true"; + + StandardTests.DisplayHeader("input"); + Console.WriteLine(input); + var model = Toml.ToModel(input, options: new TomlModelOptions + { + IgnoreMissingProperties = true, + IncludeFields = true + }); + CollectionAssert.AreEqual(new List { "1", "2", "3" }, model.Values); + + Assert.Throws(() => Toml.ToModel(input, options: new TomlModelOptions + { + IgnoreMissingProperties = false, + IncludeFields = true + })); + + } + public class SimpleModel { public SimpleModel() @@ -466,11 +824,39 @@ public SimpleModel() public List SubModels { get; } } + public class SimpleFieldsModel + { + public SimpleFieldsModel() + { + Values = new List(); + SubModels = new List(); + } + + public string? Name { get; set; } + + public List Values; + + public List? IntValues; + + public int IntValue { get; set; } + + public double DoubleValue { get; set; } + + [JsonPropertyName("sub")] + public List SubModels; + } + public class ModelWithArray { public string[]? Values { get; set; } } + + public class ModelWithArrayField + { + public string[]? Values; + } + public class ModelWithFixedList { public ModelWithFixedList() @@ -481,6 +867,16 @@ public ModelWithFixedList() public List Values { get; } } + public class ModelWithFixedListField + { + public ModelWithFixedListField() + { + Values = new List(); + } + + public List Values; + } + public class SimpleSubModel { @@ -562,6 +958,66 @@ public override string ToString() } } + public class PrimitiveFieldsModel : IEquatable + { + public sbyte Int8Value; + public short Int16Value; + public int Int32Value; + public long Int64Value; + public byte UInt8Value; + public ushort UInt16Value; + public uint UInt32Value; + public ulong UInt64Value; + public float Float32Value; + public double Float64Value; + public DateTime DateTime; + public DateTimeOffset DateTimeOffset; + public DateOnly DateOnly; + public TimeOnly TimeOnly; + public TomlDateTime TomlDateTime; + + public bool Equals(PrimitiveFieldsModel? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Int8Value == other.Int8Value && Int16Value == other.Int16Value && Int32Value == other.Int32Value && Int64Value == other.Int64Value && UInt8Value == other.UInt8Value && UInt16Value == other.UInt16Value && UInt32Value == other.UInt32Value && UInt64Value == other.UInt64Value && Float32Value.Equals(other.Float32Value) && Float64Value.Equals(other.Float64Value) && DateTime.Equals(other.DateTime) && DateTimeOffset.Equals(other.DateTimeOffset) && DateOnly.Equals(other.DateOnly) && TimeOnly.Equals(other.TimeOnly) && TomlDateTime.Equals(other.TomlDateTime); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((PrimitiveFieldsModel)obj); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Int8Value); + hashCode.Add(Int16Value); + hashCode.Add(Int32Value); + hashCode.Add(Int64Value); + hashCode.Add(UInt8Value); + hashCode.Add(UInt16Value); + hashCode.Add(UInt32Value); + hashCode.Add(UInt64Value); + hashCode.Add(Float32Value); + hashCode.Add(Float64Value); + hashCode.Add(DateTime); + hashCode.Add(DateTimeOffset); + hashCode.Add(DateOnly); + hashCode.Add(TimeOnly); + hashCode.Add(TomlDateTime); + return hashCode.ToHashCode(); + } + + public override string ToString() + { + return $"{nameof(Int8Value)}: {Int8Value}, {nameof(Int16Value)}: {Int16Value}, {nameof(Int32Value)}: {Int32Value}, {nameof(Int64Value)}: {Int64Value}, {nameof(UInt8Value)}: {UInt8Value}, {nameof(UInt16Value)}: {UInt16Value}, {nameof(UInt32Value)}: {UInt32Value}, {nameof(UInt64Value)}: {UInt64Value}, {nameof(Float32Value)}: {Float32Value}, {nameof(Float64Value)}: {Float64Value}, {nameof(DateTime)}: {DateTime.ToUniversalTime()}, {nameof(DateTimeOffset)}: {DateTimeOffset.ToUniversalTime()}, {nameof(DateOnly)}: {DateOnly}, {nameof(TimeOnly)}: {TimeOnly}, {nameof(TomlDateTime)}: {TomlDateTime}"; + } + } + public class NullableValueTypesModel : IEquatable { public sbyte? Int8Value { get; set; } @@ -635,5 +1091,79 @@ public override string ToString() return $"{nameof(Int8Value)}: {Int8Value}, {nameof(Int16Value)}: {Int16Value}, {nameof(Int32Value)}: {Int32Value}, {nameof(Int64Value)}: {Int64Value}, {nameof(UInt8Value)}: {UInt8Value}, {nameof(UInt16Value)}: {UInt16Value}, {nameof(UInt32Value)}: {UInt32Value}, {nameof(UInt64Value)}: {UInt64Value}, {nameof(Float32Value)}: {Float32Value}, {nameof(Float64Value)}: {Float64Value}, {nameof(DateTime)}: {(DateTime.HasValue ? DateTime.Value.ToUniversalTime() : "null")}, {nameof(DateTimeOffset)}: {(DateTimeOffset.HasValue ? DateTimeOffset.Value.ToUniversalTime() : "null")}, {nameof(DateOnly)}: {DateOnly}, {nameof(TimeOnly)}: {TimeOnly}, {nameof(TomlDateTime)}: {TomlDateTime}"; } } + + public class NullableValueTypeFieldsModel : IEquatable + { + public sbyte? Int8Value; + public short? Int16Value; + public int? Int32Value; + public long? Int64Value; + public byte? UInt8Value; + public ushort? UInt16Value; + public uint? UInt32Value; + public ulong? UInt64Value; + public float? Float32Value; + public double? Float64Value; + public DateTime? DateTime; + public DateTimeOffset? DateTimeOffset; + public DateOnly? DateOnly; + public TimeOnly? TimeOnly; + public TomlDateTime? TomlDateTime; + + public bool Equals(NullableValueTypeFieldsModel? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Int8Value == other.Int8Value + && Int16Value == other.Int16Value + && Int32Value == other.Int32Value + && Int64Value == other.Int64Value + && UInt8Value == other.UInt8Value + && UInt16Value == other.UInt16Value + && UInt32Value == other.UInt32Value + && UInt64Value == other.UInt64Value + && ((!Float32Value.HasValue && !other.Float32Value.HasValue) || Float32Value.Equals(other.Float32Value)) + && ((!Float64Value.HasValue && !other.Float64Value.HasValue) || Float64Value.Equals(other.Float64Value)) + && ((!DateTime.HasValue && !other.DateTime.HasValue) || DateTime.Equals(other.DateTime)) + && ((!DateTimeOffset.HasValue && !other.DateTimeOffset.HasValue) || DateTimeOffset.Equals(other.DateTimeOffset)) + && ((!DateOnly.HasValue && !other.DateOnly.HasValue) || DateOnly.Equals(other.DateOnly)) + && ((!TimeOnly.HasValue && !other.TimeOnly.HasValue) || TimeOnly.Equals(other.TimeOnly)) + && ((!TomlDateTime.HasValue && !other.TomlDateTime.HasValue) || TomlDateTime.Equals(other.TomlDateTime)); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((PrimitiveModel)obj); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Int8Value); + hashCode.Add(Int16Value); + hashCode.Add(Int32Value); + hashCode.Add(Int64Value); + hashCode.Add(UInt8Value); + hashCode.Add(UInt16Value); + hashCode.Add(UInt32Value); + hashCode.Add(UInt64Value); + hashCode.Add(Float32Value); + hashCode.Add(Float64Value); + hashCode.Add(DateTime); + hashCode.Add(DateTimeOffset); + hashCode.Add(DateOnly); + hashCode.Add(TimeOnly); + hashCode.Add(TomlDateTime); + return hashCode.ToHashCode(); + } + + public override string ToString() + { + return $"{nameof(Int8Value)}: {Int8Value}, {nameof(Int16Value)}: {Int16Value}, {nameof(Int32Value)}: {Int32Value}, {nameof(Int64Value)}: {Int64Value}, {nameof(UInt8Value)}: {UInt8Value}, {nameof(UInt16Value)}: {UInt16Value}, {nameof(UInt32Value)}: {UInt32Value}, {nameof(UInt64Value)}: {UInt64Value}, {nameof(Float32Value)}: {Float32Value}, {nameof(Float64Value)}: {Float64Value}, {nameof(DateTime)}: {(DateTime.HasValue ? DateTime.Value.ToUniversalTime() : "null")}, {nameof(DateTimeOffset)}: {(DateTimeOffset.HasValue ? DateTimeOffset.Value.ToUniversalTime() : "null")}, {nameof(DateOnly)}: {DateOnly}, {nameof(TimeOnly)}: {TimeOnly}, {nameof(TomlDateTime)}: {TomlDateTime}"; + } + } } } \ No newline at end of file diff --git a/src/Tomlyn/Model/Accessors/DynamicModelReadContext.cs b/src/Tomlyn/Model/Accessors/DynamicModelReadContext.cs index e97d09d..d44c151 100644 --- a/src/Tomlyn/Model/Accessors/DynamicModelReadContext.cs +++ b/src/Tomlyn/Model/Accessors/DynamicModelReadContext.cs @@ -15,18 +15,25 @@ internal class DynamicModelReadContext public DynamicModelReadContext(TomlModelOptions options) { GetPropertyName = options.GetPropertyName; + GetFieldName = options.GetFieldName; ConvertPropertyName = options.ConvertPropertyName; + ConvertFieldName = options.ConvertFieldName; CreateInstance = options.CreateInstance; ConvertToModel = options.ConvertToModel; IgnoreMissingProperties = options.IgnoreMissingProperties; + IncludeFields = options.IncludeFields; Diagnostics = new DiagnosticsBag(); _accessors = new Dictionary(); } public Func GetPropertyName { get; set; } + public Func GetFieldName { get; set; } + public Func ConvertPropertyName { get; set; } + public Func ConvertFieldName { get; set; } + public Func CreateInstance { get; set; } public Func? ConvertToModel { get; set; } @@ -34,7 +41,9 @@ public DynamicModelReadContext(TomlModelOptions options) public DiagnosticsBag Diagnostics { get; } public bool IgnoreMissingProperties { get; set; } - + + public bool IncludeFields { get; set; } + public DynamicAccessor GetAccessor(Type type) { if (!_accessors.TryGetValue(type, out var accessor)) diff --git a/src/Tomlyn/Model/Accessors/StandardObjectDynamicAccessor.cs b/src/Tomlyn/Model/Accessors/StandardObjectDynamicAccessor.cs index cb88695..d23dcc4 100644 --- a/src/Tomlyn/Model/Accessors/StandardObjectDynamicAccessor.cs +++ b/src/Tomlyn/Model/Accessors/StandardObjectDynamicAccessor.cs @@ -13,12 +13,18 @@ namespace Tomlyn.Model.Accessors; internal class StandardObjectDynamicAccessor : ObjectDynamicAccessor { private readonly Dictionary _props; + private readonly Dictionary _fields; + private readonly List> _orderedProps; + private readonly List> _orderedFields; public StandardObjectDynamicAccessor(DynamicModelReadContext context, Type type, ReflectionObjectKind kind) : base(context, type, kind) { _props = new Dictionary(); + _fields = new Dictionary(); _orderedProps = new List>(); + _orderedFields = new List>(); + Initialize(); } @@ -45,6 +51,28 @@ private void Initialize() _orderedProps.Add(new KeyValuePair(name, prop)); } } + + if (Context.IncludeFields) + { + foreach (var field in TargetType.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy)) + { + var name = Context.GetFieldName(field); + // If the field name is null, the field can be ignored + if (name is null) continue; + + // Skip fields that are string or value types and are read-only + if ((field.FieldType == typeof(string) || field.FieldType.IsValueType) && field.IsInitOnly) + { + continue; + } + + if (!_fields.ContainsKey(name)) + { + _fields[name] = field; + _orderedFields.Add(new KeyValuePair(name, field)); + } + } + } } public override IEnumerable> GetProperties(object obj) @@ -53,6 +81,14 @@ private void Initialize() { yield return new KeyValuePair(prop.Key, prop.Value.GetValue(obj)); } + + if (Context.IncludeFields) + { + foreach (var field in _orderedFields) + { + yield return new KeyValuePair(field.Key, field.Value.GetValue(obj)); + } + } } public override bool TryGetPropertyValue(SourceSpan span, object obj, string name, out object? value) @@ -63,6 +99,13 @@ public override bool TryGetPropertyValue(SourceSpan span, object obj, string nam value = prop.GetValue(obj); return true; } + + if (Context.IncludeFields && _fields.TryGetValue(name, out var field)) + { + value = field.GetValue(obj); + return true; + } + return false; } @@ -136,10 +179,81 @@ public override bool TrySetPropertyValue(SourceSpan span, object obj, string nam { errorMessage = $"The property {name} was not found on object type {TargetType.FullName}"; } + + if (Context.IncludeFields) + { + if (_fields.TryGetValue(name, out var field)) + { + var accessor = Context.GetAccessor(field.FieldType); + if (accessor is ListDynamicAccessor listAccessor) + { + // Coerce the value + var listValue = field.GetValue(obj); + if (value is not null && field.FieldType.IsInstanceOfType(value)) + { + if (listValue is not null) + { + foreach (var item in (IEnumerable)value) + { + listAccessor.AddElement(listValue, item); + } + + return true; + } + else + { + if (Context.TryConvertValue(span, value, field.FieldType, out value)) + { + field.SetValue(obj, value); + return true; + } + else + { + errorMessage = $"The field value of type {value?.GetType().FullName} couldn't be converted to {field.FieldType} for the list property or field {TargetType.FullName}/{name}"; + } + } + } + else + { + if (listValue is null) + { + listValue = Context.CreateInstance(listAccessor.TargetType, ObjectKind.Array); + field.SetValue(obj, listValue); + } + + if (Context.TryConvertValue(span, value, listAccessor.ElementType, out value)) + { + listAccessor.AddElement(listValue, value); + return true; + } + else + { + errorMessage = $"The field value of type {value?.GetType().FullName} couldn't be converted to {listAccessor.ElementType} for the list property or field {TargetType.FullName}/{name}"; + } + } + } + else if (Context.TryConvertValue(span, value, field.FieldType, out var newValue)) + { + // Coerce the value + field.SetValue(obj, newValue); + return true; + } + else + { + errorMessage = $"The field value of type {value?.GetType().FullName} couldn't be converted to {field.FieldHandle} for the field {TargetType.FullName}/{name}"; + } + } + else + { + errorMessage = $"The field {name} was not found on object type {TargetType.FullName}"; + } + } } catch (Exception ex) { - errorMessage = $"Unexpected error while trying to set property {name} was not found on object type {TargetType.FullName}. Reason: {ex.Message}"; + errorMessage = Context.IncludeFields + ? $"Unexpected error while trying to set property or field {name} was not found on object type {TargetType.FullName}. Reason: {ex.Message}" + : $"Unexpected error while trying to set property {name} was not found on object type {TargetType.FullName}. Reason: {ex.Message}"; } Context.Diagnostics.Error(span, errorMessage); @@ -155,6 +269,15 @@ public override bool TryGetPropertyType(SourceSpan span, string name, out Type? return true; } + if (Context.IncludeFields) + { + if (_fields.TryGetValue(name, out var field)) + { + propertyType = field.FieldType; + return true; + } + } + // Let's try to recover by using the configured ConvertPropertyName // but we emit a warning in that case. var otherName = Context.ConvertPropertyName(name); @@ -165,12 +288,29 @@ public override bool TryGetPropertyType(SourceSpan span, string name, out Type? return true; } + if (Context.IncludeFields) + { + // Let's try to recover by using the configured ConvertFieldName + // but we emit a warning in that case. + otherName = Context.ConvertFieldName(name); + if (otherName != name && _fields.TryGetValue(otherName, out var field)) + { + propertyType = field.FieldType; + Context.Diagnostics.Warning(span, $"The field `{name}` was not found, but `{otherName}` was. By default field names are lowered and split by _ by PascalCase letters. This behavior can be changed by passing a TomlModelOptions and specifying the TomlModelOptions.ConvertFieldName delegate."); + return true; + } + } + // If configured to ignore missing properties on the target type, // return false to indicate it is missing but don't set an error if (!Context.IgnoreMissingProperties) { // Otherwise, it's an error. - Context.Diagnostics.Error(span, $"The property `{name}` was not found on object type {TargetType.FullName}"); + var errorMessage = Context.IncludeFields + ? $"The property or field `{name}` was not found on object type {TargetType.FullName}" + : $"The property `{name}` was not found on object type {TargetType.FullName}"; + + Context.Diagnostics.Error(span, errorMessage); } return false; } @@ -190,11 +330,29 @@ public override bool TryCreateAndSetDefaultPropertyValue(SourceSpan span, object return true; } } - errorMessage = $"Unable to set the property {name} on object type {TargetType.FullName}."; + + if (Context.IncludeFields) + { + if (_fields.TryGetValue(name, out var field)) + { + instance = Context.CreateInstance(field.FieldType, kind); + if (instance is not null) + { + field.SetValue(obj, instance); + return true; + } + } + } + + errorMessage = Context.IncludeFields + ? $"Unable to set the property or field {name} on object type {TargetType.FullName}." + : $"Unable to set the property {name} on object type {TargetType.FullName}."; } catch (Exception ex) { - errorMessage = $"Unexpected error when creating object for property {name} on object type {TargetType.FullName}. Reason: {ex.Message}"; + errorMessage = Context.IncludeFields + ? $"Unexpected error when creating object for property or field {name} on object type {TargetType.FullName}. Reason: {ex.Message}" + : $"Unexpected error when creating object for property {name} on object type {TargetType.FullName}. Reason: {ex.Message}"; } // If configured to ignore missing properties on the target type, diff --git a/src/Tomlyn/TomlModelOptions.cs b/src/Tomlyn/TomlModelOptions.cs index 17f6986..e8a65d4 100644 --- a/src/Tomlyn/TomlModelOptions.cs +++ b/src/Tomlyn/TomlModelOptions.cs @@ -19,12 +19,21 @@ public class TomlModelOptions /// public static readonly Func DefaultConvertPropertyName = TomlNamingHelper.PascalToSnakeCase; + /// + /// Default convert name using snake case via help . + /// + public static readonly Func DefaultConvertFieldName = TomlNamingHelper.PascalToSnakeCase; + public TomlModelOptions() { GetPropertyName = DefaultGetPropertyNameImpl; + GetFieldName = DefaultGetFieldNameImpl; CreateInstance = DefaultCreateInstance; ConvertPropertyName = DefaultConvertPropertyName; + ConvertFieldName = DefaultConvertFieldName; + IgnoreMissingProperties = false; + IncludeFields = false; AttributeListForIgnore = new List() { @@ -44,6 +53,11 @@ public TomlModelOptions() /// public Func GetPropertyName { get; set; } + /// + /// Gets or sets the delegate to retrieve a field from a property. If this function returns null, the field is ignored. + /// + public Func GetFieldName { get; set; } + /// /// Gets or sets the delegate used to convert the name of the property to the name used in TOML. By default, it is using snake case via . /// @@ -52,6 +66,14 @@ public TomlModelOptions() /// public Func ConvertPropertyName { get; set; } + /// + /// Gets or sets the delegate used to convert the name of the field to the name used in TOML. By default, it is using snake case via . + /// + /// + /// This delegate is used by the default delegate. + /// + public Func ConvertFieldName { get; set; } + /// /// Gets or sets the function used when deserializing from TOML to create instance of objects. Default is set to . /// The arguments of the function are: @@ -122,6 +144,14 @@ public TomlModelOptions() /// public bool IgnoreMissingProperties { get; set; } + /// + /// Gets or sets the option to include fields from a custom model in the TOML + /// + /// + /// By default this is false + /// + public bool IncludeFields { get; set; } + /// /// Default implementation for getting the property name /// @@ -158,6 +188,42 @@ public TomlModelOptions() return ConvertPropertyName(name ?? prop.Name); } + /// + /// Default implementation for getting the field name + /// + private string? DefaultGetFieldNameImpl(FieldInfo field) + { + string? name = null; + foreach (var attribute in field.GetCustomAttributes()) + { + var fullName = attribute.GetType().FullName; + // Check if attribute is ignored + foreach (var fullNameOfIgnoreAttribute in AttributeListForIgnore) + { + if (fullName == fullNameOfIgnoreAttribute) + { + return null; + } + } + + // Allow to dynamically bind to JsonPropertyNameAttribute even if we are not targeting netstandard2.0 + foreach (var fullNameOfAttributeWithName in AttributeListForGetName) + { + if (fullName == fullNameOfAttributeWithName) + { + var nameProperty = attribute.GetType().GetProperty("Name"); + if (nameProperty != null && nameProperty.PropertyType == typeof(string)) + { + name = nameProperty.GetValue(attribute) as string; + if (name is not null) break; + } + } + } + } + + return ConvertFieldName(name ?? field.Name); + } + private static object DefaultCreateInstanceImpl(Type type, ObjectKind kind) { if (type == typeof(object))