If we build code generators for a standardised solution, we will sooner or later need something special that does not fit into the generator. When that happens, we do not need to forfeit the benefits of our automation approach. Instead, we can build on top with hand-written code. Let us explore how we can do this safely.
The reason for partial classes and interfaces
A few weeks ago, I wrote that we always should generate partial classes. Thay may have looked like a useless overhead, but now we can use that extra keyword to our advantage.
The benefit that partial classes offer us is that we can split a class definition over multiple files. We have our generated *.g.cs file and can now add a hand-written *.cs file for the same class next to the generated code.
What works with partial classes does also works with partial interfaces. Then when we define the methods in the interface, we need a way to separate the hand-written methods from the generated ones – not only in the class itself, but also in the interface. Then as with the generated classes, the generated interfaces will also be overwritten when we run the generator again.
If we want to add hand-written code to a generated class, we create a new partial class and most often a partial interface.
Extend our Customer table
To get some real-world use case, we need a bit more data inside our Customer table. We can add these new columns:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
ALTER TABLE [dbo].[Customer] ADD StreetAndNumber nvarchar(100) NULL GO ALTER TABLE [dbo].[Customer] ADD ZipCode nvarchar(10) NULL GO ALTER TABLE [dbo].[Customer] ADD City nvarchar(100) NULL GO ALTER TABLE [dbo].[Customer] ADD State nvarchar(100) NULL GO ALTER TABLE [dbo].[Customer] ADD CountryCode nvarchar(2) NULL GO |
After extending our table, we can run our three code generators in this order to update the generated code:
- ClassGeneratorFromDb.tt
- RepositoryGeneratorFromDb.tt
- IntegrationTestGeneratorFromDb.tt
We need to update the new fields in the CustomerGenerator.cs by hand to get some useful values for our tests:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
using System; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.TestDataGenerators; public class CustomerGenerator { public static Customer GetTestData() { return new Customer() { LastName = "Example", FirstName = "Joe", CreatedOn = new DateTime(2024, 4, 1, 12, 30, 45), IsActive = true, StreetAndNumber = "One Microsoft Way", City = "Redmond", ZipCode = "98052", State = "WA", CountryCode = "US" }; } public static Customer GetDataForUpdate() { return new Customer() { LastName = "Updated", FirstName = "Max", CreatedOn = new DateTime(2022, 1, 2, 3, 4, 5), IsActive = false, StreetAndNumber = "One Apple Park Way", City = "Cupertino", ZipCode = "95014", State = "CA", CountryCode = "UK" }; } } |
Extend the Customer class
It would be great if we could add a method to the Customer class that gives us back a formatted address string. We cannot add this method to the generated code, then that would be gone if we regenerate the entity. Instead, we create a Customer.cs file next to the Customer.g.cs and add the method there:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace GenerateCodeFromDb.FromDb.Entities { public partial class Customer { public string GetAddress() { var address = $"{FirstName} {LastName}" + "\n{StreetAndNumber}" + "\n{City}, {State} {ZipCode}" + "\n{Country(CountryCode)}"; return address; } private string Country(string code) { if (code.Equals("US")) { return "United States"; } else { return code; } } } } |
Since our Customer class has no interface, we do not need to create one.
We should make sure that the code behaves as it should by adding a unit test in the folder FromDb\UnitTest and name it CustomerTests.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
using System; using FluentAssertions; using NUnit.Framework; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.FromDb.UnitTests { [TestFixture] public partial class CustomerTests { [Test] public void GetAddress_formats_US_address() { var customer = new Customer { LastName = "Example", FirstName = "Joe", CreatedOn = new DateTime(2024, 4, 1, 12, 30, 45), IsActive = true, StreetAndNumber = "One Microsoft Way", City = "Redmond", ZipCode = "98052", State = "WA", CountryCode = "US" }; var formattedAddress = customer.GetAddress(); formattedAddress.Should().Be("Joe Example" + "\nOne Microsoft Way" + "\nRedmond, WA 98052" + "\nUnited States"); } } } |
With that we can prevent bugs from showing up when we change the generator or rename the fields.
Extend the repository
If we want to search Customers by a country code, we can create a method in the repository. But as with the Customer.g.cs class, we cannot put it into the generated code. Instead, we add it to a new file CustomerRepository.cs next to CustomerRepository.g.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
using Dapper; using System.Collections.Generic; using System.Linq; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.FromDb.Repositories { public partial class CustomerRepository { public List<Customer> FindByCountryCode(string countryCode) { var sql = @" SELECT [Id] ,[LastName] ,[FirstName] ,[Email] ,[IsActive] ,[CreatedOn] ,[StreetAndNumber] ,[ZipCode] ,[City] ,[State] ,[CountryCode] FROM dbo.Customer WHERE CountryCode = @countryCode "; return this.connection.Query<Customer>(sql, new { countryCode }).ToList(); } } } |
Our generated repositories have interfaces, so we need to create a partial interface for our new method and name that new file ICustomerRepository.cs and put it next to ICustomerRepository.g.cs:
1 2 3 4 5 6 7 8 9 10 |
using System.Collections.Generic; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.FromDb.Repositories { public partial interface ICustomerRepository { List<Customer> FindByCountryCode(string countryCode); } } |
We can create a partial class for our integration tests and put the code in the file CustomerRepositoryTests.cs next to the generated one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Transactions; using FluentAssertions; using Microsoft.VisualStudio.TestPlatform.ObjectModel.DataCollection; using NUnit.Framework; using GenerateCodeFromDb.FromDb.Entities; namespace GenerateCodeFromDb.FromDb.IntegrationTests { public partial class CustomerRepositoryTests { [Test] public void FindByCountryCode_returns_matching_customers() { using (new TransactionScope(TransactionScopeOption.RequiresNew)) { var idCh = InsertUserInDb("CH"); var idAt = InsertUserInDb("AT"); var result = this.testee.FindByCountryCode("CH"); result.Should().OnlyContain(c => c.CountryCode.Equals("CH")); } } private int InsertUserInDb(string countryCode) { var customer = new Customer() { FirstName = "Markus", LastName = "Muster", Email = "Markus@Muster." + countryCode, CreatedOn = DateTime.Now, CountryCode = countryCode }; return testee.Create(customer); } } } |
Our partial integration test class can reuse the setup code from the generated integration test file and does not need its own setup code.
If you dislike the long SQL statement in the hand-written method, you can always create a variable with the statement until the WHERE clause. You then can access that generated part from the hand-written method and save yourself the repetitive work of reproducing the SQL query.
Run the tests
Make sure to run the tests, then otherwise we have a test but still may not notice a bug:
Next
We now have additional methods available to us that build on top of our automation work that will not get deleted when we rerun our code generators. The tests help us to show how to use the new methods and they will prevent us from breaking the code with future changes.
Next week we end this series with more ideas on how to automate smaller parts with T4 and look beyond T4 templates for other automation opportunities.