Friday, December 07, 2007

Exposing generic methods to frameworks that don't understand generics.

How do you wrap a generic method like this:

public string WriteHtml<T>(string target, T source)

so that frameworks which don't understand generics can understand it, like this:

public string WriteHtml(string target, object source)

I've been writing a web application with Monorail. It's been loads of fun, and the speed at which I've been able to get a fully featured application up and running is awesome. I'll be blogging more about this soon, but today I just want to make a quick 'howto' about exposing generic APIs to frameworks that don't understand generics.

The problem is this: Monorail's default view engine is NVelocity, a port of the java Velocity template engine. I've found it pretty nice so far. To use it you just add stuff into a 'PropertyBag' in your controller and then reference in your view using the $parameterName syntax. You can invoke methods on your types and indeed Monorail comes with a nice collection of helpers to use within the view. I wanted to create a new helper to display a tree of objects (in this case a location hierarchy) in a combo box like this:

LocationSelect

So in my favorite TDD style I quickly got a class (HierarchicalSelect) up and running that took any object of type t with a property of IList<t> and spat out the HTML for my desired combo box. I then referenced my new helper in my template like this:

$hierarchicalSelect.WriteHtml("location.id", $location)

But it didn't work. Unhappily, NVelocity doesn't throw, it simply outputs your placeholder when it can't bind. I eventually worked out that the reason it didn't like WriteHtml() was because it's an generic method:

public string WriteHtml<T>(string target, T source)

What I needed was a way of exposing WriteHtml with the source argument typed as object rather than T. It turned out to be quite an effort. This is the obvious way:

public string WriteHtml(string target, object source)
{
    return WriteHtml(target, source);
}

But of course it doesn't work because T becomes System.Object rather than the actual type of 'source'.

Instead you have to use reflection to create a MethodInfo for WriteHtml<T> and then create a typed version before invoking it.

public string WriteHtml(string target, object source)
{
    Type sourceType = source.GetType();
    
    // get our unambiguous generic method
    MethodInfo writeHtmlMethod = typeof(HierarchicalSelect).GetMethod("WriteHtmlUnambiguous", 
        BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance);

    // create a typed version
    MethodInfo typedWriteHtmlMethod = writeHtmlMethod.MakeGenericMethod(sourceType);
    
    // invoke it.
    return (string)typedWriteHtmlMethod.Invoke(this, new object[] { target, source });
}

// need to provide this so that GetMethod can unambiguously find it.
private string WriteHtmlUnambiguous<T>(string target, T source)
{
    return WriteHtml<T>(target, source, null);
}

Just for your entertainment. Here's the full code for HierarchicalSelect:

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Reflection;
using System.Web;
using System.Web.UI;
using System.Linq;

namespace Hadlow.PropertyFinder.HtmlHelpers
{
    public class HierarchicalSelect
    {
        // NVelocity doesn't support generic methods :(
        // we have to take an object argument and then construct one of the generic methods first
        // before calling it.
        public string WriteHtml(string target, object source)
        {
            Type sourceType = source.GetType();
            
            // get our unambiguous generic method
            MethodInfo writeHtmlMethod = typeof(HierarchicalSelect).GetMethod("WriteHtmlUnambiguous", 
                BindingFlags.NonPublic | BindingFlags.InvokeMethod | BindingFlags.Instance);

            // create a typed version
            MethodInfo typedWriteHtmlMethod = writeHtmlMethod.MakeGenericMethod(sourceType);
            
            // invoke it.
            return (string)typedWriteHtmlMethod.Invoke(this, new object[] { target, source });
        }

        // need to provide this so that GetMethod can unambiguously find it.
        private string WriteHtmlUnambiguous<T>(string target, T source)
        {
            return WriteHtml<T>(target, source, null);
        }

        public string WriteHtml<T>(string target, T source)
        {
            return WriteHtml<T>(target, source, null);
        }

