File: ExpressionHelper.cs
Web Access
Project: src\src\Mvc\Mvc.ViewFeatures\src\Microsoft.AspNetCore.Mvc.ViewFeatures.csproj (Microsoft.AspNetCore.Mvc.ViewFeatures)
// 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;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
 
namespace Microsoft.AspNetCore.Mvc.ViewFeatures;
 
internal static class ExpressionHelper
{
    public static string GetUncachedExpressionText(LambdaExpression expression)
        => GetExpressionText(expression, expressionTextCache: null);
 
    public static string GetExpressionText(LambdaExpression expression, ConcurrentDictionary<LambdaExpression, string> expressionTextCache)
    {
        ArgumentNullException.ThrowIfNull(expression);
 
        if (expressionTextCache != null &&
            expressionTextCache.TryGetValue(expression, out var expressionText))
        {
            return expressionText;
        }
 
        // Determine size of string needed (length) and number of segments it contains (segmentCount). Put another
        // way, segmentCount tracks the number of times the loop below should iterate. This avoids adding ".model"
        // and / or an extra leading "." and then removing them after the loop. Other information collected in this
        // first loop helps with length and segmentCount adjustments. doNotCache is somewhat separate: If
        // true, expression strings are not cached for the expression.
        //
        // After the corrections below the first loop, length is usually exactly the size of the returned string.
        // However when containsIndexers is true, the calculation is approximate because either evaluating indexer
        // expressions multiple times or saving indexer strings can get expensive. Optimizing for the common case
        // of a collection (not a dictionary) with less than 100 elements. If that assumption proves to be
        // incorrect, the StringBuilder will be enlarged but hopefully just once.
        var doNotCache = false;
        var lastIsModel = false;
        var length = 0;
        var segmentCount = 0;
        var trailingMemberExpressions = 0;
 
        var part = expression.Body;
        while (part != null)
        {
            switch (part.NodeType)
            {
                case ExpressionType.Call:
                    // Will exit loop if at Method().Property or [i,j].Property. In that case (like [i].Property),
                    // don't cache and don't remove ".Model" (if that's .Property).
                    doNotCache = true;
                    lastIsModel = false;
 
                    var methodExpression = (MethodCallExpression)part;
                    if (IsSingleArgumentIndexer(methodExpression))
                    {
                        length += "[99]".Length;
                        part = methodExpression.Object;
                        segmentCount++;
                        trailingMemberExpressions = 0;
                    }
                    else
                    {
                        // Unsupported.
                        part = null;
                    }
                    break;
 
                case ExpressionType.ArrayIndex:
                    var binaryExpression = (BinaryExpression)part;
 
                    doNotCache = true;
                    lastIsModel = false;
                    length += "[99]".Length;
                    part = binaryExpression.Left;
                    segmentCount++;
                    trailingMemberExpressions = 0;
                    break;
 
                case ExpressionType.MemberAccess:
                    var memberExpressionPart = (MemberExpression)part;
                    var name = memberExpressionPart.Member.Name;
 
                    // If identifier contains "__", it is "reserved for use by the implementation" and likely
                    // compiler- or Razor-generated e.g. the name of a field in a delegate's generated class.
                    if (name.Contains("__"))
                    {
                        // Exit loop.
                        part = null;
                    }
                    else
                    {
                        lastIsModel = string.Equals("model", name, StringComparison.OrdinalIgnoreCase);
                        length += name.Length + 1;
                        part = memberExpressionPart.Expression;
                        segmentCount++;
                        trailingMemberExpressions++;
                    }
                    break;
 
                case ExpressionType.Parameter:
                    // Unsupported but indicates previous member access was not the view's Model.
                    lastIsModel = false;
                    part = null;
                    break;
 
                default:
                    // Unsupported.
                    part = null;
                    break;
            }
        }
 
        // If name would start with ".model", then strip that part away.
        if (lastIsModel)
        {
            length -= ".model".Length;
            segmentCount--;
            trailingMemberExpressions--;
        }
 
        // Trim the leading "." if present. The loop below special-cases the last property to avoid this addition.
        if (trailingMemberExpressions > 0)
        {
            length--;
        }
 
        Debug.Assert(segmentCount >= 0);
        if (segmentCount == 0)
        {
            Debug.Assert(!doNotCache);
            expressionTextCache?.TryAdd(expression, string.Empty);
 
            return string.Empty;
        }
 
        var builder = new StringBuilder(length);
        part = expression.Body;
        while (part != null && segmentCount > 0)
        {
            segmentCount--;
            switch (part.NodeType)
            {
                case ExpressionType.Call:
                    Debug.Assert(doNotCache);
                    var methodExpression = (MethodCallExpression)part;
 
                    InsertIndexerInvocationText(builder, methodExpression.Arguments.Single(), expression);
 
                    part = methodExpression.Object;
                    break;
 
                case ExpressionType.ArrayIndex:
                    Debug.Assert(doNotCache);
                    var binaryExpression = (BinaryExpression)part;
 
                    InsertIndexerInvocationText(builder, binaryExpression.Right, expression);
 
                    part = binaryExpression.Left;
                    break;
 
                case ExpressionType.MemberAccess:
                    var memberExpression = (MemberExpression)part;
                    var name = memberExpression.Member.Name;
                    Debug.Assert(!name.Contains("__"));
 
                    builder.Insert(0, name);
                    if (segmentCount > 0)
                    {
                        // One or more parts to the left of this part are coming.
                        builder.Insert(0, '.');
                    }
 
                    part = memberExpression.Expression;
                    break;
 
                default:
                    // Should be unreachable due to handling in above loop.
                    Debug.Assert(false);
                    break;
            }
        }
 
        Debug.Assert(segmentCount == 0);
        expressionText = builder.ToString();
        if (expressionTextCache != null && !doNotCache)
        {
            expressionTextCache.TryAdd(expression, expressionText);
        }
 
        return expressionText;
    }
 
