1

Any ideas how to create a generic fluent setter?

Imagine that I've the following class

internal class ClonableExampleClass
{
    public ClonableExampleClass()
    {
        
    }
    public string ExampleString { get; set; }
    public int ExampleInt { get; set; }
    public ClonableExampleClass ExampleNestedClass { get; set; }
    public List<ClonableExampleClass> ExampleList { get; set; }
} 

I want to:

public class Program
{
    public static Task Main(string[] args)
    {
        var exampleClass = new ClonableExampleClass
        {
            ExampleInt = 1
        };
        exampleClass
            .With(opt => opt.ExampleInt).Set(2)
            .With(opt => opt.ExampleString).Set("test");
        var json = JsonSerializer.Serialize(exampleClass);
    }
}

and the expected json to be:

{"ExampleString":"test","ExampleInt":2,"ExampleNestedClass":null,"ExampleList":null}

I've created an extension method With:

public static FluentBuilderObjectValueSetter<T> With<T>(this T sourceObject, Func<T,object> action)
{
    var property = action(sourceObject);
    return new FluentBuilderObjectValueSetter<T>(sourceObject, action);
}

Which returns FluentBuilderObjectValueSetter (I've removed the interface to make it more easy to test, for now)

public  class FluentBuilderObjectValueSetter<TObjectType> //:IFluentBuilderObjectValueSetter<TObjectType>
{
    private readonly TObjectType _sourceObject;
    private object _sourceValue;

    internal FluentBuilderObjectValueSetter(TObjectType sourceObject, ref object sourceValue)
    {
        _sourceObject = sourceObject;
        //_action = action;
        _sourceValue = sourceValue;
    }

    public TObjectType Set<TMemberType>(TMemberType value)
    {
        _sourceValue = value;
        return _sourceObject;
    }
}

But is not setting the object value

Vinicius Andrade
  • 151
  • 1
  • 4
  • 22
  • 1
    Is there any particular reason why you want to do this? You can achieve this with expression trees but there is no way to write method signature in the way compiler (without custom analyzers) will throw if user provides property without setter or just something like `opt => 1`. – Guru Stron Jan 11 '23 at 20:58
  • With records, we have the ability to use the 'with' key word that generates another object with diferent values. I want to achive the same thing, but in older versions. And i think that fluent setters are elegant – Vinicius Andrade Jan 11 '23 at 21:04
  • 3
    I would argue that migrating projects to the newer runtime which supports corresponding language version is time better spent =) – Guru Stron Jan 11 '23 at 21:11
  • I know that, but we have to many projects to do it, so we can't do that right now, unfortunally =/ – Vinicius Andrade Jan 11 '23 at 21:14

2 Answers2

3

If you need to write the code like this way, you can manipulate your source object with the with and set method flow as you want with the code block I wrote below.

Class below, hold source object and member selector expression:

public class FluentBuilder<T, TMember>
{
    private readonly T _source;
    private readonly Expression<Func<T, TMember>> _field;

    public FluentBuilder(T source, Expression<Func<T, TMember>> field)
    {
        _source = source;
        _field = field;
    }

    public T Set(TMember value)
    {
        MemberInfo memberInfo = GetMemberInfo(_field);
        SetMemberValue(memberInfo, _source, value);
        return _source;
    }

    private static MemberInfo GetMemberInfo(Expression<Func<T, TMember>> expression)
    {
        if (expression.Body is MemberExpression member)
            return member.Member;

        throw new ArgumentException("Expression is not a member access", nameof(expression));
    }

    private static void SetMemberValue(MemberInfo member, T target, object value)
    {
        switch (member.MemberType)
        {
            case MemberTypes.Field:
                ((FieldInfo)member).SetValue(target, value);
                break;
            case MemberTypes.Property:
                ((PropertyInfo)member).SetValue(target, value, null);
                break;
            default:
                throw new ArgumentException("MemberInfo must be if type FieldInfo or PropertyInfo", nameof(member));
        }
    }
}

The code below create FluentBuilder With extension method.

