Search
Recent Tweets

Entries in T4 (3)

Monday
Oct012012

Using T4 to Generate Typesafe Enum Classes and Resource Files

In working on the domain layer of an application, I wrote a couple of typesafe enumeration classes that mirrored data in a couple of reference tables in a database. If you are not familiar with the pattern, Jimmy Bogard's Enumeration classes post explains the pattern and rationale. The classes I wrote inherit from a modified version of the Enumeration class presented in Jimmy's post. The basic idea was to avoid switches and provide more functionality than an enum could offer. In our case the enum members mirrored a reference table and would be used for domain logic and for dropdown lists.

As the app progressed we had more of a need to do this for other reference tables, some of which had several records. At this point we decided to look into T4 to generate the typesafe enum classes. Since this application has a worldwide audience, the display names of the enum members needed globalization consideration since some of the enum values would end up in dropdown lists displayed to end users. I decided to create two T4 files, one to generate resource files with display name strings, and another to generate the enum classes that would use the resource strings.

Reference Datasource

This application uses XML files for various database definitions including reference data; these files get parsed to build the SQL Server database in automated fashion.

A sample of reference_data.xml looks something like:
<referenceData>
	<refTable name="CONTACT_TYPE" alias="ContactType" default="Person">
		<record CONTACT_TYPE_ID="1" CONTACT_TYPE_NAME="Company" RANK="2">
			<langs field="CONTACT_TYPE_NAME">
				<lang name="en">Company</lang>
				<lang name="de">Gesellschaft</lang>
				<lang name="es">Empresa</lang>
				<lang name="zh-CN">公司</lang>
				<lang name="fr">Société</lang>
			</langs>
		</record>
		<record CONTACT_TYPE_ID="2" CONTACT_TYPE_NAME="Person" RANK="1">
			<langs field="CONTACT_TYPE_NAME">
				<lang name="en">Person</lang>
				<lang name="de">Person</lang>
				<lang name="es">Persona</lang>
				<lang name="zh-CN">人</lang>
				<lang name="fr">Personne</lang>
			</langs>
		</record>
	</refTable>
</referenceData>

Schema Metadata and Parsing

Since multiple projects needed to perform T4 code generation off database schema information, I defined some T4 include files in a shared project to define the schema structure and XML parsing logic. This would make the T4 code easier and prevent duplicating XML parsing logic in multiple T4 files.

First SchemaMetadata.ttinclude defines the classes that hold the schema information in a T4-friendly manner:


Next SchemaReader.ttinclude would get consumed by T4 files to parse the XML data and return friendly objects defined in SchemaMetadata.ttinclude:


This shared T4 folder also included MultipleOutputHelper.ttinclude from this Damien Guard post to make splitting T4 output into multiple files a bit easier.

RefDataResources.tt

This T4 file generates one resource file per language and will setup the custom tool to produce the designer generated file to reference the resources in code:

Inside the function block of this file the schema data is loaded and the resource files are generated:
const string ReferenceData = "ReferenceData";

private SchemaMetadata LoadSchema()
{
	var loader = new SchemaReader(Host);
	var schema = loader.Load(SchemaReader.LoadOption.ReferenceData);
	return schema;
}

private void GenerateResourceFiles(SchemaMetadata schema, Manager manager, 
	DirectoryInfo t4DirInfo)
{
	var distinctLanguages = schema.ReferenceData.DistinctLanguages;
	foreach (var lang in distinctLanguages)
	{
		var resxNameNoExt = ("en" != lang) ? ReferenceData + "." + lang : ReferenceData;
		var resxName = resxNameNoExt + ".resx";
		manager.StartNewFile(resxName);

		var resxFilename = Path.Combine(t4DirInfo.FullName, resxName);
		// use .net's ResXResourceWriter so we don't have to worry about the XML format
		using (ResXResourceWriter  resx = new ResXResourceWriter(resxFilename))
		{
			var strings = schema.ReferenceData.StringsForLanguage(lang);

			foreach (var warn in schema.Warnings)
			{
				base.Warning(warn);
			}

			foreach (var de in strings)
			{
				try 
				{
					resx.AddResource(de.Key, de.Value);
				}
				catch (Exception ex)
				{
					base.Warning(ex.ToString());
				}
			}

			resx.Generate();
			resx.Close();
		}

		// we've written the file but outside the process of T4. 
		// In order to get the file to automatically added as a new output file 
		// underneath the t4 file, we must write the generated content to output stream
		Write(File.ReadAllText(resxFilename));

		manager.EndBlock();
		
	} // end for each lang loop
}

