diff --git a/Makefile.am b/Makefile.am index 6bafd1051b..0c86b9325d 100644 --- a/Makefile.am +++ b/Makefile.am @@ -93,6 +93,7 @@ csharp_EXTRA_DIST= \ csharp/src/Google.Protobuf.Test/DeprecatedMemberTest.cs \ csharp/src/Google.Protobuf.Test/EqualityTester.cs \ csharp/src/Google.Protobuf.Test/FieldCodecTest.cs \ + csharp/src/Google.Protobuf.Test/FieldMaskTreeTest.cs \ csharp/src/Google.Protobuf.Test/GeneratedMessageTest.cs \ csharp/src/Google.Protobuf.Test/Google.Protobuf.Test.csproj \ csharp/src/Google.Protobuf.Test/IssuesTest.cs \ @@ -140,6 +141,7 @@ csharp_EXTRA_DIST= \ csharp/src/Google.Protobuf/Compatibility/StreamExtensions.cs \ csharp/src/Google.Protobuf/Compatibility/TypeExtensions.cs \ csharp/src/Google.Protobuf/FieldCodec.cs \ + csharp/src/Google.Protobuf/FieldMaskTree.cs \ csharp/src/Google.Protobuf/FrameworkPortability.cs \ csharp/src/Google.Protobuf/Google.Protobuf.csproj \ csharp/src/Google.Protobuf/ICustomDiagnosticMessage.cs \ diff --git a/csharp/src/Google.Protobuf.Test/FieldMaskTreeTest.cs b/csharp/src/Google.Protobuf.Test/FieldMaskTreeTest.cs new file mode 100644 index 0000000000..b0caab9e60 --- /dev/null +++ b/csharp/src/Google.Protobuf.Test/FieldMaskTreeTest.cs @@ -0,0 +1,436 @@ +#region Copyright notice and license +// Protocol Buffers - Google's data interchange format +// Copyright 2015 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +#endregion + +using System.Collections.Generic; +using Google.Protobuf.Collections; +using Google.Protobuf.TestProtos; +using NUnit.Framework; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Protobuf +{ + public class FieldMaskTreeTest + { + [Test] + public void AddFieldPath() + { + FieldMaskTree tree = new FieldMaskTree(); + RepeatedField<string> paths = tree.ToFieldMask().Paths; + Assert.AreEqual(0, paths.Count); + + tree.AddFieldPath(""); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(1, paths.Count); + Assert.Contains("", paths); + + // New branch. + tree.AddFieldPath("foo"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(2, paths.Count); + Assert.Contains("foo", paths); + + // Redundant path. + tree.AddFieldPath("foo"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(2, paths.Count); + + // New branch. + tree.AddFieldPath("bar.baz"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(3, paths.Count); + Assert.Contains("bar.baz", paths); + + // Redundant sub-path. + tree.AddFieldPath("foo.bar"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(3, paths.Count); + + // New branch from a non-root node. + tree.AddFieldPath("bar.quz"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(4, paths.Count); + Assert.Contains("bar.quz", paths); + + // A path that matches several existing sub-paths. + tree.AddFieldPath("bar"); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(3, paths.Count); + Assert.Contains("foo", paths); + Assert.Contains("bar", paths); + } + + [Test] + public void MergeFromFieldMask() + { + FieldMaskTree tree = new FieldMaskTree(); + tree.MergeFromFieldMask(new FieldMask + { + Paths = {"foo", "bar.baz", "bar.quz"} + }); + RepeatedField<string> paths = tree.ToFieldMask().Paths; + Assert.AreEqual(3, paths.Count); + Assert.Contains("foo", paths); + Assert.Contains("bar.baz", paths); + Assert.Contains("bar.quz", paths); + + tree.MergeFromFieldMask(new FieldMask + { + Paths = {"foo.bar", "bar"} + }); + paths = tree.ToFieldMask().Paths; + Assert.AreEqual(2, paths.Count); + Assert.Contains("foo", paths); + Assert.Contains("bar", paths); + } + + [Test] + public void IntersectFieldPath() + { + FieldMaskTree tree = new FieldMaskTree(); + FieldMaskTree result = new FieldMaskTree(); + tree.MergeFromFieldMask(new FieldMask + { + Paths = {"foo", "bar.baz", "bar.quz"} + }); + + // Empty path. + tree.IntersectFieldPath("", result); + RepeatedField<string> paths = result.ToFieldMask().Paths; + Assert.AreEqual(0, paths.Count); + + // Non-exist path. + tree.IntersectFieldPath("quz", result); + paths = result.ToFieldMask().Paths; + Assert.AreEqual(0, paths.Count); + + // Sub-path of an existing leaf. + tree.IntersectFieldPath("foo.bar", result); + paths = result.ToFieldMask().Paths; + Assert.AreEqual(1, paths.Count); + Assert.Contains("foo.bar", paths); + + // Match an existing leaf node. + tree.IntersectFieldPath("foo", result); + paths = result.ToFieldMask().Paths; + Assert.AreEqual(1, paths.Count); + Assert.Contains("foo", paths); + + // Non-exist path. + tree.IntersectFieldPath("bar.foo", result); + paths = result.ToFieldMask().Paths; + Assert.AreEqual(1, paths.Count); + Assert.Contains("foo", paths); + + // Match a non-leaf node. + tree.IntersectFieldPath("bar", result); + paths = result.ToFieldMask().Paths; + Assert.AreEqual(3, paths.Count); + Assert.Contains("foo", paths); + Assert.Contains("bar.baz", paths); + Assert.Contains("bar.quz", paths); + } + + private void Merge(FieldMaskTree tree, IMessage source, IMessage destination, FieldMask.MergeOptions options, bool useDynamicMessage) + { + if (useDynamicMessage) + { + var newSource = source.Descriptor.Parser.CreateTemplate(); + newSource.MergeFrom(source.ToByteString()); + + var newDestination = source.Descriptor.Parser.CreateTemplate(); + newDestination.MergeFrom(destination.ToByteString()); + + tree.Merge(newSource, newDestination, options); + + // Clear before merging: + foreach (var fieldDescriptor in destination.Descriptor.Fields.InFieldNumberOrder()) + { + fieldDescriptor.Accessor.Clear(destination); + } + destination.MergeFrom(newDestination.ToByteString()); + } + else + { + tree.Merge(source, destination, options); + } + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void Merge(bool useDynamicMessage) + { + TestAllTypes value = new TestAllTypes + { + SingleInt32 = 1234, + SingleNestedMessage = new TestAllTypes.Types.NestedMessage {Bb = 5678}, + RepeatedInt32 = {4321}, + RepeatedNestedMessage = {new TestAllTypes.Types.NestedMessage {Bb = 8765}} + }; + + NestedTestAllTypes source = new NestedTestAllTypes + { + Payload = value, + Child = new NestedTestAllTypes {Payload = value} + }; + // Now we have a message source with the following structure: + // [root] -+- payload -+- single_int32 + // | +- single_nested_message + // | +- repeated_int32 + // | +- repeated_nested_message + // | + // +- child --- payload -+- single_int32 + // +- single_nested_message + // +- repeated_int32 + // +- repeated_nested_message + + FieldMask.MergeOptions options = new FieldMask.MergeOptions(); + + // Test merging each individual field. + NestedTestAllTypes destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload.single_int32"), + source, destination, options, useDynamicMessage); + NestedTestAllTypes expected = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1234 + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload.single_nested_message"), + source, destination, options, useDynamicMessage); + expected = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleNestedMessage = new TestAllTypes.Types.NestedMessage {Bb = 5678} + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload.repeated_int32"), + source, destination, options, useDynamicMessage); + expected = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + RepeatedInt32 = {4321} + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload.repeated_nested_message"), + source, destination, options, useDynamicMessage); + expected = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + RepeatedNestedMessage = {new TestAllTypes.Types.NestedMessage {Bb = 8765}} + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge( + new FieldMaskTree().AddFieldPath("child.payload.single_int32"), + source, + destination, + options, + useDynamicMessage); + expected = new NestedTestAllTypes + { + Child = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1234 + } + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge( + new FieldMaskTree().AddFieldPath("child.payload.single_nested_message"), + source, + destination, + options, + useDynamicMessage); + expected = new NestedTestAllTypes + { + Child = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleNestedMessage = new TestAllTypes.Types.NestedMessage {Bb = 5678} + } + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("child.payload.repeated_int32"), + source, destination, options, useDynamicMessage); + expected = new NestedTestAllTypes + { + Child = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + RepeatedInt32 = {4321} + } + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("child.payload.repeated_nested_message"), + source, destination, options, useDynamicMessage); + expected = new NestedTestAllTypes + { + Child = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + RepeatedNestedMessage = {new TestAllTypes.Types.NestedMessage {Bb = 8765}} + } + } + }; + Assert.AreEqual(expected, destination); + + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("child").AddFieldPath("payload"), + source, destination, options, useDynamicMessage); + Assert.AreEqual(source, destination); + + // Test repeated options. + destination = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + RepeatedInt32 = { 1000 } + } + }; + Merge(new FieldMaskTree().AddFieldPath("payload.repeated_int32"), + source, destination, options, useDynamicMessage); + // Default behavior is to append repeated fields. + Assert.AreEqual(2, destination.Payload.RepeatedInt32.Count); + Assert.AreEqual(1000, destination.Payload.RepeatedInt32[0]); + Assert.AreEqual(4321, destination.Payload.RepeatedInt32[1]); + // Change to replace repeated fields. + options.ReplaceRepeatedFields = true; + Merge(new FieldMaskTree().AddFieldPath("payload.repeated_int32"), + source, destination, options, useDynamicMessage); + Assert.AreEqual(1, destination.Payload.RepeatedInt32.Count); + Assert.AreEqual(4321, destination.Payload.RepeatedInt32[0]); + + // Test message options. + destination = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1000, + SingleUint32 = 2000 + } + }; + Merge(new FieldMaskTree().AddFieldPath("payload"), + source, destination, options, useDynamicMessage); + // Default behavior is to merge message fields. + Assert.AreEqual(1234, destination.Payload.SingleInt32); + Assert.AreEqual(2000, destination.Payload.SingleUint32); + + // Test merging unset message fields. + NestedTestAllTypes clearedSource = source.Clone(); + clearedSource.Payload = null; + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload"), + clearedSource, destination, options, useDynamicMessage); + Assert.IsNull(destination.Payload); + + // Skip a message field if they are unset in both source and target. + destination = new NestedTestAllTypes(); + Merge(new FieldMaskTree().AddFieldPath("payload.single_int32"), + clearedSource, destination, options, useDynamicMessage); + Assert.IsNull(destination.Payload); + + // Change to replace message fields. + options.ReplaceMessageFields = true; + destination = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1000, + SingleUint32 = 2000 + } + }; + Merge(new FieldMaskTree().AddFieldPath("payload"), + source, destination, options, useDynamicMessage); + Assert.AreEqual(1234, destination.Payload.SingleInt32); + Assert.AreEqual(0, destination.Payload.SingleUint32); + + // Test merging unset message fields. + destination = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1000, + SingleUint32 = 2000 + } + }; + Merge(new FieldMaskTree().AddFieldPath("payload"), + clearedSource, destination, options, useDynamicMessage); + Assert.IsNull(destination.Payload); + + // Test merging unset primitive fields. + destination = source.Clone(); + destination.Payload.SingleInt32 = 0; + NestedTestAllTypes sourceWithPayloadInt32Unset = destination; + destination = source.Clone(); + Merge(new FieldMaskTree().AddFieldPath("payload.single_int32"), + sourceWithPayloadInt32Unset, destination, options, useDynamicMessage); + Assert.AreEqual(0, destination.Payload.SingleInt32); + + // Change to clear unset primitive fields. + options.ReplacePrimitiveFields = true; + destination = source.Clone(); + Merge(new FieldMaskTree().AddFieldPath("payload.single_int32"), + sourceWithPayloadInt32Unset, destination, options, useDynamicMessage); + Assert.IsNotNull(destination.Payload); + } + + } +} diff --git a/csharp/src/Google.Protobuf.Test/WellKnownTypes/FieldMaskTest.cs b/csharp/src/Google.Protobuf.Test/WellKnownTypes/FieldMaskTest.cs index 1d9908b4d3..5dc5035703 100644 --- a/csharp/src/Google.Protobuf.Test/WellKnownTypes/FieldMaskTest.cs +++ b/csharp/src/Google.Protobuf.Test/WellKnownTypes/FieldMaskTest.cs @@ -30,7 +30,8 @@ // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #endregion - +using System; +using Google.Protobuf.TestProtos; using NUnit.Framework; namespace Google.Protobuf.WellKnownTypes @@ -58,5 +59,187 @@ namespace Google.Protobuf.WellKnownTypes "{ \"@warning\": \"Invalid FieldMask\", \"paths\": [ \"x\", \"foo__bar\", \"x\\\\y\" ] }", mask.ToString()); } + + [Test] + public void IsValid() + { + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload")); + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>("nonexist")); + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload.single_int32")); + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload.repeated_int32")); + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload.single_nested_message")); + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload.repeated_nested_message")); + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>("payload.nonexist")); + + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>(FieldMask.FromString("payload"))); + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>(FieldMask.FromString("nonexist"))); + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>(FieldMask.FromString("payload,nonexist"))); + + Assert.IsTrue(FieldMask.IsValid(NestedTestAllTypes.Descriptor, "payload")); + Assert.IsFalse(FieldMask.IsValid(NestedTestAllTypes.Descriptor, "nonexist")); + + Assert.IsTrue(FieldMask.IsValid(NestedTestAllTypes.Descriptor, FieldMask.FromString("payload"))); + Assert.IsFalse(FieldMask.IsValid(NestedTestAllTypes.Descriptor, FieldMask.FromString("nonexist"))); + + Assert.IsTrue(FieldMask.IsValid<NestedTestAllTypes>("payload.single_nested_message.bb")); + + // Repeated fields cannot have sub-paths. + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>("payload.repeated_nested_message.bb")); + + // Non-message fields cannot have sub-paths. + Assert.IsFalse(FieldMask.IsValid<NestedTestAllTypes>("payload.single_int32.bb")); + } + + [Test] + [TestCase(new string[] { }, "\"\"")] + [TestCase(new string[] { "foo" }, "\"foo\"")] + [TestCase(new string[] { "foo", "bar" }, "\"foo,bar\"")] + [TestCase(new string[] { "", "foo", "", "bar", "" }, "\",foo,,bar,\"")] + public void ToString(string[] input, string expectedOutput) + { + FieldMask mask = new FieldMask(); + mask.Paths.AddRange(input); + Assert.AreEqual(expectedOutput, mask.ToString()); + } + + [Test] + [TestCase("", new string[] { })] + [TestCase("foo", new string[] { "foo" })] + [TestCase("foo,bar.baz", new string[] { "foo", "bar.baz" })] + [TestCase(",foo,,bar,", new string[] { "foo", "bar" })] + public void FromString(string input, string[] expectedOutput) + { + FieldMask mask = FieldMask.FromString(input); + Assert.AreEqual(expectedOutput.Length, mask.Paths.Count); + for (int i = 0; i < expectedOutput.Length; i++) + { + Assert.AreEqual(expectedOutput[i], mask.Paths[i]); + } + } + + [Test] + public void FromString_Validated() + { + // Check whether the field paths are valid if a class parameter is provided. + Assert.DoesNotThrow(() => FieldMask.FromString<NestedTestAllTypes>(",payload")); + Assert.Throws<InvalidProtocolBufferException>(() => FieldMask.FromString<NestedTestAllTypes>("payload,nonexist")); + } + + [Test] + [TestCase(new int[] { }, new string[] { })] + [TestCase(new int[] { TestAllTypes.SingleInt32FieldNumber }, new string[] { "single_int32" })] + [TestCase(new int[] { TestAllTypes.SingleInt32FieldNumber, TestAllTypes.SingleInt64FieldNumber }, new string[] { "single_int32", "single_int64" })] + public void FromFieldNumbers(int[] input, string[] expectedOutput) + { + FieldMask mask = FieldMask.FromFieldNumbers<TestAllTypes>(input); + Assert.AreEqual(expectedOutput.Length, mask.Paths.Count); + for (int i = 0; i < expectedOutput.Length; i++) + { + Assert.AreEqual(expectedOutput[i], mask.Paths[i]); + } + } + + [Test] + public void FromFieldNumbers_Invalid() + { + Assert.Throws<ArgumentNullException>(() => + { + int invalidFieldNumber = 1000; + FieldMask.FromFieldNumbers<TestAllTypes>(invalidFieldNumber); + }); + } + + [Test] + [TestCase(new string[] { }, "\"\"")] + [TestCase(new string[] { "foo" }, "\"foo\"")] + [TestCase(new string[] { "foo", "bar" }, "\"foo,bar\"")] + [TestCase(new string[] { "", "foo", "", "bar", "" }, "\",foo,bar\"")] + public void Normalize(string[] input, string expectedOutput) + { + FieldMask mask = new FieldMask(); + mask.Paths.AddRange(input); + FieldMask result = mask.Normalize(); + Assert.AreEqual(expectedOutput, result.ToString()); + } + + [Test] + public void Union() + { + // Only test a simple case here and expect + // {@link FieldMaskTreeTest#AddFieldPath} to cover all scenarios. + FieldMask mask1 = FieldMask.FromString("foo,bar.baz,bar.quz"); + FieldMask mask2 = FieldMask.FromString("foo.bar,bar"); + FieldMask result = mask1.Union(mask2); + Assert.AreEqual(2, result.Paths.Count); + Assert.Contains("bar", result.Paths); + Assert.Contains("foo", result.Paths); + Assert.That(result.Paths, Has.No.Member("bar.baz")); + Assert.That(result.Paths, Has.No.Member("bar.quz")); + Assert.That(result.Paths, Has.No.Member("foo.bar")); + } + + [Test] + public void Union_UsingVarArgs() + { + FieldMask mask1 = FieldMask.FromString("foo"); + FieldMask mask2 = FieldMask.FromString("foo.bar,bar.quz"); + FieldMask mask3 = FieldMask.FromString("bar.quz"); + FieldMask mask4 = FieldMask.FromString("bar"); + FieldMask result = mask1.Union(mask2, mask3, mask4); + Assert.AreEqual(2, result.Paths.Count); + Assert.Contains("bar", result.Paths); + Assert.Contains("foo", result.Paths); + Assert.That(result.Paths, Has.No.Member("foo.bar")); + Assert.That(result.Paths, Has.No.Member("bar.quz")); + } + + [Test] + public void Intersection() + { + // Only test a simple case here and expect + // {@link FieldMaskTreeTest#IntersectFieldPath} to cover all scenarios. + FieldMask mask1 = FieldMask.FromString("foo,bar.baz,bar.quz"); + FieldMask mask2 = FieldMask.FromString("foo.bar,bar"); + FieldMask result = mask1.Intersection(mask2); + Assert.AreEqual(3, result.Paths.Count); + Assert.Contains("foo.bar", result.Paths); + Assert.Contains("bar.baz", result.Paths); + Assert.Contains("bar.quz", result.Paths); + Assert.That(result.Paths, Has.No.Member("foo")); + Assert.That(result.Paths, Has.No.Member("bar")); + } + + [Test] + public void Merge() + { + // Only test a simple case here and expect + // {@link FieldMaskTreeTest#Merge} to cover all scenarios. + FieldMask fieldMask = FieldMask.FromString("payload"); + NestedTestAllTypes source = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 1234, + SingleFixed64 = 4321 + } + }; + NestedTestAllTypes destination = new NestedTestAllTypes(); + fieldMask.Merge(source, destination); + Assert.AreEqual(1234, destination.Payload.SingleInt32); + Assert.AreEqual(4321, destination.Payload.SingleFixed64); + + destination = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleInt32 = 4321, + SingleInt64 = 5678 + } + }; + fieldMask.Merge(source, destination); + Assert.AreEqual(1234, destination.Payload.SingleInt32); + Assert.AreEqual(5678, destination.Payload.SingleInt64); + Assert.AreEqual(4321, destination.Payload.SingleFixed64); + } } } diff --git a/csharp/src/Google.Protobuf/FieldMaskTree.cs b/csharp/src/Google.Protobuf/FieldMaskTree.cs new file mode 100644 index 0000000000..36c823bed0 --- /dev/null +++ b/csharp/src/Google.Protobuf/FieldMaskTree.cs @@ -0,0 +1,364 @@ +#region Copyright notice and license +// Protocol Buffers - Google's data interchange format +// Copyright 2015 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +#endregion + +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using Google.Protobuf.Reflection; +using Google.Protobuf.WellKnownTypes; + +namespace Google.Protobuf +{ + /// <summary> + /// <para>A tree representation of a FieldMask. Each leaf node in this tree represent + /// a field path in the FieldMask.</para> + /// + /// <para>For example, FieldMask "foo.bar,foo.baz,bar.baz" as a tree will be:</para> + /// <code> + /// [root] -+- foo -+- bar + /// | | + /// | +- baz + /// | + /// +- bar --- baz + /// </code> + /// + /// <para>By representing FieldMasks with this tree structure we can easily convert + /// a FieldMask to a canonical form, merge two FieldMasks, calculate the + /// intersection to two FieldMasks and traverse all fields specified by the + /// FieldMask in a message tree.</para> + /// </summary> + internal sealed class FieldMaskTree + { + private const char FIELD_PATH_SEPARATOR = '.'; + + internal sealed class Node + { + public Dictionary<string, Node> Children { get; } = new Dictionary<string, Node>(); + } + + private readonly Node root = new Node(); + + /// <summary> + /// Creates an empty FieldMaskTree. + /// </summary> + public FieldMaskTree() + { + } + + /// <summary> + /// Creates a FieldMaskTree for a given FieldMask. + /// </summary> + public FieldMaskTree(FieldMask mask) + { + MergeFromFieldMask(mask); + } + + public override string ToString() + { + return ToFieldMask().ToString(); + } + + /// <summary> + /// Adds a field path to the tree. In a FieldMask, every field path matches the + /// specified field as well as all its sub-fields. For example, a field path + /// "foo.bar" matches field "foo.bar" and also "foo.bar.baz", etc. When adding + /// a field path to the tree, redundant sub-paths will be removed. That is, + /// after adding "foo.bar" to the tree, "foo.bar.baz" will be removed if it + /// exists, which will turn the tree node for "foo.bar" to a leaf node. + /// Likewise, if the field path to add is a sub-path of an existing leaf node, + /// nothing will be changed in the tree. + /// </summary> + public FieldMaskTree AddFieldPath(string path) + { + var parts = path.Split(FIELD_PATH_SEPARATOR); + if (parts.Length == 0) + { + return this; + } + + var node = root; + var createNewBranch = false; + + // Find the matching node in the tree. + foreach (var part in parts) + { + // Check whether the path matches an existing leaf node. + if (!createNewBranch + && node != root + && node.Children.Count == 0) + { + // The path to add is a sub-path of an existing leaf node. + return this; + } + + if (!node.Children.TryGetValue(part, out var childNode)) + { + createNewBranch = true; + childNode = new Node(); + node.Children.Add(part, childNode); + } + node = childNode; + } + + // Turn the matching node into a leaf node (i.e., remove sub-paths). + node.Children.Clear(); + return this; + } + + /// <summary> + /// Merges all field paths in a FieldMask into this tree. + /// </summary> + public FieldMaskTree MergeFromFieldMask(FieldMask mask) + { + foreach (var path in mask.Paths) + { + AddFieldPath(path); + } + + return this; + } + + /// <summary> + /// Converts this tree to a FieldMask. + /// </summary> + public FieldMask ToFieldMask() + { + var mask = new FieldMask(); + if (root.Children.Count != 0) + { + var paths = new List<string>(); + GetFieldPaths(root, "", paths); + mask.Paths.AddRange(paths); + } + + return mask; + } + + /// <summary> + /// Gathers all field paths in a sub-tree. + /// </summary> + private void GetFieldPaths(Node node, string path, List<string> paths) + { + if (node.Children.Count == 0) + { + paths.Add(path); + return; + } + + foreach (var entry in node.Children) + { + var childPath = path.Length == 0 ? entry.Key : path + "." + entry.Key; + GetFieldPaths(entry.Value, childPath, paths); + } + } + + /// <summary> + /// Adds the intersection of this tree with the given <paramref name="path"/> to <paramref name="output"/>. + /// </summary> + public void IntersectFieldPath(string path, FieldMaskTree output) + { + if (root.Children.Count == 0) + { + return; + } + + var parts = path.Split(FIELD_PATH_SEPARATOR); + if (parts.Length == 0) + { + return; + } + + var node = root; + foreach (var part in parts) + { + if (node != root + && node.Children.Count == 0) + { + // The given path is a sub-path of an existing leaf node in the tree. + output.AddFieldPath(path); + return; + } + + if (!node.Children.TryGetValue(part, out node)) + { + return; + } + } + + // We found a matching node for the path. All leaf children of this matching + // node is in the intersection. + var paths = new List<string>(); + GetFieldPaths(node, path, paths); + foreach (var value in paths) + { + output.AddFieldPath(value); + } + } + + /// <summary> + /// Merges all fields specified by this FieldMaskTree from <paramref name="source"/> to <paramref name="destination"/>. + /// </summary> + public void Merge(IMessage source, IMessage destination, FieldMask.MergeOptions options) + { + if (source.Descriptor != destination.Descriptor) + { + throw new InvalidProtocolBufferException("Cannot merge messages of different types."); + } + + if (root.Children.Count == 0) + { + return; + } + + Merge(root, "", source, destination, options); + } + + /// <summary> + /// Merges all fields specified by a sub-tree from <paramref name="source"/> to <paramref name="destination"/>. + /// </summary> + private void Merge( + Node node, + string path, + IMessage source, + IMessage destination, + FieldMask.MergeOptions options) + { + if (source.Descriptor != destination.Descriptor) + { + throw new InvalidProtocolBufferException($"source ({source.Descriptor}) and destination ({destination.Descriptor}) descriptor must be equal"); + } + + var descriptor = source.Descriptor; + foreach (var entry in node.Children) + { + var field = descriptor.FindFieldByName(entry.Key); + if (field == null) + { + Debug.WriteLine($"Cannot find field \"{entry.Key}\" in message type \"{descriptor.FullName}\""); + continue; + } + + if (entry.Value.Children.Count != 0) + { + if (field.IsRepeated + || field.FieldType != FieldType.Message) + { + Debug.WriteLine($"Field \"{field.FullName}\" is not a singular message field and cannot have sub-fields."); + continue; + } + + var sourceField = field.Accessor.GetValue(source); + var destinationField = field.Accessor.GetValue(destination); + if (sourceField == null + && destinationField == null) + { + // If the message field is not present in both source and destination, skip recursing + // so we don't create unnecessary empty messages. + continue; + } + + if (destinationField == null) + { + // If we have to merge but the destination does not contain the field, create it. + destinationField = field.MessageType.Parser.CreateTemplate(); + field.Accessor.SetValue(destination, destinationField); + } + + var childPath = path.Length == 0 ? entry.Key : path + "." + entry.Key; + Merge(entry.Value, childPath, (IMessage)sourceField, (IMessage)destinationField, options); + continue; + } + + if (field.IsRepeated) + { + if (options.ReplaceRepeatedFields) + { + field.Accessor.Clear(destination); + } + + var sourceField = (IList)field.Accessor.GetValue(source); + var destinationField = (IList)field.Accessor.GetValue(destination); + foreach (var element in sourceField) + { + destinationField.Add(element); + } + } + else + { + var sourceField = field.Accessor.GetValue(source); + if (field.FieldType == FieldType.Message) + { + if (options.ReplaceMessageFields) + { + if (sourceField == null) + { + field.Accessor.Clear(destination); + } + else + { + field.Accessor.SetValue(destination, sourceField); + } + } + else + { + if (sourceField != null) + { + var sourceByteString = ((IMessage)sourceField).ToByteString(); + var destinationValue = (IMessage)field.Accessor.GetValue(destination); + if (destinationValue != null) + { + destinationValue.MergeFrom(sourceByteString); + } + else + { + field.Accessor.SetValue(destination, field.MessageType.Parser.ParseFrom(sourceByteString)); + } + } + } + } + else + { + if (sourceField != null + || !options.ReplacePrimitiveFields) + { + field.Accessor.SetValue(destination, sourceField); + } + else + { + field.Accessor.Clear(destination); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/csharp/src/Google.Protobuf/JsonFormatter.cs b/csharp/src/Google.Protobuf/JsonFormatter.cs index 4ae10d8b73..31fd8879cb 100644 --- a/csharp/src/Google.Protobuf/JsonFormatter.cs +++ b/csharp/src/Google.Protobuf/JsonFormatter.cs @@ -271,7 +271,25 @@ namespace Google.Protobuf } return result.ToString(); } - + + internal static string FromJsonName(string name) + { + StringBuilder result = new StringBuilder(name.Length); + foreach (char ch in name) + { + if (char.IsUpper(ch)) + { + result.Append('_'); + result.Append(char.ToLowerInvariant(ch)); + } + else + { + result.Append(ch); + } + } + return result.ToString(); + } + private static void WriteNull(TextWriter writer) { writer.Write("null"); diff --git a/csharp/src/Google.Protobuf/WellKnownTypes/FieldMaskPartial.cs b/csharp/src/Google.Protobuf/WellKnownTypes/FieldMaskPartial.cs index 4b0670f6fd..27e513c42a 100644 --- a/csharp/src/Google.Protobuf/WellKnownTypes/FieldMaskPartial.cs +++ b/csharp/src/Google.Protobuf/WellKnownTypes/FieldMaskPartial.cs @@ -35,15 +35,18 @@ using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; +using Google.Protobuf.Reflection; namespace Google.Protobuf.WellKnownTypes { // Manually-written partial class for the FieldMask well-known type. public partial class FieldMask : ICustomDiagnosticMessage { + private const char FIELD_PATH_SEPARATOR = ','; + private const char FIELD_SEPARATOR_REGEX = '.'; + /// <summary> - /// Converts a timestamp specified in seconds/nanoseconds to a string. + /// Converts a field mask specified by paths to a string. /// </summary> /// <remarks> /// If the value is a normalized duration in the range described in <c>field_mask.proto</c>, @@ -55,7 +58,7 @@ namespace Google.Protobuf.WellKnownTypes /// <exception cref="InvalidOperationException">The represented field mask is invalid, and <paramref name="diagnosticOnly"/> is <c>false</c>.</exception> internal static string ToJson(IList<string> paths, bool diagnosticOnly) { - var firstInvalid = paths.FirstOrDefault(p => !ValidatePath(p)); + var firstInvalid = paths.FirstOrDefault(p => !IsPathValid(p)); if (firstInvalid == null) { var writer = new StringWriter(); @@ -84,11 +87,103 @@ namespace Google.Protobuf.WellKnownTypes } } + /// <summary> + /// Returns a string representation of this <see cref="FieldMask"/> for diagnostic purposes. + /// </summary> + /// <remarks> + /// Normally the returned value will be a JSON string value (including leading and trailing quotes) but + /// when the value is non-normalized or out of range, a JSON object representation will be returned + /// instead, including a warning. This is to avoid exceptions being thrown when trying to + /// diagnose problems - the regular JSON formatter will still throw an exception for non-normalized + /// values. + /// </remarks> + /// <returns>A string representation of this value.</returns> + public string ToDiagnosticString() + { + return ToJson(Paths, true); + } + + /// <summary> + /// Parses from a string to a FieldMask. + /// </summary> + public static FieldMask FromString(string value) + { + return FromStringEnumerable<Empty>(new List<string>(value.Split(FIELD_PATH_SEPARATOR))); + } + + /// <summary> + /// Parses from a string to a FieldMask and validates all field paths. + /// </summary> + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static FieldMask FromString<T>(string value) where T : IMessage + { + return FromStringEnumerable<T>(new List<string>(value.Split(FIELD_PATH_SEPARATOR))); + } + + /// <summary> + /// Constructs a FieldMask for a list of field paths in a certain type. + /// </summary> + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static FieldMask FromStringEnumerable<T>(IEnumerable<string> paths) where T : IMessage + { + var mask = new FieldMask(); + foreach (var path in paths) + { + if (path.Length == 0) + { + // Ignore empty field paths. + continue; + } + + if (typeof(T) != typeof(Empty) + && !IsValid<T>(path)) + { + throw new InvalidProtocolBufferException(path + " is not a valid path for " + typeof(T)); + } + + mask.Paths.Add(path); + } + + return mask; + } + + /// <summary> + /// Constructs a FieldMask from the passed field numbers. + /// </summary> + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static FieldMask FromFieldNumbers<T>(params int[] fieldNumbers) where T : IMessage + { + return FromFieldNumbers<T>((IEnumerable<int>)fieldNumbers); + } + + /// <summary> + /// Constructs a FieldMask from the passed field numbers. + /// </summary> + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static FieldMask FromFieldNumbers<T>(IEnumerable<int> fieldNumbers) where T : IMessage + { + var descriptor = Activator.CreateInstance<T>().Descriptor; + + var mask = new FieldMask(); + foreach (var fieldNumber in fieldNumbers) + { + var field = descriptor.FindFieldByNumber(fieldNumber); + if (field == null) + { + throw new ArgumentNullException($"{fieldNumber} is not a valid field number for {descriptor.Name}"); + } + + mask.Paths.Add(field.Name); + } + + return mask; + } + /// <summary> /// Checks whether the given path is valid for a field mask. /// </summary> /// <returns>true if the path is valid; false otherwise</returns> - private static bool ValidatePath(string input) + private static bool IsPathValid(string input) { for (int i = 0; i < input.Length; i++) { @@ -110,19 +205,166 @@ namespace Google.Protobuf.WellKnownTypes } /// <summary> - /// Returns a string representation of this <see cref="FieldMask"/> for diagnostic purposes. + /// Checks whether paths in a given fields mask are valid. /// </summary> - /// <remarks> - /// Normally the returned value will be a JSON string value (including leading and trailing quotes) but - /// when the value is non-normalized or out of range, a JSON object representation will be returned - /// instead, including a warning. This is to avoid exceptions being thrown when trying to - /// diagnose problems - the regular JSON formatter will still throw an exception for non-normalized - /// values. - /// </remarks> - /// <returns>A string representation of this value.</returns> - public string ToDiagnosticString() + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static bool IsValid<T>(FieldMask fieldMask) where T : IMessage { - return ToJson(Paths, true); + var descriptor = Activator.CreateInstance<T>().Descriptor; + + return IsValid(descriptor, fieldMask); + } + + /// <summary> + /// Checks whether paths in a given fields mask are valid. + /// </summary> + public static bool IsValid(MessageDescriptor descriptor, FieldMask fieldMask) + { + foreach (var path in fieldMask.Paths) + { + if (!IsValid(descriptor, path)) + { + return false; + } + } + + return true; + } + + /// <summary> + /// Checks whether a given field path is valid. + /// </summary> + /// <typeparam name="T">The type to validate the field paths against.</typeparam> + public static bool IsValid<T>(string path) where T : IMessage + { + var descriptor = Activator.CreateInstance<T>().Descriptor; + + return IsValid(descriptor, path); + } + + /// <summary> + /// Checks whether paths in a given fields mask are valid. + /// </summary> + public static bool IsValid(MessageDescriptor descriptor, string path) + { + var parts = path.Split(FIELD_SEPARATOR_REGEX); + if (parts.Length == 0) + { + return false; + } + + foreach (var name in parts) + { + var field = descriptor?.FindFieldByName(name); + if (field == null) + { + return false; + } + + if (!field.IsRepeated + && field.FieldType == FieldType.Message) + { + descriptor = field.MessageType; + } + else + { + descriptor = null; + } + } + + return true; + } + + /// <summary> + /// Converts this FieldMask to its canonical form. In the canonical form of a + /// FieldMask, all field paths are sorted alphabetically and redundant field + /// paths are removed. + /// </summary> + public FieldMask Normalize() + { + return new FieldMaskTree(this).ToFieldMask(); + } + + /// <summary> + /// Creates a union of two or more FieldMasks. + /// </summary> + public FieldMask Union(params FieldMask[] otherMasks) + { + var maskTree = new FieldMaskTree(this); + foreach (var mask in otherMasks) + { + maskTree.MergeFromFieldMask(mask); + } + + return maskTree.ToFieldMask(); + } + + /// <summary> + /// Calculates the intersection of two FieldMasks. + /// </summary> + public FieldMask Intersection(FieldMask additionalMask) + { + var tree = new FieldMaskTree(this); + var result = new FieldMaskTree(); + foreach (var path in additionalMask.Paths) + { + tree.IntersectFieldPath(path, result); + } + + return result.ToFieldMask(); + } + + /// <summary> + /// Merges fields specified by this FieldMask from one message to another with the + /// specified merge options. + /// </summary> + public void Merge(IMessage source, IMessage destination, MergeOptions options) + { + new FieldMaskTree(this).Merge(source, destination, options); + } + + /// <summary> + /// Merges fields specified by this FieldMask from one message to another. + /// </summary> + public void Merge(IMessage source, IMessage destination) + { + Merge(source, destination, new MergeOptions()); + } + + /// <summary> + /// Options to customize merging behavior. + /// </summary> + public sealed class MergeOptions + { + /// <summary> + /// Whether to replace message fields(i.e., discard existing content in + /// destination message fields) when merging. + /// Default behavior is to merge the source message field into the + /// destination message field. + /// </summary> + public bool ReplaceMessageFields { get; set; } = false; + + /// <summary> + /// Whether to replace repeated fields (i.e., discard existing content in + /// destination repeated fields) when merging. + /// Default behavior is to append elements from source repeated field to the + /// destination repeated field. + /// </summary> + public bool ReplaceRepeatedFields { get; set; } = false; + + /// <summary> + /// Whether to replace primitive (non-repeated and non-message) fields in + /// destination message fields with the source primitive fields (i.e., if the + /// field is set in the source, the value is copied to the + /// destination; if the field is unset in the source, the field is cleared + /// from the destination) when merging. + /// + /// Default behavior is to always set the value of the source primitive + /// field to the destination primitive field, and if the source field is + /// unset, the default value of the source field is copied to the + /// destination. + /// </summary> + public bool ReplacePrimitiveFields { get; set; } = false; } } }