Search code examples
c#linqlambdaexpression-treesvisitor-pattern

Using ExpressionVisitor to modify expression for automatic translations


I'm trying to add support for multilingual classification strings in my Entity Framework model. This is what I have:

The entity:

public partial class ServiceState : ITranslatableEntity<ServiceStateTranslation>
{
    public int Id { get; set; }

    public string Name { get; set; }

    public virtual ICollection<ServiceStateTranslation> Translations { get; set; }
}

ITranslatableEntity interface:

public interface ITranslatableEntity<T>
{
    ICollection<T> Translations { get; set; }
}

Then the entity containing the translations:

public partial class ServiceStateTranslation
{
    public int Id { get; set; }

    [Index("IX_ClassificationTranslation", 1, IsUnique = true)]
    public int MainEntityId { get; set; }

    public ServiceState MainEntity { get; set; }

    [Index("IX_ClassificationTranslation", 2, IsUnique = true)]
    public string LanguageCode { get; set; }

    public string Name { get; set; }
}

The names of the properties containing localized strings are always the same in the main entity and the translation entity (Name in this case).

Using such model I can do something like this:

var result = query.Select(x => new
        {
            Name = x.Name,
            StateName =
                currentLanguageCode == DEFAULTLANGUAGECODE
                    ? x.ServiceState.Name
                    : x.ServiceState.Translations.Where(i => i.LanguageCode == currentLanguageCode)
                        .Select(i => i.Name)
                        .FirstOrDefault() ?? x.ServiceState.Name
        }).ToList();

The problem is I don't like writing this kind of code for every query containing any translateable entity, so I'm thinking about using QueryInterceptor and an ExpressionVisitor which would do some magic and allow me to replace the query with something like this:

var result = query.Select(x => new
        {
            Name = x.Name,
            StateName = x.ServiceState.Name
        }).ToLocalizedList(currentLanguageCode, DEFAULTLANGUAGECODE);

I suppose it is possible to create an ExpressionVisitor which would:

  • Only change the expressions inside the Select blocks for navigation properties implementing ITranslatableEntity<> interface
  • If the current language is not default, change the expression x.ServiceState.Name to

    x.ServiceState.Translations.Where(i => i.LanguageCode == currentLanguageCode)
                    .Select(i => i.Name)
                    .FirstOrDefault() ?? x.ServiceState.Name
    

But I'm not that familiar with expression visitors and trees, so I'm a little bit lost here. Could someone get me on the right track?