In the above code block there are a couple of things worth pointing out. First, the filename of the main / default language resource file will be ReferenceData.resx for English (en), otherwise ReferenceData.lang.resx for other languages. Second, the output from .NET's ResXResourceWriter gets read in with File.ReadAllText and written to T4 output with Write; otherwise the generated content would just exist on disk and would not get added into the project nested under the T4 file.

Finally, to get the designer generated class created, a function is created to set the custom tool property on the main ReferenceData.resx file that was generated. For the initial add that would be enough. However we also invoke execution of the custom tool with RunCustomTool() to handle the case where reference data is modified later on and the T4 transformation is performed again:
private void SetCustomToolOnMainResourceFile(DirectoryInfo t4DirInfo)
{
	// WARNING: You are entering the dark land of EnvDTE COM. You've been warned
	var hostServiceProvider = (IServiceProvider) Host;
	var dte = (EnvDTE.DTE) hostServiceProvider.GetService(typeof(EnvDTE.DTE));
	var filename = Path.Combine(t4DirInfo.FullName, "ReferenceData.resx");
	var projectItem = dte.Solution.FindProjectItem(filename);
	projectItem.Properties.Item("CustomTool").Value = "PublicResXFileCodeGenerator";
	projectItem.Properties.Item("CustomToolNamespace").Value = "App.Domain";
	var projItemObj = (VSProjectItem)projectItem.Object;
	projItemObj.RunCustomTool();
}

Running the T4 produces the following files:


The resource strings are created with a TableName_FieldNameValue format:


Enums.tt

Enums.tt reads the schema data just as in RefDataResources.tt and generates the C# typesafe enum class. The full source is available with the sample code for this post.


A small example of the generated output (ContactType.generated.cs):
//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated from a template.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.CodeDom.Compiler;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;

namespace App.Domain
{
	public partial class ContactType : Enumeration
	{		
		/// <summary>
		/// ContactType of Company (ID: 1, "Company")
		/// </summary>
		[GeneratedCode("TextTemplatingFileGenerator", "11")]
		public static readonly ContactType Company 
			= new ContactType( 1, ReferenceData.ContactType_Company );

		/// <summary>
		/// ContactType of Person (ID: 2, "Person")
		/// </summary>
		[GeneratedCode("TextTemplatingFileGenerator", "11")]
		public static readonly ContactType Person 
			= new ContactType( 2, ReferenceData.ContactType_Person );


		[ExcludeFromCodeCoverage, DebuggerNonUserCode, GeneratedCode("TextTemplatingFileGenerator", "11")]
		private ContactType( int value, string displayName ) : base( value, displayName ) { }

		[ExcludeFromCodeCoverage, DebuggerNonUserCode, GeneratedCode("TextTemplatingFileGenerator", "11")]
		public static ContactType Default()
		{
			return Person;
		}

		[UsedImplicitly, Obsolete("ORM runtime use only")]
		[ExcludeFromCodeCoverage, DebuggerNonUserCode, GeneratedCode("TextTemplatingFileGenerator", "11")]
		private ContactType() { }
	}
}

Of course there's more value in automatically generating all of the reference enums or at least those with more members:


Testing It Out

First some basic tests just to ensure the resource files and strongly typed resource class generated correctly:
using System.Globalization;
using NUnit.Framework;
using System.Threading;

