diff --git a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs index 05609873187d..83c7549b574b 100644 --- a/src/Components/Authorization/test/AuthorizeRouteViewTest.cs +++ b/src/Components/Authorization/test/AuthorizeRouteViewTest.cs @@ -34,7 +34,6 @@ public AuthorizeRouteViewTest() serviceCollection.AddSingleton(); serviceCollection.AddSingleton(_testAuthorizationService); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); diff --git a/src/Components/Components/src/Binding/CascadingModelBinder.cs b/src/Components/Components/src/Binding/CascadingModelBinder.cs index 994e8430c3d6..0b1b0e2a5d4e 100644 --- a/src/Components/Components/src/Binding/CascadingModelBinder.cs +++ b/src/Components/Components/src/Binding/CascadingModelBinder.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reflection.Metadata; using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; @@ -11,12 +13,13 @@ namespace Microsoft.AspNetCore.Components; /// /// Defines the binding context for data bound from external sources. /// -public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, IDisposable +public sealed class CascadingModelBinder : IComponent, ICascadingValueSupplier, IDisposable { + private readonly Dictionary _providersByCascadingParameterAttributeType = new(); + private RenderHandle _handle; private ModelBindingContext? _bindingContext; private bool _hasPendingQueuedRender; - private BindingInfo? _bindingInfo; /// /// The binding context name. @@ -40,7 +43,9 @@ public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, [Inject] internal NavigationManager Navigation { get; set; } = null!; - [Inject] internal IFormValueSupplier FormValueSupplier { get; set; } = null!; + [Inject] internal IEnumerable ModelBindingProviders { get; set; } = Enumerable.Empty(); + + internal ModelBindingContext? BindingContext => _bindingContext; void IComponent.Attach(RenderHandle renderHandle) { @@ -110,24 +115,25 @@ internal void UpdateBindingInformation(string url) // BindingContextId = <>((<>&)|?)handler=my-handler var name = ModelBindingContext.Combine(ParentContext, Name); var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name); + var bindingContextDidChange = + _bindingContext is null || + !string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) || + !string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal); - var bindingContext = _bindingContext != null && - string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) && - string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ? - _bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue); - - // It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes. - if (IsFixed && _bindingContext != null && _bindingContext != bindingContext) + if (bindingContextDidChange) { - // Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized - // as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations: - // * Component ParentContext hierarchy changes. - // * Technically, the component won't be retained in this case and will be destroyed instead. - // * A parent changes Name. - throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized."); - } + if (IsFixed && _bindingContext is not null) + { + // Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized + // as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations: + // * Component ParentContext hierarchy changes. + // * Technically, the component won't be retained in this case and will be destroyed instead. + // * A parent changes Name. + throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized."); + } - _bindingContext = bindingContext; + _bindingContext = new ModelBindingContext(name, bindingId, CanBind); + } string GenerateBindingContextId(string name) { @@ -135,60 +141,92 @@ string GenerateBindingContextId(string name) var hashIndex = bindingId.IndexOf('#'); return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex)); } + + bool CanBind(Type type) + { + foreach (var provider in ModelBindingProviders) + { + if (provider.SupportsParameterType(type)) + { + return true; + } + } + + return false; + } } - void IDisposable.Dispose() + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) + => TryGetProvider(in parameterInfo, out var provider) + && provider.CanSupplyValue(_bindingContext, parameterInfo); + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { - Navigation.LocationChanged -= HandleLocationChanged; + // We expect there to always be a provider at this point, because CanSupplyValue must have returned true. + var provider = GetProviderOrThrow(parameterInfo); + + if (!provider.AreValuesFixed) + { + provider.Subscribe(subscriber); + } } - bool ICascadingValueComponent.CanSupplyValue(Type valueType, string? valueName) + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { - var formName = string.IsNullOrEmpty(valueName) ? - (_bindingContext?.Name) : - ModelBindingContext.Combine(_bindingContext, valueName); + // We expect there to always be a provider at this point, because CanSupplyValue must have returned true. + var provider = GetProviderOrThrow(parameterInfo); - if (_bindingInfo != null && - string.Equals(_bindingInfo.FormName, formName, StringComparison.Ordinal) && - _bindingInfo.ValueType.Equals(valueType)) + if (!provider.AreValuesFixed) { - // We already bound the value, but some component might have been destroyed and - // re-created. If the type and name of the value that we bound are the same, - // we can provide the value that we bound. - return true; + provider.Unsubscribe(subscriber); } + } - // Can't supply the value if this context is for a form with a different name. - if (FormValueSupplier.CanBind(formName!, valueType)) - { - var bindingSucceeded = FormValueSupplier.TryBind(formName!, valueType, out var boundValue); - _bindingInfo = new BindingInfo(formName, valueType, bindingSucceeded, boundValue); - if (!bindingSucceeded) - { - // Report errors - } + object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + => TryGetProvider(in parameterInfo, out var provider) + ? provider.GetCurrentValue(_bindingContext, parameterInfo) + : null; - return true; + private CascadingModelBindingProvider GetProviderOrThrow(in CascadingParameterInfo parameterInfo) + { + if (!TryGetProvider(parameterInfo, out var provider)) + { + throw new InvalidOperationException($"No model binding provider could be found for parameter '{parameterInfo.PropertyName}'."); } - return false; + return provider; } - void ICascadingValueComponent.Subscribe(ComponentState subscriber) + private bool TryGetProvider(in CascadingParameterInfo parameterInfo, [NotNullWhen(true)] out CascadingModelBindingProvider? result) { - throw new InvalidOperationException("Form values are always fixed."); - } + var attributeType = parameterInfo.Attribute.GetType(); - void ICascadingValueComponent.Unsubscribe(ComponentState subscriber) - { - throw new InvalidOperationException("Form values are always fixed."); - } + if (_providersByCascadingParameterAttributeType.TryGetValue(attributeType, out result)) + { + return result is not null; + } - object? ICascadingValueComponent.CurrentValue => _bindingInfo == null ? - throw new InvalidOperationException("Tried to access form value before it was bound.") : - _bindingInfo.BoundValue; + // We deliberately cache 'null' results to avoid searching for the same attribute type multiple times. + result = FindProviderForAttributeType(attributeType); + _providersByCascadingParameterAttributeType[attributeType] = result; + return result is not null; - bool ICascadingValueComponent.CurrentValueIsFixed => true; + CascadingModelBindingProvider? FindProviderForAttributeType(Type attributeType) + { + foreach (var provider in ModelBindingProviders) + { + if (provider.SupportsCascadingParameterAttributeType(attributeType)) + { + return provider; + } + } + + return null; + } + } - private record BindingInfo(string? FormName, Type ValueType, bool BindingResult, object? BoundValue); + void IDisposable.Dispose() + { + Navigation.LocationChanged -= HandleLocationChanged; + } } diff --git a/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs b/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs new file mode 100644 index 000000000000..4b75e496a3ce --- /dev/null +++ b/src/Components/Components/src/Binding/CascadingModelBindingProvider.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Binding; + +/// +/// Provides values that get supplied to cascading parameters with . +/// +public abstract class CascadingModelBindingProvider +{ + /// + /// Gets whether values supplied by this instance will not change. + /// + protected internal abstract bool AreValuesFixed { get; } + + /// + /// Determines whether this instance can provide values for parameters annotated with the specified attribute type. + /// + /// The attribute type. + /// true if this instance can provide values for parameters annotated with the specified attribute type, otherwise false. + protected internal abstract bool SupportsCascadingParameterAttributeType(Type attributeType); + + /// + /// Determines whether this instance can provide values to parameters with the specified type. + /// + /// The parameter type. + /// true if this instance can provide values to parameters with the specified type, otherwise false. + protected internal abstract bool SupportsParameterType(Type parameterType); + + /// + /// Determines whether this instance can supply a value for the specified parameter. + /// + /// The current . + /// The for the component parameter. + /// true if a value can be supplied, otherwise false. + protected internal abstract bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo); + + /// + /// Gets the value for the specified parameter. + /// + /// The current . + /// The for the component parameter. + /// The value to supply to the parameter. + protected internal abstract object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo); + + /// + /// Subscribes to changes in supplied values, if they can change. + /// + /// + /// This method must be implemented if is false. + /// + /// The for the subscribing component. + protected internal virtual void Subscribe(ComponentState subscriber) + => throw new InvalidOperationException( + $"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Subscribe)}'."); + + /// + /// Unsubscribes from changes in supplied values, if they can change. + /// + /// + /// This method must be implemented if is false. + /// + /// The for the unsubscribing component. + protected internal virtual void Unsubscribe(ComponentState subscriber) + => throw new InvalidOperationException( + $"'{nameof(CascadingModelBindingProvider)}' instances that have non-fixed values must provide an implementation for '{nameof(Unsubscribe)}'."); +} diff --git a/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs b/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs new file mode 100644 index 000000000000..e6e9c976016b --- /dev/null +++ b/src/Components/Components/src/Binding/CascadingQueryModelBindingProvider.cs @@ -0,0 +1,138 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; + +namespace Microsoft.AspNetCore.Components.Binding; + +/// +/// Enables component parameters to be supplied from the query string with . +/// +public sealed class CascadingQueryModelBindingProvider : CascadingModelBindingProvider, IDisposable +{ + private readonly QueryParameterValueSupplier _queryParameterValueSupplier = new(); + private readonly NavigationManager _navigationManager; + + private HashSet? _subscribers; + private bool _isSubscribedToLocationChanges; + private bool _queryParametersMightHaveChanged = true; + + /// + protected internal override bool AreValuesFixed => false; + + /// + /// Constructs a new instance of . + /// + public CascadingQueryModelBindingProvider(NavigationManager navigationManager) + { + _navigationManager = navigationManager; + } + + /// + protected internal override bool SupportsCascadingParameterAttributeType(Type attributeType) + => attributeType == typeof(SupplyParameterFromQueryAttribute); + + /// + protected internal override bool SupportsParameterType(Type type) + => QueryParameterValueSupplier.CanSupplyValueForType(type); + + /// + protected internal override bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + // We can always supply a value; it'll just be null if there's no match. + => true; + + /// + protected internal override object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + { + if (_queryParametersMightHaveChanged) + { + _queryParametersMightHaveChanged = false; + UpdateQueryParameters(); + } + + var queryParameterName = parameterInfo.Attribute.Name ?? parameterInfo.PropertyName; + return _queryParameterValueSupplier.GetQueryParameterValue(parameterInfo.PropertyType, queryParameterName); + } + + /// + protected internal override void Subscribe(ComponentState subscriber) + { + SubscribeToLocationChanges(); + + _subscribers ??= new(); + _subscribers.Add(subscriber); + } + + /// + protected internal override void Unsubscribe(ComponentState subscriber) + { + _subscribers!.Remove(subscriber); + + if (_subscribers.Count == 0) + { + UnsubscribeFromLocationChanges(); + } + } + + private void UpdateQueryParameters() + { + var query = GetQueryString(_navigationManager.Uri); + + _queryParameterValueSupplier.ReadParametersFromQuery(query); + + static ReadOnlyMemory GetQueryString(string url) + { + var queryStartPos = url.IndexOf('?'); + + if (queryStartPos < 0) + { + return default; + } + + var queryEndPos = url.IndexOf('#', queryStartPos); + return url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos)); + } + } + + private void SubscribeToLocationChanges() + { + if (_isSubscribedToLocationChanges) + { + return; + } + + _isSubscribedToLocationChanges = true; + _queryParametersMightHaveChanged = true; + _navigationManager.LocationChanged += OnLocationChanged; + } + + private void UnsubscribeFromLocationChanges() + { + if (!_isSubscribedToLocationChanges) + { + return; + } + + _isSubscribedToLocationChanges = false; + _navigationManager.LocationChanged -= OnLocationChanged; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + _queryParametersMightHaveChanged = true; + + if (_subscribers is not null) + { + foreach (var subscriber in _subscribers) + { + subscriber.NotifyCascadingValueChanged(ParameterViewLifetime.Unbound); + } + } + } + + void IDisposable.Dispose() + { + UnsubscribeFromLocationChanges(); + } +} diff --git a/src/Components/Components/src/Binding/IFormValueSupplier.cs b/src/Components/Components/src/Binding/IFormValueSupplier.cs index b8696a224339..4b5403222409 100644 --- a/src/Components/Components/src/Binding/IFormValueSupplier.cs +++ b/src/Components/Components/src/Binding/IFormValueSupplier.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Components.Binding; /// -/// Binds form data valuesto a model. +/// Binds form data values to a model. /// public interface IFormValueSupplier { diff --git a/src/Components/Components/src/CascadingParameterAttribute.cs b/src/Components/Components/src/CascadingParameterAttribute.cs index 70cb5998ff72..bb9be43a5b08 100644 --- a/src/Components/Components/src/CascadingParameterAttribute.cs +++ b/src/Components/Components/src/CascadingParameterAttribute.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Components; /// supplies values with a compatible type and name. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class CascadingParameterAttribute : Attribute +public sealed class CascadingParameterAttribute : CascadingParameterAttributeBase { /// /// If specified, the parameter value will be supplied by the closest @@ -20,5 +20,5 @@ public sealed class CascadingParameterAttribute : Attribute /// that supplies a value with a compatible /// type. /// - public string? Name { get; set; } + public override string? Name { get; set; } } diff --git a/src/Components/Components/src/CascadingParameterAttributeBase.cs b/src/Components/Components/src/CascadingParameterAttributeBase.cs new file mode 100644 index 000000000000..f85fbaa6cffc --- /dev/null +++ b/src/Components/Components/src/CascadingParameterAttributeBase.cs @@ -0,0 +1,16 @@ +// 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; + +/// +/// Represents a parameter whose value cascades down the component hierarchy. +/// +public abstract class CascadingParameterAttributeBase : Attribute +{ + /// + /// Gets or sets the name for the parameter, which correlates to the name + /// of a cascading value. + /// + public abstract string? Name { get; set; } +} diff --git a/src/Components/Components/src/CascadingParameterInfo.cs b/src/Components/Components/src/CascadingParameterInfo.cs new file mode 100644 index 000000000000..2d8493ff70f4 --- /dev/null +++ b/src/Components/Components/src/CascadingParameterInfo.cs @@ -0,0 +1,32 @@ +// 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; + +/// +/// Contains information about a cascading parameter. +/// +public readonly struct CascadingParameterInfo +{ + /// + /// Gets the property's attribute. + /// + public CascadingParameterAttributeBase Attribute { get; } + + /// + /// Gets the name of the parameter's property. + /// + public string PropertyName { get; } + + /// + /// Gets the type of the parameter's property. + /// + public Type PropertyType { get; } + + internal CascadingParameterInfo(CascadingParameterAttributeBase attribute, string propertyName, Type propertyType) + { + Attribute = attribute; + PropertyName = propertyName; + PropertyType = propertyType; + } +} diff --git a/src/Components/Components/src/CascadingParameterState.cs b/src/Components/Components/src/CascadingParameterState.cs index 7af4dc1b9cce..1c9b95b720b4 100644 --- a/src/Components/Components/src/CascadingParameterState.cs +++ b/src/Components/Components/src/CascadingParameterState.cs @@ -13,21 +13,21 @@ namespace Microsoft.AspNetCore.Components; internal readonly struct CascadingParameterState { - private static readonly ConcurrentDictionary _cachedInfos = new(); + private static readonly ConcurrentDictionary _cachedInfos = new(); - public string LocalValueName { get; } - public ICascadingValueComponent ValueSupplier { get; } + public CascadingParameterInfo ParameterInfo { get; } + public ICascadingValueSupplier ValueSupplier { get; } - public CascadingParameterState(string localValueName, ICascadingValueComponent valueSupplier) + public CascadingParameterState(in CascadingParameterInfo parameterInfo, ICascadingValueSupplier valueSupplier) { - LocalValueName = localValueName; + ParameterInfo = parameterInfo; ValueSupplier = valueSupplier; } public static IReadOnlyList FindCascadingParameters(ComponentState componentState) { var componentType = componentState.Component.GetType(); - var infos = GetReflectedCascadingParameterInfos(componentType); + var infos = GetCascadingParameterInfos(componentType); // For components known not to have any cascading parameters, bail out early if (infos.Length == 0) @@ -48,23 +48,21 @@ public static IReadOnlyList FindCascadingParameters(Com { // Although not all parameters might be matched, we know the maximum number resultStates ??= new List(infos.Length - infoIndex); - - resultStates.Add(new CascadingParameterState(info.ConsumerValueName, supplier)); + resultStates.Add(new CascadingParameterState(info, supplier)); } } return resultStates ?? (IReadOnlyList)Array.Empty(); } - private static ICascadingValueComponent? GetMatchingCascadingValueSupplier(in ReflectedCascadingParameterInfo info, ComponentState componentState) + private static ICascadingValueSupplier? GetMatchingCascadingValueSupplier(in CascadingParameterInfo info, ComponentState componentState) { var candidate = componentState; do { - if (candidate.Component is ICascadingValueComponent candidateSupplier - && candidateSupplier.CanSupplyValue(info.ValueType, info.SupplierValueName)) + if (candidate.Component is ICascadingValueSupplier valueSupplier && valueSupplier.CanSupplyValue(info)) { - return candidateSupplier; + return valueSupplier; } candidate = candidate.ParentComponentState; @@ -74,64 +72,37 @@ public static IReadOnlyList FindCascadingParameters(Com return null; } - private static ReflectedCascadingParameterInfo[] GetReflectedCascadingParameterInfos( + private static CascadingParameterInfo[] GetCascadingParameterInfos( [DynamicallyAccessedMembers(Component)] Type componentType) { if (!_cachedInfos.TryGetValue(componentType, out var infos)) { - infos = CreateReflectedCascadingParameterInfos(componentType); + infos = CreateCascadingParameterInfos(componentType); _cachedInfos[componentType] = infos; } return infos; } - private static ReflectedCascadingParameterInfo[] CreateReflectedCascadingParameterInfos( + private static CascadingParameterInfo[] CreateCascadingParameterInfos( [DynamicallyAccessedMembers(Component)] Type componentType) { - List? result = null; + List? result = null; var candidateProps = ComponentProperties.GetCandidateBindableProperties(componentType); foreach (var prop in candidateProps) { - var attribute = prop.GetCustomAttribute(); - if (attribute != null) + var cascadingParameterAttribute = prop.GetCustomAttributes() + .OfType().SingleOrDefault(); + if (cascadingParameterAttribute != null) { - result ??= new List(); - - result.Add(new ReflectedCascadingParameterInfo( - prop.Name, - prop.PropertyType, - attribute.Name)); - } - - var hostParameterAttribute = prop.GetCustomAttributes() - .OfType().SingleOrDefault(); - if (hostParameterAttribute != null) - { - result ??= new List(); - - result.Add(new ReflectedCascadingParameterInfo( + result ??= new List(); + result.Add(new CascadingParameterInfo( + cascadingParameterAttribute, prop.Name, - prop.PropertyType, - hostParameterAttribute.Name)); + prop.PropertyType)); } } - return result?.ToArray() ?? Array.Empty(); - } - - readonly struct ReflectedCascadingParameterInfo - { - public string ConsumerValueName { get; } - public string? SupplierValueName { get; } - public Type ValueType { get; } - - public ReflectedCascadingParameterInfo( - string consumerValueName, Type valueType, string? supplierValueName) - { - ConsumerValueName = consumerValueName; - SupplierValueName = supplierValueName; - ValueType = valueType; - } + return result?.ToArray() ?? Array.Empty(); } } diff --git a/src/Components/Components/src/CascadingValue.cs b/src/Components/Components/src/CascadingValue.cs index facb9821e415..a040894ac57c 100644 --- a/src/Components/Components/src/CascadingValue.cs +++ b/src/Components/Components/src/CascadingValue.cs @@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Components; /// /// A component that provides a cascading value to all descendant components. /// -public class CascadingValue : ICascadingValueComponent, IComponent +public class CascadingValue : ICascadingValueSupplier, IComponent { private RenderHandle _renderHandle; private HashSet? _subscribers; // Lazily instantiated @@ -41,10 +41,6 @@ public class CascadingValue : ICascadingValueComponent, IComponent /// [Parameter] public bool IsFixed { get; set; } - object? ICascadingValueComponent.CurrentValue => Value; - - bool ICascadingValueComponent.CurrentValueIsFixed => IsFixed; - /// public void Attach(RenderHandle renderHandle) { @@ -130,37 +126,39 @@ public Task SetParametersAsync(ParameterView parameters) return Task.CompletedTask; } - bool ICascadingValueComponent.CanSupplyValue(Type requestedType, string? requestedName) + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) { - if (!requestedType.IsAssignableFrom(typeof(TValue))) + if (parameterInfo.Attribute is not CascadingParameterAttribute cascadingParameterAttribute || !parameterInfo.PropertyType.IsAssignableFrom(typeof(TValue))) { return false; } + // We only consider explicitly requested names, not the property name. + var requestedName = cascadingParameterAttribute.Name; + return (requestedName == null && Name == null) // Match on type alone || string.Equals(requestedName, Name, StringComparison.OrdinalIgnoreCase); // Also match on name } - void ICascadingValueComponent.Subscribe(ComponentState subscriber) + object? ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + return Value; + } + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { -#if DEBUG if (IsFixed) { // Should not be possible. User code cannot trigger this. // Checking only to catch possible future framework bugs. throw new InvalidOperationException($"Cannot subscribe to a {typeof(CascadingValue<>).Name} when {nameof(IsFixed)} is true."); } -#endif - - if (_subscribers == null) - { - _subscribers = new HashSet(); - } + _subscribers ??= new HashSet(); _subscribers.Add(subscriber); } - void ICascadingValueComponent.Unsubscribe(ComponentState subscriber) + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { _subscribers?.Remove(subscriber); } diff --git a/src/Components/Components/src/ICascadingValueComponent.cs b/src/Components/Components/src/ICascadingValueComponent.cs deleted file mode 100644 index b18735c86a9e..000000000000 --- a/src/Components/Components/src/ICascadingValueComponent.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Rendering; - -namespace Microsoft.AspNetCore.Components; - -internal interface ICascadingValueComponent -{ - // This interface exists only so that CascadingParameterState has a way - // to work with all CascadingValue types regardless of T. - - bool CanSupplyValue(Type valueType, string? valueName); - - object? CurrentValue { get; } - - bool CurrentValueIsFixed { get; } - - void Subscribe(ComponentState subscriber); - - void Unsubscribe(ComponentState subscriber); -} diff --git a/src/Components/Components/src/ICascadingValueSupplier.cs b/src/Components/Components/src/ICascadingValueSupplier.cs new file mode 100644 index 000000000000..c535d9cfda16 --- /dev/null +++ b/src/Components/Components/src/ICascadingValueSupplier.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +internal interface ICascadingValueSupplier +{ + bool IsFixed { get; } + + bool CanSupplyValue(in CascadingParameterInfo parameterInfo); + + object? GetCurrentValue(in CascadingParameterInfo parameterInfo); + + void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); + + void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo); +} diff --git a/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs b/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs deleted file mode 100644 index 8f407e0cdd5e..000000000000 --- a/src/Components/Components/src/IHostEnvironmentCascadingParameter.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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; - -// Marks a cascading parameter that can be offered via an attribute that is not -// directly defined in the Components assembly. For example [SupplyParameterFromForm]. -internal interface IHostEnvironmentCascadingParameter -{ - public string? Name { get; set; } -} diff --git a/src/Components/Components/src/ParameterView.cs b/src/Components/Components/src/ParameterView.cs index 37ba9c2dfbcd..1b3a25d635d1 100644 --- a/src/Components/Components/src/ParameterView.cs +++ b/src/Components/Components/src/ParameterView.cs @@ -423,7 +423,8 @@ public bool MoveNext() _currentIndex = nextIndex; var state = _cascadingParameters[_currentIndex]; - _current = new ParameterValue(state.LocalValueName, state.ValueSupplier.CurrentValue!, true); + var currentValue = state.ValueSupplier.GetCurrentValue(state.ParameterInfo); + _current = new ParameterValue(state.ParameterInfo.PropertyName, currentValue!, true); return true; } else diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 625c0e05e595..f37e9e93b125 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,16 @@ #nullable enable +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.AreValuesFixed.get -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.CanSupplyValue(Microsoft.AspNetCore.Components.ModelBindingContext? bindingContext, in Microsoft.AspNetCore.Components.CascadingParameterInfo parameterInfo) -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.GetCurrentValue(Microsoft.AspNetCore.Components.ModelBindingContext? bindingContext, in Microsoft.AspNetCore.Components.CascadingParameterInfo parameterInfo) -> object? +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.SupportsCascadingParameterAttributeType(System.Type! attributeType) -> bool +abstract Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.SupportsParameterType(System.Type! parameterType) -> bool +abstract Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.Name.get -> string? +abstract Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.Name.set -> void abstract Microsoft.AspNetCore.Components.RenderModeAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode! +Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider +Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.CascadingModelBindingProvider() -> void +Microsoft.AspNetCore.Components.Binding.CascadingQueryModelBindingProvider +Microsoft.AspNetCore.Components.Binding.CascadingQueryModelBindingProvider.CascadingQueryModelBindingProvider(Microsoft.AspNetCore.Components.NavigationManager! navigationManager) -> void Microsoft.AspNetCore.Components.Binding.IFormValueSupplier Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanBind(string! formName, System.Type! valueType) -> bool Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanConvertSingleValue(System.Type! type) -> bool @@ -12,6 +23,13 @@ Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.get -> bool Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.set -> void Microsoft.AspNetCore.Components.CascadingModelBinder.Name.get -> string! Microsoft.AspNetCore.Components.CascadingModelBinder.Name.set -> void +Microsoft.AspNetCore.Components.CascadingParameterAttributeBase +Microsoft.AspNetCore.Components.CascadingParameterAttributeBase.CascadingParameterAttributeBase() -> void +Microsoft.AspNetCore.Components.CascadingParameterInfo +Microsoft.AspNetCore.Components.CascadingParameterInfo.Attribute.get -> Microsoft.AspNetCore.Components.CascadingParameterAttributeBase! +Microsoft.AspNetCore.Components.CascadingParameterInfo.CascadingParameterInfo() -> void +Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyName.get -> string! +Microsoft.AspNetCore.Components.CascadingParameterInfo.PropertyType.get -> System.Type! Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.IComponentRenderMode Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task! @@ -60,10 +78,20 @@ Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentParamete Microsoft.AspNetCore.Components.StreamRenderingAttribute Microsoft.AspNetCore.Components.StreamRenderingAttribute.Enabled.get -> bool Microsoft.AspNetCore.Components.StreamRenderingAttribute.StreamRenderingAttribute(bool enabled) -> void +*REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? +*REMOVED*Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void +override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.get -> string? +override Microsoft.AspNetCore.Components.CascadingParameterAttribute.Name.set -> void override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int override Microsoft.AspNetCore.Components.EventCallback.Equals(object? obj) -> bool override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int override Microsoft.AspNetCore.Components.EventCallback.Equals(object? obj) -> bool +*REMOVED*Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string? +*REMOVED*Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void +override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string? +override Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void +virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Subscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void +virtual Microsoft.AspNetCore.Components.Binding.CascadingModelBindingProvider.Unsubscribe(Microsoft.AspNetCore.Components.Rendering.ComponentState! subscriber) -> void virtual Microsoft.AspNetCore.Components.Rendering.ComponentState.DisposeAsync() -> System.Threading.Tasks.ValueTask virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.AddPendingTask(Microsoft.AspNetCore.Components.Rendering.ComponentState? componentState, System.Threading.Tasks.Task! task) -> void virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.CreateComponentState(int componentId, Microsoft.AspNetCore.Components.IComponent! component, Microsoft.AspNetCore.Components.Rendering.ComponentState? parentComponentState) -> Microsoft.AspNetCore.Components.Rendering.ComponentState! diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index 9569e8e6a84e..507d5bcd353a 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -170,8 +170,7 @@ private static void ThrowForUnknownIncomingParameterName([DynamicallyAccessedMem if (propertyInfo != null) { if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && - !propertyInfo.IsDefined(typeof(CascadingParameterAttribute)) && - !propertyInfo.GetCustomAttributes().OfType().Any()) + !propertyInfo.GetCustomAttributes().OfType().Any()) { throw new InvalidOperationException( $"Object of type '{targetType.FullName}' has a property matching the name '{parameterName}', " + @@ -262,8 +261,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) foreach (var propertyInfo in GetCandidateBindableProperties(targetType)) { ParameterAttribute? parameterAttribute = null; - CascadingParameterAttribute? cascadingParameterAttribute = null; - IHostEnvironmentCascadingParameter? hostEnvironmentCascadingParameter = null; + CascadingParameterAttributeBase? cascadingParameterAttribute = null; var attributes = propertyInfo.GetCustomAttributes(); foreach (var attribute in attributes) @@ -273,18 +271,15 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) case ParameterAttribute parameter: parameterAttribute = parameter; break; - case CascadingParameterAttribute cascadingParameter: + case CascadingParameterAttributeBase cascadingParameter: cascadingParameterAttribute = cascadingParameter; break; - case IHostEnvironmentCascadingParameter hostEnvironmentAttribute: - hostEnvironmentCascadingParameter = hostEnvironmentAttribute; - break; default: break; } } - var isParameter = parameterAttribute != null || cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null; + var isParameter = parameterAttribute != null || cascadingParameterAttribute != null; if (!isParameter) { continue; @@ -299,7 +294,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType) var propertySetter = new PropertySetter(targetType, propertyInfo) { - Cascading = cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null, + Cascading = cascadingParameterAttribute != null, }; if (_underlyingWriters.ContainsKey(propertyName)) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 187ffb6d8f79..9262cb7423c4 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -131,7 +131,6 @@ private async void RenderRootComponentsOnHotReload() // Before re-rendering the root component, also clear any well-known caches in the framework ComponentFactory.ClearCache(); ComponentProperties.ClearCache(); - Routing.QueryParameterValueSupplier.ClearCache(); await Dispatcher.InvokeAsync(() => { diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index f7de9f106215..c2b9276485a1 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -194,9 +194,9 @@ private bool AddCascadingParameterSubscriptions() for (var i = 0; i < numCascadingParameters; i++) { var valueSupplier = _cascadingParameters[i].ValueSupplier; - if (!valueSupplier.CurrentValueIsFixed) + if (!valueSupplier.IsFixed) { - valueSupplier.Subscribe(this); + valueSupplier.Subscribe(this, _cascadingParameters[i].ParameterInfo); hasSubscription = true; } } @@ -210,9 +210,9 @@ private void RemoveCascadingParameterSubscriptions() for (var i = 0; i < numCascadingParameters; i++) { var supplier = _cascadingParameters[i].ValueSupplier; - if (!supplier.CurrentValueIsFixed) + if (!supplier.IsFixed) { - supplier.Unsubscribe(this); + supplier.Unsubscribe(this, _cascadingParameters[i].ParameterInfo); } } } diff --git a/src/Components/Components/src/RouteView.cs b/src/Components/Components/src/RouteView.cs index 3d07cbc4f903..bb778771a09f 100644 --- a/src/Components/Components/src/RouteView.cs +++ b/src/Components/Components/src/RouteView.cs @@ -8,7 +8,6 @@ using System.Reflection; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.Routing; namespace Microsoft.AspNetCore.Components; @@ -103,23 +102,6 @@ void RenderPageCore(RenderTreeBuilder builder) builder.AddComponentParameter(1, kvp.Key, kvp.Value); } - var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType); - if (queryParameterSupplier is not null) - { - // Since this component does accept some parameters from query, we must supply values for all of them, - // even if the querystring in the URI is empty. So don't skip the following logic. - var relativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); - var url = NavigationManager.Uri; - ReadOnlyMemory query = default; - var queryStartPos = url.IndexOf('?'); - if (queryStartPos >= 0) - { - var queryEndPos = url.IndexOf('#', queryStartPos); - query = url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos)); - } - queryParameterSupplier.RenderParametersFromQueryString(builder, query); - } - builder.CloseComponent(); } } diff --git a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs index b217d09878ff..967169ee0191 100644 --- a/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs +++ b/src/Components/Components/src/Routing/QueryParameterValueSupplier.cs @@ -1,189 +1,60 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Buffers; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Microsoft.AspNetCore.Components.Reflection; -using Microsoft.AspNetCore.Components.Rendering; +using System.Runtime.InteropServices; using Microsoft.AspNetCore.Internal; -using static Microsoft.AspNetCore.Internal.LinkerFlags; namespace Microsoft.AspNetCore.Components.Routing; internal sealed class QueryParameterValueSupplier { - public static void ClearCache() => _cacheByType.Clear(); + private readonly Dictionary, StringSegmentAccumulator> _queryParameterValuesByName = new(QueryParameterNameComparer.Instance); - private static readonly ConcurrentDictionary _cacheByType = new(); - - // These two arrays contain the same number of entries, and their corresponding positions refer to each other. - // Holding the info like this means we can use Array.BinarySearch with less custom implementation. - private readonly ReadOnlyMemory[] _queryParameterNames; - private readonly QueryParameterDestination[] _destinations; - - public static QueryParameterValueSupplier? ForType([DynamicallyAccessedMembers(Component)] Type componentType) + public void ReadParametersFromQuery(ReadOnlyMemory query) { - if (!_cacheByType.TryGetValue(componentType, out var instanceOrNull)) - { - // If the component doesn't have any query parameters, store a null value for it - // so we know the upstream code can't try to render query parameter frames for it. - var sortedMappings = GetSortedMappings(componentType); - instanceOrNull = sortedMappings == null ? null : new QueryParameterValueSupplier(sortedMappings); - _cacheByType.TryAdd(componentType, instanceOrNull); - } + _queryParameterValuesByName.Clear(); - return instanceOrNull; - } + var queryStringEnumerable = new QueryStringEnumerable(query); - private QueryParameterValueSupplier(QueryParameterMapping[] sortedMappings) - { - _queryParameterNames = new ReadOnlyMemory[sortedMappings.Length]; - _destinations = new QueryParameterDestination[sortedMappings.Length]; - for (var i = 0; i < sortedMappings.Length; i++) + foreach (var suppliedPair in queryStringEnumerable) { - ref var mapping = ref sortedMappings[i]; - _queryParameterNames[i] = mapping.QueryParameterName; - _destinations[i] = mapping.Destination; + var decodedName = suppliedPair.DecodeName(); + var decodedValue = suppliedPair.DecodeValue(); + + // This is safe because we don't mutate the dictionary while the ref local is in scope. + ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(_queryParameterValuesByName, decodedName, out _); + values.Add(decodedValue); } } - public void RenderParametersFromQueryString(RenderTreeBuilder builder, ReadOnlyMemory queryString) + public object? GetQueryParameterValue(Type targetType, string queryParameterName) { - // If there's no querystring contents, we can skip renting from the pool - if (queryString.IsEmpty) - { - for (var destinationIndex = 0; destinationIndex < _destinations.Length; destinationIndex++) - { - ref var destination = ref _destinations[destinationIndex]; - var blankValue = destination.IsArray ? destination.Parser.ParseMultiple(default, string.Empty) : null; - builder.AddComponentParameter(0, destination.ComponentParameterName, blankValue); - } - return; - } + var isArray = targetType.IsArray; + var elementType = isArray ? targetType.GetElementType()! : targetType; - // Temporary workspace in which we accumulate the data while walking the querystring. - var valuesByMapping = ArrayPool.Shared.Rent(_destinations.Length); - - try + if (!UrlValueConstraint.TryGetByTargetType(elementType, out var parser)) { - // Capture values by destination in a single pass through the querystring - var queryStringEnumerable = new QueryStringEnumerable(queryString); - foreach (var suppliedPair in queryStringEnumerable) - { - var decodedName = suppliedPair.DecodeName(); - var mappingIndex = Array.BinarySearch(_queryParameterNames, decodedName, QueryParameterNameComparer.Instance); - if (mappingIndex >= 0) - { - var decodedValue = suppliedPair.DecodeValue(); - - if (_destinations[mappingIndex].IsArray) - { - valuesByMapping[mappingIndex].Add(decodedValue); - } - else - { - valuesByMapping[mappingIndex].SetSingle(decodedValue); - } - } - } - - // Finally, emit the parameter attributes by parsing all the string segments and building arrays - for (var mappingIndex = 0; mappingIndex < _destinations.Length; mappingIndex++) - { - ref var destination = ref _destinations[mappingIndex]; - ref var values = ref valuesByMapping[mappingIndex]; + throw new InvalidOperationException($"Querystring values cannot be parsed as type '{elementType}'."); + } - var parsedValue = destination.IsArray - ? destination.Parser.ParseMultiple(values, destination.ComponentParameterName) - : values.Count == 0 - ? default - : destination.Parser.Parse(values[0].Span, destination.ComponentParameterName); + var values = _queryParameterValuesByName.GetValueOrDefault(queryParameterName.AsMemory()); - builder.AddComponentParameter(0, destination.ComponentParameterName, parsedValue); - } - } - finally + if (isArray) { - ArrayPool.Shared.Return(valuesByMapping, true); + return parser.ParseMultiple(values, queryParameterName); } - } - - private static QueryParameterMapping[]? GetSortedMappings([DynamicallyAccessedMembers(Component)] Type componentType) - { - var candidateProperties = MemberAssignment.GetPropertiesIncludingInherited(componentType, ComponentProperties.BindablePropertyFlags); - HashSet>? usedQueryParameterNames = null; - List? mappings = null; - foreach (var propertyInfo in candidateProperties) + if (values.Count > 0) { - if (!propertyInfo.IsDefined(typeof(ParameterAttribute))) - { - continue; - } - - var fromQueryAttribute = propertyInfo.GetCustomAttribute(); - if (fromQueryAttribute is not null) - { - // Found a parameter that's assignable from querystring - var componentParameterName = propertyInfo.Name; - var queryParameterName = (string.IsNullOrEmpty(fromQueryAttribute.Name) - ? componentParameterName - : fromQueryAttribute.Name).AsMemory(); - - // If it's an array type, capture that info and prepare to parse the element type - Type effectiveType = propertyInfo.PropertyType; - var isArray = false; - if (effectiveType.IsArray) - { - isArray = true; - effectiveType = effectiveType.GetElementType()!; - } - - if (!UrlValueConstraint.TryGetByTargetType(effectiveType, out var parser)) - { - throw new NotSupportedException($"Querystring values cannot be parsed as type '{propertyInfo.PropertyType}'."); - } - - // Add the destination for this component parameter name - usedQueryParameterNames ??= new(QueryParameterNameComparer.Instance); - if (usedQueryParameterNames.Contains(queryParameterName)) - { - throw new InvalidOperationException($"The component '{componentType}' declares more than one mapping for the query parameter '{queryParameterName}'."); - } - usedQueryParameterNames.Add(queryParameterName); - - mappings ??= new(); - mappings.Add(new QueryParameterMapping - { - QueryParameterName = queryParameterName, - Destination = new QueryParameterDestination(componentParameterName, parser, isArray) - }); - } + return parser.Parse(values[0].Span, queryParameterName); } - mappings?.Sort((a, b) => QueryParameterNameComparer.Instance.Compare(a.QueryParameterName, b.QueryParameterName)); - return mappings?.ToArray(); - } - - private readonly struct QueryParameterMapping - { - public ReadOnlyMemory QueryParameterName { get; init; } - public QueryParameterDestination Destination { get; init; } + return default; } - private readonly struct QueryParameterDestination + public static bool CanSupplyValueForType(Type targetType) { - public readonly string ComponentParameterName; - public readonly UrlValueConstraint Parser; - public readonly bool IsArray; - - public QueryParameterDestination(string componentParameterName, UrlValueConstraint parser, bool isArray) - { - ComponentParameterName = componentParameterName; - Parser = parser; - IsArray = isArray; - } + var elementType = targetType.IsArray ? targetType.GetElementType()! : targetType; + return UrlValueConstraint.TryGetByTargetType(elementType, out _); } } diff --git a/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs b/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs index f88fe737c598..ffae75576ff7 100644 --- a/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs +++ b/src/Components/Components/src/SupplyParameterFromQueryAttribute.cs @@ -8,11 +8,11 @@ namespace Microsoft.AspNetCore.Components; /// current URL querystring. They may also supply further values if the URL querystring changes. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class SupplyParameterFromQueryAttribute : Attribute +public sealed class SupplyParameterFromQueryAttribute : CascadingParameterAttributeBase { /// /// Gets or sets the name of the querystring parameter. If null, the querystring /// parameter is assumed to have the same name as the associated property. /// - public string? Name { get; set; } + public override string? Name { get; set; } } diff --git a/src/Components/Components/test/CascadingModelBinderTest.cs b/src/Components/Components/test/CascadingModelBinderTest.cs index c20703b51d8f..48a6f5ecff15 100644 --- a/src/Components/Components/test/CascadingModelBinderTest.cs +++ b/src/Components/Components/test/CascadingModelBinderTest.cs @@ -20,6 +20,7 @@ public CascadingModelBinderTest() _navigationManager = new TestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); } diff --git a/src/Components/Components/test/CascadingParameterStateTest.cs b/src/Components/Components/test/CascadingParameterStateTest.cs index 6edf09f31e21..89a1a0148402 100644 --- a/src/Components/Components/test/CascadingParameterStateTest.cs +++ b/src/Components/Components/test/CascadingParameterStateTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -83,7 +82,7 @@ public void FindCascadingParameters_IfHasPartialMatchesInAncestors_ReturnsMatche // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -103,15 +102,15 @@ public void FindCascadingParameters_IfHasMultipleMatchesInAncestors_ReturnsMatch var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[3].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -129,15 +128,15 @@ public void FindCascadingParameters_InheritedParameters_ReturnsMatches() var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithInheritedCascadingParams.CascadingParam3), match.LocalValueName); + Assert.Equal(nameof(ComponentWithInheritedCascadingParams.CascadingParam3), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); }); } @@ -156,7 +155,7 @@ public void FindCascadingParameters_ComponentRequestsBaseType_ReturnsMatches() // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -175,7 +174,7 @@ public void FindCascadingParameters_ComponentRequestsImplementedInterface_Return // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -209,7 +208,7 @@ public void FindCascadingParameters_TypeAssignmentIsValidForNullValue_ReturnsMat // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithGenericCascadingParam.LocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -303,7 +302,7 @@ public void FindCascadingParameters_MatchingNameAndType_ReturnsMatches() // Assert Assert.Collection(result, match => { - Assert.Equal(nameof(ComponentWithNamedCascadingParam.SomeLocalName), match.LocalValueName); + Assert.Equal(nameof(ComponentWithNamedCascadingParam.SomeLocalName), match.ParameterInfo.PropertyName); Assert.Same(states[0].Component, match.ValueSupplier); }); } @@ -323,15 +322,15 @@ public void FindCascadingParameters_MultipleMatchingAncestors_ReturnsClosestMatc var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[2].Component, match.ValueSupplier); }, match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam2), match.ParameterInfo.PropertyName); Assert.Same(states[3].Component, match.ValueSupplier); }); } @@ -349,12 +348,12 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() var result = CascadingParameterState.FindCascadingParameters(states.Last()); // Assert - Assert.Collection(result.OrderBy(x => x.LocalValueName), + Assert.Collection(result.OrderBy(x => x.ParameterInfo.PropertyName), match => { - Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.LocalValueName); + Assert.Equal(nameof(ComponentWithCascadingParams.CascadingParam1), match.ParameterInfo.PropertyName); Assert.Same(states[1].Component, match.ValueSupplier); - Assert.Null(match.ValueSupplier.CurrentValue); + Assert.Null(match.ValueSupplier.GetCurrentValue(match.ParameterInfo)); }); } @@ -362,15 +361,14 @@ public void FindCascadingParameters_CanOverrideNonNullValueWithNull() public void FindCascadingParameters_HandlesSupplyParameterFromFormValues() { // Arrange + var provider = new TestCascadingFormModelBindingProvider + { + FormName = "", + CurrentValue = "some value", + }; var cascadingModelBinder = new CascadingModelBinder { - FormValueSupplier = new TestFormValueSupplier() - { - FormName = "", - ValueType = typeof(string), - BindResult = true, - BoundValue = "some value" - }, + ModelBindingProviders = new[] { provider }, Navigation = Mock.Of(), Name = "" }; @@ -393,17 +391,16 @@ public void FindCascadingParameters_HandlesSupplyParameterFromFormValues() public void FindCascadingParameters_HandlesSupplyParameterFromFormValues_WithName() { // Arrange + var provider = new TestCascadingFormModelBindingProvider + { + FormName = "some-name", + CurrentValue = "some value", + }; var cascadingModelBinder = new CascadingModelBinder { - FormValueSupplier = new TestFormValueSupplier() - { - FormName = "some-name", - ValueType = typeof(string), - BindResult = true, - BoundValue = "some value" - }, + ModelBindingProviders = new[] { provider }, Navigation = new TestNavigationManager(), - Name = "" + Name = "some-name" }; cascadingModelBinder.UpdateBindingInformation("https://localhost/"); @@ -519,32 +516,25 @@ class CascadingValueTypeBaseClass { } class CascadingValueTypeDerivedClass : CascadingValueTypeBaseClass, ICascadingValueTypeDerivedClassInterface { } interface ICascadingValueTypeDerivedClassInterface { } - private class TestFormValueSupplier : IFormValueSupplier + private class TestCascadingFormModelBindingProvider : CascadingModelBindingProvider { - public string FormName { get; set; } + public required string FormName { get; init; } - public Type ValueType { get; set; } + public required string CurrentValue { get; init; } - public object BoundValue { get; set; } + protected internal override bool AreValuesFixed => true; - public bool BindResult { get; set; } + protected internal override bool CanSupplyValue(ModelBindingContext bindingContext, in CascadingParameterInfo parameterInfo) + => string.Equals(bindingContext.Name, FormName, StringComparison.Ordinal); - public bool CanBind(string formName, Type valueType) - { - return string.Equals(formName, FormName, StringComparison.Ordinal) && - valueType == ValueType; - } + protected internal override object GetCurrentValue(ModelBindingContext bindingContext, in CascadingParameterInfo parameterInfo) + => CurrentValue; - public bool CanConvertSingleValue(Type type) - { - return type == ValueType; - } + protected internal override bool SupportsCascadingParameterAttributeType(Type attributeType) + => attributeType == typeof(SupplyParameterFromFormAttribute); - public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue) - { - boundValue = BoundValue; - return BindResult; - } + protected internal override bool SupportsParameterType(Type parameterType) + => parameterType == typeof(string); } class TestNavigationManager : NavigationManager @@ -557,11 +547,11 @@ public TestNavigationManager() } [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class SupplyParameterFromFormAttribute : Attribute, IHostEnvironmentCascadingParameter +public sealed class SupplyParameterFromFormAttribute : CascadingParameterAttributeBase { /// /// Gets or sets the name for the parameter. The name is used to match /// the form data and decide whether or not the value needs to be bound. /// - public string Name { get; set; } + public override string Name { get; set; } } diff --git a/src/Components/Components/test/CascadingParameterTest.cs b/src/Components/Components/test/CascadingParameterTest.cs index 0330b92fede7..ba97eeb110f0 100644 --- a/src/Components/Components/test/CascadingParameterTest.cs +++ b/src/Components/Components/test/CascadingParameterTest.cs @@ -384,6 +384,59 @@ public void ParameterViewSuppliedWithCascadingParametersCannotBeUsedAfterSynchro Assert.Equal($"The {nameof(ParameterView)} instance can no longer be read because it has expired. {nameof(ParameterView)} can only be read synchronously and must not be stored for later use.", ex.Message); } + [Fact] + public void CanSupplyCascadingValuesForSpecificCascadingParameterAttributeType() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent(builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", "Hello 1"); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(builder => + { + builder.OpenComponent>(0); + builder.AddComponentParameter(1, "Value", "Hello 2"); + builder.AddComponentParameter(2, "ChildContent", new RenderFragment(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + builder.OpenComponent(1); + builder.CloseComponent(); + })); + builder.CloseComponent(); + })); + builder.CloseComponent(); + }); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + var batch = renderer.Batches.Single(); + var nestedComponent1 = FindComponent(batch, out var nestedComponentId1); + var nestedComponent2 = FindComponent(batch, out var nestedComponentId2); + var nestedComponentDiff1 = batch.DiffsByComponentId[nestedComponentId1].Single(); + var nestedComponentDiff2 = batch.DiffsByComponentId[nestedComponentId2].Single(); + + // The nested components were rendered with the correct parameters + Assert.Collection(nestedComponentDiff1.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Value 1 is 'Hello 1'."); + }); + Assert.Collection(nestedComponentDiff2.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Value 2 is 'Hello 2'."); + }); + } + private static T FindComponent(CapturedBatch batch, out int componentId) { var componentFrame = batch.ReferenceFrames.Single( @@ -441,4 +494,79 @@ class SecondCascadingParameterConsumerComponent : CascadingParameterCons { [CascadingParameter] T2 SecondCascadingParameter { get; set; } } + + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + class CustomCascadingParameter1Attribute : CascadingParameterAttributeBase + { + public override string Name { get; set; } + } + + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + class CustomCascadingParameter2Attribute : CascadingParameterAttributeBase + { + public override string Name { get; set; } + } + + class CustomCascadingValueProducer : AutoRenderComponent, ICascadingValueSupplier + { + [Parameter] public object Value { get; set; } + + [Parameter] public RenderFragment ChildContent { get; set; } + + bool ICascadingValueSupplier.IsFixed => true; + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, ChildContent); + } + + bool ICascadingValueSupplier.CanSupplyValue(in CascadingParameterInfo parameterInfo) + { + if (parameterInfo.Attribute is not TAttribute || + parameterInfo.PropertyType != typeof(object) || + parameterInfo.PropertyName != nameof(Value)) + { + return false; + } + + return true; + } + + object ICascadingValueSupplier.GetCurrentValue(in CascadingParameterInfo cascadingParameterState) + { + return Value; + } + + void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + throw new NotImplementedException(); + } + + void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) + { + throw new NotImplementedException(); + } + } + + class CustomCascadingValueConsumer1 : AutoRenderComponent + { + [CustomCascadingParameter1(Name = nameof(Value))] + public object Value { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Value 1 is '{Value}'."); + } + } + + class CustomCascadingValueConsumer2 : AutoRenderComponent + { + [CustomCascadingParameter2(Name = nameof(Value))] + public object Value { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddContent(0, $"Value 2 is '{Value}'."); + } + } } diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index bcc032c7e080..a85b6d9cd03e 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -646,11 +646,10 @@ class HasNonPublicCascadingParameter class ParameterViewBuilder : IEnumerable { - private readonly List<(string Name, object Value, bool Cascading)> _keyValuePairs - = new List<(string, object, bool)>(); + private readonly List<(string Name, object Value, bool Cascading)> _parameters = new(); public void Add(string name, object value, bool cascading = false) - => _keyValuePairs.Add((name, value, cascading)); + => _parameters.Add((name, value, cascading)); public IEnumerator GetEnumerator() => throw new NotImplementedException(); @@ -660,11 +659,11 @@ public ParameterView Build() var builder = new RenderTreeBuilder(); builder.OpenComponent(0); - foreach (var kvp in _keyValuePairs) + foreach (var (name, value, cascading) in _parameters) { - if (!kvp.Cascading) + if (!cascading) { - builder.AddComponentParameter(1, kvp.Name, kvp.Value); + builder.AddComponentParameter(1, name, value); } } builder.CloseComponent(); @@ -672,11 +671,11 @@ public ParameterView Build() var view = new ParameterView(ParameterViewLifetime.Unbound, builder.GetFrames().Array, ownerIndex: 0); var cascadingParameters = new List(); - foreach (var kvp in _keyValuePairs) + foreach (var (name, value, cascading) in _parameters) { - if (kvp.Cascading) + if (cascading) { - cascadingParameters.Add(new CascadingParameterState(kvp.Name, new TestCascadingValueProvider(kvp.Value))); + cascadingParameters.Add(new CascadingParameterState(new(null, name, value.GetType()), new TestCascadingValueProvider(value))); } } @@ -684,28 +683,33 @@ public ParameterView Build() } } - private class TestCascadingValueProvider : ICascadingValueComponent + private class TestCascadingValueProvider : ICascadingValueSupplier { + private readonly object _value; + public TestCascadingValueProvider(object value) { - CurrentValue = value; + _value = value; } - public object CurrentValue { get; } - - public bool CurrentValueIsFixed => throw new NotImplementedException(); + public bool IsFixed => throw new NotImplementedException(); - public bool CanSupplyValue(Type valueType, string valueName) + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } - public void Subscribe(ComponentState subscriber) + public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + { + return _value; + } + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } - public void Unsubscribe(ComponentState subscriber) + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) { throw new NotImplementedException(); } diff --git a/src/Components/Components/test/ParameterViewTest.cs b/src/Components/Components/test/ParameterViewTest.cs index 740359e5cb0a..e4a358d491cc 100644 --- a/src/Components/Components/test/ParameterViewTest.cs +++ b/src/Components/Components/test/ParameterViewTest.cs @@ -95,8 +95,8 @@ public void EnumerationIncludesCascadingParameters() RenderTreeFrame.Attribute(1, "attribute 1", attribute1Value) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("attribute 2", new TestCascadingValue(attribute2Value)), - new CascadingParameterState("attribute 3", new TestCascadingValue(attribute3Value)), + new CascadingParameterState(new(null, "attribute 2", attribute2Value.GetType()), new TestCascadingValue(attribute2Value)), + new CascadingParameterState(new(null, "attribute 3", attribute3Value.GetType()), new TestCascadingValue(attribute3Value)), }); // Assert @@ -190,7 +190,7 @@ public void CanGetValueOrDefault_WithNonExistingValue() RenderTreeFrame.Attribute(1, "some other entry", new object()) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("another entry", new TestCascadingValue(null)) + new CascadingParameterState(new(null, "another entry", typeof(object)), new TestCascadingValue(null)) }); // Act @@ -305,9 +305,9 @@ public void CanGetValueOrDefault_WithMatchingCascadingParameter() RenderTreeFrame.Attribute(1, "unrelated value", new object()) }, 0).WithCascadingParameters(new List { - new CascadingParameterState("unrelated value 2", new TestCascadingValue(null)), - new CascadingParameterState("my entry", new TestCascadingValue(myEntryValue)), - new CascadingParameterState("unrelated value 3", new TestCascadingValue(null)), + new CascadingParameterState(new(null, "unrelated value 2", typeof(object)), new TestCascadingValue(null)), + new CascadingParameterState(new(null, "my entry", myEntryValue.GetType()), new TestCascadingValue(myEntryValue)), + new CascadingParameterState(new(null, "unrelated value 3", typeof(object)), new TestCascadingValue(null)), }); // Act @@ -595,24 +595,27 @@ public Task SetParametersAsync(ParameterView parameters) => throw new NotImplementedException(); } - private class TestCascadingValue : ICascadingValueComponent + private class TestCascadingValue : ICascadingValueSupplier { + private readonly object _value; + public TestCascadingValue(object value) { - CurrentValue = value; + _value = value; } - public object CurrentValue { get; } - - public bool CurrentValueIsFixed => false; + public bool IsFixed => false; - public bool CanSupplyValue(Type valueType, string valueName) + public bool CanSupplyValue(in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); - public void Subscribe(ComponentState subscriber) + public object GetCurrentValue(in CascadingParameterInfo parameterInfo) + => _value; + + public void Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); - public void Unsubscribe(ComponentState subscriber) + public void Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo) => throw new NotImplementedException(); } } diff --git a/src/Components/Components/test/RouteViewTest.cs b/src/Components/Components/test/RouteViewTest.cs index c791d7243363..f7bdad0d3445 100644 --- a/src/Components/Components/test/RouteViewTest.cs +++ b/src/Components/Components/test/RouteViewTest.cs @@ -22,6 +22,7 @@ public RouteViewTest() _navigationManager = new RouteViewTestNavigationManager(); serviceCollection.AddSingleton(_navigationManager); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); var services = serviceCollection.BuildServiceProvider(); _renderer = new TestRenderer(services); diff --git a/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs b/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs index e96bd3a9deab..631b35b76ddd 100644 --- a/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs +++ b/src/Components/Components/test/Routing/QueryParameterValueSupplierTest.cs @@ -1,98 +1,53 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Components.Rendering; +// 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.Routing; public class QueryParameterValueSupplierTest { - private class NoQueryParameters : ComponentBase { } - - [Fact] - public void ComponentWithNoQueryParametersHasNoSupplier() - { - Assert.Null(QueryParameterValueSupplier.ForType(typeof(NoQueryParameters))); - } - - private class IgnorableProperties : ComponentBase - { - [Parameter] public string Invalid1 { get; set; } - [SupplyParameterFromQuery] public string Invalid2 { get; set; } - [Parameter, SupplyParameterFromQuery] public string Valid { get; set; } - [Parameter] public object InvalidAndUnsupportedType { get; set; } - } - - [Fact] - public void SuppliesParametersOnlyForPropertiesWithMatchingAttributes() - { - var query = $"?{nameof(IgnorableProperties.Invalid1)}=a&{nameof(IgnorableProperties.Invalid2)}=b&{nameof(IgnorableProperties.Valid)}=c"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(IgnorableProperties.Valid), "c")); - } - - private class ValidTypes : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public bool BoolVal { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime DateTimeVal { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal DecimalVal { get; set; } - [Parameter, SupplyParameterFromQuery] public double DoubleVal { get; set; } - [Parameter, SupplyParameterFromQuery] public float FloatVal { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid GuidVal { get; set; } - [Parameter, SupplyParameterFromQuery] public int IntVal { get; set; } - [Parameter, SupplyParameterFromQuery] public long LongVal { get; set; } - [Parameter, SupplyParameterFromQuery] public string StringVal { get; set; } - - [Parameter, SupplyParameterFromQuery] public bool? NullableBoolVal { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime? NullableDateTimeVal { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal? NullableDecimalVal { get; set; } - [Parameter, SupplyParameterFromQuery] public double? NullableDoubleVal { get; set; } - [Parameter, SupplyParameterFromQuery] public float? NullableFloatVal { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid? NullableGuidVal { get; set; } - [Parameter, SupplyParameterFromQuery] public int? NullableIntVal { get; set; } - [Parameter, SupplyParameterFromQuery] public long? NullableLongVal { get; set; } - } + private readonly QueryParameterValueSupplier _supplier = new(); [Fact] public void SupportsExpectedValueTypes() { var query = - $"{nameof(ValidTypes.BoolVal)}=true&" + - $"{nameof(ValidTypes.DateTimeVal)}=2020-01-02+03:04:05.678-09:00&" + - $"{nameof(ValidTypes.DecimalVal)}=-1.234&" + - $"{nameof(ValidTypes.DoubleVal)}=-2.345&" + - $"{nameof(ValidTypes.FloatVal)}=-3.456&" + - $"{nameof(ValidTypes.GuidVal)}=9e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidTypes.IntVal)}=-54321&" + - $"{nameof(ValidTypes.LongVal)}=-99987654321&" + - $"{nameof(ValidTypes.StringVal)}=Some+string+%26+more&" + - $"{nameof(ValidTypes.NullableBoolVal)}=true&" + - $"{nameof(ValidTypes.NullableDateTimeVal)}=2021-01-02+03:04:05.678Z&" + - $"{nameof(ValidTypes.NullableDecimalVal)}=1.234&" + - $"{nameof(ValidTypes.NullableDoubleVal)}=2.345&" + - $"{nameof(ValidTypes.NullableFloatVal)}=3.456&" + - $"{nameof(ValidTypes.NullableGuidVal)}=1e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidTypes.NullableIntVal)}=54321&" + - $"{nameof(ValidTypes.NullableLongVal)}=99987654321&"; - - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidTypes.BoolVal), true), - AssertKeyValuePair(nameof(ValidTypes.DateTimeVal), new DateTimeOffset(2020, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-9)).LocalDateTime), - AssertKeyValuePair(nameof(ValidTypes.DecimalVal), -1.234m), - AssertKeyValuePair(nameof(ValidTypes.DoubleVal), -2.345), - AssertKeyValuePair(nameof(ValidTypes.FloatVal), -3.456f), - AssertKeyValuePair(nameof(ValidTypes.GuidVal), new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9")), - AssertKeyValuePair(nameof(ValidTypes.IntVal), -54321), - AssertKeyValuePair(nameof(ValidTypes.LongVal), -99987654321), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), true), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime()), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), 1.234m), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), 2.345), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), 3.456f), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9")), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), 54321), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), 99987654321), - AssertKeyValuePair(nameof(ValidTypes.StringVal), "Some string & more")); + $"BoolVal=true&" + + $"DateTimeVal=2020-01-02+03:04:05.678-09:00&" + + $"DecimalVal=-1.234&" + + $"DoubleVal=-2.345&" + + $"FloatVal=-3.456&" + + $"GuidVal=9e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"IntVal=-54321&" + + $"LongVal=-99987654321&" + + $"StringVal=Some+string+%26+more&" + + $"NullableBoolVal=true&" + + $"NullableDateTimeVal=2021-01-02+03:04:05.678Z&" + + $"NullableDecimalVal=1.234&" + + $"NullableDoubleVal=2.345&" + + $"NullableFloatVal=3.456&" + + $"NullableGuidVal=1e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"NullableIntVal=54321&" + + $"NullableLongVal=99987654321&"; + + ReadQuery(query); + + AssertKeyValuePair("BoolVal", true); + AssertKeyValuePair("DateTimeVal", new DateTimeOffset(2020, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(-9)).LocalDateTime); + AssertKeyValuePair("DecimalVal", -1.234m); + AssertKeyValuePair("DoubleVal", -2.345); + AssertKeyValuePair("FloatVal", -3.456f); + AssertKeyValuePair("GuidVal", new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9")); + AssertKeyValuePair("IntVal", -54321); + AssertKeyValuePair("LongVal", -99987654321); + AssertKeyValuePair("NullableBoolVal", true); + AssertKeyValuePair("NullableDateTimeVal", new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime()); + AssertKeyValuePair("NullableDecimalVal", 1.234m); + AssertKeyValuePair("NullableDoubleVal", 2.345); + AssertKeyValuePair("NullableFloatVal", 3.456f); + AssertKeyValuePair("NullableGuidVal", new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9")); + AssertKeyValuePair("NullableIntVal", 54321); + AssertKeyValuePair("NullableLongVal", 99987654321); + AssertKeyValuePair("StringVal", "Some string & more"); } [Theory] @@ -101,92 +56,72 @@ public void SupportsExpectedValueTypes() [InlineData("?unrelated=123")] public void SuppliesNullForValueTypesIfNotSpecified(string query) { - // Although we could supply default(T) for missing values, there's precedent in the routing - // system for supplying null for missing route parameters. The component is then responsible - // for interpreting null as a blank value for the parameter, regardless of its type. To keep - // the rules aligned, we do the same thing for querystring parameters. - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidTypes.BoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.DoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.FloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.GuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.IntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.LongVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.StringVal), (object)null)); - } - - private class ValidArrayTypes : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public bool[] BoolVals { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime[] DateTimeVals { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal[] DecimalVals { get; set; } - [Parameter, SupplyParameterFromQuery] public double[] DoubleVals { get; set; } - [Parameter, SupplyParameterFromQuery] public float[] FloatVals { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid[] GuidVals { get; set; } - [Parameter, SupplyParameterFromQuery] public int[] IntVals { get; set; } - [Parameter, SupplyParameterFromQuery] public long[] LongVals { get; set; } - [Parameter, SupplyParameterFromQuery] public string[] StringVals { get; set; } - - [Parameter, SupplyParameterFromQuery] public bool?[] NullableBoolVals { get; set; } - [Parameter, SupplyParameterFromQuery] public DateTime?[] NullableDateTimeVals { get; set; } - [Parameter, SupplyParameterFromQuery] public decimal?[] NullableDecimalVals { get; set; } - [Parameter, SupplyParameterFromQuery] public double?[] NullableDoubleVals { get; set; } - [Parameter, SupplyParameterFromQuery] public float?[] NullableFloatVals { get; set; } - [Parameter, SupplyParameterFromQuery] public Guid?[] NullableGuidVals { get; set; } - [Parameter, SupplyParameterFromQuery] public int?[] NullableIntVals { get; set; } - [Parameter, SupplyParameterFromQuery] public long?[] NullableLongVals { get; set; } + ReadQuery(query); + + // Although we could supply default(T) for missing values, there's precedent in the routing + // system for supplying null for missing route parameters. The component is then responsible + // for interpreting null as a blank value for the parameter, regardless of its type. To keep + // the rules aligned, we do the same thing for querystring parameters. + AssertKeyValuePair("BoolVal", null); + AssertKeyValuePair("DateTimeVal", null); + AssertKeyValuePair("DecimalVal", null); + AssertKeyValuePair("DoubleVal", null); + AssertKeyValuePair("FloatVal", null); + AssertKeyValuePair("GuidVal", null); + AssertKeyValuePair("IntVal", null); + AssertKeyValuePair("LongVal", null); + AssertKeyValuePair("NullableBoolVal", null); + AssertKeyValuePair("NullableDateTimeVal", null); + AssertKeyValuePair("NullableDecimalVal", null); + AssertKeyValuePair("NullableDoubleVal", null); + AssertKeyValuePair("NullableFloatVal", null); + AssertKeyValuePair("NullableGuidVal", null); + AssertKeyValuePair("NullableIntVal", null); + AssertKeyValuePair("NullableLongVal", null); + AssertKeyValuePair("StringVal", null); } [Fact] public void SupportsExpectedArrayTypes() { var query = - $"{nameof(ValidArrayTypes.BoolVals)}=true&" + - $"{nameof(ValidArrayTypes.DateTimeVals)}=2020-01-02+03:04:05.678Z&" + - $"{nameof(ValidArrayTypes.DecimalVals)}=-1.234&" + - $"{nameof(ValidArrayTypes.DoubleVals)}=-2.345&" + - $"{nameof(ValidArrayTypes.FloatVals)}=-3.456&" + - $"{nameof(ValidArrayTypes.GuidVals)}=9e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidArrayTypes.IntVals)}=-54321&" + - $"{nameof(ValidArrayTypes.LongVals)}=-99987654321&" + - $"{nameof(ValidArrayTypes.StringVals)}=Some+string+%26+more&" + - $"{nameof(ValidArrayTypes.NullableBoolVals)}=true&" + - $"{nameof(ValidArrayTypes.NullableDateTimeVals)}=2021-01-02+03:04:05.678Z&" + - $"{nameof(ValidArrayTypes.NullableDecimalVals)}=1.234&" + - $"{nameof(ValidArrayTypes.NullableDoubleVals)}=2.345&" + - $"{nameof(ValidArrayTypes.NullableFloatVals)}=3.456&" + - $"{nameof(ValidArrayTypes.NullableGuidVals)}=1e7257ad-03aa-42c7-9819-be08b177fef9&" + - $"{nameof(ValidArrayTypes.NullableIntVals)}=54321&" + - $"{nameof(ValidArrayTypes.NullableLongVals)}=99987654321&"; - - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidArrayTypes.BoolVals), new[] { true }), - AssertKeyValuePair(nameof(ValidArrayTypes.DateTimeVals), new[] { new DateTime(2020, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }), - AssertKeyValuePair(nameof(ValidArrayTypes.DecimalVals), new[] { -1.234m }), - AssertKeyValuePair(nameof(ValidArrayTypes.DoubleVals), new[] { -2.345 }), - AssertKeyValuePair(nameof(ValidArrayTypes.FloatVals), new[] { -3.456f }), - AssertKeyValuePair(nameof(ValidArrayTypes.GuidVals), new[] { new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9") }), - AssertKeyValuePair(nameof(ValidArrayTypes.IntVals), new[] { -54321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.LongVals), new[] { -99987654321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), new[] { true }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), new[] { new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), new[] { 1.234m }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), new[] { 2.345 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), new[] { 3.456f }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), new[] { new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9") }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), new[] { 54321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), new[] { 99987654321 }), - AssertKeyValuePair(nameof(ValidArrayTypes.StringVals), new[] { "Some string & more" })); + $"BoolVals=true&" + + $"DateTimeVals=2020-01-02+03:04:05.678Z&" + + $"DecimalVals=-1.234&" + + $"DoubleVals=-2.345&" + + $"FloatVals=-3.456&" + + $"GuidVals=9e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"IntVals=-54321&" + + $"LongVals=-99987654321&" + + $"StringVals=Some+string+%26+more&" + + $"NullableBoolVals=true&" + + $"NullableDateTimeVals=2021-01-02+03:04:05.678Z&" + + $"NullableDecimalVals=1.234&" + + $"NullableDoubleVals=2.345&" + + $"NullableFloatVals=3.456&" + + $"NullableGuidVals=1e7257ad-03aa-42c7-9819-be08b177fef9&" + + $"NullableIntVals=54321&" + + $"NullableLongVals=99987654321&"; + + ReadQuery(query); + + AssertKeyValuePair("BoolVals", new[] { true }); + AssertKeyValuePair("DateTimeVals", new[] { new DateTime(2020, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }); + AssertKeyValuePair("DecimalVals", new[] { -1.234m }); + AssertKeyValuePair("DoubleVals", new[] { -2.345 }); + AssertKeyValuePair("FloatVals", new[] { -3.456f }); + AssertKeyValuePair("GuidVals", new[] { new Guid("9e7257ad-03aa-42c7-9819-be08b177fef9") }); + AssertKeyValuePair("IntVals", new[] { -54321 }); + AssertKeyValuePair("LongVals", new[] { -99987654321 }); + AssertKeyValuePair("NullableBoolVals", new[] { true }); + AssertKeyValuePair("NullableDateTimeVals", new[] { new DateTime(2021, 1, 2, 3, 4, 5, 678, DateTimeKind.Utc).ToLocalTime() }); + AssertKeyValuePair("NullableDecimalVals", new[] { 1.234m }); + AssertKeyValuePair("NullableDoubleVals", new[] { 2.345 }); + AssertKeyValuePair("NullableFloatVals", new[] { 3.456f }); + AssertKeyValuePair("NullableGuidVals", new[] { new Guid("1e7257ad-03aa-42c7-9819-be08b177fef9") }); + AssertKeyValuePair("NullableIntVals", new[] { 54321 }); + AssertKeyValuePair("NullableLongVals", new[] { 99987654321 }); + AssertKeyValuePair("StringVals", new[] { "Some string & more" }); } [Theory] @@ -195,133 +130,91 @@ public void SupportsExpectedArrayTypes() [InlineData("?unrelated=123")] public void SuppliesEmptyArrayForArrayTypesIfNotSpecified(string query) { - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(ValidArrayTypes.BoolVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DateTimeVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DecimalVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.DoubleVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.FloatVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.GuidVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.IntVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.LongVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), Array.Empty()), - AssertKeyValuePair(nameof(ValidArrayTypes.StringVals), Array.Empty())); - } - - class OverrideParameterName : ComponentBase - { - [Parameter, SupplyParameterFromQuery(Name = "anothername1")] public string Value1 { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "anothername2")] public string Value2 { get; set; } - } - - [Fact] - public void CanOverrideParameterName() - { - var query = $"anothername1=Some+value+1&Value2=Some+value+2"; - Assert.Collection(GetSuppliedParameters(query), - // Because we specified the mapped name, we receive the value - AssertKeyValuePair(nameof(OverrideParameterName.Value1), "Some value 1"), - // If we specify the component parameter name directly, we do not receive the value - AssertKeyValuePair(nameof(OverrideParameterName.Value2), (object)null)); - } - - class MapSingleQueryParameterToMultipleProperties : ComponentBase - { - [Parameter, SupplyParameterFromQuery(Name = "a")] public int ValueAsInt { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "b")] public DateTime ValueAsDateTime { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "A")] public long ValueAsLong { get; set; } - } - - [Fact] - public void CannotMapSingleQueryParameterToMultipleProperties() - { - var ex = Assert.Throws( - () => QueryParameterValueSupplier.ForType(typeof(MapSingleQueryParameterToMultipleProperties))); - Assert.Contains("declares more than one mapping for the query parameter 'a'.", ex.Message, StringComparison.OrdinalIgnoreCase); - } - - class UnsupportedType : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int IntValid { get; set; } - [Parameter, SupplyParameterFromQuery] public object ObjectValue { get; set; } - } - - [Fact] - public void RejectsUnsupportedType() - { - var ex = Assert.Throws( - () => QueryParameterValueSupplier.ForType(typeof(UnsupportedType))); - Assert.Equal("Querystring values cannot be parsed as type 'System.Object'.", ex.Message); + ReadQuery(query); + + AssertKeyValuePair("BoolVals", Array.Empty()); + AssertKeyValuePair("DateTimeVals", Array.Empty()); + AssertKeyValuePair("DecimalVals", Array.Empty()); + AssertKeyValuePair("DoubleVals", Array.Empty()); + AssertKeyValuePair("FloatVals", Array.Empty()); + AssertKeyValuePair("GuidVals", Array.Empty()); + AssertKeyValuePair("IntVals", Array.Empty()); + AssertKeyValuePair("LongVals", Array.Empty()); + AssertKeyValuePair("NullableBoolVals", Array.Empty()); + AssertKeyValuePair("NullableDateTimeVals", Array.Empty()); + AssertKeyValuePair("NullableDecimalVals", Array.Empty()); + AssertKeyValuePair("NullableDoubleVals", Array.Empty()); + AssertKeyValuePair("NullableFloatVals", Array.Empty()); + AssertKeyValuePair("NullableGuidVals", Array.Empty()); + AssertKeyValuePair("NullableIntVals", Array.Empty()); + AssertKeyValuePair("NullableLongVals", Array.Empty()); + AssertKeyValuePair("StringVals", Array.Empty()); } [Theory] - [InlineData(nameof(ValidTypes.BoolVal), "abc", typeof(bool))] - [InlineData(nameof(ValidTypes.DateTimeVal), "2020-02-31", typeof(DateTime))] - [InlineData(nameof(ValidTypes.DecimalVal), "1.2.3", typeof(decimal))] - [InlineData(nameof(ValidTypes.DoubleVal), "1x", typeof(double))] - [InlineData(nameof(ValidTypes.FloatVal), "1e1000", typeof(float))] - [InlineData(nameof(ValidTypes.GuidVal), "123456-789-0", typeof(Guid))] - [InlineData(nameof(ValidTypes.IntVal), "5000000000", typeof(int))] - [InlineData(nameof(ValidTypes.LongVal), "this+is+a+long+value", typeof(long))] - [InlineData(nameof(ValidTypes.NullableBoolVal), "abc", typeof(bool?))] - [InlineData(nameof(ValidTypes.NullableDateTimeVal), "2020-02-31", typeof(DateTime?))] - [InlineData(nameof(ValidTypes.NullableDecimalVal), "1.2.3", typeof(decimal?))] - [InlineData(nameof(ValidTypes.NullableDoubleVal), "1x", typeof(double?))] - [InlineData(nameof(ValidTypes.NullableFloatVal), "1e1000", typeof(float?))] - [InlineData(nameof(ValidTypes.NullableGuidVal), "123456-789-0", typeof(Guid?))] - [InlineData(nameof(ValidTypes.NullableIntVal), "5000000000", typeof(int?))] - [InlineData(nameof(ValidTypes.NullableLongVal), "this+is+a+long+value", typeof(long?))] + [InlineData("BoolVal", "abc", typeof(bool))] + [InlineData("DateTimeVal", "2020-02-31", typeof(DateTime))] + [InlineData("DecimalVal", "1.2.3", typeof(decimal))] + [InlineData("DoubleVal", "1x", typeof(double))] + [InlineData("FloatVal", "1e1000", typeof(float))] + [InlineData("GuidVal", "123456-789-0", typeof(Guid))] + [InlineData("IntVal", "5000000000", typeof(int))] + [InlineData("LongVal", "this+is+a+long+value", typeof(long))] + [InlineData("NullableBoolVal", "abc", typeof(bool?))] + [InlineData("NullableDateTimeVal", "2020-02-31", typeof(DateTime?))] + [InlineData("NullableDecimalVal", "1.2.3", typeof(decimal?))] + [InlineData("NullableDoubleVal", "1x", typeof(double?))] + [InlineData("NullableFloatVal", "1e1000", typeof(float?))] + [InlineData("NullableGuidVal", "123456-789-0", typeof(Guid?))] + [InlineData("NullableIntVal", "5000000000", typeof(int?))] + [InlineData("NullableLongVal", "this+is+a+long+value", typeof(long?))] public void RejectsUnparseableValues(string key, string value, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{key}={value}")); + ReadQuery($"?{key}={value}"); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '{value.Replace('+', ' ')}' as type '{targetType}' for '{key}'.", ex.Message); } [Theory] - [InlineData(nameof(ValidArrayTypes.BoolVals), "true", "abc", typeof(bool))] - [InlineData(nameof(ValidArrayTypes.DateTimeVals), "2020-02-28", "2020-02-31", typeof(DateTime))] - [InlineData(nameof(ValidArrayTypes.DecimalVals), "1.23", "1.2.3", typeof(decimal))] - [InlineData(nameof(ValidArrayTypes.DoubleVals), "1", "1x", typeof(double))] - [InlineData(nameof(ValidArrayTypes.FloatVals), "1000", "1e1000", typeof(float))] - [InlineData(nameof(ValidArrayTypes.GuidVals), "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid))] - [InlineData(nameof(ValidArrayTypes.IntVals), "5000000", "5000000000", typeof(int))] - [InlineData(nameof(ValidArrayTypes.LongVals), "-1234", "this+is+a+long+value", typeof(long))] - [InlineData(nameof(ValidArrayTypes.NullableBoolVals), "true", "abc", typeof(bool?))] - [InlineData(nameof(ValidArrayTypes.NullableDateTimeVals), "2020-02-28", "2020-02-31", typeof(DateTime?))] - [InlineData(nameof(ValidArrayTypes.NullableDecimalVals), "1.23", "1.2.3", typeof(decimal?))] - [InlineData(nameof(ValidArrayTypes.NullableDoubleVals), "1", "1x", typeof(double?))] - [InlineData(nameof(ValidArrayTypes.NullableFloatVals), "1000", "1e1000", typeof(float?))] - [InlineData(nameof(ValidArrayTypes.NullableGuidVals), "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid?))] - [InlineData(nameof(ValidArrayTypes.NullableIntVals), "5000000", "5000000000", typeof(int?))] - [InlineData(nameof(ValidArrayTypes.NullableLongVals), "-1234", "this+is+a+long+value", typeof(long?))] + [InlineData("BoolVals", "true", "abc", typeof(bool))] + [InlineData("DateTimeVals", "2020-02-28", "2020-02-31", typeof(DateTime))] + [InlineData("DecimalVals", "1.23", "1.2.3", typeof(decimal))] + [InlineData("DoubleVals", "1", "1x", typeof(double))] + [InlineData("FloatVals", "1000", "1e1000", typeof(float))] + [InlineData("GuidVals", "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid))] + [InlineData("IntVals", "5000000", "5000000000", typeof(int))] + [InlineData("LongVals", "-1234", "this+is+a+long+value", typeof(long))] + [InlineData("NullableBoolVals", "true", "abc", typeof(bool?))] + [InlineData("NullableDateTimeVals", "2020-02-28", "2020-02-31", typeof(DateTime?))] + [InlineData("NullableDecimalVals", "1.23", "1.2.3", typeof(decimal?))] + [InlineData("NullableDoubleVals", "1", "1x", typeof(double?))] + [InlineData("NullableFloatVals", "1000", "1e1000", typeof(float?))] + [InlineData("NullableGuidVals", "9e7257ad-03aa-42c7-9819-be08b177fef9", "123456-789-0", typeof(Guid?))] + [InlineData("NullableIntVals", "5000000", "5000000000", typeof(int?))] + [InlineData("NullableLongVals", "-1234", "this+is+a+long+value", typeof(long?))] public void RejectsUnparseableArrayEntries(string key, string validValue, string invalidValue, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{key}={validValue}&{key}={invalidValue}")); + ReadQuery($"?{key}={validValue}&{key}={invalidValue}"); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType.MakeArrayType(), key)); Assert.Equal($"Cannot parse the value '{invalidValue.Replace('+', ' ')}' as type '{targetType}' for '{key}'.", ex.Message); } [Theory] - [InlineData(nameof(ValidTypes.BoolVal), typeof(bool))] - [InlineData(nameof(ValidTypes.DateTimeVal), typeof(DateTime))] - [InlineData(nameof(ValidTypes.DecimalVal), typeof(decimal))] - [InlineData(nameof(ValidTypes.DoubleVal), typeof(double))] - [InlineData(nameof(ValidTypes.FloatVal), typeof(float))] - [InlineData(nameof(ValidTypes.GuidVal), typeof(Guid))] - [InlineData(nameof(ValidTypes.IntVal), typeof(int))] - [InlineData(nameof(ValidTypes.LongVal), typeof(long))] + [InlineData("BoolVal", typeof(bool))] + [InlineData("DateTimeVal", typeof(DateTime))] + [InlineData("DecimalVal", typeof(decimal))] + [InlineData("DoubleVal", typeof(double))] + [InlineData("FloatVal", typeof(float))] + [InlineData("GuidVal", typeof(Guid))] + [InlineData("IntVal", typeof(int))] + [InlineData("LongVal", typeof(long))] public void RejectsBlankValuesWhenNotNullable(string key, Type targetType) { - var ex = Assert.Throws( - () => GetSuppliedParameters($"?{nameof(ValidTypes.StringVal)}=somevalue&{key}=")); + ReadQuery($"?StringVal=somevalue&{key}="); + + var ex = Assert.Throws(() => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '' as type '{targetType}' for '{key}'.", ex.Message); } @@ -329,23 +222,25 @@ public void RejectsBlankValuesWhenNotNullable(string key, Type targetType) public void AcceptsBlankValuesWhenNullable() { var query = - $"{nameof(ValidTypes.NullableBoolVal)}=&" + - $"{nameof(ValidTypes.NullableDateTimeVal)}=&" + - $"{nameof(ValidTypes.NullableDecimalVal)}=&" + - $"{nameof(ValidTypes.NullableDoubleVal)}=&" + - $"{nameof(ValidTypes.NullableFloatVal)}=&" + - $"{nameof(ValidTypes.NullableGuidVal)}=&" + - $"{nameof(ValidTypes.NullableIntVal)}=&" + - $"{nameof(ValidTypes.NullableLongVal)}=&"; - Assert.Collection(GetSuppliedParameters(query).Where(pair => pair.key.StartsWith("Nullable", StringComparison.Ordinal)), - AssertKeyValuePair(nameof(ValidTypes.NullableBoolVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDateTimeVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDecimalVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableDoubleVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableFloatVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableGuidVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableIntVal), (object)null), - AssertKeyValuePair(nameof(ValidTypes.NullableLongVal), (object)null)); + $"NullableBoolVal=&" + + $"NullableDateTimeVal=&" + + $"NullableDecimalVal=&" + + $"NullableDoubleVal=&" + + $"NullableFloatVal=&" + + $"NullableGuidVal=&" + + $"NullableIntVal=&" + + $"NullableLongVal=&"; + + ReadQuery(query); + + AssertKeyValuePair("NullableBoolVal", null); + AssertKeyValuePair("NullableDateTimeVal", null); + AssertKeyValuePair("NullableDecimalVal", null); + AssertKeyValuePair("NullableDoubleVal", null); + AssertKeyValuePair("NullableFloatVal", null); + AssertKeyValuePair("NullableGuidVal", null); + AssertKeyValuePair("NullableIntVal", null); + AssertKeyValuePair("NullableLongVal", null); } [Theory] @@ -353,35 +248,39 @@ public void AcceptsBlankValuesWhenNullable() [InlineData("=")] public void EmptyStringValuesAreSuppliedAsEmptyString(string queryPart) { - var query = $"?{nameof(ValidTypes.StringVal)}{queryPart}"; - var suppliedParameters = GetSuppliedParameters(query).ToDictionary(x => x.key, x => x.value); - Assert.Equal(string.Empty, suppliedParameters[nameof(ValidTypes.StringVal)]); + ReadQuery($"?StringVal{queryPart}"); + + Assert.Equal(string.Empty, _supplier.GetQueryParameterValue(typeof(string), "StringVal")); } [Fact] public void EmptyStringArrayValuesAreSuppliedAsEmptyStrings() { - var query = $"?{nameof(ValidArrayTypes.StringVals)}=a&" + - $"{nameof(ValidArrayTypes.StringVals)}&" + - $"{nameof(ValidArrayTypes.StringVals)}=&" + - $"{nameof(ValidArrayTypes.StringVals)}=b"; - var suppliedParameters = GetSuppliedParameters(query).ToDictionary(x => x.key, x => x.value); - Assert.Equal(new[] { "a", string.Empty, string.Empty, "b" }, suppliedParameters[nameof(ValidArrayTypes.StringVals)]); + var query = $"?StringVals=a&" + + $"StringVals&" + + $"StringVals=&" + + $"StringVals=b"; + + ReadQuery(query); + + Assert.Equal(new[] { "a", string.Empty, string.Empty, "b" }, _supplier.GetQueryParameterValue(typeof(string[]), "StringVals")); } [Theory] - [InlineData(nameof(ValidArrayTypes.BoolVals), typeof(bool))] - [InlineData(nameof(ValidArrayTypes.DateTimeVals), typeof(DateTime))] - [InlineData(nameof(ValidArrayTypes.DecimalVals), typeof(decimal))] - [InlineData(nameof(ValidArrayTypes.DoubleVals), typeof(double))] - [InlineData(nameof(ValidArrayTypes.FloatVals), typeof(float))] - [InlineData(nameof(ValidArrayTypes.GuidVals), typeof(Guid))] - [InlineData(nameof(ValidArrayTypes.IntVals), typeof(int))] - [InlineData(nameof(ValidArrayTypes.LongVals), typeof(long))] + [InlineData("BoolVals", typeof(bool))] + [InlineData("DateTimeVals", typeof(DateTime))] + [InlineData("DecimalVals", typeof(decimal))] + [InlineData("DoubleVals", typeof(double))] + [InlineData("FloatVals", typeof(float))] + [InlineData("GuidVals", typeof(Guid))] + [InlineData("IntVals", typeof(int))] + [InlineData("LongVals", typeof(long))] public void RejectsBlankArrayEntriesWhenNotNullable(string key, Type targetType) { + ReadQuery($"?StringVal=somevalue&{key}="); + var ex = Assert.Throws( - () => GetSuppliedParameters($"?{nameof(ValidTypes.StringVal)}=somevalue&{key}=")); + () => _supplier.GetQueryParameterValue(targetType, key)); Assert.Equal($"Cannot parse the value '' as type '{targetType}' for '{key}'.", ex.Message); } @@ -389,120 +288,65 @@ public void RejectsBlankArrayEntriesWhenNotNullable(string key, Type targetType) public void AcceptsBlankArrayEntriesWhenNullable() { var query = - $"{nameof(ValidArrayTypes.NullableBoolVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDateTimeVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDecimalVals)}=&" + - $"{nameof(ValidArrayTypes.NullableDoubleVals)}=&" + - $"{nameof(ValidArrayTypes.NullableFloatVals)}=&" + - $"{nameof(ValidArrayTypes.NullableGuidVals)}=&" + - $"{nameof(ValidArrayTypes.NullableIntVals)}=&" + - $"{nameof(ValidArrayTypes.NullableLongVals)}=&"; - Assert.Collection(GetSuppliedParameters(query).Where(pair => pair.key.StartsWith("Nullable", StringComparison.Ordinal)), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableBoolVals), new bool?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDateTimeVals), new DateTime?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDecimalVals), new decimal?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableDoubleVals), new double?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableFloatVals), new float?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableGuidVals), new Guid?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableIntVals), new int?[] { null }), - AssertKeyValuePair(nameof(ValidArrayTypes.NullableLongVals), new long?[] { null })); - } - - private class SpecialQueryParameterName : ComponentBase - { - public const string NameThatLooksEncoded = "name+that+looks+%5Bencoded%5D"; - [Parameter, SupplyParameterFromQuery(Name = NameThatLooksEncoded)] public string Key { get; set; } + $"NullableBoolVals=&" + + $"NullableDateTimeVals=&" + + $"NullableDecimalVals=&" + + $"NullableDoubleVals=&" + + $"NullableFloatVals=&" + + $"NullableGuidVals=&" + + $"NullableIntVals=&" + + $"NullableLongVals=&"; + + ReadQuery(query); + + AssertKeyValuePair("NullableBoolVals", new bool?[] { null }); + AssertKeyValuePair("NullableDateTimeVals", new DateTime?[] { null }); + AssertKeyValuePair("NullableDecimalVals", new decimal?[] { null }); + AssertKeyValuePair("NullableDoubleVals", new double?[] { null }); + AssertKeyValuePair("NullableFloatVals", new float?[] { null }); + AssertKeyValuePair("NullableGuidVals", new Guid?[] { null }); + AssertKeyValuePair("NullableIntVals", new int?[] { null }); + AssertKeyValuePair("NullableLongVals", new long?[] { null }); } [Fact] public void DecodesKeysAndValues() { - var encodedName = Uri.EscapeDataString(SpecialQueryParameterName.NameThatLooksEncoded); + var nameThatLooksEncoded = "name+that+looks+%5Bencoded%5D"; + var encodedName = Uri.EscapeDataString(nameThatLooksEncoded); var query = $"?{encodedName}=Some+%5Bencoded%5D+value"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(SpecialQueryParameterName.Key), "Some [encoded] value")); - } - private class KeyCaseMatching : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int KeyOne { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "keytwo")] public int KeyTwo { get; set; } + ReadQuery(query); + + AssertKeyValuePair(nameThatLooksEncoded, "Some [encoded] value"); } [Fact] public void MatchesKeysCaseInsensitively() { - var query = $"?KEYONE=1&KEYTWO=2"; - Assert.Collection(GetSuppliedParameters(query), - AssertKeyValuePair(nameof(KeyCaseMatching.KeyOne), 1), - AssertKeyValuePair(nameof(KeyCaseMatching.KeyTwo), 2)); - } + ReadQuery($"?KEYONE=1&KEYTWO=2"); - private class KeysWithNonAsciiChars : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public string Имя_моей_собственности { get; set; } - [Parameter, SupplyParameterFromQuery(Name = "خاصية_أخرى")] public string AnotherProperty { get; set; } + AssertKeyValuePair("KeyOne", 1); + AssertKeyValuePair("KeyTwo", 2); } [Fact] public void MatchesKeysWithNonAsciiChars() { - var query = $"?{nameof(KeysWithNonAsciiChars.Имя_моей_собственности)}=first&خاصية_أخرى=second"; - var result = GetSuppliedParameters(query); - Assert.Collection(result, - AssertKeyValuePair(nameof(KeysWithNonAsciiChars.AnotherProperty), "second"), - AssertKeyValuePair(nameof(KeysWithNonAsciiChars.Имя_моей_собственности), "first")); - } - - private class SingleValueOverwriting : ComponentBase - { - [Parameter, SupplyParameterFromQuery] public int Age { get; set; } - [Parameter, SupplyParameterFromQuery] public int? Id { get; set; } - [Parameter, SupplyParameterFromQuery] public string Name { get; set; } - } + ReadQuery($"?Имя_моей_собственности=first&خاصية_أخرى=second"); - [Fact] - public void ForNonArrayValuesOnlyOneValueIsSupplied() - { - // For simplicity and speed, the value assignment logic doesn't check if the a single-valued destination is - // already populated, and just overwrites in a left-to-right manner. For nullable values it's possible to - // overwrite a value with null, or a string with empty. - Assert.Collection(GetSuppliedParameters($"?age=123&age=456&age=789&id=1&id&name=Bobbins&name"), - AssertKeyValuePair(nameof(SingleValueOverwriting.Age), 789), - AssertKeyValuePair(nameof(SingleValueOverwriting.Id), (int?)null), - AssertKeyValuePair(nameof(SingleValueOverwriting.Name), string.Empty)); + AssertKeyValuePair("خاصية_أخرى", "second"); + AssertKeyValuePair("Имя_моей_собственности", "first"); } - private static IEnumerable<(string key, object value)> GetSuppliedParameters(string query) where TComponent : IComponent + private void ReadQuery(string query) { - var supplier = QueryParameterValueSupplier.ForType(typeof(TComponent)); - using var builder = new RenderTreeBuilder(); - builder.OpenComponent(0); - supplier.RenderParametersFromQueryString(builder, query.AsMemory()); - builder.CloseComponent(); - - var frames = builder.GetFrames(); - return frames.Array.Take(frames.Count) - .Where(frame => frame.FrameType == RenderTree.RenderTreeFrameType.Attribute) - .Select(frame => (frame.AttributeName, frame.AttributeValue)) - .OrderBy(pair => pair.AttributeName) // The order isn't defined, so use alphabetical for tests - .ToList(); + _supplier.ReadParametersFromQuery(query.AsMemory()); } - private Action<(string key, object value)> AssertKeyValuePair(string expectedKey, T expectedValue) + private void AssertKeyValuePair(string key, object expectedValue) { - return pair => - { - Assert.Equal(expectedKey, pair.key); - if (expectedValue is null) - { - Assert.Null(pair.value); - } - else - { - Assert.IsType(expectedValue); - Assert.Equal(expectedValue, pair.value); - } - }; + var actualValue = _supplier.GetQueryParameterValue(typeof(T), key); + Assert.Equal(expectedValue, actualValue); } } diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 8ef454f5bbf9..2e291faabbbf 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -58,6 +58,8 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection // Form handling services.TryAddScoped(); services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); return new DefaultRazorComponentsBuilder(services); } diff --git a/src/Components/Endpoints/test/RazorComponentsServiceCollectionExtensionsTest.cs b/src/Components/Endpoints/test/RazorComponentsServiceCollectionExtensionsTest.cs index 2d8026719787..3341e31d93e0 100644 --- a/src/Components/Endpoints/test/RazorComponentsServiceCollectionExtensionsTest.cs +++ b/src/Components/Endpoints/test/RazorComponentsServiceCollectionExtensionsTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Binding; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; @@ -83,6 +84,11 @@ private Dictionary MultiRegistrationServiceTypes { return new Dictionary() { + [typeof(CascadingModelBindingProvider)] = new[] + { + typeof(CascadingFormModelBindingProvider), + typeof(CascadingQueryModelBindingProvider), + } }; } } diff --git a/src/Components/Web/src/Binding/CascadingFormModelBindingProvider.cs b/src/Components/Web/src/Binding/CascadingFormModelBindingProvider.cs new file mode 100644 index 000000000000..d2c28112453e --- /dev/null +++ b/src/Components/Web/src/Binding/CascadingFormModelBindingProvider.cs @@ -0,0 +1,64 @@ +// 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.Binding; + +/// +/// Enables component parameters to be supplied from the query string with . +/// +public sealed class CascadingFormModelBindingProvider : CascadingModelBindingProvider +{ + private readonly IFormValueSupplier _formValueSupplier; + + /// + protected internal override bool AreValuesFixed => true; + + /// + /// Constructs a new instance of . + /// + /// The . + public CascadingFormModelBindingProvider(IFormValueSupplier formValueSupplier) + { + _formValueSupplier = formValueSupplier; + } + + /// + protected internal override bool SupportsCascadingParameterAttributeType(Type attributeType) + => attributeType == typeof(SupplyParameterFromFormAttribute); + + /// + protected internal override bool SupportsParameterType(Type type) + => _formValueSupplier.CanConvertSingleValue(type); + + /// + protected internal override bool CanSupplyValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + { + var (formName, valueType) = GetFormNameAndValueType(bindingContext, parameterInfo); + return _formValueSupplier.CanBind(formName!, valueType); + } + + /// + protected internal override object? GetCurrentValue(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + { + var (formName, valueType) = GetFormNameAndValueType(bindingContext, parameterInfo); + + if (!_formValueSupplier.TryBind(formName!, valueType, out var boundValue)) + { + // TODO: Report errors + return null; + } + + return boundValue; + } + + private static (string FormName, Type ValueType) GetFormNameAndValueType(ModelBindingContext? bindingContext, in CascadingParameterInfo parameterInfo) + { + var valueType = parameterInfo.PropertyType; + var valueName = parameterInfo.Attribute.Name; + var formName = string.IsNullOrEmpty(valueName) ? + (bindingContext?.Name) : + ModelBindingContext.Combine(bindingContext, valueName); + + return (formName!, valueType); + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 780363a5e104..cb3b73f4a142 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -2,6 +2,8 @@ *REMOVED*override Microsoft.AspNetCore.Components.Forms.InputFile.OnInitialized() -> void abstract Microsoft.AspNetCore.Components.Forms.FormDataProvider.Entries.get -> System.Collections.Generic.IReadOnlyDictionary! abstract Microsoft.AspNetCore.Components.Forms.FormDataProvider.Name.get -> string? +Microsoft.AspNetCore.Components.Binding.CascadingFormModelBindingProvider +Microsoft.AspNetCore.Components.Binding.CascadingFormModelBindingProvider.CascadingFormModelBindingProvider(Microsoft.AspNetCore.Components.Binding.IFormValueSupplier! formValueSupplier) -> void Microsoft.AspNetCore.Components.Forms.FormDataProvider Microsoft.AspNetCore.Components.Forms.FormDataProvider.FormDataProvider() -> void Microsoft.AspNetCore.Components.Forms.FormDataProvider.IsFormDataAvailable.get -> bool @@ -18,8 +20,6 @@ Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer. Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.StaticHtmlRenderer(System.IServiceProvider! serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void Microsoft.AspNetCore.Components.RenderTree.WebRenderer.WaitUntilAttachedAsync() -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute -Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.get -> string? -Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.set -> void Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.SupplyParameterFromFormAttribute() -> void Microsoft.AspNetCore.Components.Web.AutoRenderMode Microsoft.AspNetCore.Components.Web.AutoRenderMode.AutoRenderMode() -> void @@ -65,6 +65,8 @@ Microsoft.AspNetCore.Components.Web.WebAssemblyRenderMode.WebAssemblyRenderMode( override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.Dispatcher.get -> Microsoft.AspNetCore.Components.Dispatcher! override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.HandleException(System.Exception! exception) -> void override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.UpdateDisplayAsync(in Microsoft.AspNetCore.Components.RenderTree.RenderBatch renderBatch) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.get -> string? +override Microsoft.AspNetCore.Components.SupplyParameterFromFormAttribute.Name.set -> void override Microsoft.AspNetCore.Components.Web.RenderModeAutoAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode! override Microsoft.AspNetCore.Components.Web.RenderModeServerAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode! override Microsoft.AspNetCore.Components.Web.RenderModeWebAssemblyAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode! diff --git a/src/Components/Web/src/SupplyParameterFromFormAttribute.cs b/src/Components/Web/src/SupplyParameterFromFormAttribute.cs index 2ae657c95583..be92c35bc25a 100644 --- a/src/Components/Web/src/SupplyParameterFromFormAttribute.cs +++ b/src/Components/Web/src/SupplyParameterFromFormAttribute.cs @@ -8,11 +8,11 @@ namespace Microsoft.AspNetCore.Components; /// the form data for the form with the specified name. /// [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public sealed class SupplyParameterFromFormAttribute : Attribute, IHostEnvironmentCascadingParameter +public sealed class SupplyParameterFromFormAttribute : CascadingParameterAttributeBase { /// /// Gets or sets the name for the parameter. The name is used to match /// the form data and decide whether or not the value needs to be bound. /// - public string? Name { get; set; } + public override string? Name { get; set; } } diff --git a/src/Components/Web/test/Forms/EditFormTest.cs b/src/Components/Web/test/Forms/EditFormTest.cs index 59f449e95a21..46f980eeaf7a 100644 --- a/src/Components/Web/test/Forms/EditFormTest.cs +++ b/src/Components/Web/test/Forms/EditFormTest.cs @@ -20,6 +20,8 @@ public EditFormTest() var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); _testRenderer = new(services.BuildServiceProvider()); } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 3345d113eb4b..93b2b7e25e50 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -263,5 +263,6 @@ internal void InitializeDefaultServices() }); Services.AddSingleton(); Services.AddSingleton(); + Services.AddSingleton(); } } diff --git a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs index e522547c6971..497a23cf0d2f 100644 --- a/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs +++ b/src/Components/WebView/WebView/src/ComponentsWebViewServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static IServiceCollection AddBlazorWebView(this IServiceCollection servic services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); return services; } diff --git a/src/Components/test/E2ETest/Tests/RoutingTest.cs b/src/Components/test/E2ETest/Tests/RoutingTest.cs index 85b074fa9dbf..228b4382476b 100644 --- a/src/Components/test/E2ETest/Tests/RoutingTest.cs +++ b/src/Components/test/E2ETest/Tests/RoutingTest.cs @@ -1451,11 +1451,13 @@ public void CanArriveAtQueryStringPageWithNoQuery() var app = Browser.MountTestComponent(); Assert.Equal("Hello Abc .", app.FindElement(By.Id("test-info")).Text); Assert.Equal("0", app.FindElement(By.Id("value-QueryInt")).Text); + Assert.Equal("0", app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); AssertHighlightedLinks("With query parameters (none)"); } @@ -1468,11 +1470,13 @@ public void CanArriveAtQueryStringPageWithStringQuery() var app = Browser.MountTestComponent(); Assert.Equal("Hello Abc .", app.FindElement(By.Id("test-info")).Text); Assert.Equal("0", app.FindElement(By.Id("value-QueryInt")).Text); + Assert.Equal("0", app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal("Hello there", app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); AssertHighlightedLinks("With query parameters (none)", "With query parameters (passing string value)"); } @@ -1488,11 +1492,13 @@ public void CanArriveAtQueryStringPageWithDateTimeQuery() var app = Browser.MountTestComponent(); Assert.Equal("Hello Abc .", app.FindElement(By.Id("test-info")).Text); Assert.Equal("0", app.FindElement(By.Id("value-QueryInt")).Text); + Assert.Equal("0", app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(dateTime.ToString("hh:mm:ss on yyyy-MM-dd", CultureInfo.InvariantCulture), app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(dateOnly.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(timeOnly.ToString("hh:mm:ss", CultureInfo.InvariantCulture), app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); AssertHighlightedLinks("With query parameters (none)", "With query parameters (passing Date Time values)"); } @@ -1507,11 +1513,13 @@ public void CanNavigateToQueryStringPageWithNoQuery() Assert.Equal("Hello Abc .", app.FindElement(By.Id("test-info")).Text); Assert.Equal("0", app.FindElement(By.Id("value-QueryInt")).Text); + Assert.Equal("0", app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); AssertHighlightedLinks("With query parameters (none)"); } @@ -1527,11 +1535,13 @@ public void CanNavigateBetweenPagesWithQueryStrings() Browser.Equal("Hello Abc .", () => app.FindElement(By.Id("test-info")).Text); Assert.Equal("0", app.FindElement(By.Id("value-QueryInt")).Text); + Assert.Equal("0", app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal("Hello there", app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); var instanceId = app.FindElement(By.Id("instance-id")).Text; Assert.True(!string.IsNullOrWhiteSpace(instanceId)); @@ -1540,22 +1550,26 @@ public void CanNavigateBetweenPagesWithQueryStrings() // We can also navigate to a different query while retaining the same component instance app.FindElement(By.LinkText("With IntValue and LongValues")).Click(); Browser.Equal("123", () => app.FindElement(By.Id("value-QueryInt")).Text); + Browser.Equal("123", () => app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("3 values (50, 100, -20)", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("3 values (50, 100, -20)", app.FindElement(By.Id("value-nested-LongValues")).Text); Assert.Equal(instanceId, app.FindElement(By.Id("instance-id")).Text); AssertHighlightedLinks("With query parameters (none)"); // We can also click back to go the preceding query while retaining the same component instance Browser.Navigate().Back(); Browser.Equal("0", () => app.FindElement(By.Id("value-QueryInt")).Text); + Browser.Equal("0", () => app.FindElement(By.Id("value-nested-QueryInt")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateTimeValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableDateOnlyValue")).Text); Assert.Equal(string.Empty, app.FindElement(By.Id("value-NullableTimeOnlyValue")).Text); Assert.Equal("Hello there", app.FindElement(By.Id("value-StringValue")).Text); Assert.Equal("0 values ()", app.FindElement(By.Id("value-LongValues")).Text); + Assert.Equal("0 values ()", app.FindElement(By.Id("value-nested-LongValues")).Text); Assert.Equal(instanceId, app.FindElement(By.Id("instance-id")).Text); AssertHighlightedLinks("With query parameters (none)", "With query parameters (passing string value)"); } diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/NestedQueryParameters.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/NestedQueryParameters.razor new file mode 100644 index 000000000000..475642b1bbd0 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/NestedQueryParameters.razor @@ -0,0 +1,12 @@ +
+ Nested query parameters: +

