19

I'm looking at creating a T4 template to generate enums of my database. Essentially, I want the same feature as SubSonic e.g. Product.Columns.ProductId for Linq-to-SQL or Entity Framework 4.

Any help would be much appreciated. thanks.

Patman
  • 41
  • 8
Nickz
  • 1,880
  • 17
  • 35

2 Answers2

35

I have written one for my needs that converts a lookup table of your choice to an enum: Put this code inside an EnumGenerator.ttinclude file:

<#@ template debug="true" hostSpecific="true" #>
<#@ output extension=".generated.cs" #>
<#@ Assembly Name="System.Data" #>
<#@ import namespace="System.Data" #>
<#@ import namespace="System.Data.SqlClient" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#
    string tableName = Path.GetFileNameWithoutExtension(Host.TemplateFile);
    string path = Path.GetDirectoryName(Host.TemplateFile);
    string columnId = tableName + "ID";
    string columnName = "Name";
    string connectionString = "data source=.;initial catalog=DBName;integrated security=SSPI";
#>
using System;
using System.CodeDom.Compiler;

namespace Services.<#= GetSubNamespace() #>
{
    /// <summary>
    /// <#= tableName #> auto generated enumeration
    /// </summary>
    [GeneratedCode("TextTemplatingFileGenerator", "10")]
    public enum <#= tableName #>
    {
<#
    SqlConnection conn = new SqlConnection(connectionString);
    string command = string.Format("select {0}, {1} from {2} order by {0}", columnId, columnName, tableName);
    SqlCommand comm = new SqlCommand(command, conn);

    conn.Open();

    SqlDataReader reader = comm.ExecuteReader();
    bool loop = reader.Read();

    while(loop)
    {
#>      /// <summary>
        /// <#= reader[columnName] #> configuration setting.
        /// </summary>
        <#= Pascalize(reader[columnName]) #> = <#= reader[columnId] #><# loop = reader.Read(); #><#= loop ? ",\r\n" : string.Empty #>
<#
    }
#>  }
}
<#+
    private string Pascalize(object value)
    {
        Regex rx = new Regex(@"(?:[^a-zA-Z0-9]*)(?<first>[a-zA-Z0-9])(?<reminder>[a-zA-Z0-9]*)(?:[^a-zA-Z0-9]*)");
        return rx.Replace(value.ToString(), m => m.Groups["first"].ToString().ToUpper() + m.Groups["reminder"].ToString().ToLower());
    }

