File: FieldIdentifier.cs
Web Access
Project: src\src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj (Microsoft.AspNetCore.Components.Forms)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Components.HotReload;
 
namespace Microsoft.AspNetCore.Components.Forms;
 
/// <summary>
/// Uniquely identifies a single field that can be edited. This may correspond to a property on a
/// model object, or can be any other named value.
/// </summary>
public readonly struct FieldIdentifier : IEquatable<FieldIdentifier>
{
    private static readonly ConcurrentDictionary<(Type ModelType, MemberInfo Member), Func<object, object>> _fieldAccessors = new();
 
    static FieldIdentifier()
    {
        HotReloadManager.Default.OnDeltaApplied += ClearCache;
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
    /// </summary>
    /// <param name="accessor">An expression that identifies an object member.</param>
    /// <typeparam name="TField">The field <see cref="Type"/>.</typeparam>
    public static FieldIdentifier Create<TField>(Expression<Func<TField>> accessor)
    {
        ArgumentNullException.ThrowIfNull(accessor);
 
        ParseAccessor(accessor, out var model, out var fieldName);
        return new FieldIdentifier(model, fieldName);
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="FieldIdentifier"/> structure.
    /// </summary>
    /// <param name="model">The object that owns the field.</param>
    /// <param name="fieldName">The name of the editable field.</param>
    public FieldIdentifier(object model, string fieldName)
    {
        ArgumentNullException.ThrowIfNull(model);
 
        if (model.GetType().IsValueType)
        {
            throw new ArgumentException("The model must be a reference-typed object.", nameof(model));
        }
 
        Model = model;
 
        // Note that we do allow an empty string. This is used by some validation systems
        // as a place to store object-level (not per-property) messages.
        FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
    }
 
    /// <summary>
    /// Gets the object that owns the editable field.
    /// </summary>
    public object Model { get; }
 
    /// <summary>
    /// Gets the name of the editable field.
    /// </summary>
    public string FieldName { get; }
 
    /// <inheritdoc />
    public override int GetHashCode()
    {
        // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
        var modelHash = RuntimeHelpers.GetHashCode(Model);
        var fieldHash = StringComparer.Ordinal.GetHashCode(FieldName);
        return (
            modelHash,
            fieldHash
        )
        .GetHashCode();
    }
 
    /// <inheritdoc />
    public override bool Equals(object? obj)
        => obj is FieldIdentifier otherIdentifier
        && Equals(otherIdentifier);
 
    /// <inheritdoc />
    public bool Equals(FieldIdentifier otherIdentifier)
    {
        return ReferenceEquals(otherIdentifier.Model, Model) &&
            string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
    }
 
    private static void ParseAccessor<T>(Expression<Func<T>> accessor, out object model, out string fieldName)
    {
        var accessorBody = accessor.Body;
 
        // Unwrap casts to object
        if (accessorBody is UnaryExpression unaryExpression
            && unaryExpression.NodeType == ExpressionType.Convert
            && unaryExpression.Type == typeof(object))
        {
            accessorBody = unaryExpression.Operand;
        }
 
        switch (accessorBody)
        {
            case MemberExpression memberExpression:
                // Identify the field name. We don't mind whether it's a property or field, or even something else.
                fieldName = memberExpression.Member.Name;
                // Get a reference to the model object
                // i.e., given a value like "(something).MemberName", determine the runtime value of "(something)",
                switch (memberExpression.Expression)
                {
                    case ConstantExpression constant when constant.Value == null:
                        throw new ArgumentException("The provided expression must evaluate to a non-null value.");
                    case ConstantExpression constant when constant.Value != null:
                        model = constant.Value;
                        break;
                    case MemberExpression member when member.Expression is ConstantExpression:
                        model = GetModelFromMemberAccess(member);
                        break;
                    case not null:
                        // It would be great to cache this somehow, but it's unclear there's a reasonable way to do
                        // so, given that it embeds captured values such as "this". We could consider special-casing
                        // for "() => something.Member" and building a cache keyed by "something.GetType()" with values
                        // of type Func<object, object> so we can cheaply map from "something" to "something.Member".
                        var modelLambda = Expression.Lambda(memberExpression.Expression);
                        var modelLambdaCompiled = (Func<object?>)modelLambda.Compile();
                        var result = modelLambdaCompiled() ??
                            throw new ArgumentException("The provided expression must evaluate to a non-null value.");
 
                        model = result;
                        break;
                    default:
                        throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
                }
                break;
            case MethodCallExpression methodCallExpression when ExpressionFormatter.IsSingleArgumentIndexer(accessorBody):
                fieldName = ExpressionFormatter.FormatIndexArgument(methodCallExpression.Arguments[0]);
                model = GetModelFromIndexer(methodCallExpression.Object!);
                break;
            case BinaryExpression binaryExpression when binaryExpression.NodeType == ExpressionType.ArrayIndex:
                fieldName = ExpressionFormatter.FormatIndexArgument(binaryExpression.Right);
                model = GetModelFromIndexer(binaryExpression.Left);
                break;
            default:
                throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
        }
    }
 
    internal static object GetModelFromMemberAccess(
        MemberExpression member,
        ConcurrentDictionary<(Type ModelType, MemberInfo Member), Func<object, object>>? cache = null)
    {
        cache ??= _fieldAccessors;
        Func<object, object>? accessor = null;
        object? value = null;
        switch (member.Expression)
        {
            case ConstantExpression model:
                value = model.Value ?? throw new ArgumentException("The provided expression must evaluate to a non-null value.");
                accessor = cache.GetOrAdd((value.GetType(), member.Member), CreateAccessor);
                break;
            default:
                break;
        }
 
        if (accessor == null)
        {
            throw new InvalidOperationException($"Unable to compile expression: {member}");
        }
 
        if (value == null)
        {
            throw new ArgumentException("The provided expression must evaluate to a non-null value.");
        }
 
        var result = accessor(value);
        return result;
 
        [UnconditionalSuppressMessage(
            "Trimming",
            "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code",
            Justification = "Application code does not get trimmed. We expect the members in the expression to not be trimmed.")]
        static Func<object, object> CreateAccessor((Type model, MemberInfo member) arg)
        {
            var parameter = Expression.Parameter(typeof(object), "value");
            Expression expression = Expression.Convert(parameter, arg.model);
 
            expression = Expression.MakeMemberAccess(expression, arg.member);
            expression = Expression.Convert(expression, typeof(object));
            var lambda = Expression.Lambda<Func<object, object>>(expression, parameter);
 
            var func = lambda.Compile();
            return func;
        }
    }
 
    private static object GetModelFromIndexer(Expression methodCallExpression)
    {
        object model;
        var methodCallObjectLambda = Expression.Lambda(methodCallExpression!);
        var methodCallObjectLambdaCompiled = (Func<object?>)methodCallObjectLambda.Compile();
        var result = methodCallObjectLambdaCompiled();
        if (result is null)
        {
            throw new ArgumentException("The provided expression must evaluate to a non-null value.");
        }
        model = result;
        return model;
    }
 
    private static void ClearCache()
    {
        _fieldAccessors.Clear();
    }
}