public static class FluentBuilderExtension
{
    public static FluentBuilder<T, TMember> With<T, TMember>(this T obj, Expression<Func<T, TMember>> field)
    {
        return new FluentBuilder<T, TMember>(obj, field);
    }
}

Using code blocks which are above, you can write the code like this:

public static void Main(string[] args)
{
    var exampleClass = new ClonableExampleClass
    {
        ExampleInt = 1
    };


    exampleClass.With(x => x.ExampleInt).Set(2)
                .With(x => x.ExampleString).Set("test");
    string json = JsonSerializer.Serialize(exampleClass);
    Console.WriteLine(json);
}
Adem Catamak
  • 1,987
  • 2
  • 17
  • 25
  • 1
    I'll test it in a few minutes, and I'll came back with a feedback. As @Chris Yungmann said, your way looks more robust, so if everthing works as expected I'll mark it as an answer – Vinicius Andrade Jan 11 '23 at 21:54
  • 1
    @ViniciusAndrade note that in performance sensitive scenarios this can cause some problems. And as mentioned in the comments to the question - this is not type-safe and will fail at runtime if incorrect expression is provided. – Guru Stron Jan 11 '23 at 22:32
  • @GuruStron I wondering how would i pass an incorrect expression? – Vinicius Andrade Jan 11 '23 at 22:44
  • 1
    @ViniciusAndrade I've shown at least one example in comments. `.With(opt => 1)`. Or if property has no public setter. – Guru Stron Jan 11 '23 at 22:47
  • Can you think anyway to work around it? – Vinicius Andrade Jan 11 '23 at 23:13
1

Drawing some inspiration from this answer, I came up with the following:

using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;

public class FluentBuilderObjectValueSetter<TObjectType, TPropertyType>
{
    private readonly TObjectType _sourceObject;
    private readonly Expression<Func<TObjectType, TPropertyType>> _selector;
    
    internal FluentBuilderObjectValueSetter(TObjectType sourceObject, Expression<Func<TObjectType, TPropertyType>> selector)
    {
        _sourceObject = sourceObject;
        _selector = selector;
    }
    
    internal TObjectType Set(TPropertyType value) {
        var memberSelectorExpression = _selector.Body as MemberExpression;
        if (memberSelectorExpression is not null)
        {
            var property = memberSelectorExpression.Member as PropertyInfo;
            if (property is not null)
            {
                property.SetValue(_sourceObject, value, null);
            }
        }
        return _sourceObject;
    }
}

public static class ExtensionMethods {
    public static FluentBuilderObjectValueSetter<TObjectType, TPropertyType> With<TObjectType, TPropertyType>(this TObjectType sourceObject, Expression<Func<TObjectType, TPropertyType>> selector)
    {
        return new FluentBuilderObjectValueSetter<TObjectType, TPropertyType>(sourceObject, selector);
    }
}

public class Program
{
    internal class ClonableExampleClass
    {
        public string ExampleString { get; set; }
        public int ExampleInt { get; set; }
        public ClonableExampleClass ExampleNestedClass { get; set; }
        public List<ClonableExampleClass> ExampleList { get; set; }
    }
    
    public static void Main()
    {
        var exampleClass = new ClonableExampleClass
        {
            ExampleInt = 1
        };
        exampleClass
            .With(opt => opt.ExampleInt).Set(2)
            .With(opt => opt.ExampleString).Set("test");
        var json = JsonSerializer.Serialize(exampleClass);
        Console.WriteLine(json);
    }
}
Chris Yungmann
  • 1,079
  • 6
  • 14
  • 2
    (Note that this currently only handles properties and not fields. @Adem's answer appears a little more robust as it handles fields as well and also throws exceptions on unexpected inputs) – Chris Yungmann Jan 11 '23 at 21:44
  • 1
    After posting the answer I realized that our answers were very similar and I deleted it to avoid information pollution. I've restored the answer because you noticed and liked my answer. Have a good day @Chris – Adem Catamak Jan 11 '23 at 21:51
  • 1
    Thanks a lot for helping with this answer too @ChrisYungmann – Vinicius Andrade Jan 11 '23 at 22:05