    private string GetSubNamespace()
    {
        Regex rx = new Regex(@"(?:.+Services\s)");
        string path = Path.GetDirectoryName(Host.TemplateFile);
        return rx.Replace(path, string.Empty).Replace("\\", ".");
    }
#>

Then whenever you'd like an enum to be generated, just create a tt file with the same name as database table like UserType.tt and put this code in:

<#@ include file="..\..\T4 Templates\EnumGenerator.ttinclude" #>

An even more advanced template is now available in my blog post.

martin clayton
  • 76,436
  • 32
  • 213
  • 198
Robert Koritnik
  • 103,639
  • 52
  • 277
  • 404
  • 2
    That's slick. I was thinking of doing this myself, but you've saved me the trouble. – 3Dave Dec 28 '10 at 05:16
  • 2
    @David Lively: Thanks. I used this on several projects so I thought I should share it with others as well. – Robert Koritnik Dec 28 '10 at 09:44
  • Pascalize doesn't seem to remove unwanted characters not in {'a'..'z','A'..'Z','0'..'9'} range such as '\' or '-'. Any chance for a fix for that line? – Danny Varod May 12 '11 at 18:02
  • @Danny: Can you give me an actual example so I can test my RegExp which seems fine actually. But I may be wrong. – Robert Koritnik May 12 '11 at 18:20
  • "Audio-Video Solutions" and "Transceiver\Converter" and "Headset Adapters & Accessories". – Danny Varod May 12 '11 at 19:18
  • @Danny: Are you sure you're using this code? Because when I run your three values I get: **AudioVideoSolutions**, **TransceiverConverter** and **HeadsetAdaptersAccessories** which seem fine. I've also checked the same values in [RegExHero](http://regexhero.net/tester) and everything seems to work as expected. You can test it as well if you wanted to. – Robert Koritnik May 13 '11 at 06:33
  • I used the function from your post and I remember seeing that expression, however, I'll double check next week. – Danny Varod May 13 '11 at 10:01
  • @Danny: Yes do that. `Pascalize` method is the same here in my answer as well in my blog post. So whichever code you used it should work as expected. Because the way it works is that it extracts only those words that have valid characters, everything else gets cut out. The first non-capturing group matches any invalid characters. Only the other two with valid characters get used for end result. The first one's omitted. – Robert Koritnik May 13 '11 at 17:16
  • Hi, I checked again (used the .NET Regex Tester link you provided too). These definitely fail (without the quotes): "A\B", "abc-d\e", "N/A", "hello (world)", "123a b c", "ab-c d e", "abc 123d". – Danny Varod May 15 '11 at 17:05
  • @Danny: I don't know whether you understand regular expression syntax but the one used only works with multi-letter word sequences. ie. *A\B* and *N/A* both have 2 single letter sequences which won't work. If you'd like to support these, you'll have to change reg ex. A question mark at the end will match single letter sequences, but you'll have to change code to not use `reminder` named group when it doesn't exist (it's undefined with single letter sequences, because only the first letter exists). `(?:[^a-zA-Z]*)(?[a-zA-Z])(?[a-zA-Z0-9]+)?`. – Robert Koritnik May 16 '11 at 07:55
  • 1
    @Danny: or even better use this one `(?:^|[^a-zA-Z]*)(?[a-zA-Z])(?[a-zA-Z0-9]*)(?:[^a-zA-Z0-9]*$)?` which will work for all your cases you've provided. Even though I wouldn't want to support single letter sequences there was a bug with non-word character(s) at the end. So I also updated my regular expression in the answer above. – Robert Koritnik May 16 '11 at 09:52
  • Fixed most of the values, still have a few exceptions: "ABC 1500D" and "ABC D" both map to "AbcD" instead of "AbcD" and "Abc1500d". "ABC DE-12" keeps the '-' and "ABC 123", "ABC DEF 123" keep a ' '. – Danny Varod May 16 '11 at 11:27
  • How about an opposite approach - select all non [a-zA-Z0-9] characters and replace with '-', add '-' to beginning of string, then if first chars after '-' are a-z replace with uppercase, remove '-'s, then if first char is not a letter, prefix with '_'. – Danny Varod May 16 '11 at 11:30
  • @Danny: I suppose you can change regular expression to your requirements (or the whole process as per your description). Some of your values (although valid in testing) are unlikely in real-life scenario. Except for the numbered sequences which do make sense. – Robert Koritnik May 16 '11 at 12:05
  • Unfortunately, the values are from an actual DB (with slight modifications). – Danny Varod May 16 '11 at 12:10
  • @Danny: `(?^)?(?(start)[^a-zA-Z]*|[^a-zA-Z0-9]*)(?(?(start)[a-zA-Z]|[a-zA-Z0-9]))(?[a-zA-Z0-9]*)(?:[^a-zA-Z0-9])?` = Starts with letter, supports single letter/number sequences, supports sequences starting with numbers. This one will work with your last exceptions as well. *ABC 1500D* => Abc1500d, *ABC D* => AbcD, *ABC DE-12* => AbcDe12, *ABC 123* => Abc123, *ABC DEF 123* => AbcDef123 – Robert Koritnik May 16 '11 at 12:25
  • @Danny: Sequences starting with number are of course supported for all sequences except the first one. ie: *123 ABC 456 DEF* => **Abc456Def**. Does this last regular expression satisfy your requirements? Or are there any other special cases that don't get converted as expected/required. – Robert Koritnik May 16 '11 at 12:32
  • @Danny: Final expression `(?^)?(?(start)[^a-zA-Z]*|[^a-zA-Z0-9]*)(?(?(start)[a-zA-Z]|[a-zA-Z0-9]))(?[a-zA-Z0-9]*)(?:[^a-zA-Z0-9]*)` – Robert Koritnik May 16 '11 at 12:45
  • I have values such as "123 ABC" and "456 ABC" which I need to tell apart. The method I wrote (see below) seems to cover all the inputs. Regex is supposed to run faster, however, I can not notice a performance hit, while transforming the templates. – Danny Varod May 16 '11 at 12:56
  • @Danny Ok. enum values must follow C# identifier naming conventions which means that this regular expression will be the right one: `(?:[^a-zA-Z0-9_]*)(?[a-zA-Z0-9_])(?[a-zA-Z0-9_]*)(?:[^a-zA-Z0-9_]*)` or you could exclude underscores as well if you wanted to (even though they can be present in identifier names as per C# spec). – Robert Koritnik May 16 '11 at 13:06
3

Alternative implementation of Pascalize:

private static string Pascalize(object value)
{
    if (value == null)
        return null;

    string result = value.ToString();

    if (string.IsNullOrWhiteSpace(result))
        return null;

    result = new String(result.Select(ch => ((ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'z') ? ch : ((ch >= 'A' && ch <= 'Z') ? ((char)((int)ch + (int)'a' - (int)'A')) : '-'))).ToArray());
    string[] words = result.Split(new [] {'-'}, StringSplitOptions.RemoveEmptyEntries);
    words = words.Select(w => ((w[0] >= 'a' && w[0] <= 'z') ? (w.Substring(0, 1).ToUpper() + w.Substring(1)) : w)).ToArray();
    result = words.Aggregate((st1, st2) => st1 + st2);
    if (result[0] >= '0' && result[0] <= '9')
        result = '_' + result;

    return result;
}

If you end up with a null result, I recommend using "Value" + id as value.

Result must be compared to previous values, you can add '_' to end if you have duplicates created (due to bad data or omitted characters).

E.g.

int value = Convert.ToInt32(reader[enumTable.ValueId]);
string name = Pascalize(reader[enumTable.ValueName].ToString());
if (string.IsNullOrWhiteSpace(name))
    name = "Value" + value;

while (result.Values.Where(v => v.Name == name).SingleOrDefault() != null)
    name += '_';
Danny Varod
  • 17,324
  • 5
  • 69
  • 111