Search code examples
c#asp.net-mvchtml-helperfluent-interface

Fluent API: Reference types take values from eachother


I'm currently writing a grid for MVC that is an HtmlHelper extension and I'm running into a strange problem with it.

First of all, this is the code I'm using the construct the grid:

RenderedOutput = HtmlHelper.GridFor(Model)
.WithColumns(column =>
{
    column.Bind(x => x.Name)
        .WithCss("inline");
    column.Bind(x => x.Age)
        .WithCss("inline fixed right");
})
.Render();

Now, the entire fluent API is built up by using interfaces, to make the copy-past work a bit smaller, I do not post the interfaces here but I'll post the implementation:

First of all, the HtmlHelper:

public static IGridBuilder<TEntity> GridFor<TModel, TEntity>(this HtmlHelper<TModel> htmlHelper,
    IEnumerable<TEntity> dataSource)
{
    return new GridBuilder<TEntity>(htmlHelper, dataSource);
}

So this return a GridBuilder, of which the implementation is here:

#region Constructors

public GridBuilder(HtmlHelper helper, IEnumerable<TModel> data)
{
    HtmlHelper = helper;
    DataSource = data;

    ColumnBuilders = new List<IColumnBuilder<TModel>>();
}

#endregion

#region Properties

private string Id { get; set; }

#endregion

#region IGridBuilder Members

public IList<IColumnBuilder<TModel>> ColumnBuilders { get; protected set; }

public IEnumerable<TModel> DataSource { get; private set; }

public HtmlHelper HtmlHelper { get; private set; }

public IGridBuilder<TModel> WithId(string id)
{
    Id = id;
    return this;
}

public IGridBuilder<TModel> WithColumns(Action<IColumnBuilder<TModel>> bindAllColumns)
{
    bindAllColumns(new ColumnBuilder<TModel>(this));

    return this;
}

public HtmlString Render()
{
    var outputBuilder = new StringBuilder();

    var headerMember = new TagBuilder("div");
    headerMember.MergeAttribute("class", "gridHolder v-scroll");

    if (!string.IsNullOrEmpty(Id))
    {
        headerMember.GenerateId(Id);
    }

    outputBuilder.AppendLine(headerMember.ToString(TagRenderMode.StartTag));

    // Process all the available columns.
    var rowBuilder = new TagBuilder("div");
    rowBuilder.MergeAttribute("class", "row");
    outputBuilder.AppendLine(rowBuilder.ToString(TagRenderMode.StartTag));

    foreach (var column in ColumnBuilders)
    {
        var columnBuilder = new TagBuilder("div");
        outputBuilder.AppendLine(columnBuilder.ToString(TagRenderMode.StartTag));
        outputBuilder.AppendLine(columnBuilder.ToString(TagRenderMode.EndTag));
    }

    outputBuilder.AppendLine(rowBuilder.ToString(TagRenderMode.EndTag));

    outputBuilder.AppendLine(headerMember.ToString(TagRenderMode.EndTag));

    return new HtmlString(outputBuilder.ToString());
}

#endregion

}

The GridBuilder takes an Action of IColumnBuilder to build the column, so here it is:

public class ColumnBuilder<TModel> : IColumnBuilder<TModel>
{
    #region Constructors

    public ColumnBuilder(IGridBuilder<TModel> gridBuilder)
    {
        GridBuilderReference = gridBuilder;
    }

    #endregion

    #region IColumnBuilder Members

    public IGridBuilder<TModel> GridBuilderReference { get; private set; }

    public string CssClass { get; private set; }

    public IColumnBuilder<TModel> Bind<TItem>(Expression<Func<TModel, TItem>> propertySelector)
    {
        // Reset the properties. This is needed because they are not cleared automatticly. It's not a new instance which is created.
        CssClass = null;

        GridBuilderReference.ColumnBuilders.Add(this);

        return this;
    }

    public IColumnBuilder<TModel> WithCss(string className)
    {
        CssClass = className;

        return this;
    }

    #endregion
}

First of all, it's my first fluent interface implementation so if it's not the good approach please point me in the right direction.

The situation:

  • When I'm constructing the grid, I pass in an action to bind the columns and those columns are fluent again as well, (I can add a class on a column for example). Now, to render those, I tought it was a good idea to hold a reference to all IColumnBuilder instances in my IGridBuilder, so that in my GridBuilder renders method I can do something like

    // Rendering before the columns.

    foreach (var c in myColumns) { c.Render(); }

    // Rendering after the columns.

Therefore, I've created a list in the GridBuilder that will contain all the ColumnBuilders. When I execute a WithColumns, my GridBuilder object (this) is passed, and then in ColumnBuilder, on each Bind() function I add the ColumnBuilder object to the passed reference.

