diff --git a/src/Components/Endpoints/src/Binding/Converters/CompiledComplexTypeConverter.cs b/src/Components/Endpoints/src/Binding/Converters/CompiledComplexTypeConverter.cs new file mode 100644 index 000000000000..e78c99d18f09 --- /dev/null +++ b/src/Components/Endpoints/src/Binding/Converters/CompiledComplexTypeConverter.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Endpoints.Binding; + +internal class CompiledComplexTypeConverter(CompiledComplexTypeConverter.ConverterDelegate body) : FormDataConverter +{ + public delegate bool ConverterDelegate(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found); + + internal override bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found) => + body(ref context, type, options, out result, out found); +} diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs new file mode 100644 index 000000000000..68d22f323d09 --- /dev/null +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactory.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Endpoints.Binding; + +internal abstract class ComplexTypeExpressionConverterFactory +{ + internal abstract FormDataConverter CreateConverter(Type type, FormDataMapperOptions options); +} diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs new file mode 100644 index 000000000000..4b87ce6accff --- /dev/null +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexType/ComplexTypeExpressionConverterFactoryOfT.cs @@ -0,0 +1,160 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq.Expressions; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Components.Endpoints.Binding; + +internal sealed class ComplexTypeExpressionConverterFactory : ComplexTypeExpressionConverterFactory +{ + internal override CompiledComplexTypeConverter CreateConverter(Type type, FormDataMapperOptions options) + { + var body = CreateConverterBody(type, options); + return new CompiledComplexTypeConverter(body); + } + + private CompiledComplexTypeConverter.ConverterDelegate CreateConverterBody(Type type, FormDataMapperOptions options) + { + var properties = PropertyHelper.GetVisibleProperties(type); + + var (readerParam, typeParam, optionsParam, resultParam, foundValueParam) = CreateFormDataConverterParameters(); + var parameters = new List() { readerParam, typeParam, optionsParam, resultParam, foundValueParam }; + + // Variables + var propertyFoundValue = Expression.Variable(typeof(bool), "foundValueForProperty"); + var succeeded = Expression.Variable(typeof(bool), "succeeded"); + var localFoundValueVar = Expression.Variable(typeof(bool), "localFoundValue"); + + var variables = new List() { propertyFoundValue, succeeded, localFoundValueVar }; + var propertyLocals = new List(); + + var body = new List() + { + Expression.Assign(succeeded, Expression.Constant(true)), + }; + + // Create the property blocks + + // var propertyConverter = options.ResolveConverter(typeof(string)); + // reader.PushPrefix("Property"); + // succeeded &= propertyConverter.TryRead(ref reader, typeof(string), options, out propertyVar, out foundProperty); + // found ||= foundProperty; + // reader.PopPrefix("Property"); + for (var i = 0; i < properties.Length; i++) + { + // Declare variable for the converter + var property = properties[i].Property; + var propertyConverterType = typeof(FormDataConverter<>).MakeGenericType(property.PropertyType); + var propertyConverterVar = Expression.Variable(propertyConverterType, $"{property.Name}Converter"); + variables.Add(propertyConverterVar); + + // Declare variable for property value. + var propertyVar = Expression.Variable(property.PropertyType, property.Name); + propertyLocals.Add(propertyVar); + + // Resolve and assign converter + + // Create the block to try and map the property and update variables. + // returnParam &= { PushPrefix(property.Name); var res = TryRead(...); PopPrefix(...); return res; } + // var propertyConverter = options.ResolveConverter()); + var propertyConverter = Expression.Assign( + propertyConverterVar, + Expression.Call( + optionsParam, + nameof(FormDataMapperOptions.ResolveConverter), + new[] { property.PropertyType }, + Array.Empty())); + body.Add(propertyConverter); + + // reader.PushPrefix("Property"); + body.Add(Expression.Call( + readerParam, + nameof(FormDataReader.PushPrefix), + Array.Empty(), + Expression.Constant(property.Name))); + + // succeeded &= propertyConverter.TryRead(ref reader, typeof(string), options, out propertyVar, out foundProperty); + var callTryRead = Expression.AndAssign( + succeeded, + Expression.Call( + propertyConverterVar, + nameof(FormDataConverter.TryRead), + Type.EmptyTypes, + readerParam, + typeParam, + optionsParam, + propertyVar, + propertyFoundValue)); + body.Add(callTryRead); + + // reader.PopPrefix("Property"); + body.Add(Expression.Call( + readerParam, + nameof(FormDataReader.PopPrefix), + Array.Empty(), + Expression.Constant(property.Name))); + + body.Add(Expression.OrAssign(localFoundValueVar, propertyFoundValue)); + } + + body.Add(Expression.IfThen( + localFoundValueVar, + Expression.Block(CreateInstanceAndAssignProperties(type, resultParam, properties, propertyLocals)))); + + // foundValue && !failures; + + body.Add(Expression.Assign(foundValueParam, localFoundValueVar)); + body.Add(succeeded); + + variables.AddRange(propertyLocals); + + return CreateConverterFunction(parameters, variables, body); + + static IEnumerable CreateInstanceAndAssignProperties( + Type model, + ParameterExpression resultParam, + PropertyHelper[] props, + List variables) + { + if (!model.IsValueType) + { + yield return Expression.Assign(resultParam, Expression.New(model)); + } + + for (var i = 0; i < props.Length; i++) + { + yield return Expression.Assign(Expression.Property(resultParam, props[i].Property), variables[i]); + } + } + } + + private static CompiledComplexTypeConverter.ConverterDelegate CreateConverterFunction( + List parameters, + List variables, + List body) + { + var lambda = Expression.Lambda.ConverterDelegate>( + Expression.Block(variables, body), + parameters); + + return lambda.Compile(); + } + + private static FormDataConverterReadParameters CreateFormDataConverterParameters() + { + return new( + Expression.Parameter(typeof(FormDataReader).MakeByRefType(), "reader"), + Expression.Parameter(typeof(Type), "type"), + Expression.Parameter(typeof(FormDataMapperOptions), "options"), + Expression.Parameter(typeof(T).MakeByRefType(), "result"), + Expression.Parameter(typeof(bool).MakeByRefType(), "foundValue")); + } + + private record struct FormDataConverterReadParameters( + ParameterExpression ReaderParam, + ParameterExpression TypeParam, + ParameterExpression OptionsParam, + ParameterExpression ResultParam, + ParameterExpression FoundValueParam); +} diff --git a/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs b/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs new file mode 100644 index 000000000000..f633aca53e1e --- /dev/null +++ b/src/Components/Endpoints/src/Binding/Factories/ComplexTypeConverterFactory.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Components.Endpoints.Binding; + +// This factory is registered last, which means, dictionaries and collections, have already +// been processed by the time we get here. +internal class ComplexTypeConverterFactory : IFormDataConverterFactory +{ + internal static readonly ComplexTypeConverterFactory Instance = new(); + + public bool CanConvert(Type type, FormDataMapperOptions options) + { + if (type.GetConstructor(Type.EmptyTypes) == null && !type.IsValueType) + { + // For right now, require a public parameterless constructor. + return false; + } + if (type.IsGenericTypeDefinition) + { + return false; + } + + // Check that all properties have a valid converter. + var propertyHelper = PropertyHelper.GetVisibleProperties(type); + foreach (var helper in propertyHelper) + { + if (options.ResolveConverter(helper.Property.PropertyType) == null) + { + return false; + } + } + + return true; + } + + // We are going to compile a function that maps all the properties for the type. + // Beware that the code below is not the actual exact code, just a simplification to understand what is happening at a high level. + // The general flow is as follows. For a type like Address { Street, City, Country, ZipCode } + // we will generate a function that looks like: + // public bool TryRead(ref FormDataReader reader, Type type, FormDataSerializerOptions options, out Address? result, out bool found) + // { + // bool foundProperty; + // bool succeeded = true; + // string street; + // string city; + // string country; + // string zipCode; + // FormDataConveter streetConverter; + // FormDataConveter cityConverter; + // FormDataConveter countryConverter; + // FormDataConveter zipCodeConverter; + + // var streetConverter = options.ResolveConverter(typeof(string)); + // reader.PushPrefix("Street"); + // succeeded &= streetConverter.TryRead(ref reader, typeof(string), options, out street, out foundProperty); + // found ||= foundProperty; + // reader.PopPrefix("Street"); + // + // var cityConverter = options.ResolveConverter(typeof(string)); + // reader.PushPrefix("City"); + // succeeded &= ciryConverter.TryRead(ref reader, typeof(string), options, out street, out foundProperty); + // found ||= foundProperty; + // reader.PopPrefix("City"); + // + // var countryConverter = options.ResolveConverter(typeof(string)); + // reader.PushPrefix("Country"); + // succeeded &= countryConverter.TryRead(ref reader, typeof(string), options, out street, out foundProperty); + // found ||= foundProperty; + // reader.PopPrefix("Country"); + // + // var zipCodeConverter = options.ResolveConverter(typeof(string)); + // reader.PushPrefix("ZipCode"); + // succeeded &= zipCodeConverter.TryRead(ref reader, typeof(string), options, out street, out foundProperty); + // found ||= foundProperty; + // reader.PopPrefix("ZipCode"); + // + // if(found) + // { + // result = new Address(); + // result.Street = street; + // result.City = city; + // result.Country = country; + // result.ZipCode = zipCode; + // } + // else + // { + // result = null; + // } + // + // return succeeded; + // } + // + // The actual blocks above are going to be generated using System.Linq.Expressions. + // Instead of resolving the property converters every time, we might consider caching the converters in a dictionary and passing an + // extra parameter to the function with them in it. + // The final converter is something like + // internal class CompiledComplexTypeConverter + // (ConverterDelegate converterFunc) + // { + // public bool TryRead(ref FormDataReader reader, Type type, FormDataSerializerOptions options, out object? result, out bool found) + // { + // return converterFunc(ref reader, type, options, out result, out found); + // } + // } + + public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) + { + if (Activator.CreateInstance(typeof(ComplexTypeExpressionConverterFactory<>).MakeGenericType(type)) + is not ComplexTypeExpressionConverterFactory factory) + { + throw new InvalidOperationException($"Could not create a converter factory for type {type}."); + } + + return factory.CreateConverter(type, options); + } +} diff --git a/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs index 48ef460c6c6a..4947f4ffaa24 100644 --- a/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs +++ b/src/Components/Endpoints/src/Binding/FormDataMapperOptions.cs @@ -17,6 +17,7 @@ public FormDataMapperOptions() _factories.Add((type, options) => ParsableConverterFactory.Instance.CanConvert(type, options) ? ParsableConverterFactory.Instance.CreateConverter(type, options) : null); _factories.Add((type, options) => NullableConverterFactory.Instance.CanConvert(type, options) ? NullableConverterFactory.Instance.CreateConverter(type, options) : null); _factories.Add((type, options) => CollectionConverterFactory.Instance.CanConvert(type, options) ? CollectionConverterFactory.Instance.CreateConverter(type, options) : null); + _factories.Add((type, options) => ComplexTypeConverterFactory.Instance.CanConvert(type, options) ? ComplexTypeConverterFactory.Instance.CreateConverter(type, options) : null); } // Not configurable for now, this is the max number of elements we will bind. This is important for diff --git a/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs index 96e181ba603a..819244cf19a4 100644 --- a/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs +++ b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs @@ -523,6 +523,89 @@ private void CanDeserialize_Collection(T } } + [Fact] + public void CanDeserialize_ComplexValueType_Address() + { + // Arrange + var expected = new Address() { City = "Redmond", Street = "1 Microsoft Way", Country = "United States", ZipCode = 98052 }; + var data = new Dictionary() + { + ["City"] = "Redmond", + ["Country"] = "United States", + ["Street"] = "1 Microsoft Way", + ["ZipCode"] = "98052", + }; + var reader = new FormDataReader(data, CultureInfo.InvariantCulture); + var options = new FormDataMapperOptions(); + + // Act + var result = FormDataMapper.Map
(reader, options); + + // Assert + Assert.Equal(expected.City, result.City); + Assert.Equal(expected.Street, result.Street); + Assert.Equal(expected.Country, result.Country); + Assert.Equal(expected.ZipCode, result.ZipCode); + } + + [Fact] + public void CanDeserialize_ComplexReferenceType_Customer() + { + // Arrange + var expected = new Customer() { Age = 20, Name = "John Doe", Email = "john.doe@example.com", IsPreferred = true }; + var data = new Dictionary() + { + ["Age"] = "20", + ["Name"] = "John Doe", + ["Email"] = "john.doe@example.com", + ["IsPreferred"] = "true", + }; + + var reader = new FormDataReader(data, CultureInfo.InvariantCulture); + var options = new FormDataMapperOptions(); + + // Act + var result = FormDataMapper.Map(reader, options); + + // Assert + Assert.NotNull(result); + Assert.Equal(expected.Age, result.Age); + Assert.Equal(expected.Name, result.Name); + Assert.Equal(expected.Email, result.Email); + Assert.Equal(expected.IsPreferred, result.IsPreferred); + } + + [Fact] + public void CanDeserialize_ComplexReferenceType_Inheritance() + { + // Arrange + var expected = new FrequentCustomer() { Age = 20, Name = "John Doe", Email = "john@example.com" , IsPreferred = true, TotalVisits = 10, PreferredStore = "Redmond", MonthlyFrequency = 0.8 }; + var data = new Dictionary() + { + ["Age"] = "20", + ["Name"] = "John Doe", + ["Email"] = "john@example.com", + ["IsPreferred"] = "true", + ["TotalVisits"] = "10", + ["PreferredStore"] = "Redmond", + ["MonthlyFrequency"] = "0.8", + }; + + var reader = new FormDataReader(data, CultureInfo.InvariantCulture); + var options = new FormDataMapperOptions(); + + // Act + var result = FormDataMapper.Map(reader, options); + Assert.NotNull(result); + Assert.Equal(expected.Age, result.Age); + Assert.Equal(expected.Name, result.Name); + Assert.Equal(expected.Email, result.Email); + Assert.Equal(expected.IsPreferred, result.IsPreferred); + Assert.Equal(expected.TotalVisits, result.TotalVisits); + Assert.Equal(expected.PreferredStore, result.PreferredStore); + Assert.Equal(expected.MonthlyFrequency, result.MonthlyFrequency); + } + public static TheoryData NullableBasicTypes { get @@ -805,6 +888,31 @@ public static void Return(int[] array) } } +internal struct Address +{ + public string Street { get; set; } + public string City { get; set; } + public string Country { get; set; } + public int ZipCode { get; set; } +} + +internal class Customer +{ + public string Name { get; set; } + public string Email { get; set; } + public int Age { get; set; } + public bool IsPreferred { get; set; } +} + +internal class FrequentCustomer : Customer +{ + public int TotalVisits { get; set; } + + public string PreferredStore { get; set; } + + public double MonthlyFrequency { get; set; } +} + // Implements ICollection delegating to List _inner; internal class CustomCollection : ICollection {