In this first article, we aim to implement the initial working version of the Builder pattern as described in the parent article. However, we'll ignore two important features for now: class inheritance and immutable collections.
Our goal is to write an aspect that will perform the following transformations:
- Introduce a nested class named
Builder
with the following members:- A copy constructor initializing the
Builder
class from an instance of the source class. - A public constructor for users of our class, accepting values for all required properties.
- A writable property for each property of the source type.
- A
Build
method that instantiates the source type with the values set in theBuilder
, calling theValidate
method if present.
- A copy constructor initializing the
- Add the following members to the source type:
- A private constructor called by the
Builder.Build
method. - A
ToBuilder
method returning a newBuilder
initialized with the current instance.
- A private constructor called by the
Here's an illustration of the changes performed by this aspect when applied to a simple class:
1using Metalama.Samples.Builder1;
2using System.ComponentModel.DataAnnotations;
3
4namespace Metalama.Samples.Builder1.Tests.SimpleExample;
5
6[GenerateBuilder]
7public partial class Song
8{
9 [Required] public string Artist { get; }
10
11 [Required] public string Title { get; }
12
13 public TimeSpan? Duration { get; }
14
15 public string Genre { get; } = "General";
16}
1using Metalama.Samples.Builder1;
2using System.ComponentModel.DataAnnotations;
3
4namespace Metalama.Samples.Builder1.Tests.SimpleExample;
5
6[GenerateBuilder]
7public partial class Song
8{
9 [Required] public string Artist { get; }
10
11 [Required] public string Title { get; }
12
13 public TimeSpan? Duration { get; }
14
15 public string Genre { get; } = "General";
16
17 private Song(string artist, string title, TimeSpan? duration, string genre)
18 {
19 Artist = artist;
20 Title = title;
21 Duration = duration;
22 Genre = genre;
23 }
24
25 public Builder ToBuilder()
26 {
27 return new Builder(this);
28 }
29
30 public class Builder
31 {
32 public Builder(string artist, string title)
33 {
34 Artist = artist;
35 Title = title;
36 }
37
38 internal Builder(Song source)
39 {
40 Artist = source.Artist;
41 Title = source.Title;
42 Duration = source.Duration;
43 Genre = source.Genre;
44 }
45
46 private string _artist = default!;
47
48 public string Artist
49 {
50 get
51 {
52 return _artist;
53 }
54
55 set
56 {
57 _artist = value;
58 }
59 }
60
61 private TimeSpan? _duration;
62
63 public TimeSpan? Duration
64 {
65 get
66 {
67 return _duration;
68 }
69
70 set
71 {
72 _duration = value;
73 }
74 }
75
76 private string _genre = "General";
77
78 public string Genre
79 {
80 get
81 {
82 return _genre;
83 }
84
85 set
86 {
87 _genre = value;
88 }
89 }
90
91 private string _title = default!;
92
93 public string Title
94 {
95 get
96 {
97 return _title;
98 }
99
100 set
101 {
102 _title = value;
103 }
104 }
105
106 public Song Build()
107 {
108 var instance = new Song(Artist, Title, Duration, Genre)!;
109 return instance;
110 }
111 }
112}
1. Setting up the infrastructure
We are about to author complex aspects that introduce members with relationships to each other. Before diving in, a bit of planning and infrastructure is necessary.
It's good practice to keep the T# template logic simple and do the hard work in the BuildAspect method. The question is how to pass data from BuildAspect
to T# templates. As explained in Sharing state with advice, a convenient approach is to use tags. Since we will have many tags, it's even more convenient to use strongly-typed tags containing all the data that BuildAspect
needs to pass to templates. This is the purpose of the Tags
record.
A critical member of the Tags
record is a collection of PropertyMapping
objects. The PropertyMapping
class maps a source property to a Builder
property, as well as to the corresponding parameter in different constructors.
Here's the source code for the Tags
and PropertyMapping
types:
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4namespace Metalama.Samples.Builder1;
5
6public partial class GenerateBuilderAttribute
7{
8 [CompileTime]
9 private record Tags(
10 INamedType SourceType,
11 IReadOnlyList<PropertyMapping> Properties,
12 IConstructor SourceConstructor,
13 IConstructor BuilderCopyConstructor);
14
15 [CompileTime]
16 private class PropertyMapping
17 {
18 public PropertyMapping(IProperty sourceProperty, bool isRequired)
19 {
20 this.SourceProperty = sourceProperty;
21 this.IsRequired = isRequired;
22 }
23
24 public IProperty SourceProperty { get; }
25
26 public bool IsRequired { get; }
27
28 public IProperty? BuilderProperty { get; set; }
29
30 public int? SourceConstructorParameterIndex { get; set; }
31
32 public int? BuilderConstructorParameterIndex { get; set; }
33 }
34}
The first thing we do in the BuildAspect
method is build a list of properties. When we initialize this list, we don't yet know all data items because they haven't been created yet.
12public override void BuildAspect(IAspectBuilder<INamedType> builder)
13{
14 base.BuildAspect(builder);
15
16 var sourceType = builder.Target;
17
18 // Create a list of PropertyMapping items for all properties that we want to build using the Builder.
19 var properties = sourceType.Properties.Where(
20 p => p.Writeability != Writeability.None &&
21 !p.IsStatic)
22 .Select(
23 p => new PropertyMapping(p,
24 p.Attributes.OfAttributeType(typeof(RequiredAttribute)).Any()))
25 .ToList();
As you can see, we use the RequiredAttribute custom attribute to determine if a property is required or optional. We chose not to use the required
keyword because required
properties cannot be initialized from the constructor, making code generation more cumbersome for a first example.
Spoiler alert: here's how we share the Tags
class with advice at the end of the BuildAspect
method:
120builder.Tags = new Tags(builder.Target, properties, sourceConstructor,
121 builderCopyConstructor);
2. Creating the Builder type and the properties
Let's now create a nested type using IntroduceClass:
28// Introduce the Builder nested type.
29var builderType = builder.IntroduceClass(
30 "Builder",
31 buildType: t => t.Accessibility = Accessibility.Public);
For details about creating new types, see Introducing types.
Introducing properties is straightforward, as described in Introducing members:
34// Add builder properties and update the mapping.
35foreach (var property in properties)
36{
37 property.BuilderProperty =
38 builderType.IntroduceAutomaticProperty(
39 property.SourceProperty.Name,
40 property.SourceProperty.Type,
41 buildProperty: p =>
42 {
43 p.Accessibility = Accessibility.Public;
44 p.InitializerExpression = property.SourceProperty.InitializerExpression;
45 })
46 .Declaration;
47}
Note that we copy the InitializerExpression
(i.e., the expression to the right of the =
sign on an automatic property) from the source property to the builder property. This ensures that these properties will have the proper default value, even in the Builder
class.
The created property is then stored in the BuilderProperty
property of the PropertyMapping
object, so we can refer to it later.
3. Creating the Builder public constructor
Our next task is to create the public constructor of the Builder
nested type, which should have parameters for all required properties.
50// Add a builder constructor accepting the required properties and update the mapping.
51builderType.IntroduceConstructor(
52 nameof(this.BuilderConstructorTemplate),
53 buildConstructor: c =>
54 {
55 c.Accessibility = Accessibility.Public;
56
57 foreach (var property in properties.Where(m => m.IsRequired))
58 {
59 var parameter = c.AddParameter(
60 NameHelper.ToParameterName(property.SourceProperty.Name),
61 property.SourceProperty.Type);
62
63 property.BuilderConstructorParameterIndex = parameter.Index;
64 }
65 });
We use the AddParameter method to dynamically create a parameter for each required property. We save the ordinal of this parameter in the BuilderConstructorParameterIndex
property of the PropertyMapping
object for later reference in the constructor implementation.
Here is BuilderConstructorTemplate
, the template for this constructor. You can now see how we use the Tags
and PropertyMapping
objects. This code iterates through required properties and assigns a property of the Builder
type to the value of the corresponding constructor parameter.
124[Template]
125private void BuilderConstructorTemplate()
126{
127 var tags = (Tags)meta.Tags.Source!;
128
129 foreach (var property in tags.Properties.Where(p => p.IsRequired))
130 {
131 property.BuilderProperty!.Value =
132 meta.Target.Parameters[property.BuilderConstructorParameterIndex!.Value].Value;
133 }
134}
135
4. Implementing the Build method
The Build
method of the Builder
type is responsible for creating an instance of the source (immutable) type from the values of the Builder
.
It requires a new constructor in the source type accepting a parameter for all properties. Here's how to create it:
68// Add a constructor to the source type with all properties.
69var sourceConstructor = builder.IntroduceConstructor(
70 nameof(this.SourceConstructorTemplate),
71 buildConstructor: c =>
72 {
73 c.Accessibility = Accessibility.Private;
74
75 foreach (var property in properties)
76 {
77 var parameter = c.AddParameter(
78 NameHelper.ToParameterName(property.SourceProperty.Name),
79 property.SourceProperty.Type);
80
81 property.SourceConstructorParameterIndex = parameter.Index;
82 }
83 })
84 .Declaration;
We store the resulting constructor in a local variable, so we can pass it to the Tags
object later in the BuildAspect
method.
The template for this constructor is SourceConstructorTemplate
. It simply assigns the properties based on constructor parameters.
148[Template]
149private void SourceConstructorTemplate()
150{
151 var tags = (Tags)meta.Tags.Source!;
152
153 foreach (var property in tags.Properties)
154 {
155 property.SourceProperty.Value =
156 meta.Target.Parameters[property.SourceConstructorParameterIndex!.Value].Value;
157 }
158}
159
Equipped with this constructor, we can now introduce the Build
method:
87// Add a Build method to the builder.
88builderType.IntroduceMethod(
89 nameof(this.BuildMethodTemplate),
90 IntroductionScope.Instance,
91 buildMethod: m =>
92 {
93 m.Name = "Build";
94 m.Accessibility = Accessibility.Public;
95 m.ReturnType = sourceType;
96 });
The T# template for the Build
method first invokes the newly introduced constructor, then tries to find and call the optional Validate
method before returning the new instance of the source type.
160[Template]
161private dynamic BuildMethodTemplate()
162{
163 var tags = (Tags)meta.Tags.Source!;
164
165 // Build the object.
166 var instance = tags.SourceConstructor.Invoke(
167 tags.Properties.Select(x => x.BuilderProperty!))!;
168
169 // Find and invoke the Validate method, if any.
170 var validateMethod = tags.SourceType.AllMethods.OfName("Validate")
171 .SingleOrDefault(m => m.Parameters.Count == 0);
172
173 if (validateMethod != null)
174 {
175 validateMethod.With((IExpression)instance).Invoke();
176 }
177
178 // Return the object.
179 return instance;
180}
181
5. Implementing the ToBuilder method
Our last task is to add a ToBuilder
method to the source type, which must create an instance of the Builder
type initialized with the values of the current instance.
First, we'll need a new constructor in the Builder
type, called the copy constructor. In theory, we could reuse the public constructor for this purpose, but the next articles in this series will be simpler if we use a copy constructor here.
Let's add this code to BuildAspect
:
100// Add a builder constructor that creates a copy from the source type.
101var builderCopyConstructor = builderType.IntroduceConstructor(
102 nameof(this.BuilderCopyConstructorTemplate),
103 buildConstructor: c =>
104 {
105 c.Accessibility = Accessibility.Internal;
106 c.Parameters[0].Type = sourceType;
107 }).Declaration;
Unsurprisingly, the template of this constructor just goes through the list of PropertyMapping
and assigns the Builder
properties from the corresponding source properties:
136[Template]
137private void BuilderCopyConstructorTemplate(dynamic source)
138{
139 var tags = (Tags)meta.Tags.Source!;
140
141 foreach (var property in tags.Properties)
142 {
143 property.BuilderProperty!.Value =
144 property.SourceProperty.With((IExpression)source).Value;
145 }
146}
147
We can finally introduce the ToBuilder
method:
110// Add a ToBuilder method to the source type.
111builder.IntroduceMethod(nameof(this.ToBuilderMethodTemplate), buildMethod: m =>
112{
113 m.Accessibility = Accessibility.Public;
114 m.Name = "ToBuilder";
115 m.ReturnType = builderType.Declaration;
116});
Here is the template for the constructor body. It only invokes the constructor.
182[Template]
183private dynamic ToBuilderMethodTemplate()
184{
185 var tags = (Tags)meta.Tags.Source!;
186
187 return tags.BuilderCopyConstructor.Invoke(meta.This);
188}
Conclusion
As you can see, automating the Builder
aspect with Metalama can seem complex at the beginning, but the process can be split into individual simple tasks. It's crucial to start with proper analysis and planning. You should first know what you want and how exactly you want to transform the code. Once this is clear, the implementation becomes quite straightforward.
In the next article, we will see how to take type inheritance into account.