But this has a strange behaviour (for example, the list containing the ColumnBuilders, does all match the properties of the last ColumnBuilder executed).


Solution

  • Method 1

    public class ColumnBuilderFactory<TModel> : IColumnBuilderFactory<TModel>
    {
        #region Constructors
    
        public ColumnBuilderFactory(IGridBuilder<TModel> gridBuilder)
        {
            gridBuilderReference = gridBuilder;
        }
    
        #endregion
    
        #region IColumnBuilderFactory Members
    
        private IGridBuilder<TModel> gridBuilderReference { get; private set; }
    
        internal IList<IColumnBuilder<TModel>> Columns {get; private set; }
    
        public IColumnBuilder<TModel> New()
        {
            var column = new ColumnBuilder(gridBuilderReference);
            Columns.Add(column);
            return column;
        }
    
        #endregion
    }
    

    and GridBuilder WithColumns becomes

    public IGridBuilder<TModel> WithColumns(Action<IColumnBuilderFactory<TModel>> bindAllColumns)
    {
        var factory = new ColumnBuilderFactory<TModel>(this);
        bindAllColumns(factory);
    
        foreach(var column in factory)
        {
            this.ColumnBuilders.Add(column );
        }        
    
        return this;
    }
    

    This has the usage

    RenderedOutput = HtmlHelper.GridFor(Model)
    .WithColumns(columnFactory =>
    {
        columnFactory.New().Bind(x => x.Name)
            .WithCss("inline");
        columnFactory.New().Bind(x => x.Age)
            .WithCss("inline fixed right");
    })
    .Render();
    

    Method 2

    The alternative does much the same by cheating and ignoring this (using the FluentAPI to replace it), basically it's the same as your current usage but Bind becomes

    public IColumnBuilder<TModel> Bind<TItem>(Expression<Func<TModel, TItem>> propertySelector)
    {
        var builder = new ColumnBuilder<TModel>(GridBuilderReference);
    
        GridBuilderReference.ColumnBuilders.Add(builder);
    
        return builder;
    }
    

    It relies on the GC tidying up the first(useless) instance

    so.....

    RenderedOutput = HtmlHelper.GridFor(Model)
    .WithColumns(column =>
    {
    //column at this point isn't used, it's only there to avoid a NullReferenceException on the first call to Bind
        column
    .Bind(x => x.Name)
    //Bind has returned a new ColumnBuilder to play with so the next call will be on the new instance
            .WithCss("inline");
    //Again this is just so we can make the next bind call
        column
    //Again bind replaces the previous instance with a new one so we won't overwrite Name with this call
    .Bind(x => x.Age)
    //This now sets the css on the new ColumnBuilder we just created for Age
            .WithCss("inline fixed right");
    })
    .Render();
    

    Example Console App showing Method 2

    using System;
    using System.Collections.Generic;
    using System.Linq.Expressions;
    
    namespace ConsoleApplication3
    {
        class Program
        {
            static void Main(string[] args)
            {
                var master = FluentHelper.GridFor(new Model())
                .WithColumns(column =>
                    {
                        column.Bind(x => x.Name)
                            .WithCSS("inline");
                        column.Bind(x => x.Age)
                            .WithCSS("inline fixed right");
                    });
    
                foreach(var c in master.children)
                {
                    Console.WriteLine(c.Binding.ToString());
                    Console.WriteLine(c.CSS);
                }
    
                Console.ReadKey(true);
            }
        }
    
        class Model
        {
            public string Name { get; set; }
            public string Age { get; set; }
        }
    
        class GridBuilder<T>
        {
            public GridBuilder<T> WithColumns(Action<ColumnBuilder<T>> bindAllColumns)
            {
                bindAllColumns(new ColumnBuilder<T>(this));
    
                return this;
            }
    
            public List<ColumnBuilder<T>> children = new List<ColumnBuilder<T>>();
        }
    
        class ColumnBuilder<T>
        {
            private GridBuilder<T> grid;
    
            public string Binding;
            public string CSS;
    
            public ColumnBuilder(GridBuilder<T> grid)
            {
                // TODO: Complete member initialization
                this.grid = grid;
            }
    
            public void WithCSS(string css)
            {
                this.CSS = css;
            }
    
            public ColumnBuilder<T> Bind<TItem>(Expression<Func<T, TItem>> propertySelector)
            {
                var builder = new ColumnBuilder<T>(grid);
    
                builder.Binding = (propertySelector.Body as MemberExpression).Member.Name;
    
                grid.children.Add(builder);
    
                return builder;
            }
        }
    
        static class FluentHelper
        {
    
            internal static GridBuilder<T> GridFor<T>(T model)
            {
                return new GridBuilder<T>();
            }
        }
    }