Nested IntValue: @IntValue

+

Nested LongValues: @LongValues.Length values (@string.Join(", ", LongValues.Select(x => x.ToString()).ToArray()))

+
+ +@code +{ + [SupplyParameterFromQuery] public int IntValue { get ; set; } + + [SupplyParameterFromQuery(Name = "l")] public long[] LongValues { get ; set; } +} diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/WithQueryParameters.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/WithQueryParameters.razor index 46a19b816bcc..14631cfe8fd0 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/WithQueryParameters.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/WithQueryParameters.razor @@ -7,6 +7,8 @@

StringValue: @StringValue

LongValues: @LongValues.Length values (@string.Join(", ", LongValues.Select(x => x.ToString()).ToArray()))

+ +

Instance ID: @instanceId

@@ -23,15 +25,15 @@ [Parameter] public string OptionalLastName { get ; set; } - [Parameter, SupplyParameterFromQuery] public int IntValue { get ; set; } + [SupplyParameterFromQuery] public int IntValue { get ; set; } - [Parameter, SupplyParameterFromQuery] public DateTime? NullableDateTimeValue { get ; set; } + [SupplyParameterFromQuery] public DateTime? NullableDateTimeValue { get ; set; } - [Parameter, SupplyParameterFromQuery] public DateOnly? NullableDateOnlyValue { get ; set; } + [SupplyParameterFromQuery] public DateOnly? NullableDateOnlyValue { get ; set; } - [Parameter, SupplyParameterFromQuery] public TimeOnly? NullableTimeOnlyValue { get ; set; } + [SupplyParameterFromQuery] public TimeOnly? NullableTimeOnlyValue { get ; set; } - [Parameter, SupplyParameterFromQuery] public string StringValue { get ; set; } + [SupplyParameterFromQuery] public string StringValue { get ; set; } - [Parameter, SupplyParameterFromQuery(Name = "l")] public long[] LongValues { get ; set; } + [SupplyParameterFromQuery(Name = "l")] public long[] LongValues { get ; set; } }