    private static void InsertIndexerInvocationText(
        StringBuilder builder,
        Expression indexExpression,
        LambdaExpression parentExpression)
    {
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentNullException.ThrowIfNull(indexExpression);
        ArgumentNullException.ThrowIfNull(parentExpression);
 
        if (parentExpression.Parameters == null)
        {
            throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull(
                nameof(parentExpression.Parameters),
                nameof(parentExpression)));
        }
 
        var converted = Expression.Convert(indexExpression, typeof(object));
        var fakeParameter = Expression.Parameter(typeof(object), null);
        var lambda = Expression.Lambda<Func<object, object>>(converted, fakeParameter);
        Func<object, object> func;
 
        try
        {
            func = CachedExpressionCompiler.Process(lambda) ?? lambda.Compile();
        }
        catch (InvalidOperationException ex)
        {
            var parameters = parentExpression.Parameters.ToArray();
            throw new InvalidOperationException(
                Resources.FormatExpressionHelper_InvalidIndexerExpression(indexExpression, parameters[0].Name),
                ex);
        }
 
        builder.Insert(0, ']');
        builder.Insert(0, Convert.ToString(func(null), CultureInfo.InvariantCulture));
        builder.Insert(0, '[');
    }
 
    public static bool IsSingleArgumentIndexer(Expression expression)
    {
        if (!(expression is MethodCallExpression methodExpression) || methodExpression.Arguments.Count != 1)
        {
            return false;
        }
 
        // Check whether GetDefaultMembers() (if present in CoreCLR) would return a member of this type. Compiler
        // names the indexer property, if any, in a generated [DefaultMember] attribute for the containing type.
        var declaringType = methodExpression.Method.DeclaringType;
        var defaultMember = declaringType.GetCustomAttribute<DefaultMemberAttribute>(inherit: true);
        if (defaultMember == null)
        {
            return false;
        }
 
        // Find default property (the indexer) and confirm its getter is the method in this expression.
        var runtimeProperties = declaringType.GetRuntimeProperties();
        foreach (var property in runtimeProperties)
        {
            if ((string.Equals(defaultMember.MemberName, property.Name, StringComparison.Ordinal) &&
                property.GetMethod == methodExpression.Method))
            {
                return true;
            }
        }
 
        return false;
    }
}