        public string WriteHtml<T>(string target, T source, IDictionary attributes)
        {
            string id = CreateHtmlId(target);
            string name = target;

            StringBuilder sb = new StringBuilder();
            StringWriter sbWriter = new StringWriter(sb);
            HtmlTextWriter writer = new HtmlTextWriter(sbWriter);

            writer.WriteBeginTag("select");
            writer.WriteAttribute("id", id);
            writer.WriteAttribute("name", name);
            writer.Write(" ");
            writer.Write(GetAttributes(attributes));
            writer.Write(HtmlTextWriter.TagRightChar);
            writer.WriteLine();

            if (source != null)
            {
                HierarchicalThing<T> root = new HierarchicalThing<T>(source);
                WriteOptions(writer, root, 0);
            }

            writer.WriteEndTag("select");

            return sbWriter.ToString();
        }

        private void WriteOptions<T>(
            HtmlTextWriter writer, 
            HierarchicalThing<T> item, 
            int level)
        {
            writer.WriteBeginTag("option");

            //if (item.IsSelected)
            //{
            //    writer.Write(" selected=\"selected\"");
            //}

            writer.WriteAttribute("value", HttpUtility.HtmlEncode(item.Id));
            writer.Write(HtmlTextWriter.TagRightChar);
            writer.Write(GetLevelString(level) + HttpUtility.HtmlEncode(item.ToString()));
            writer.WriteEndTag("option");
            writer.WriteLine();

            level++;
            foreach (HierarchicalThing<T> child in item.Children)
            {
                WriteOptions(writer, child, level);
            }
        }

        private string GetLevelString(int level)
        {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < level; i++)
            {
                sb.Append("    ");
            }
            return sb.ToString();
        }

        private string CreateHtmlId(string name)
        {
            StringBuilder sb = new StringBuilder(name.Length);

            bool canUseUnderline = false;

            foreach (char c in name.ToCharArray())
            {
                switch (c)
                {
                    case '.':
                    case '[':
                    case ']':
                        if (canUseUnderline)
                        {
                            sb.Append('_');
                            canUseUnderline = false;
                        }
                        break;
                    default:
                        canUseUnderline = true;
                        sb.Append(c);
                        break;
                }

            }

            return sb.ToString();
        }

        private string GetAttributes(IDictionary attributes)
        {
            if (attributes == null || attributes.Count == 0) return string.Empty;

            StringBuilder contents = new StringBuilder();

            foreach (DictionaryEntry entry in attributes)
            {
                if (entry.Value == null || entry.Value.ToString() == string.Empty)
                {
                    contents.Append(entry.Key);
                }
                else
                {
                    contents.AppendFormat("{0}=\"{1}\"", entry.Key, entry.Value);
                }
                contents.Append(' ');
            }

            return contents.ToString();
        }

        /// <summary>
        /// Represents a hierarchial object with one property that has a type of 
        /// IList<sametype>
        /// </summary>
        /// <typeparam name="T"></typeparam>
        private class HierarchicalThing<T>
        {
            readonly T source;
            readonly PropertyInfo childrenProperty;
            readonly string id;

            public string Id
            {
                get { return id; }
            }

            public HierarchicalThing(T source)
            {
                this.source = source;
                Type sourceType = source.GetType();

                childrenProperty = FindChildProperty(sourceType);
                id = FindIdValue(source);

                if (childrenProperty == null)
                {
                    throw new ApplicationException("The source object must have a property that " +
                        "represents it's children. This property much have a type of IList<source type>. " +
                        "No such property was found in type: " + sourceType.Name);
                }
            }

            private string FindIdValue(T source)
            {
                return source.GetType().GetProperties().First(p =>
                    p.GetCustomAttributes(true).Any(a =>
                        a is Castle.ActiveRecord.PrimaryKeyAttribute)
                        ).GetValue(source, null).ToString();
            }

            private PropertyInfo FindChildProperty(Type sourceType)
            {
                return sourceType.GetProperties().First(p => p.PropertyType == typeof(IList<T>));
            }

            public HierarchicalThing<T>[] Children
            {
                get
                {
                    List<HierarchicalThing<T>> children = new List<HierarchicalThing<T>>();
                    IList<T> childValues = (IList<T>)childrenProperty.GetValue(source, null);
                    foreach (T childValue in childValues)
                    {
                        children.Add(new HierarchicalThing<T>(childValue));
                    }
                    return children.ToArray();
                }
            }

            public override string ToString()
            {
                return source.ToString();
            }
        }
    }
}

No comments:

Post a Comment

Note: only a member of this blog may post a comment.