File: ExpressionHelperTest.cs
Web Access
Project: src\src\Mvc\Mvc.ViewFeatures\test\Microsoft.AspNetCore.Mvc.ViewFeatures.Test.csproj (Microsoft.AspNetCore.Mvc.ViewFeatures.Test)
// 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.Linq.Expressions;
 
namespace Microsoft.AspNetCore.Mvc.ViewFeatures;
 
public class ExpressionHelperTest
{
    private readonly ConcurrentDictionary<LambdaExpression, string> _expressionTextCache = new ConcurrentDictionary<LambdaExpression, string>(LambdaExpressionComparer.Instance);
 
    public static TheoryData<Expression, string> ExpressionAndTexts
    {
        get
        {
            var i = 3;
            var value = "Test";
            var key = "TestModel";
            var myModels = new List<TestModel>();
            var models = new List<TestModel>();
            var modelTest = new TestModel();
            var modelType = typeof(TestModel);
 
            var data = new TheoryData<Expression, string>
                {
                    {
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory),
                        "SelectedCategory"
                    },
                    {
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName),
                        "SelectedCategory.CategoryName"
                    },
                    {
                        (Expression<Func<TestModel, int>>)(testModel => testModel.SelectedCategory.CategoryId),
                        "SelectedCategory.CategoryId"
                    },
                    {
                        (Expression<Func<LowerModel, int>>)(testModel => testModel.selectedcategory.CategoryId),
                        "selectedcategory.CategoryId"
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => model.SelectedCategory.CategoryName.MainCategory),
                        "SelectedCategory.CategoryName.MainCategory"
                    },
                    {
                        (Expression<Func<TestModel, TestModel>>)(model => model),
                        string.Empty
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => value),
                        "value"
                    },
                    {
                        (Expression<Func<TestModel, int>>)(model => models[0].SelectedCategory.CategoryId),
                        "models[0].SelectedCategory.CategoryId"
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => modelTest.Name),
                        "modelTest.Name"
                    },
                    {
                        (Expression<Func<TestModel, Type>>)(model => modelType),
                        "modelType"
                    },
                    {
                        (Expression<Func<IList<TestModel>, Category>>)(model => model[2].SelectedCategory),
                        "[2].SelectedCategory"
                    },
                    {
                        (Expression<Func<IList<TestModel>, Category>>)(model => model[i].SelectedCategory),
                        "[3].SelectedCategory"
                    },
                    {
                        (Expression<Func<IList<LowerModel>, Category>>)(model => model[i].selectedcategory),
                        "[3].selectedcategory"
                    },
                    {
                        (Expression<Func<IDictionary<string, TestModel>, string>>)(model => model[key].SelectedCategory.CategoryName.MainCategory),
                        "[TestModel].SelectedCategory.CategoryName.MainCategory"
                    },
                    {
                        (Expression<Func<TestModel, int>>)(model => model.PreferredCategories[i].CategoryId),
                        "PreferredCategories[3].CategoryId"
                    },
                    {
                        (Expression<Func<IList<TestModel>, Category>>)(model => myModels[i].SelectedCategory),
                        "myModels[3].SelectedCategory"
                    },
                    {
                        (Expression<Func<IList<TestModel>, int>>)(model => model[2].PreferredCategories[i].CategoryId),
                        "[2].PreferredCategories[3].CategoryId"
                    },
                    {
                        (Expression<Func<IList<LowerModel>, int>>)(model => model[2].preferredcategories[i].CategoryId),
                        "[2].preferredcategories[3].CategoryId"
                    },
                    {
                        (Expression<Func<IList<TestModel>, string>>)(model => model.FirstOrDefault().Name),
                        "Name"
                    },
                    {
                        (Expression<Func<IList<LowerModel>, string>>)(model => model.FirstOrDefault().name),
                        "name"
                    },
                    {
                        (Expression<Func<IList<TestModel>, string>>)(model => model.FirstOrDefault().Model),
                        "Model"
                    },
                    {
                        (Expression<Func<IList<TestModel>, int>>)(model => model.FirstOrDefault().SelectedCategory.CategoryId),
                        "SelectedCategory.CategoryId"
                    },
                    {
                        (Expression<Func<IList<TestModel>, string>>)(model => model.FirstOrDefault().SelectedCategory.CategoryName.MainCategory),
                        "SelectedCategory.CategoryName.MainCategory"
                    },
                    {
                        (Expression<Func<IList<TestModel>, int>>)(model => model.FirstOrDefault().PreferredCategories.Count),
                        "PreferredCategories.Count"
                    },
                    {
                        (Expression<Func<IList<TestModel>, int>>)(model => model.FirstOrDefault().PreferredCategories.FirstOrDefault().CategoryId),
                        "CategoryId"
                    },
                    // Constants are not supported.
                    {
                        // Namespace never appears in expression name. "Model" there doesn't matter.
                        (Expression<Func<TestModel, int>>)(m => Microsoft.AspNetCore.Mvc.ViewFeatures.Model.Constants.WoodstockYear),
                        string.Empty
                    },
                    {
                        // Class name never appears in expression name. "Model" there doesn't matter.
                        (Expression<Func<TestModel, int>>)(m => Model.Constants.WoodstockYear),
                        string.Empty
                    },
                    // ExpressionHelper treats static properties like other member accesses. Similarly to
                    // RazorPage.Model, name "Model" is ignored at LHS of these expressions. This is a rare case because
                    // static properties are the only leftmost member accesses that can reach beyond the current class.
                    {
                        (Expression<Func<TestModel, string>>)(m => Model.Constants.Model.Name),
                        "Name"
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => AStaticClass.Model),
                        string.Empty
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => AStaticClass.Test),
                        "Test"
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => AnotherStaticClass.Model.Name),
                        "Name"
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => AnotherStaticClass.Test.Name),
                        "Test.Name"
                    },
                };
 
            {
                // Nearly impossible in a .cshtml file because model is a keyword.
                var model = "Some string";
                data.Add((Expression<Func<TestModel, string>>)(m => model), string.Empty);
            }
 
            {
                // Model property in RazorPage is "special" (in a good way).
                var Model = new TestModel();
                data.Add((Expression<Func<TestModel, TestModel>>)(m => Model), string.Empty);
                data.Add((Expression<Func<TestModel, TestModel>>)(model => Model), string.Empty);
                data.Add((Expression<Func<TestModel, Category>>)(m => Model.SelectedCategory), "SelectedCategory");
            }
 
            return data;
        }
    }
 
    public static TheoryData<Expression> CachedExpressions
    {
        get
        {
            var key = "TestModel";
            var myModel = new TestModel();
 
            return new TheoryData<Expression>
                {
                    (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory),
                    (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName),
                    (Expression<Func<TestModel, int>>)(testModel => testModel.SelectedCategory.CategoryId),
                    (Expression<Func<TestModel, string>>)(model => model.SelectedCategory.CategoryName.MainCategory),
                    (Expression<Func<TestModel, string>>)(testModel => key),
                    (Expression<Func<TestModel, TestModel>>)(m => m),
                    (Expression<Func<TestModel, Category>>)(m => myModel.SelectedCategory),
                };
        }
    }
 
    public static TheoryData<Expression> IndexerExpressions
    {
        get
        {
            var i = 3;
            var key = "TestModel";
            var myModels = new List<TestModel>();
 
            return new TheoryData<Expression>
                {
                    (Expression<Func<IList<TestModel>, Category>>)(model => model[2].SelectedCategory),
                    (Expression<Func<IList<TestModel>, Category>>)(model => myModels[i].SelectedCategory),
                    (Expression<Func<IList<TestModel>, CategoryName>>)(testModel => testModel[i].SelectedCategory.CategoryName),
                    (Expression<Func<TestModel, int>>)(model => model.PreferredCategories[i].CategoryId),
                    (Expression<Func<IDictionary<string, TestModel>, string>>)(model => model[key].SelectedCategory.CategoryName.MainCategory),
                };
        }
    }
 
    public static TheoryData<Expression> UnsupportedExpressions
    {
        get
        {
            var i = 2;
            var j = 3;
 
            return new TheoryData<Expression>
                {
                    // Indexers that have multiple arguments.
                    (Expression<Func<TestModel[][], string>>)(model => model[23][3].Name),
                    (Expression<Func<TestModel[][], string>>)(model => model[i][3].Name),
                    (Expression<Func<TestModel[][], string>>)(model => model[23][j].Name),
                    (Expression<Func<TestModel[][], string>>)(model => model[i][j].Name),
                    // Calls that aren't indexers.
                    (Expression<Func<IList<TestModel>, string>>)(model => model.FirstOrDefault().Name),
                    (Expression<Func<IList<TestModel>, string>>)(model => model.FirstOrDefault().SelectedCategory.CategoryName.MainCategory),
                    (Expression<Func<IList<TestModel>, int>>)(model => model.FirstOrDefault().PreferredCategories.FirstOrDefault().CategoryId),
                };
        }
    }
 
    public static TheoryData<Expression, Expression> EquivalentExpressions
    {
        get
        {
            var value = "Test";
            var Model = "Test";
 
            return new TheoryData<Expression, Expression>
                {
                    {
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory),
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory)
                    },
                    {
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName),
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName)
                    },
                    {
                        (Expression<Func<TestModel, int>>)(testModel => testModel.SelectedCategory.CategoryId),
                        (Expression<Func<TestModel, int>>)(testModel => testModel.SelectedCategory.CategoryId)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => model.SelectedCategory.CategoryName.MainCategory),
                        (Expression<Func<TestModel, string>>)(model => model.SelectedCategory.CategoryName.MainCategory)
                    },
                    {
                        (Expression<Func<TestModel, TestModel>>)(model => model),
                        (Expression<Func<TestModel, TestModel>>)(m => m)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => value),
                        (Expression<Func<TestModel, string>>)(m => value)
                    },
                    {
                        // These two expressions are not actually equivalent. However ExpressionHelper returns
                        // string.Empty for these two expressions and hence they are considered as equivalent by the
                        // cache.
                        (Expression<Func<TestModel, string>>)(m => Model),
                        (Expression<Func<TestModel, TestModel>>)(m => m)
                    },
                };
        }
    }
 
    public static TheoryData<Expression, Expression> NonEquivalentExpressions
    {
        get
        {
            var value = "test";
            var key = "TestModel";
            var Model = "Test";
            var myModel = new TestModel();
 
            return new TheoryData<Expression, Expression>
                {
                    {
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory),
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName)
                    },
                    {
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName),
                        (Expression<Func<LowerModel, CategoryName>>)(model => model.selectedcategory.CategoryName)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => model.Model),
                        (Expression<Func<TestModel, string>>)(model => model.Name)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => model.Model),
                        (Expression<Func<LowerModel, string>>)(model => model.model)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(model => model.Name),
                        (Expression<Func<LowerModel, string>>)(model => model.name)
                    },
                    {
                        (Expression<Func<TestModel, CategoryName>>)(model => model.SelectedCategory.CategoryName),
                        (Expression<Func<TestModel, string>>)(model => value)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(testModel => testModel.SelectedCategory.CategoryName.MainCategory),
                        (Expression<Func<TestModel, string>>)(testModel => value)
                    },
                    {
                        (Expression<Func<IList<TestModel>, Category>>)(model => model[2].SelectedCategory),
                        (Expression<Func<TestModel, string>>)(model => model.SelectedCategory.CategoryName.MainCategory)
                    },
                    {
                        (Expression<Func<IList<TestModel>, Category>>)(model => model[2].SelectedCategory),
                        (Expression<Func<IList<LowerModel>, Category>>)(model => model[2].selectedcategory)
                    },
                    {
                        (Expression<Func<TestModel, int>>)(testModel => testModel.SelectedCategory.CategoryId),
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory)
                    },
                    {
                        (Expression<Func<IDictionary<string, TestModel>, string>>)(model => model[key].SelectedCategory.CategoryName.MainCategory),
                        (Expression<Func<TestModel, Category>>)(model => model.SelectedCategory)
                    },
                    {
                        (Expression<Func<IDictionary<string, TestModel>, string>>)(model => model[key].SelectedCategory.CategoryName.MainCategory),
                        (Expression<Func<IDictionary<string, LowerModel>, string>>)(model => model[key].selectedcategory.CategoryName.MainCategory)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => Model),
                        (Expression<Func<TestModel, string>>)(m => m.Model)
                    },
                    {
                        (Expression<Func<TestModel, TestModel>>)(m => m),
                        (Expression<Func<TestModel, string>>)(m => m.Model)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => myModel.Name),
                        (Expression<Func<TestModel, string>>)(m => m.Name)
                    },
                    {
                        (Expression<Func<TestModel, string>>)(m => key),
                        (Expression<Func<TestModel, string>>)(m => value)
                    },
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(ExpressionAndTexts))]
    public void GetExpressionText_ReturnsExpectedExpressionText(LambdaExpression expression, string expressionText)
    {
        // Act
        var text = ExpressionHelper.GetExpressionText(expression, _expressionTextCache);
 
        // Assert
        Assert.Equal(expressionText, text);
    }
 
    [Theory]
    [MemberData(nameof(CachedExpressions))]
    public void GetExpressionText_CachesExpression(LambdaExpression expression)
    {
        // Act - 1
        var text1 = ExpressionHelper.GetExpressionText(expression, _expressionTextCache);
 
        // Act - 2
        var text2 = ExpressionHelper.GetExpressionText(expression, _expressionTextCache);
 
        // Assert
        Assert.Same(text1, text2); // cached
    }
 
    [Theory]
    [MemberData(nameof(IndexerExpressions))]
    [MemberData(nameof(UnsupportedExpressions))]
    public void GetExpressionText_DoesNotCacheIndexerOrUnsupportedExpression(LambdaExpression expression)
    {
        // Act - 1
        var text1 = ExpressionHelper.GetExpressionText(expression, _expressionTextCache);
 
        // Act - 2
        var text2 = ExpressionHelper.GetExpressionText(expression, _expressionTextCache);
 
        // Assert
        Assert.Equal(text1, text2, StringComparer.Ordinal);
        Assert.NotSame(text1, text2); // not cached
    }
 
    [Theory]
    [MemberData(nameof(EquivalentExpressions))]
    public void GetExpressionText_CacheEquivalentExpressions(LambdaExpression expression1, LambdaExpression expression2)
    {
        // Act - 1
        var text1 = ExpressionHelper.GetExpressionText(expression1, _expressionTextCache);
 
        // Act - 2
        var text2 = ExpressionHelper.GetExpressionText(expression2, _expressionTextCache);
 
        // Assert
        Assert.Same(text1, text2); // cached
    }
 
    [Theory]
    [MemberData(nameof(NonEquivalentExpressions))]
    public void GetExpressionText_CheckNonEquivalentExpressions(LambdaExpression expression1, LambdaExpression expression2)
    {
        // Act - 1
        var text1 = ExpressionHelper.GetExpressionText(expression1, _expressionTextCache);
 
        // Act - 2
        var text2 = ExpressionHelper.GetExpressionText(expression2, _expressionTextCache);
 
        // Assert
        Assert.NotEqual(text1, text2, StringComparer.Ordinal);
        Assert.NotSame(text1, text2);
    }
 
    [Fact]
    public void GetExpressionText_WithinALoop_ReturnsExpectedText()
    {
        // Arrange 0
        var collection = new List<TestModel>();
 
        for (var i = 0; i < 2; i++)
        {
            // Arrange i
            var expectedText = $"collection[{i}].SelectedCategory.CategoryId";
 
            // Act i
            var result = ExpressionHelper.GetExpressionText(
                (Expression<Func<List<TestModel>, int>>)(m => collection[i].SelectedCategory.CategoryId),
                _expressionTextCache);
 
            // Assert i
            Assert.Equal(expectedText, result);
        }
    }
 
    private class TestModel
    {
        public string Name { get; set; }
        public string Model { get; set; }
        public Category SelectedCategory { get; set; }
 
        public IList<Category> PreferredCategories { get; set; }
    }
 
    private class LowerModel
    {
        public string name { get; set; }
 
        public string model { get; set; }
 
        public Category selectedcategory { get; set; }
 
        public IList<Category> preferredcategories { get; set; }
    }
 
    private class Category
    {
        public int CategoryId { get; set; }
        public CategoryName CategoryName { get; set; }
    }
 
    private class CategoryName
    {
        public string MainCategory { get; set; }
        public string SubCategory { get; set; }
    }
 
    private static class AStaticClass
    {
        public static string Model { get; set; }
        public static string Test { get; set; }
    }
 
    private static class AnotherStaticClass
    {
        public static Model.Model Model { get; set; }
        public static Model.Model Test { get; set; }
    }
}