diff --git a/nuget.config b/nuget.config index d2686ace..c24cbe65 100644 --- a/nuget.config +++ b/nuget.config @@ -2,7 +2,8 @@ - + + - + + + + diff --git a/src/Persistence.Tests/PaYaml/Serialization/PaYamlSerializerTests.cs b/src/Persistence.Tests/PaYaml/Serialization/PaYamlSerializerTests.cs index e9695fce..583cbfdc 100644 --- a/src/Persistence.Tests/PaYaml/Serialization/PaYamlSerializerTests.cs +++ b/src/Persistence.Tests/PaYaml/Serialization/PaYamlSerializerTests.cs @@ -12,6 +12,14 @@ namespace Persistence.Tests.PaYaml.Serialization; [TestClass] public class PaYamlSerializerTests : VSTestBase { + [TestMethod] + public void DeserializeAppInstanceSetsLocationInfo() + { + PaYamlSerializer.Deserialize("App: {}")!.App!.Start.Should().Be(new(1, 6)); + PaYamlSerializer.Deserialize("\nApp: {}")!.App!.Start.Should().Be(new(2, 6)); + PaYamlSerializer.Deserialize("\nApp:\n Properties: {}")!.App!.Start.Should().Be(new(3, 2)); + } + [TestMethod] public void DeserializeNamedObjectSetsLocationInfo() { @@ -27,6 +35,7 @@ public void DeserializeNamedObjectSetsLocationInfo() paModule.App.Properties.ShouldNotBeNull(); paModule.App.Properties.Should().ContainName("Foo").WhoseNamedObject.Start.Should().Be(new(3, 9)); paModule.App.Properties.Should().ContainName("Bar").WhoseNamedObject.Start.Should().Be(new(4, 9)); + paModule.App.Start.Should().Be(new(2, 5), "`App.Start` location info should now be set correctly"); } [TestMethod] diff --git a/src/Persistence/Extensions/JsonExtensions.cs b/src/Persistence/Extensions/JsonExtensions.cs index efb3977b..9ac8b396 100644 --- a/src/Persistence/Extensions/JsonExtensions.cs +++ b/src/Persistence/Extensions/JsonExtensions.cs @@ -8,10 +8,10 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.Extensions; public static class JsonExtensions { /// - /// A fluent way of making a instance immutable. + /// A fluent way of calling to make a instance immutable. /// Especially useful for shared static instances. /// - public static JsonSerializerOptions ToReadOnly(this JsonSerializerOptions options) + public static JsonSerializerOptions MakeReadOnlyFluent(this JsonSerializerOptions options) { options.MakeReadOnly(populateMissingResolver: true); return options; diff --git a/src/Persistence/MsApp/Serialization/MsappSerialization.cs b/src/Persistence/MsApp/Serialization/MsappSerialization.cs index 9691acfc..52819a52 100644 --- a/src/Persistence/MsApp/Serialization/MsappSerialization.cs +++ b/src/Persistence/MsApp/Serialization/MsappSerialization.cs @@ -38,10 +38,10 @@ public static class MsappSerialization // But this may have impact on other code which depends on this property. new JsonDateTimeAssumesUtcConverter(), }, - }.ToReadOnly(); + }.MakeReadOnlyFluent(); internal static readonly JsonSerializerOptions DocumentJsonSerializeOptions = new JsonSerializerOptions(DefaultSharedJsonSerializeOptions) - .ToReadOnly(); + .MakeReadOnlyFluent(); /// /// This should match the options used in DocumentServer for deserializing msapp json files. @@ -51,7 +51,7 @@ public static class MsappSerialization { // Note: The docsvr doesn't indent the Header.json file. WriteIndented = false, - }.ToReadOnly(); + }.MakeReadOnlyFluent(); /// /// Serialization options used for the 'packed.json' file in the msapp archive. @@ -76,5 +76,5 @@ public static class MsappSerialization // In order to ensure forward-compatible deserialization, we ignore unknown members // Any object model that wants to also survive round-tripping, must use JsonExtensionData to capture those unknown members. UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - }.ToReadOnly(); + }.MakeReadOnlyFluent(); } diff --git a/src/Persistence/MsappPacking/Serialization/MsaprSerialization.cs b/src/Persistence/MsappPacking/Serialization/MsaprSerialization.cs index 833f6bd9..71601e28 100644 --- a/src/Persistence/MsappPacking/Serialization/MsaprSerialization.cs +++ b/src/Persistence/MsappPacking/Serialization/MsaprSerialization.cs @@ -39,5 +39,5 @@ public static class MsaprSerialization // In order to ensure forward-compatible deserialization, we ignore unknown members // Any object model that wants to also survive round-tripping, must use JsonExtensionData to capture those unknown members. UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - }.ToReadOnly(); + }.MakeReadOnlyFluent(); } diff --git a/src/Persistence/PaYaml/Models/IMayHavePaYamlLocation.cs b/src/Persistence/PaYaml/Models/IMayHavePaYamlLocation.cs new file mode 100644 index 00000000..f4470407 --- /dev/null +++ b/src/Persistence/PaYaml/Models/IMayHavePaYamlLocation.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Models; + +/// +/// An interface for objects that may have a YAML location. +/// This is used to keep a consistent API for objects that may have a location in the YAML file. +/// +public interface IMayHavePaYamlLocation +{ + /// + /// The location in the YAML file where this object starts. + /// + PaYamlLocation? Start { get; } +} diff --git a/src/Persistence/PaYaml/Models/ISetPaYamlNodeLocation.cs b/src/Persistence/PaYaml/Models/ISetPaYamlNodeLocation.cs new file mode 100644 index 00000000..58a904f3 --- /dev/null +++ b/src/Persistence/PaYaml/Models/ISetPaYamlNodeLocation.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Models; + +internal interface ISetPaYamlNodeLocation : IMayHavePaYamlLocation +{ + /// + /// Sets the location of the node in the YAML file. + /// + void SetNodeLocation(PaYamlLocation? start); +} diff --git a/src/Persistence/PaYaml/Models/SchemaV3/AppInstance.cs b/src/Persistence/PaYaml/Models/SchemaV3/AppInstance.cs index a2e0c334..2c983e6a 100644 --- a/src/Persistence/PaYaml/Models/SchemaV3/AppInstance.cs +++ b/src/Persistence/PaYaml/Models/SchemaV3/AppInstance.cs @@ -6,12 +6,17 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Models.SchemaV3; -public record AppInstance +public record AppInstance : IMayHavePaYamlLocation, ISetPaYamlNodeLocation { [YamlIgnore] - public PaYamlLocation? Start { get; init; } + public PaYamlLocation? Start { get; private set; } public NamedObjectMapping? Properties { get; init; } + void ISetPaYamlNodeLocation.SetNodeLocation(PaYamlLocation? start) + { + Start = start; + } + // WorkItem 27966436: Support saving AppHost instances to top-level property 'App' } diff --git a/src/Persistence/PaYaml/Serialization/PaYamlSerializationContext.cs b/src/Persistence/PaYaml/Serialization/PaYamlSerializationContext.cs index 4a3773c4..c5dd2826 100644 --- a/src/Persistence/PaYaml/Serialization/PaYamlSerializationContext.cs +++ b/src/Persistence/PaYaml/Serialization/PaYamlSerializationContext.cs @@ -3,6 +3,7 @@ using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.Serialization.NodeDeserializers; namespace Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Serialization; @@ -29,6 +30,7 @@ internal void ApplyToDeserializerBuilder(DeserializerBuilder builder) builder .WithDuplicateKeyChecking() .IgnoreFields() + .WithSetNodeLocationNodeDeserializerWrapper() ; if (Options.MaximumRecursion.HasValue) diff --git a/src/Persistence/PaYaml/Serialization/SetNodeLocationNodeDeserializerWrapper.cs b/src/Persistence/PaYaml/Serialization/SetNodeLocationNodeDeserializerWrapper.cs new file mode 100644 index 00000000..89d5a187 --- /dev/null +++ b/src/Persistence/PaYaml/Serialization/SetNodeLocationNodeDeserializerWrapper.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Models; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace Microsoft.PowerPlatform.PowerApps.Persistence.PaYaml.Serialization; + +/// +/// A node deserializer that wraps an existing which will set the location information on the value if it implements . +/// +internal sealed class SetNodeLocationNodeDeserializerWrapper(TNodeDeserializerToWrap wrappedNodeDeserializer) : INodeDeserializer + where TNodeDeserializerToWrap : INodeDeserializer +{ + public bool Deserialize(IParser reader, Type expectedType, Func nestedObjectDeserializer, out object? value, ObjectDeserializer rootDeserializer) + { + // Capture the start location before deserializing the node + var start = reader.Current?.Start; + if (wrappedNodeDeserializer.Deserialize(reader, expectedType, nestedObjectDeserializer, out value, rootDeserializer)) + { + if (start != null && value is ISetPaYamlNodeLocation setNodeLocationValue) + { + // Only set the location if it hasn't been set yet + if (setNodeLocationValue.Start is null) + { + setNodeLocationValue.SetNodeLocation(PaYamlLocation.FromMark(start.Value)); + } + } + + return true; + } + + return false; + } +} + +internal static class SetNodeLocationNodeDeserializerWrapperExtensions +{ + /// + /// Wraps the specified with a which will set the location information on the value if it implements . + /// + /// The type of the to be replaced and wrapped. + public static DeserializerBuilder WithSetNodeLocationNodeDeserializerWrapper(this DeserializerBuilder builder) + where TNodeDeserializerToWrap : INodeDeserializer + { + _ = builder ?? throw new ArgumentNullException(nameof(builder)); + + return builder.WithNodeDeserializer( + baseDeserializer => new SetNodeLocationNodeDeserializerWrapper((TNodeDeserializerToWrap)baseDeserializer), + s => s.InsteadOf()); + } +}