Teaching SQLite how to handle DateTimeOffset values #5

If you followed all the steps in the previous blog post, created your own SQLite DateTimeOffset serializer post-build task and tested it, you might wonder how to automate it. At the moment, we need to build the target assembly, register its file path in the post-build task’s code, build and run the post-build task. There must be a better way to do this!

Automatic start:

The simplest approach is to register our console program as post-build step to any project in Visual Studio. To do so, open the target project’s Properties window and switch to the Build Events section: Here you can define simple scripts that should be run automatically before / after the build. When entering file path and name of our post-build application into the Post-build event command line text box, the post-build task will run after every successful build.

Simple post-build step registration

Obviously, this does not fully solve our problem, since the post-build application still contains the target assembly’s file path as a hardcoded property. To get around this, we’d need to pass the assembly path as console parameter into the post-build application, instead of defining it as property… However, there is an even better approach towards reliable post-build tasks, so let’s take a look at this one instead!

Post-build tasks vs. post-build applications:

Although we’ve been talking about post-build tasks, in fact we were only building simple console applications to be run after the build. However, .NET framwork provides an API for creating actual BuildTasks that offer a lot more flexibility and reliability.

To test this, we need a few additional framework references. Open the post-build project’s references window, and check the Microsoft.Build, Microsoft.Build.Framework, and Microsoft.Build.Utilities.v4.0 libraries:

Necessary references for declaring build tasks

Since the result shall be a task instead of an application, we need to change the project properties as well in order to produce a .dll library instead of a .exe program: This is done by changing the Output type from Console Application to Class Library in the project’s properties. In addition, the main class must be derived from the abstract class Microsoft.Build.Utilities.Task.

Class Libraries can not run stand-alone, so they do not feature an entry point. This means there is no need for a Main method in our code base any more, so let’s replace it by a simple method – for this, we choose to override the Execute() method that is declared by the abstract class Microsoft.Build.Utilities.Task:

public class PostBuildTask : Task
{
	public string AssemblyPath { get; set; } = @"C:\workspace\some_program_containing_sqlite_tables.exe";

	public override bool Execute()
	{
		// content of the original program's Main method goes here...
		
		return true;
	}
}

As you might have noticed, there is one main difference to the original Main method: The Execute method return a bool value. This indicates whether the post-build task has successfully finished (not if the target assembly has actually been maniputaled!), so we return true in all cases

Logging and exception handling:

One of the advantages of using .NET build tasks is the integrated logging. The Micro.Build.Utilities.Task class we’re inheriting from provides a public instance of type TaskLoggingHelper. This logging helper class offers a bunch of different logging methods, all of which are printed to the target project’s output console when the build task is being executed.

Within the main Execute method, we can just call one of these logging methods to inform the user about our post-build task’s status, for example to print out each property that is about to being manipulated:

foreach (var property in flaggedProperties)
{
	Log.LogMessage("Processing property {0}: Injecting string property for DateTimeOffset serialization.", property.Property.FullName);
	type.RebuildProperty(property);
}

In addition, we can use this logging mechanism to improve our rudimental exception handling: I decided to pass the instance of TaskLoggingHelper to all extension methods, so we can log exceptions directly to the output stack. For example, a safe variant of the FindDateTimeOffsetToStringMethod method could look like this:

internal static MethodReference FindDateTimeOffsetToStringMethod(this ModuleDefinition module, TaskLoggingHelper log)
{
	TypeDefinition dateTimeOffsetType;
	try
	{
		dateTimeOffsetType = module.Import(typeof(System.DateTimeOffset)).Resolve();
	}
	catch (Exception e)
	{
		log.LogErrorFromException(e);
		return null;
	}
	var foreignToStringMethod = dateTimeOffsetType.Methods.Single(m =>
		m.Name.Equals("ToString") &&
		m.Parameters.Count == 1 &&
		m.Parameters[0].ParameterType.MetadataType == MetadataType.String);
	var toStringMethod = module.Import(foreignToStringMethod);
	return toStringMethod;
}

Get rid of hard-coded values:

By now, the build task is getting better and better, but it still contains the AssemblyPath that defines the target assembly’s file path in a hard-coded string value. Fortunately, we defined this as public property, as this makes it easy to migrate it to a build task parameter: To do so, simple remove the hard-coded value, and flag the property with the Microsoft.Build.Framework.Required attribute:

public class PostBuildTask : Task
{
	[Required]
	public string AssemblyPath { get; set; }

	public override bool Execute()
	{
		// ...
	}
}

This allows us to pass the target path to the build task as parameter during execution. Let’s take a look at how to register a build task to a project, in order to be able to pass the parameter!

Registering build tasks:

Switch back to the sample SQLite project, open its containing folder in Windows Explorer, and look for a *.csproject file. This file contains the project definition, among others including references to all source files – load it into your favorite text editor, and scroll to the very end! There should be a section looking similar to the following code snippet:

<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>

As long as no build tasks are registered to the project, this part should be commented out. Active the last two of these lines (since we want to register a post-build, not a pre-build task) by moving them out of the comment area, and create two new XML nodes as reference to our build task:

<UsingTask TaskName="SQLite.Net.DateTimeOffset.PostBuild.PostBuildTask" AssemblyFile="C:\Documents\SQLite.Net.DateTimeOffset.PostBuild\SQLite.Net.DateTimeOffset.PostBuild\bin\Debug\SQLite.Net.DateTimeOffset.PostBuild.dll" />

<Target Name="AfterBuild">
	<PostBuildTask />
</Target>

The first line is necessary to specify the actual library that contains the build task. Remember to provide your actual file path here. The task’s name should always contain the full namespace to avoid ambiguities. This task can then be referenced within one of the Target nodes, in our case we use it as AfterBuild target.

Parameterized build tasks:

One more step before we can test the whole setup: The idea behind decorating our AssemblyPath property with the [Required] attribute was to force it being loaded during execution. This means, we need to extend the XML declaration with the parameter specification. In its simplest form, the parameter can just be used as XML attribute:

<Target Name="AfterBuild">
	<PostBuildTask AssemblyPath="C:\workspace\some_program_containing_sqlite_tables.exe" />
</Target>

Obviously, this is not the optimum solution, since it (again) hard-codes the target assembly’s path (even if we’re inside the target project’s source by now, which is better than a hard-coded string inside the build tasks source code, but still far from ideal). Instead, let’s take a look at the official list of build command macros: These macros can be used anywhere within a *.csproj project file and will be replaced by the actual values automatically during build. Since we need the absolute path name of the primary output file for the build, we can make use of the $(TargetPath) macro:

<Target Name="AfterBuild">
	<PostBuildTask AssemblyPath="$(TargetPath)" />
</Target>

The post-build task should be fully functional by now, there also only two improvements to the task’s source code I’d like to suggest – read more about it in the final part of this tutorial!