Solution

  • OK, looks like I've come up with a working solution.

    public class ClassificationTranslationVisitor : ExpressionVisitor
    {
        private string langCode = "en";
        private string defaultLangCode = "en";
        private string memberName = null;
        private Expression originalNode = null;
    
        public ClassificationTranslationVisitor(string langCode, string defaultLanguageCode)
        {
            this.langCode = langCode;
            this.defaultLangCode = defaultLanguageCode;
        }
    
        protected override Expression VisitParameter(ParameterExpression node)
        {
            if (langCode == defaultLangCode)
            {
                return base.VisitParameter(node);
            }
    
            if (!node.Type.GetCustomAttributes(typeof(TranslatableAttribute), false).Any() && originalNode == null)
            {
                return base.VisitParameter(node);
            }
    
            if (IsGenericInterface(node.Type, typeof(ITranslatableEntity<>)))
            {
                return AddTranslation(node);
            }
    
            return base.VisitParameter(node);
        }
    
        protected override Expression VisitMember(MemberExpression node)
        {
            if (node == null || node.Member == null || node.Member.DeclaringType == null)
            {
                return base.VisitMember(node);
            }
    
            if (langCode == defaultLangCode)
            {
                return base.VisitMember(node);
            }
    
            if (!node.Member.GetCustomAttributes(typeof(TranslatableAttribute), false).Any() && originalNode == null)
            {
                return base.VisitMember(node); 
            }
    
            if (IsGenericInterface(node.Member.DeclaringType, typeof(ITranslatableEntity<>)))
            {
                memberName = node.Member.Name;
                originalNode = node;
                return Visit(node.Expression);
            }
    
            if (IsGenericInterface(node.Type, typeof(ITranslatableEntity<>)))
            {
                return AddTranslation(node);
            }
    
            return base.VisitMember(node);
        }
    
        private Expression AddTranslation(Expression node)
        {
            var expression = Expression.Property(node, "Translations");
            var resultWhere = CreateWhereExpression(expression);
            var resultSelect = CreateSelectExpression(resultWhere);
            var resultIsNull = Expression.Equal(resultSelect, Expression.Constant(null));
            var testResult = Expression.Condition(resultIsNull, originalNode, resultSelect);
            memberName = null;
            originalNode = null;
            return testResult;
        }
    
        private Expression CreateWhereExpression(Expression ex)
        {
            var type = ex.Type.GetGenericArguments().First();
            var test = CreateExpression(t => t.LanguageCode == langCode, type);
            if (test == null)
                return null;
            return Expression.Call(typeof(Enumerable), "Where", new[] { type }, ex, test);
        }
    
        private Expression CreateSelectExpression(Expression ex)
        {
            var type = ex.Type.GetGenericArguments().First();
    
            ParameterExpression itemParam = Expression.Parameter(type, "lang");
            Expression selector = Expression.Property(itemParam, memberName);
            var columnLambda = Expression.Lambda(selector, itemParam);
    
            var result = Expression.Call(typeof(Enumerable), "Select", new[] { type, typeof(string) }, ex, columnLambda);
            var stringResult = Expression.Call(typeof(Enumerable), "FirstOrDefault", new[] { typeof(string) }, result);
            return stringResult;
        }
    
        /// <summary>
        /// Adapt a QueryConditional to the member we're currently visiting.
        /// </summary>
        /// <param name="condition">The condition to adapt</param>
        /// <param name="type">The type of the current member (=Navigation property)</param>
        /// <returns>The adapted QueryConditional</returns>
        private LambdaExpression CreateExpression(Expression<Func<ITranslation, bool>> condition, Type type)
        {
            var lambda = (LambdaExpression)condition;
            var conditionType = condition.GetType().GetGenericArguments().First().GetGenericArguments().First();
            // Only continue when the condition is applicable to the Type of the member
            if (conditionType == null)
                return null;
            if (!conditionType.IsAssignableFrom(type))
                return null;
    
            var newParams = new[] { Expression.Parameter(type, "bo") };
            var paramMap = lambda.Parameters.Select((original, i) => new { original, replacement = newParams[i] }).ToDictionary(p => p.original, p => p.replacement);
            var fixedBody = ParameterRebinder.ReplaceParameters(paramMap, lambda.Body);
            lambda = Expression.Lambda(fixedBody, newParams);
    
            return lambda;
        }
    
        private bool IsGenericInterface(Type type, Type interfaceType)
        {
            return type.GetInterfaces().Any(x =>
                x.IsGenericType &&
                x.GetGenericTypeDefinition() == interfaceType);
        }
    }
    
    public class ParameterRebinder : ExpressionVisitor
    {
        private readonly Dictionary<ParameterExpression, ParameterExpression> map;
    
        public ParameterRebinder(Dictionary<ParameterExpression, ParameterExpression> map)
        {
            this.map = map ?? new Dictionary<ParameterExpression, ParameterExpression>();
        }
    
        public static Expression ReplaceParameters(Dictionary<ParameterExpression, ParameterExpression> map, Expression exp)
        {
            return new ParameterRebinder(map).Visit(exp);
        }
    
        protected override Expression VisitParameter(ParameterExpression node)
        {
            ParameterExpression replacement;
    
            if (map.TryGetValue(node, out replacement))
                node = replacement;
    
            return base.VisitParameter(node);
        }
    }
    

    I've also added TranslatableAttribute which is required on any properties which are about to be translated.

    The code is certainly missing some checks, but it already works on my environment. I'm also not checking if the replaced expression is inside a Select block, but it looks like it's not that necessary with TranslatableAttribute.

    I've used ParameterRebinder and some other code from this answer ExpressionVisitor soft delete