From cf3dcd91954c59d914ddbe667bd00a210d5929b6 Mon Sep 17 00:00:00 2001 From: "Andrew Petrochuk (from Dev Box)" Date: Sun, 3 Dec 2023 19:45:22 -0800 Subject: [PATCH] Yaml attributes --- .../PAConvert/Yaml/Attributes/YamlObject.cs | 19 +++ .../PAConvert/Yaml/Attributes/YamlProperty.cs | 49 +++++++ src/PAModel/PAConvert/Yaml/YamlLexer.cs | 32 +++-- .../PAConvert/Yaml/YamlLexerOptions.cs | 14 ++ .../PAConvert/Yaml/YamlParseException.cs | 12 ++ .../PAConvert/Yaml/YamlPocoDeserializer.cs | 123 ++++++++++++++++++ .../PAConvert/Yaml/YamlPocoSerializer.cs | 121 +++++++++++++++-- src/PAModel/PAConvert/Yaml/YamlWriter.cs | 6 + .../YamlSerializerTests/SimpleObject.cs | 33 +++++ .../YamlSerializerTests.cs | 84 ++++++++++++ 10 files changed, 472 insertions(+), 21 deletions(-) create mode 100644 src/PAModel/PAConvert/Yaml/Attributes/YamlObject.cs create mode 100644 src/PAModel/PAConvert/Yaml/Attributes/YamlProperty.cs create mode 100644 src/PAModel/PAConvert/Yaml/YamlLexerOptions.cs create mode 100644 src/PAModel/PAConvert/Yaml/YamlParseException.cs create mode 100644 src/PAModel/PAConvert/Yaml/YamlPocoDeserializer.cs create mode 100644 src/PAModelTests/YamlSerializerTests/SimpleObject.cs create mode 100644 src/PAModelTests/YamlSerializerTests/YamlSerializerTests.cs diff --git a/src/PAModel/PAConvert/Yaml/Attributes/YamlObject.cs b/src/PAModel/PAConvert/Yaml/Attributes/YamlObject.cs new file mode 100644 index 00000000..29c4269a --- /dev/null +++ b/src/PAModel/PAConvert/Yaml/Attributes/YamlObject.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; + +/// +/// Specifies that a object should be serialized to YAML. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +[DebuggerDisplay("{Name}")] +public class YamlObject : Attribute +{ + /// + /// Name of the object in the YAML file. + /// + public string Name { get; set; } +} diff --git a/src/PAModel/PAConvert/Yaml/Attributes/YamlProperty.cs b/src/PAModel/PAConvert/Yaml/Attributes/YamlProperty.cs new file mode 100644 index 00000000..98067827 --- /dev/null +++ b/src/PAModel/PAConvert/Yaml/Attributes/YamlProperty.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; + +/// +/// Specifies that a property should be serialized to YAML. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] +[DebuggerDisplay("{Name}")] +public class YamlProperty : Attribute, IComparable +{ + /// + /// Name of the property in the YAML file. + /// + public string Name { get; set; } + + /// + /// Order in which the property should be serialized. + /// If not specified, the property will be serialized last. + /// + public int Order { get; init; } = int.MaxValue; + + /// + /// Default value of the property. + /// + public object DefaultValue { get; set; } + + /// + /// Compare the order of this property to another. + /// + /// + /// + public int CompareTo(YamlProperty other) + { + if (other == null) + return 1; + + if (Order != other.Order) + return Order.CompareTo(other.Order); + + if (Name == null) + return -1; + + return Name.CompareTo(other.Name); + } +} diff --git a/src/PAModel/PAConvert/Yaml/YamlLexer.cs b/src/PAModel/PAConvert/Yaml/YamlLexer.cs index 67c489a0..79edde97 100644 --- a/src/PAModel/PAConvert/Yaml/YamlLexer.cs +++ b/src/PAModel/PAConvert/Yaml/YamlLexer.cs @@ -129,7 +129,6 @@ internal class YamlLexer : IDisposable // for error handling private readonly string _currentFileName; - private int _currentLine; // 1-based line number private string _currentLineContents = null; @@ -159,6 +158,14 @@ public YamlLexer(TextReader source, string filenameHint = null) }; } + private int _currentLine; + /// + /// Current line number. 1-based. + /// + public int CurrentLine => _currentLine; + + public YamlLexerOptions Options { get; set; } = YamlLexerOptions.EnforceLeadingEquals; + private LineParser PeekLine() { if (_currentLineContents == null) @@ -374,9 +381,10 @@ private YamlToken ReadNextWorker() iSpaces++; // skip optional spaces } + YamlToken error; if (line.Current == 0) // EOL { - var error = _currentIndent.Peek().CheckDuplicate(propName, _currentLine); + error = _currentIndent.Peek().CheckDuplicate(propName, _currentLine); if (error != null) { error.Span = Loc(line); @@ -487,19 +495,23 @@ private YamlToken ReadNextWorker() value = value.Substring(1); // move past '=' } - else + else if (Options.HasFlag(YamlLexerOptions.EnforceLeadingEquals)) { // Warn on legal yaml escapes (>) that we don't support in our subset here. return Error(line, "Expected either '=' for a single line expression or '|' to begin a multiline expression"); } + else + { + // Single line. Property doesn't include \n at end. + value = line.RestOfLine; + MoveNextLine(); + } + error = _currentIndent.Peek().CheckDuplicate(propName, _currentLine); + if (error != null) { - var error = _currentIndent.Peek().CheckDuplicate(propName, _currentLine); - if (error != null) - { - error.Span = Loc(line); - return error; - } + error.Span = Loc(line); + return error; } int endIndex = line._line.Length + 1; @@ -697,7 +709,7 @@ public int EatIndent() private string DebuggerToString() { var idx = Math.Min(_line.Length, this._idx); - return _line.Substring(0, idx) + "|" + _line.Substring(idx); + return _line.Substring(0, idx) + "¤" + _line.Substring(idx); } } diff --git a/src/PAModel/PAConvert/Yaml/YamlLexerOptions.cs b/src/PAModel/PAConvert/Yaml/YamlLexerOptions.cs new file mode 100644 index 00000000..f45fb99f --- /dev/null +++ b/src/PAModel/PAConvert/Yaml/YamlLexerOptions.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; + +[Flags] +public enum YamlLexerOptions +{ + None = 0, + EnforceLeadingEquals = 1 +} diff --git a/src/PAModel/PAConvert/Yaml/YamlParseException.cs b/src/PAModel/PAConvert/Yaml/YamlParseException.cs new file mode 100644 index 00000000..6a6bdac9 --- /dev/null +++ b/src/PAModel/PAConvert/Yaml/YamlParseException.cs @@ -0,0 +1,12 @@ +namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; + +public class YamlParseException : Exception +{ + public YamlParseException(string message, int line, Exception innerException = null) + : base(message, innerException) + { + Line = line; + } + + public int Line { get; init; } +} diff --git a/src/PAModel/PAConvert/Yaml/YamlPocoDeserializer.cs b/src/PAModel/PAConvert/Yaml/YamlPocoDeserializer.cs new file mode 100644 index 00000000..367f98f2 --- /dev/null +++ b/src/PAModel/PAConvert/Yaml/YamlPocoDeserializer.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using System.IO; +using System.Linq; +using System.Reflection; +using YamlDotNet.Serialization; + +namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; + +/// +/// Deserializer from Yaml +/// +public class YamlPocoDeserializer : IDisposable +{ + YamlLexer _yamlLexer; + private bool _isDisposed; + + public YamlPocoDeserializer() + { + } + + public YamlPocoDeserializer(Stream stream) + { + _ = stream ?? throw new ArgumentNullException(nameof(stream)); + _yamlLexer = new YamlLexer(new StreamReader(stream)); + } + + public YamlPocoDeserializer(TextReader reader) + { + _ = reader ?? throw new ArgumentNullException(nameof(reader)); + _yamlLexer = new YamlLexer(reader); + } + + public YamlLexerOptions Options + { + get => _yamlLexer.Options; + set => _yamlLexer.Options = value; + } + + public T Deserialize(string name = null) + { + var result = Activator.CreateInstance(); + + // Read the object name. + var objType = typeof(T); + var yamlObject = objType.GetCustomAttribute() ?? new YamlObject() { Name = objType.Name }; + if (string.IsNullOrWhiteSpace(yamlObject.Name)) + yamlObject.Name = string.IsNullOrWhiteSpace(name) ? objType.Name : name; + + // Build dictionary of expected properties that have the YamlProperty attribute. + var yamlProps = new Dictionary(); + foreach (var prop in objType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(prop => Attribute.IsDefined(prop, typeof(YamlProperty)))) + { + var yamlProperty = prop.GetCustomAttribute(); + if (string.IsNullOrWhiteSpace(yamlProperty.Name)) + yamlProperty.Name = prop.Name; + yamlProps.Add(yamlProperty.Name, (prop, yamlProperty)); + } + + // Stream must start with expected object. + YamlToken token; + token = _yamlLexer.ReadNext(); + if (token.Kind != YamlTokenKind.StartObj) + { + if (token.Kind == YamlTokenKind.Error) + throw new YamlParseException(token.Value, _yamlLexer.CurrentLine); + else + throw new YamlParseException($"Expected '{YamlTokenKind.StartObj}', found '{token.Kind}'", _yamlLexer.CurrentLine); + } + if (!token.Property.Equals(yamlObject.Name)) + throw new YamlParseException($"Expected '{yamlObject.Name}', found '{token.Property}'", _yamlLexer.CurrentLine); + + // Parse the stream. + while ((token = _yamlLexer.ReadNext()) != YamlToken.EndObj) + { + if (token.Kind == YamlTokenKind.Property) + { + if (yamlProps.TryGetValue(token.Property, out var propInfo)) + { + try + { + var typedValue = Convert.ChangeType(token.Value, propInfo.info.PropertyType); + propInfo.info.SetValue(result, typedValue); + } + catch (Exception ex) when (ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { + throw new YamlParseException($"Error parsing property '{token.Property}'", _yamlLexer.CurrentLine, ex); + } + } + } + } + + return result; + } + + #region IDisposable + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _yamlLexer?.Dispose(); + _yamlLexer = null; + } + + _isDisposed = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion +} diff --git a/src/PAModel/PAConvert/Yaml/YamlPocoSerializer.cs b/src/PAModel/PAConvert/Yaml/YamlPocoSerializer.cs index 93a354c1..d6f6664a 100644 --- a/src/PAModel/PAConvert/Yaml/YamlPocoSerializer.cs +++ b/src/PAModel/PAConvert/Yaml/YamlPocoSerializer.cs @@ -12,8 +12,26 @@ namespace Microsoft.PowerPlatform.Formulas.Tools.Yaml; /// /// Serializer for Writing POCOs as canonical yaml. /// -public static class YamlPocoSerializer +public class YamlPocoSerializer : IDisposable { + YamlWriter _yamlWriter; + private bool _isDisposed; + + public YamlPocoSerializer() + { + } + + public YamlPocoSerializer(YamlWriter yamlWriter) + { + _yamlWriter = yamlWriter ?? throw new ArgumentNullException(nameof(yamlWriter)); + } + + public YamlPocoSerializer(Stream stream) + { + _ = stream ?? throw new ArgumentNullException(nameof(stream)); + _yamlWriter = new YamlWriter(stream); + } + public static T Read(TextReader reader) { var deserializer = new DeserializerBuilder().Build(); @@ -23,23 +41,75 @@ public static T Read(TextReader reader) return obj; } - // Write the object out to the stream in a canonical way. Such as: - // - object ordering - // - encodings - // - multi-line - // - newlines + /// + /// Write the object out to the stream in a canonical way. Such as: + /// - object ordering + /// - encodings + /// - multi-line + /// - newlines + /// + /// + /// + /// public static void CanonicalWrite(TextWriter writer, object obj) { - if (obj == null) - { - throw new ArgumentNullException(nameof(obj)); - } + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + _ = obj ?? throw new ArgumentNullException(nameof(obj)); var yaml = new YamlWriter(writer); WriteObject(yaml, obj); } + /// + /// Serialize the object to the stream. + /// + /// + /// Object name in Yaml + /// + public void Serialize(object obj, string name = null) + { + _ = obj ?? throw new ArgumentNullException(nameof(obj)); + _ = _yamlWriter ?? throw new InvalidOperationException("Writer is not set"); + + // Write the object name. + var objType = obj.GetType(); + var yamlObject = objType.GetCustomAttribute() ?? new YamlObject() { Name = objType.Name }; + if (string.IsNullOrWhiteSpace(yamlObject.Name)) + yamlObject.Name = objType.Name; + _yamlWriter.WriteStartObject(string.IsNullOrWhiteSpace(name) ? yamlObject.Name : name); + + // Get only the properties that have the YamlProperty attribute and non-default values. + var yamlProps = new List<(PropertyInfo info, YamlProperty attr)>(); + foreach (var prop in objType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(prop => Attribute.IsDefined(prop, typeof(YamlProperty)))) + { + var yamlProperty = prop.GetCustomAttribute(); + var yamlPropertyValue = prop.GetValue(obj); + if (yamlProperty.DefaultValue == null && yamlPropertyValue == null) + continue; + else if (yamlProperty.DefaultValue != null && yamlProperty.DefaultValue.Equals(yamlPropertyValue)) + continue; + + if (string.IsNullOrWhiteSpace(yamlProperty.Name)) + yamlProperty.Name = prop.Name; + yamlProps.Add((prop, yamlProperty)); + } + + // Sort the properties by order, then by name. + yamlProps.Sort((prop1, prop2) => + { + return prop1.attr.CompareTo(prop2.attr); + }); + + // Write non-default sorted properties. + var propsValues = new List>(); + foreach (var prop in yamlProps) + { + WriteAnything(_yamlWriter, prop.attr.Name, prop.info.GetValue(obj)); + } + } + private static void WriteAnything(YamlWriter yaml, string propName, object obj) { if (obj == null) @@ -118,7 +188,11 @@ private static void WriteObject(YamlWriter yaml, object obj) WriteCanonicalList(yaml, list); } - // Write a list of properties (such as an object or dictionary) in an ordered way. + /// + /// Write a list of properties (such as an object or dictionary) in an ordered way. + /// + /// + /// private static void WriteCanonicalList(YamlWriter yaml, List> list) { // Critical to sort to preserve a canonical order. @@ -129,4 +203,29 @@ private static void WriteCanonicalList(YamlWriter yaml, List(); + + // Assert + simpleObjectOut.Should().BeEquivalentTo(simpleObjectIn); + } + + [TestMethod] + [DataRow(@"", 0)] + [DataRow(@"some garbage", 1)] + [DataRow(@"some garbage:", 1)] + [DataRow(@"Simple Object:some garbage", 1)] + [DataRow(@"Simple Object: some garbage", 1)] + [DataRow(@"Simple Object: + X: abc", 2)] + public void InvalidYaml(string invalidYaml, int errorLine) + { + // Arrange + var deserializer = new YamlPocoDeserializer(new StringReader(invalidYaml)) + { + Options = YamlLexerOptions.None + }; + + // Act + Action action = () => deserializer.Deserialize(); + + // Assert + action.Should() + .Throw() + .Where(e => e.Line == errorLine); + } +}