From d64f1db4580b539c23886b4fd08651185d57bb3b Mon Sep 17 00:00:00 2001 From: Anne Thompson Date: Tue, 28 Feb 2023 15:03:18 -0800 Subject: [PATCH] Allow camelCase JSON properties to be accessed with PascalCase DynamicJson property accessors (#34082) * Initial work on JsonData property name casing * pr fb * More expressive casing options; behave as standard Azure SDK model type by default. * Export API * PR fb and implement Set * tidy * Add comments for tricky tests. * Add overload taking casing enum directly * Change default case mapping * update API --- .../api/Azure.Core.Experimental.net461.cs | 16 + .../api/Azure.Core.Experimental.net6.0.cs | 16 + .../Azure.Core.Experimental.netstandard2.0.cs | 16 + .../src/BinaryDataExtensions.cs | 18 +- .../src/DynamicJson.cs | 67 ++++- .../src/DynamicJsonNameMapping.cs | 29 ++ .../src/DynamicJsonOptions.cs | 29 ++ .../src/MutableJsonChange.cs | 1 - .../tests/DynamicJsonTests.cs | 283 ++++++++++++++++++ 9 files changed, 472 insertions(+), 3 deletions(-) create mode 100644 sdk/core/Azure.Core.Experimental/src/DynamicJsonNameMapping.cs create mode 100644 sdk/core/Azure.Core.Experimental/src/DynamicJsonOptions.cs diff --git a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net461.cs b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net461.cs index 2ef726bf52bef..f3b2028dcdac5 100644 --- a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net461.cs +++ b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net461.cs @@ -111,6 +111,8 @@ namespace Azure.Core.Dynamic public static partial class BinaryDataExtensions { public static dynamic ToDynamic(this System.BinaryData data) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonNameMapping propertyNameCasing) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonOptions options) { throw null; } } public abstract partial class DynamicData { @@ -174,6 +176,20 @@ public readonly partial struct DynamicJsonProperty public string Name { get { throw null; } } public Azure.Core.Dynamic.DynamicJson Value { get { throw null; } } } + public enum DynamicJsonNameMapping + { + None = 0, + PascalCaseGetters = 1, + PascalCaseGettersCamelCaseSetters = 2, + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct DynamicJsonOptions + { + private int _dummyPrimitive; + public static readonly Azure.Core.Dynamic.DynamicJsonOptions AzureDefault; + public DynamicJsonOptions() { throw null; } + public Azure.Core.Dynamic.DynamicJsonNameMapping PropertyNameCasing { get { throw null; } set { } } + } } namespace Azure.Core.Json { diff --git a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net6.0.cs b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net6.0.cs index 2ef726bf52bef..f3b2028dcdac5 100644 --- a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net6.0.cs +++ b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.net6.0.cs @@ -111,6 +111,8 @@ namespace Azure.Core.Dynamic public static partial class BinaryDataExtensions { public static dynamic ToDynamic(this System.BinaryData data) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonNameMapping propertyNameCasing) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonOptions options) { throw null; } } public abstract partial class DynamicData { @@ -174,6 +176,20 @@ public readonly partial struct DynamicJsonProperty public string Name { get { throw null; } } public Azure.Core.Dynamic.DynamicJson Value { get { throw null; } } } + public enum DynamicJsonNameMapping + { + None = 0, + PascalCaseGetters = 1, + PascalCaseGettersCamelCaseSetters = 2, + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct DynamicJsonOptions + { + private int _dummyPrimitive; + public static readonly Azure.Core.Dynamic.DynamicJsonOptions AzureDefault; + public DynamicJsonOptions() { throw null; } + public Azure.Core.Dynamic.DynamicJsonNameMapping PropertyNameCasing { get { throw null; } set { } } + } } namespace Azure.Core.Json { diff --git a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.netstandard2.0.cs b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.netstandard2.0.cs index 2ef726bf52bef..f3b2028dcdac5 100644 --- a/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.netstandard2.0.cs +++ b/sdk/core/Azure.Core.Experimental/api/Azure.Core.Experimental.netstandard2.0.cs @@ -111,6 +111,8 @@ namespace Azure.Core.Dynamic public static partial class BinaryDataExtensions { public static dynamic ToDynamic(this System.BinaryData data) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonNameMapping propertyNameCasing) { throw null; } + public static dynamic ToDynamic(this System.BinaryData data, Azure.Core.Dynamic.DynamicJsonOptions options) { throw null; } } public abstract partial class DynamicData { @@ -174,6 +176,20 @@ public readonly partial struct DynamicJsonProperty public string Name { get { throw null; } } public Azure.Core.Dynamic.DynamicJson Value { get { throw null; } } } + public enum DynamicJsonNameMapping + { + None = 0, + PascalCaseGetters = 1, + PascalCaseGettersCamelCaseSetters = 2, + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct DynamicJsonOptions + { + private int _dummyPrimitive; + public static readonly Azure.Core.Dynamic.DynamicJsonOptions AzureDefault; + public DynamicJsonOptions() { throw null; } + public Azure.Core.Dynamic.DynamicJsonNameMapping PropertyNameCasing { get { throw null; } set { } } + } } namespace Azure.Core.Json { diff --git a/sdk/core/Azure.Core.Experimental/src/BinaryDataExtensions.cs b/sdk/core/Azure.Core.Experimental/src/BinaryDataExtensions.cs index 5cb815aecd7b6..78cf716765df9 100644 --- a/sdk/core/Azure.Core.Experimental/src/BinaryDataExtensions.cs +++ b/sdk/core/Azure.Core.Experimental/src/BinaryDataExtensions.cs @@ -16,7 +16,23 @@ public static class BinaryDataExtensions /// public static dynamic ToDynamic(this BinaryData data) { - return new DynamicJson(MutableJsonDocument.Parse(data).RootElement); + return new DynamicJson(MutableJsonDocument.Parse(data).RootElement, new DynamicJsonOptions()); + } + + /// + /// Return the content of the BinaryData as a dynamic type. + /// + public static dynamic ToDynamic(this BinaryData data, DynamicJsonNameMapping propertyNameCasing) + { + return new DynamicJson(MutableJsonDocument.Parse(data).RootElement, new DynamicJsonOptions() { PropertyNameCasing = propertyNameCasing }); + } + + /// + /// Return the content of the BinaryData as a dynamic type. + /// + public static dynamic ToDynamic(this BinaryData data, DynamicJsonOptions options) + { + return new DynamicJson(MutableJsonDocument.Parse(data).RootElement, options); } } } diff --git a/sdk/core/Azure.Core.Experimental/src/DynamicJson.cs b/sdk/core/Azure.Core.Experimental/src/DynamicJson.cs index ce6016d44c626..e870314777d24 100644 --- a/sdk/core/Azure.Core.Experimental/src/DynamicJson.cs +++ b/sdk/core/Azure.Core.Experimental/src/DynamicJson.cs @@ -24,10 +24,12 @@ public sealed partial class DynamicJson : DynamicData, IDisposable private static readonly MethodInfo SetViaIndexerMethod = typeof(DynamicJson).GetMethod(nameof(SetViaIndexer), BindingFlags.NonPublic | BindingFlags.Instance)!; private MutableJsonElement _element; + private DynamicJsonOptions _options; - internal DynamicJson(MutableJsonElement element) + internal DynamicJson(MutableJsonElement element, DynamicJsonOptions options = default) { _element = element; + _options = options; } internal override void WriteTo(Stream stream) @@ -39,14 +41,46 @@ internal override void WriteTo(Stream stream) private object? GetProperty(string name) { + Argument.AssertNotNullOrEmpty(name, nameof(name)); + if (_element.TryGetProperty(name, out MutableJsonElement element)) { return new DynamicJson(element); } + if (PascalCaseGetters() && char.IsUpper(name[0])) + { + if (_element.TryGetProperty(GetAsCamelCase(name), out element)) + { + return new DynamicJson(element); + } + } + return null; } + private bool PascalCaseGetters() + { + return + _options.PropertyNameCasing == DynamicJsonNameMapping.PascalCaseGetters || + _options.PropertyNameCasing == DynamicJsonNameMapping.PascalCaseGettersCamelCaseSetters; + } + + private bool CamelCaseSetters() + { + return _options.PropertyNameCasing == DynamicJsonNameMapping.PascalCaseGettersCamelCaseSetters; + } + + private static string GetAsCamelCase(string value) + { + if (value.Length < 2) + { + return value.ToLowerInvariant(); + } + + return $"{char.ToLowerInvariant(value[0])}{value.Substring(1)}"; + } + private object? GetViaIndexer(object index) { switch (index) @@ -72,6 +106,37 @@ private IEnumerable GetEnumerable() private object? SetProperty(string name, object value) { + Argument.AssertNotNullOrEmpty(name, nameof(name)); + + if (_options.PropertyNameCasing == DynamicJsonNameMapping.None) + { + _element = _element.SetProperty(name, value); + return null; + } + + if (!char.IsUpper(name[0])) + { + // Lookup name is camelCase, so set unchanged. + _element = _element.SetProperty(name, value); + return null; + } + + // Other mappings have PascalCase getters, and lookup name is PascalCase. + // So, if it exists in either form, we'll set it in that form. + if (_element.TryGetProperty(name, out MutableJsonElement element)) + { + element.Set(value); + return null; + } + + if (_element.TryGetProperty(GetAsCamelCase(name), out element)) + { + element.Set(value); + return null; + } + + // It's a new property, so set according to the mapping. + name = CamelCaseSetters() ? GetAsCamelCase(name) : name; _element = _element.SetProperty(name, value); // Binding machinery expects the call site signature to return an object diff --git a/sdk/core/Azure.Core.Experimental/src/DynamicJsonNameMapping.cs b/sdk/core/Azure.Core.Experimental/src/DynamicJsonNameMapping.cs new file mode 100644 index 0000000000000..71a0d290c454a --- /dev/null +++ b/sdk/core/Azure.Core.Experimental/src/DynamicJsonNameMapping.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Azure.Core.Dynamic +{ + /// + /// Options for setting new DynamicJson properties. + /// + public enum DynamicJsonNameMapping + { + /// + /// Properties are accessed and written in the JSON buffer with the same casing as the DynamicJson property. + /// + None = 0, + + /// + /// A "PascalCase" DynamicJson property can be used to read and set "camelCase" properties that exist in the JSON buffer. + /// New properties are written to the JSON buffer with the same casing as the DynamicJson property. + /// + PascalCaseGetters = 1, + + /// + /// Default settings for Azure services. + /// A "PascalCase" DynamicJson property can be used to read and set "camelCase" properties that exist in the JSON buffer. + /// New properties are written to the JSON buffer using "camelCase" property names. + /// + PascalCaseGettersCamelCaseSetters = 2 + } +} diff --git a/sdk/core/Azure.Core.Experimental/src/DynamicJsonOptions.cs b/sdk/core/Azure.Core.Experimental/src/DynamicJsonOptions.cs new file mode 100644 index 0000000000000..bfad8b64c0792 --- /dev/null +++ b/sdk/core/Azure.Core.Experimental/src/DynamicJsonOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Azure.Core.Dynamic +{ + /// + /// Provides the ability for the user to define custom behavior when accessing JSON through a dynamic layer. + /// + public struct DynamicJsonOptions + { + /// + /// Gets the default for Azure services. + /// + public static readonly DynamicJsonOptions AzureDefault = new() + { + PropertyNameCasing = DynamicJsonNameMapping.PascalCaseGettersCamelCaseSetters + }; + + /// + /// Creates a new instance of DynamicJsonOptions. + /// + public DynamicJsonOptions() { } + + /// + /// Specifies how properties on will be accessed in the underlying JSON buffer. + /// + public DynamicJsonNameMapping PropertyNameCasing { get; set; } + } +} diff --git a/sdk/core/Azure.Core.Experimental/src/MutableJsonChange.cs b/sdk/core/Azure.Core.Experimental/src/MutableJsonChange.cs index 88bfd050dee98..8d5b1a63238e8 100644 --- a/sdk/core/Azure.Core.Experimental/src/MutableJsonChange.cs +++ b/sdk/core/Azure.Core.Experimental/src/MutableJsonChange.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System; -using System.Text; using System.Text.Json; namespace Azure.Core.Json diff --git a/sdk/core/Azure.Core.Experimental/tests/DynamicJsonTests.cs b/sdk/core/Azure.Core.Experimental/tests/DynamicJsonTests.cs index f295dd3f57353..f4564bde2bf85 100644 --- a/sdk/core/Azure.Core.Experimental/tests/DynamicJsonTests.cs +++ b/sdk/core/Azure.Core.Experimental/tests/DynamicJsonTests.cs @@ -405,6 +405,289 @@ public void DisposingAChildDisposesTheParent() Assert.Throws(() => { var foo = json.Foo; }); } + [Test] + public void CanGetCamelCasePropertyEitherCase() + { + string json = """{ "foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonOptions.AzureDefault); + + Assert.AreEqual(1, (int)dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanGetCamelCasePropertyNoMapping() + { + string json = """{ "foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(); + + Assert.AreEqual(1, (int)dynamicJson.foo); + Assert.AreEqual(null, dynamicJson.Foo); + } + + [Test] + public void CanGetCamelCasePropertyPascalGetters() + { + string json = """{ "foo" : 1 }"""; + + DynamicJsonOptions options = new() + { + PropertyNameCasing = DynamicJsonNameMapping.PascalCaseGetters + }; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(options); + + Assert.AreEqual(1, (int)dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanGetCamelCasePropertyPascalGettersCamelSetters() + { + string json = """{ "foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonOptions.AzureDefault); + + Assert.AreEqual(1, (int)dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanGetPascalCasePropertyNoMapping() + { + string json = """{ "Foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(); + + Assert.AreEqual(null, dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanGetPascalCasePropertyPascalGetters() + { + string json = """{ "Foo" : 1 }"""; + + DynamicJsonOptions options = new() + { + PropertyNameCasing = DynamicJsonNameMapping.PascalCaseGetters + }; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(options); + + Assert.AreEqual(null, dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanGetPascalCasePropertyPascalGettersCamelSetters() + { + string json = """{ "Foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonOptions.AzureDefault); + + Assert.AreEqual(null, dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + } + + [Test] + public void CanSetCamelCaseNoMapping() + { + string json = """{ "foo": 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(); + + // Existing property access + dynamicJson.foo = 2; + + // New property access + dynamicJson.bar = 3; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(null, dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(null, dynamicJson.Bar); + + dynamicJson.Foo = 4; + dynamicJson.Bar = 5; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(4, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(5, (int)dynamicJson.Bar); + } + + [Test] + public void CanSetCamelCasePascalGetters() + { + string json = """{ "foo": 1 }"""; + + DynamicJsonOptions options = new() + { + PropertyNameCasing = DynamicJsonNameMapping.PascalCaseGetters + }; + dynamic dynamicJson = new BinaryData(json).ToDynamic(options); + + // Existing property access + dynamicJson.foo = 2; + + // New property is created as camelCase + dynamicJson.bar = 3; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(2, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(3, (int)dynamicJson.Bar); + + // PascalCase getters find camelCase properties and sets them. + dynamicJson.Foo = 4; + dynamicJson.Bar = 5; + + // New property is created as PascalCase + dynamicJson.Baz = 6; + + Assert.AreEqual(4, (int)dynamicJson.foo); + Assert.AreEqual(4, (int)dynamicJson.Foo); + Assert.AreEqual(5, (int)dynamicJson.bar); + Assert.AreEqual(5, (int)dynamicJson.Bar); + Assert.AreEqual(null, dynamicJson.baz); + Assert.AreEqual(6, (int)dynamicJson.Baz); + } + + [Test] + public void CanSetCamelCasePascalGettersCamelSetters() + { + string json = """{ "foo": 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonOptions.AzureDefault); + + // Existing property access + dynamicJson.foo = 2; + + // New property is created as camelCase + dynamicJson.bar = 3; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(2, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(3, (int)dynamicJson.Bar); + + // PascalCase getters find camelCase properties and sets them. + dynamicJson.Foo = 4; + dynamicJson.Bar = 5; + + // New property is created as camelCase + dynamicJson.Baz = 6; + + Assert.AreEqual(4, (int)dynamicJson.foo); + Assert.AreEqual(4, (int)dynamicJson.Foo); + Assert.AreEqual(5, (int)dynamicJson.bar); + Assert.AreEqual(5, (int)dynamicJson.Bar); + Assert.AreEqual(6, (int)dynamicJson.baz); + Assert.AreEqual(6, (int)dynamicJson.Baz); + } + + [Test] + public void CanSetPascalCaseNoMapping() + { + string json = """{ "Foo": 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(); + + // This adds a new property, since it doesn't find `Foo`. + dynamicJson.foo = 2; + + // New property access + dynamicJson.bar = 3; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(null, dynamicJson.Bar); + + // This updates the PascalCase property and not the camelCase one. + dynamicJson.Foo = 4; + + // This creates a new PascalCase property. + dynamicJson.Bar = 5; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(4, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(5, (int)dynamicJson.Bar); + } + + [Test] + public void CanSetPascalCasePascalGetters() + { + string json = """{ "Foo": 1 }"""; + + DynamicJsonOptions options = new() + { + PropertyNameCasing = DynamicJsonNameMapping.PascalCaseGetters + }; + dynamic dynamicJson = new BinaryData(json).ToDynamic(options); + + // This property doesn't exist, so it creates a new camelCase property. + dynamicJson.foo = 2; + + // New property is created as camelCase + dynamicJson.bar = 3; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(1, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(3, (int)dynamicJson.Bar); + + // This updates the PascalCase property and not the camelCase one. + dynamicJson.Foo = 4; + + // The PascalCase getter finds `bar`, so it updates the camelCase property. + dynamicJson.Bar = 5; + + // New property is created as PascalCase + dynamicJson.Baz = 6; + + Assert.AreEqual(2, (int)dynamicJson.foo); + Assert.AreEqual(4, (int)dynamicJson.Foo); + Assert.AreEqual(5, (int)dynamicJson.bar); + Assert.AreEqual(5, (int)dynamicJson.Bar); + Assert.AreEqual(null, dynamicJson.baz); + Assert.AreEqual(6, (int)dynamicJson.Baz); + } + + [Test] + public void CanSetPascalCasePascalGettersCamelSetters() + { + string json = """{ "Foo": 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonOptions.AzureDefault); + + // Existing property access does not add a camelCase property. + dynamicJson.Foo = 2; + + // New property is created as camelCase + dynamicJson.Bar = 3; + + Assert.AreEqual(null, dynamicJson.foo); + Assert.AreEqual(2, (int)dynamicJson.Foo); + Assert.AreEqual(3, (int)dynamicJson.bar); + Assert.AreEqual(3, (int)dynamicJson.Bar); + } + + [Test] + public void CanPassPropertyNameCasingEnumDirectly() + { + string json = """{ "foo" : 1 }"""; + + dynamic dynamicJson = new BinaryData(json).ToDynamic(DynamicJsonNameMapping.None); + + Assert.AreEqual(1, (int)dynamicJson.foo); + Assert.AreEqual(null, dynamicJson.Foo); + } + #region Helpers internal static dynamic GetDynamicJson(string json) {