namespace App.Domain.Tests
{
    [TestFixture, Category("Unit")]
    public class ReferenceDataI18NTests
    {
        [Test]
        public void English_To_French_Strings_Change()
        {
            var orig = Thread.CurrentThread.CurrentUICulture;
            Assert.AreEqual("Company", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo("fr-FR");
            Assert.AreEqual("Société", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = orig;
        }

        [Test]
        public void English_To_Spanish_Strings_Change()
        {
            var orig = Thread.CurrentThread.CurrentUICulture;
            Assert.AreEqual("Company", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo("es");
            Assert.AreEqual("Empresa", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = orig;
        }

        [Test]
        public void English_To_German_Strings_Change()
        {
            var orig = Thread.CurrentThread.CurrentUICulture;
            Assert.AreEqual("Company", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo("de");
            Assert.AreEqual("Gesellschaft", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = orig;
        }

        [Test]
        public void English_To_Chinese_Taiwan_Strings_Change()
        {
            var orig = Thread.CurrentThread.CurrentUICulture;
            Assert.AreEqual("Company", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = new CultureInfo("zh-CN");
            Assert.AreEqual("公司", ReferenceData.ContactType_Company);
            Thread.CurrentThread.CurrentUICulture = orig;
        }
    }
}

Just to make sure the Enumeration class works as expected, some tests to exercise it through one of the concrete classes:
using System.Linq;
using FluentAssertions;
using NUnit.Framework;

namespace App.Domain.Tests
{
    [TestFixture(
        Description = "Tests base Enumeration class via PhoneType concrete class")]
    [Category("Unit")]
    public class EnumerationTests
    {
        [Test]
        public void FromValue_Matches_Type_Value()
        {
            var fromValue = Enumeration.FromValue<PhoneType>(PhoneType.Cell.Value);
            Assert.AreEqual(PhoneType.Cell, fromValue);

            fromValue = Enumeration.FromValue<PhoneType>(PhoneType.Voice.Value);
            Assert.AreEqual(PhoneType.Voice, fromValue);
        }

        [Test]
        public void FromDisplayName_Matches_Type_Name()
        {
            var fromName = Enumeration.FromDisplayName<PhoneType>(PhoneType.Fax.DisplayName);
            Assert.AreEqual(PhoneType.Fax.DisplayName, fromName.DisplayName);

            fromName = Enumeration.FromDisplayName<PhoneType>(PhoneType.Pager.DisplayName);
            Assert.AreEqual(PhoneType.Pager.DisplayName, fromName.DisplayName);
        }

        [Test]
        public void ToString_Equals_DisplayName()
        {
            Assert.AreEqual(PhoneType.Cell.DisplayName, PhoneType.Cell.ToString());
        }

        [Test]
        public void Absolute_Difference_Math_Is_Correct()
        {
            var diff = Enumeration.AbsoluteDifference(PhoneType.Cell, PhoneType.Voice);
            Assert.AreEqual(3, diff);
            diff = Enumeration.AbsoluteDifference(PhoneType.Voice, PhoneType.Cell);
            Assert.AreEqual(3, diff);
        }

        [Test]
        public void GetAll_Contains_Expected_Members()
        {
            var all = Enumeration.GetAll<PhoneType>().ToList();
            Assert.AreEqual(4, all.Count);
            Assert.NotNull(all.FirstOrDefault(x => x == PhoneType.Cell));
            Assert.NotNull(all.FirstOrDefault(x => x == PhoneType.Fax));
            Assert.NotNull(all.FirstOrDefault(x => x == PhoneType.Pager));
            Assert.NotNull(all.FirstOrDefault(x => x == PhoneType.Voice));
            Assert.NotNull(all.FirstOrDefault(x => x == PhoneType.Default()));
        }

        [Test]
        public void Equality_Two_Are_Equal()
        {
            var one = PhoneType.Cell;
            var two = PhoneType.Cell;
            Assert.AreEqual(one, two);
            Assert.True(one == two);
        }

        [Test]
        public void Equality_Two_Different_Not_Equal()
        {
            var one = PhoneType.Cell;
            var two = PhoneType.Voice;
            Assert.AreNotEqual(one, two);
            Assert.True(one != two);
        }

        [Test]
        public void Equality_One_Null_Not_Equal()
        {
            PhoneType.Cell.Equals(null).Should().BeFalse();
        }

        [Test]
        public void Compare_To_Succeeds()
        {
            var diff = PhoneType.Cell.CompareTo(PhoneType.Fax);
            diff.Should().Be(1);

            diff = PhoneType.Fax.CompareTo(PhoneType.Cell);
            diff.Should().Be(-1);
        }

        [Test]
        public void Invalid_Parse_Number_Throws()
        {
            Assert.That(() => { Enumeration.FromValue<PhoneType>(999); }, Throws.Exception);
        }
    }
}

There's More

  • For simplicity some complexity was removed from the T4. This includes reference tables that have foreign keys to other reference tables (including strongly typed properties and ctor params) and resource strings for fields other than the primary display name.
  • One thing that bothered me initially with this was it felt a bit like introducing Data Driven Design into an otherwise Domain Driven Design paradigm. This was offset somewhat with aliases for table names (or a naming convention pattern) and reference tables could be selectively ignored in code generation through the use of attributes or other means. Another possible issue is class name conflicts with existing types in the domain project; this could be offset with a different namespace and/or naming convention.
  • Many apps load reference data from the database each time it's needed, or load it once and cache it until invalidated. Other apps may let users edit select sets of reference type data. If the data is likely to change during an app session or if users can edit some of it, chances are it isn't truly reference data to begin with. With a good deployment process, any compiled reference data info can be easily deployed and various reference data is likely to be tied to app business and presentation rules anyway.
  • Several of the enum classes would have corresponding partial classes for extended logic. For this reason, various code generation type attributes were places directly on generated members and not on the class itself, per various guidance.
  • An example use in the domain would be a ContactType enum property on a Contact object and requiring different fields when attempting to add contacts of different types. This app uses this component strategy in Fluent NHibernate to map the data from the database reference table into the domain class. For the UI side, Enumeration.GetAllMembers can be used along with AutoMapper to get the id and text values into simple ViewModel types for select lists.

Code

T4RefDataCode.zip
Monday
Oct312011

T4MVC with separate view and controller projects

T4MVC

A friend of mine recently informed me of T4MVC, a great little T4 template that generates some helper classes that allow for strongly referencing ASP.NET MVC controllers, actions, and views without those nasty hard-coded magic strings I detest. ReSharper helps validate those magic strings but I prefer to eliminate them entirely wherever possible.

The Issue

I tried T4MVC this morning and it worked for my controllers but not for my views. The problem was the template makes the assumption that your views and controllers reside in the same project. That works fine with the default, out of the box setup with a new ASP.NET MVC solution. However I went with this solution organization from Jimmy Bogard where my web project only has content (views, css, javascript, images etc.) and all my managed code (including controllers) resides in my "Core" assembly (see this post for sample solution). I added the NuGet package reference in my Core assembly where it could generate code for my controllers but not for my views. I will not go into the rationale for this style of organization as Jimmy does a good job of explaining that. His post is a couple of years old but even today in ASP.NET MVC 3 I still find it relevant.

Changing T4MVC.tt

I first logged a T4MVC issue requesting this support. Perhaps it will be added someday but I am not holding my breath. In the meantime, it was not overly difficult modifying the default template to support this separation. My workaround changes were quick and only lightly tested with my limited scenario however. In other words, your mileage may vary and this is not how I would implement this support in the official T4MVC project. That said, the code is below:

Declarations section:
//<Custom>
static Project ViewProject;
//</Custom>

PrepareDataToRender:
void PrepareDataToRender(TextTransformation tt) {
    //...
	
    // Get the path of the root folder of the app
	//<Custom>
    //AppRoot = Path.GetDirectoryName(Project.FullName) + '\\';
	//</Custom>

    MvcVersion = GetMvcVersion();

    // Use the proper return type of render helpers
    HtmlStringType = MvcVersion < 2 ? "string" : "MvcHtmlString";

	// <Custom>
    //ProcessAreas(Project);
	ViewProject = GetViewsProject(Dte);
	AppRoot = Path.GetDirectoryName(ViewProject.FullName) + '\\';
	ProcessAreas(ViewProject);
	// </Custom>
}

New functions to get a flat list of all projects (handling solution folders) and locating the Views/web project within:
//<Custom>
Project GetViewsProject(DTE dte) {
	var projects = Projects(dte);

	foreach (Project proj in projects)
	{
		if (proj.Name == ViewsProject)
		{
			return proj;
		}
	}
	
	return null;
}

public static IList<Project> Projects(DTE dte)
{
	Projects projects = dte.Solution.Projects;
	List<Project> list = new List<Project>();
	var item = projects.GetEnumerator();

	while (item.MoveNext())
	{
		var project = item.Current as Project;
		if (project == null)
		{
			continue;
		}

		if (project.Kind == ProjectKinds.vsProjectKindSolutionFolder)
		{
			list.AddRange(GetSolutionFolderProjects(project));
		}
		else
		{
			list.Add(project);
		}
	}

	return list;
}

private static IEnumerable<Project> GetSolutionFolderProjects(Project solutionFolder)
{
	List<Project> list = new List<Project>();
	for (var i = 1; i <= solutionFolder.ProjectItems.Count; i++)
	{
		var subProject = solutionFolder.ProjectItems.Item(i).SubProject;
		if (subProject == null)
		{
			continue;
		}

		// If this is another solution folder, do a recursive call, otherwise add
		if (subProject.Kind == ProjectKinds.vsProjectKindSolutionFolder)
		{
			list.AddRange(GetSolutionFolderProjects(subProject));
		}
		else
		{
			list.Add(subProject);
		}
	}

	return list;
}
//</Custom>

ProcessAreas:
void ProcessAreas(Project project) {
    // Process the default area
	// <Custom>
    ProcessDefaultArea();
	//</Custom>
	
	//...
}

Modified clone of ProcessArea:
//<Custom>
void ProcessDefaultArea() {
	string name = null;
    var area = new AreaInfo() { Name = name };
	var viewItems = ViewProject.ProjectItems;
	var controllerItems = Project.ProjectItems;
	
    ProcessAreaControllers(controllerItems, area);
    ProcessAreaViews(viewItems, area);
    Areas.Add(area);

    if (String.IsNullOrEmpty(name))
        DefaultArea = area;
}
//</Custom>

Per a reader's comment I changed static file processing to use the view project:
namespace <#=LinksNamespace #> {
<#
foreach (string folder in StaticFilesFolders) {
    ProcessStaticFiles(ViewProject, folder);
}
#>
}

Finally in T4MVC.tt.settings.t4 a new constant for the web project containing the views:
// <Custom>
const string ViewsProject = "MyWebProjectName";
// </Custom>

Download: T4MVC.tt | T4MVC.tt.settings.t4

Wrapup

With this in place I can enjoy the benefits of eliminating the magic strings for controller, action, and view names while also being able to have my views and controllers in separate projects. One downside is this could become a maintenance burden should T4MVC.tt change in a future release without this support baked in.

Updates

  1. 06/06/2012 - Changed resolution of the view project to handle solution folders; initial version just enumerated top level projects and didn't handle subprojects. Also changed static file processing to use ViewProject.
Tuesday
Oct192010

Web.config automation with T4 and a macro

Recently I blogged about my pain with getting Silverlight, WCF, IIS, Windows Authentication and Oracle to play nicely together. One of the pain points from that was needing one set of config settings for running the app and a different set anytime I needed to update the service reference. Specifically, for updating the service reference, serviceMetadata.httpGetEnabled must be true, IIS needs to allow anonymous access, and the Mex endpoint needs to be defined. When running the app the exact opposite is true.

With at least 3 developers working on the app and more WCF changes, updating the service reference was becoming a pain. Ideally our end state might be getting rid of the service reference and instead using ChannelFactory<T> along with some T4 to do "WCF the manual way, the right way" but without all the manual pain of creating begin/end async methods and all the other hoopla.

Until then we decided to come up with a solution for managing the config pain in the short term.

Some solutions

Web.config transformations This seemed ideal as I could just define what to replace, add or remove that varied per build configuration. It generated the correct config file just fine. The first problem I had with it is only actually uses the custom config when you go to publish the web app. As I'm not deploying to another server this didn't help much. I suppose I could have deployed to my own box but that is not ideal either. I was hoping simply changing the build configuration might determine which web.config to use. The other issue is that a separate build configuration is a hassle with 13 projects, half of which must target x86 and the other Any CPU.
MSBuild, build events and scripts The next thing I tried was directly invoking MSBuild with something like msbuild "%1" /T:TransformWebConfig /P:Configuration=%2. While that generated the specific config directly, the output was buried in subfolders and getting a "reference" to that location and dealing with copying the file and source control was trouble.

I went down the path of custom build events and started both batch files and powershell scripts but each route had some annoying roadblock / limitation of sorts.
T4 T4 seemed ideal in ways in that different web.config files could be created without having to have separate build configurations. I decided to go down this path and would later add a Visual Studio macro to take it one step further.

A starting point

With some quick research I came across Using T4 to auto-generate web.config files. That seemed to be a good start so I installed T4 Toolbox and Tangible T4 Editor (free edition) and began the T4 work. In referencing that blog post though there were a few things that were not obvious to me and some issues I had.

These issues included:
  • Visual Studio hangs - The Tangible T4 editor was hanging Visual Studio on opening any .tt file. After reading through the extension page comments, I realized the initial open built a cache and I had to wait 5+ minutes for it to complete. Later it still could take 10+ seconds opening a .tt file so I emailed the developer who indicated it was a side by side issue with Resharper that was being worked on. Patience is a virtue...
  • Compile transform errors - Next I ran into errors such as "Compiling transformation: The type or namespace name 'Generator' could not be found" and likewise for 'Template'. First I did not realize I needed to include T4Toolbox.tt, located at %PROGRAMFILES%\T4 Toolbox\. Reading over some T4 Toolbox blog entries I next realized the generator and template files needed to have the custom tool file property cleared as they are not intended for direct use but to be called from another template.
  • Excel / CSV file - The post indicated an Excel file for storing values that differed per config but not what format. Turns out it was a tab-delimited CSV file. Editing that in Excel though meant losing the formatting unless saving as xlsx or xls, and saving in those formats meant saving a different copy to be read by the T4 templates. Excel also caused some formatting issues with certain data such as additional quotes. Editing the tab-delimited CSV file in a text editor wasn't much better. I decided to change the "config values" file storage to XML and rewrote the generator to account for that.
  • Generator creation - I could not see where the filename was being passed to the generator in the post. I ended up creating a "calling / driver" T4 template to create and invoke the generator.

WebConfig.xml

This file stores the values that differ per web.config version. The config name can be anything but it will get stamped into the generated filename when run. As this solution was only for local development I only needed two config modes, one for updating the service reference and one for running the app. The individual items can be named as desired but I found it helpful to mimic the name/path in the web.config. Values can be simple values or tags.
<?xml version="1.0" encoding="utf-8" ?>
<configs>
	<config name="UpdateServiceReference">
		<item name="serviceMetadata.httpGetEnabled">true</item>
		<item name="system.web.authorization.deny"/>
		<item name="MexEndpoint">
			<endpoint name="MexEndpoint" address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
		</item>
	</config>
	<config name="Runtime">
		<item name="serviceMetadata.httpGetEnabled">false</item>
		<item name="system.web.authorization.deny">?</item>
		<item name="MexEndpoint"/>
	</config>
</configs>

WebConfigGenerator.tt

The generator takes the path to the "config values" xml file (WebConfig.xml here), reads in the data and creates a dictionary, and generates one config file per configuration using WebConfigTemplate.tt as the template. Make sure to clear the Custom Tool value for this file in file properties.
<#@ include file="WebConfigTemplate.tt" #>
<#@ assembly name="System.Xml.Linq" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Xml.Linq" #>

<#+
public class WebConfigGenerator : Generator
{
	private readonly string webConfigSettingsLocation;
	
	public WebConfigGenerator(string webConfigSettingsLocation)
	{
		this.webConfigSettingsLocation = webConfigSettingsLocation;
	}

	protected override void RunCore()
	{
		XDocument xDoc = XDocument.Load(this.webConfigSettingsLocation);		
		
		foreach (var xConfig in xDoc.Element("configs").Elements("config"))
		{
			var configName = xConfig.Attribute("name").Value.ToString();
			var values = new Dictionary<string, string>();
			
			foreach (XElement xItem in xConfig.Elements("item"))
			{
				if (!xItem.HasElements)
					values.Add(xItem.Attribute("name").Value.ToString(), xItem.Value.ToString());
				else 
				{
					var result = string.Concat(xItem.Nodes());
					values.Add(xItem.Attribute("name").Value.ToString(), result);
				}
			}
			
			var webConfigTemplate = new WebConfigTemplate(values);
			//webConfigTemplate.Output.File = "../Web.config." + configName;
			webConfigTemplate.Output.File = "WebConfig." + configName + ".config";

			if (configName == "local")
				webConfigTemplate.Output.BuildAction = BuildAction.None;
			else
				webConfigTemplate.Output.BuildAction = BuildAction.Content;
			webConfigTemplate.Render();		
		}		
	}
}
#>

WebConfigTemplate.tt

This contains the entire web.config but has variable placeholders for each area that needs to be dynamic. Unlike with a web.config transformation, this is not describing only what needs to change. Therefore with this approach it is best not to directly modify web.config but instead this template, then take the desired T4 config output and update the root web.config with that. This is a disadvantage of this technique though in practice I do not see it as an issue for my needs. Make sure to clear the Custom Tool value for this file in file properties.

<#@ import namespace="System" #>
<#@ import namespace="System.Collections.Generic" #>

<#+
public class WebConfigTemplate : Template
{
	private readonly Dictionary<string, string> values;
	
	public WebConfigTemplate(Dictionary<string, string> values)
	{
		this.values = values;
	}

	public override string TransformText()
	{ // make sure there is no whitespace at all before the xml declaration!!!
	#><?xml version="1.0" encoding="UTF-8"?>
<configuration>
	<!-- PUT YOUR WEB.CONFIG HERE and use the <#= values["MyKeyHere"] #> -->
</configuration>
	
    <#+    return this.GenerationEnvironment.ToString();
	}
}
#>

WebConfig.tt

This is the "top-level", calling template that invokes the generator and passes it the filename where the config settings are stored.
<#@ template  debug="true" hostSpecific="true" #>
<#@ output extension=".config" #>
<#@ Assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>

<#@ include file="T4Toolbox.tt" #>
<#@ include file="WebConfigGenerator.tt" #>


<#
    var file = Host.ResolvePath(@"WebConfig.xml");
	var configGen = new WebConfigGenerator(file);
    configGen.Run();
#>

Configuration generated, now what?



Invoking "Transform all templates" or changing WebConfig.tt will result in adding/updating the different config files and Visual Studio will prompt to checkout from Source Control. However the "real" web.config file still needs to be checked out and its contents replaced with the config version to be switched to...

Enter the macro

The Visual Studio macro takes care of selecting the Web.config file in Solution Explorer, issuing a TFS checkout if not already checked out, and replacing Web.config with the desired target config. When ApplySelectedConfig() is called, it will take whatever WebConfig.configName.config is selected in Solution Explorer (displaying an error if no correct selection) and overwrite web.config with that. Other public methods exist to switch to a known config filename without requiring any selection in Solution Explorer.
Imports System
Imports EnvDTE
Imports EnvDTE80
Imports EnvDTE90
Imports EnvDTE90a
Imports EnvDTE100
Imports System.Diagnostics
Imports System.IO

Public Module WebConfigModule

    Public Sub UpdateServiceReference()
        MsgBox("For now please use Update Service Reference manually.", MsgBoxStyle.Information, "Not Ready")
    End Sub


    Public Sub ApplySelectedConfig()
        Dim solExplore = GetSolutionExplorer()

        If (solExplore Is Nothing OrElse solExplore.SelectedItems Is Nothing) Then
            MsgBox("No selected item detected in solution explorer", MsgBoxStyle.Exclamation, "No Selected Document")
        End If

        Dim selItem As ProjectItem = solExplore.SelectedItems(0).Object

        If IsNothing(selItem) Then
            MsgBox("You must select a file (specific WebConfig.*.config)", MsgBoxStyle.Exclamation, "No Active Document")
            Return
        End If

        If Not selItem.Name.StartsWith("WebConfig.") OrElse Not selItem.Name.EndsWith(".config") Then
            MsgBox("You must select an environment web.config.* file", MsgBoxStyle.Exclamation, "No Env Config Selected")
            Return
        End If

        Dim configFilenameToApply = selItem.FileNames(0)
        ApplyConfigFilename(configFilenameToApply)
    End Sub

    Public Sub ApplyUpdateServiceConfig()
        ApplyConfigName("WebConfig.UpdateServiceReference.config")
    End Sub


    Public Sub ApplyRuntimeConfig()
        ApplyConfigName("WebConfig.Runtime.config")
    End Sub


    '--------------------------------------------------------------------------
    ' Private methods... helpers etc.
    '--------------------------------------------------------------------------

    Private Sub ApplyConfigName(ByVal configName As String)
        Dim solution As Solution2 = DTE.Solution
        If Not (solution.IsOpen) Then
            MsgBox("You must have the solution open", MsgBoxStyle.Exclamation, "No solution")
            Return
        End If

        Dim configProjItem = solution.FindProjectItem(configName)

        If configProjItem Is Nothing Then
            MsgBox("Could not find " + configName, MsgBoxStyle.Exclamation, "Config not found")
            Return
        End If

        configProjItem.ExpandView()
        Dim configFilenameToApply = configProjItem.FileNames(0)
        ApplyConfigFilename(configFilenameToApply)
    End Sub

    Private Sub ApplyConfigFilename(ByVal configFilenameToApply)
        Dim solution As Solution2 = DTE.Solution
        If Not (solution.IsOpen) Then Return

        Dim webConfigProjItem = solution.FindProjectItem("Web.config")
        webConfigProjItem.ExpandView()

        Dim webConfigFilename = webConfigProjItem.FileNames(0)

        Dim SolutionExplorerPath As String
        Dim items As EnvDTE.UIHierarchyItems = DTE.ToolWindows.SolutionExplorer.UIHierarchyItems
        Dim item As Object = FindItem(items, webConfigFilename, SolutionExplorerPath)

        If item Is Nothing Then
            MsgBox("Couldn't find web.config in Solution Explorer.")
            Return
        End If

        ' select web.config in solution explorer
        DTE.Windows.Item(Constants.vsWindowKindSolutionExplorer).Activate()
        DTE.ActiveWindow.Object.GetItem(SolutionExplorerPath).Select(vsUISelectionType.vsUISelectionTypeSelect)

        ' and issue a checkout if needed
        Dim isCheckedOut = DTE.SourceControl.IsItemCheckedOut(webConfigFilename)
        If Not isCheckedOut Then
            DTE.ExecuteCommand("File.TfsCheckOut")
        End If

        'overwrite web.config 
        File.Copy(configFilenameToApply, webConfigFilename, True)
    End Sub

    Private Function FindItem(ByVal Children As UIHierarchyItems, ByVal FileName As String, ByRef SolutionExplorerPath As String) As Object
        For Each CurrentItem As UIHierarchyItem In Children
            Dim TypeName As String = Microsoft.VisualBasic.Information.TypeName(CurrentItem.Object)
            If TypeName = "ProjectItem" Then
                Dim projectitem As EnvDTE.ProjectItem = CType(CurrentItem.Object, EnvDTE.ProjectItem)
                Dim i As Integer = 1
                While i <= projectitem.FileCount
                    Debug.WriteLine(projectitem.FileNames(i))

                    If projectitem.FileNames(i) = FileName Then
                        SolutionExplorerPath = CurrentItem.Name
                        Return CurrentItem
                    End If
                    i = i + 1
                End While
            End If

            Dim ChildItem As UIHierarchyItem = FindItem(CurrentItem.UIHierarchyItems, FileName, SolutionExplorerPath)
            If Not ChildItem Is Nothing Then
                SolutionExplorerPath = CurrentItem.Name + "\" + SolutionExplorerPath
                Return ChildItem
            End If
        Next
    End Function

    Private Function GetSolutionExplorer() As UIHierarchy
        Dim solExplore As UIHierarchy
        solExplore = DTE.Windows.Item(Constants.vsext_wk_SProjectWindow).Object()
        Return solExplore
    End Function

End Module

Running the Macro

There are multiple ways to run a Visual Studio macro but I prefer the immediate/command window:


Afterwards a macro method can be called to revert the config changes though usually I end up undoing pending changes on Web.config when the work is done and I need to switch back to the original configuration.

Conclusion

This is one of a few ways to handle this situation. It may seem a little crazy in ways but it works for now and each technique has its own pros and cons.

One current problem with the T4 generation is that sometimes visual studio prompts to checkout T4Toolbox.tt. I am not sure why this happens yet and in comparing changes, nothing is different. T4Toolbox.tt could be excluded from source control but that could break initial get latest version operations, at least when performed from the solution and not Source Control Explorer.

I started to automate updating the service reference itself but will have to come back to that another day. While SvcUtil could be called directly, source control would likely be a problem. However I'm assuming that inside the macro it would be possible to update the service reference programmatically similar to this code.

Subscribe to this feed