Record 'Lenses' - expression tree for with expression

Viewed 337

Is there a way to build an expression tree for the new with operator?

I am trying to implement a 'Lens' feature for records that will only need a selector and will auto generate the mutator

My goal is to convert from a 'selector':

Expression<Func<T, TMember>> expression (ie employee => employee.Name)

To a 'mutator':

(employee, newName) => employee with { Name = newName }

I did manage to do this for the simple case above, see my answer below, however that will not work for a nested case ie:

record Employee(string Name, int Age);
record Manager(String Name, Employee Employee);

Here I want to change ie from

manager => manager.Employee.Name

to

(manager, newEmployeeName) => manager with { Employee = manager.Employee with { Name = newEmployeeName}}

Any help ?

2 Answers

CalcMutator method that can deal with nested properties would look something like this

static Func<T, TMember, T> CalcMutator(Expression<Func<T, TMember>> expression)
{
    var typeParam = expression.Parameters.First();
    var valueParam = Expression.Parameter(typeof(TMember), "v");

    var variables = new List<ParameterExpression>();
    var blockExpressions = new List<Expression>();

    var property = (MemberExpression)expression.Body;
    Expression currentValue = valueParam;
    var index = 0;

    while (property != null)
    {
        var variable = Expression.Variable(property.Expression.Type, $"v_{index}");
        variables.Add(variable);

        var cloneMethod = property.Expression.Type.GetMethod("<Clone>$");
        if (cloneMethod is null) throw new Exception($"CalcMutatorNo Clone method on {typeof(T)}");
        var cloneCall = Expression.Call(property.Expression, cloneMethod);

        var assignClonedToVariable = Expression.Assign(variable, cloneCall);
        
        var accessVariableProperty = Expression.MakeMemberAccess(variable, property.Member);
        var assignVariablePropertyValue = Expression.Assign(accessVariableProperty, currentValue);

        blockExpressions.Add(assignClonedToVariable);
        blockExpressions.Add(assignVariablePropertyValue);

        property = property.Expression as MemberExpression;
        currentValue = variable;
        index++;
    }

    // Return root object
    blockExpressions.Add(currentValue);

    var block = Expression.Block(variables, blockExpressions);
    var assignLambda = (Expression<Func<T, TMember, T>>)Expression.Lambda(block, typeParam, valueParam);
    return assignLambda.Compile();
}

Please keep in mind that Cache implemented with ImmutableDictionary is not thread safe. If you want to ensure that the cached expressions can safely be used in multi-threaded environments, it's better to use ConcurrentDictionary for the cache instead or to apply some synchronization primitives around ImmutableDictionary.

Following the lead from @JL0PD I ended up converting:

t => t.Member (ie employee => employee.Name)

into:

(t, v) => { 
  var c = t.<Clone>$(); 
  c.Member = v; 
  return c; 
}

ie:

(employee, newName) => { 
  var c = employee.<Clone>$(); 
  c.Name=newName; 
  return c; 
}

Below is a full implemetation of a record Lens including caching of delegates

Note that this does not cover nested mutators so my question above still stands

static class RecLens<T, TMember> {
  public static (Func<T, TMember> Selector, Func<T, TMember, T> Mutator) Get(Expression<Func<T, TMember>> expression) {
    if (!IsExpressionValid(expression.Body)) throw new Exception($"Lens Invalid expression ({expression})");

    // create unique cache key, calc same key for x=>x.p and y=>y.p
    var exprStr = expression.Body.ToString();
    var dotPos = exprStr.IndexOf(Type.Delimiter);
    var cacheKey = typeof(T).FullName + '|' + (dotPos > 0 ? exprStr.Remove(0, exprStr.IndexOf(Type.Delimiter) + 1) : "root");

    if (!Cache.TryGetValue(cacheKey, out var res)) {
      res = (expression.Compile(), CalcMutator(expression));
      Cache = Cache.Add(cacheKey, res);
    }

    return res;
  }

  // key: "{srcType.FullName}|{member}" , ie: "Test.Organization|DevelopmentDepartment.Manager"
  static ImmutableDictionary<string, (Func<T, TMember>, Func<T, TMember, T>)> Cache = ImmutableDictionary<string, (Func<T, TMember>, Func<T, TMember, T>)>.Empty;

  // create delegate: (t, v) => { var c=t.<Clone>$(); c.Member = v; return c; }
  static Func<T, TMember, T> CalcMutator(Expression<Func<T, TMember>> expression) {
    var result = Expression.Variable(typeof(T), "c");
    var typeParam = Expression.Parameter(typeof(T), "t");
    var valueParam = Expression.Parameter(typeof(TMember), "v");
    var cloneMethod = typeof(T).GetMethod("<Clone>$");
    if (cloneMethod is null) throw new Exception($"CalcMutatorNo Clone method on {typeof(T)}");
    var cloneCall = Expression.Call(typeParam, cloneMethod);
    var assignResult = Expression.Assign(result, cloneCall);
    var memberInfo = (expression.Body as MemberExpression)!.Member;
    var resultMemberAccess = Expression.MakeMemberAccess(result, memberInfo);
    var assign = Expression.Assign(resultMemberAccess, valueParam);
    var block = Expression.Block(new[] { result }, assignResult, assign, result);
    var assignLambda = (Expression<Func<T, TMember, T>>)Expression.Lambda(block, typeParam, valueParam);
    return assignLambda.Compile();
  }

  // verify that expr is a member expression of its parameter
  static bool IsExpressionValid(Expression expr, bool first = true) {
    if (expr is ParameterExpression) return !first;
    if (expr is MemberExpression memberExpr && memberExpr.Expression is object) return IsExpressionValid(memberExpr.Expression, false);
    return false;
  }
}

To use:

record Employee(string Name, int Age);
var (Selector, Mutator) = RecLens<Employee, string>.Get(e => e.Name);

var dave = new Employee("Dave", 30);
var name = Selector(dave); // "Dave"
var john = Mutator(dave, "John"); // Employee("John", 30)
Related