In the previous articles, we created an aspect that implements the Builder
pattern for properties of "plain" types. However, properties of collection types require different handling.
Since the Builder pattern is typically used to build immutable objects, it is good practice for properties of the immutable class to be of an immutable type, such as ImmutableArray<T> or ImmutableDictionary<TKey, TValue>. In the Builder
class, though, it's more convenient if the collections are mutable. For instance, for a source property of type ImmutableArray<string>
, the builder property could be an ImmutableArray<string>.Builder
.
In this article, we'll update the aspect so that the collection properties of the Builder
class are of the builder collection type.
Additionally, we want the collection properties in the Builder
type to be lazy, meaning we only allocate a collection builder if the collection is evaluated.
Here is an example of a transformation performed by the aspect.
1using System.ComponentModel.DataAnnotations;
2
3namespace Metalama.Samples.Builder3.Tests.DerivedType;
4
5#pragma warning disable CS8618 // Non-nullable property must contain a non-null value when exiting constructor.
6
7[GenerateBuilder]
8public class Article
9{
10 [Required] public string Url { get; }
11
12 [Required] public string Name { get; }
13}
14
15public class WebArticle : Article
16{
17 public string Keywords { get; }
18}
1using System.ComponentModel.DataAnnotations;
2
3namespace Metalama.Samples.Builder3.Tests.DerivedType;
4
5#pragma warning disable CS8618 // Non-nullable property must contain a non-null value when exiting constructor.
6
7[GenerateBuilder]
8public class Article
9{
10 [Required] public string Url { get; }
11
12 [Required] public string Name { get; }
13
14 protected Article(string url, string name)
15 {
16 Url = url;
17 Name = name;
18 }
19
20 public virtual Builder ToBuilder()
21 {
22 return new Builder(this);
23 }
24
25 public class Builder
26 {
27 public Builder(string url, string name)
28 {
29 Url = url;
30 Name = name;
31 }
32
33 protected internal Builder(Article source)
34 {
35 Url = source.Url;
36 Name = source.Name;
37 }
38
39 private string _name = default!;
40
41 public string Name
42 {
43 get
44 {
45 return _name;
46 }
47
48 set
49 {
50 _name = value;
51 }
52 }
53
54 private string _url = default!;
55
56 public string Url
57 {
58 get
59 {
60 return _url;
61 }
62
63 set
64 {
65 _url = value;
66 }
67 }
68
69 public Article Build()
70 {
71 var instance = new Article(Url, Name)!;
72 return instance;
73 }
74 }
75}
76
77public class WebArticle : Article
78{
79 public string Keywords { get; }
80
81 protected WebArticle(string keywords, string url, string name) : base(url, name)
82 {
83 Keywords = keywords;
84 }
85
86 public override Builder ToBuilder()
87 {
88 return new Builder(this);
89 }
90
91 public new class Builder : Article.Builder
92 {
93 public Builder(string url, string name) : base(url, name)
94 {
95 }
96
97 protected internal Builder(WebArticle source) : base(source)
98 {
99 Keywords = source.Keywords;
100 }
101
102 private string _keywords = default!;
103
104 public string Keywords
105 {
106 get
107 {
108 return _keywords;
109 }
110
111 set
112 {
113 _keywords = value;
114 }
115 }
116
117 public new WebArticle Build()
118 {
119 var instance = new WebArticle(Keywords, Url, Name)!;
120 return instance;
121 }
122 }
123}
Step 1. Setting up more abstractions
We'll now update the aspect to support two kinds of properties: standard ones and properties of an immutable collection type. We'll only support collection types from the System.Collections.Immutable namespace, but the same approach can be used for different types.
Since we have two kinds of properties, we'll make the PropertyMapping
class abstract. It will have two implementations: StandardPropertyMapping
and ImmutableCollectionPropertyMapping
. Any implementation-specific method must be abstracted in the PropertyMapping
class and implemented separately in derived classes.
These implementation-specific methods are as follows:
GetBuilderPropertyValue() : IExpression
returns an expression that contains the value of theBuilder
property. The type of the expression must be the type of the property in the source type, not in the builder type. For standard properties, this will return the builder property itself. For immutable collection properties, this will be the new immutable collection constructed from the immutable collection builder.ImplementBuilderArtifacts()
will be called for non-inherited properties and must add declarations required to implement the property. For standard properties, this is just the public property in theBuilder
type. For immutable collections, this is more complex and will be discussed later.TryImportBuilderArtifactsFromBaseType
will be called for inherited properties and must find the required declarations from the base type.SetBuilderPropertyValue
is the template used in the copy constructor to store the initial value of the property.
The BuilderProperty
property of the PropertyMapping
class now becomes an implementation detail and is removed from the abstract class.
Here is the new PropertyMapping
class:
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5using System.Diagnostics.CodeAnalysis;
6
7namespace Metalama.Samples.Builder3;
8
9[CompileTime]
10internal abstract partial class PropertyMapping : ITemplateProvider
11{
12 protected PropertyMapping(IProperty sourceProperty, bool isRequired, bool isInherited)
13 {
14 this.SourceProperty = sourceProperty;
15 this.IsRequired = isRequired;
16 this.IsInherited = isInherited;
17 }
18
19 public IProperty SourceProperty { get; }
20 public bool IsRequired { get; }
21 public bool IsInherited { get; }
22 public int? SourceConstructorParameterIndex { get; set; }
23 public int? BuilderConstructorParameterIndex { get; set; }
24
25 /// <summary>
26 /// Gets an expression that contains the value of the Builder property. The type of the
27 /// expression must be the type of the property in the <i>source</i> type, not in the builder
28 /// type.
29 /// </summary>
30 public abstract IExpression GetBuilderPropertyValue();
31
32 /// <summary>
33 /// Adds the properties, fields and methods required to implement this property.
34 /// </summary>
35 public abstract void ImplementBuilderArtifacts(IAdviser<INamedType> builderType);
36
37 /// <summary>
38 /// Imports, from the base type, the properties, field and methods required for
39 /// the current property.
40 /// </summary>
41 public abstract bool TryImportBuilderArtifactsFromBaseType(INamedType baseType,
42 ScopedDiagnosticSink diagnosticSink);
43
44 /// <summary>
45 /// A template for the code that sets the relevant data in the Builder type for the current property.
46 /// </summary>
47 [Template]
48 public virtual void SetBuilderPropertyValue(IExpression expression, IExpression builderInstance)
49 {
50 // Abstract templates are not supported, so we must create a virtual method and override it.
51 throw new NotSupportedException();
52 }
53}
Note that the PropertyMapping
class now implements the (empty) ITemplateProvider interface. This is required because SetBuilderPropertyValue
is an auxiliary template, i.e. a template called from another top-level template. Note also that SetBuilderPropertyValue
cannot be abstract due to current limitations in Metalama, so we had to make it virtual. For details regarding auxiliary templates, see Calling auxiliary templates.
The implementation of PropertyMapping
for standard properties is directly extracted from the aspect implementation in the previous article.
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5
6namespace Metalama.Samples.Builder3;
7
8internal class StandardPropertyMapping : PropertyMapping
9{
10 private IProperty? _builderProperty;
11
12 public StandardPropertyMapping(IProperty sourceProperty, bool isRequired, bool isInherited)
13 : base(sourceProperty, isRequired, isInherited)
14 {
15 }
16
17 public override IExpression GetBuilderPropertyValue() => this._builderProperty!;
18
19 public override void ImplementBuilderArtifacts(IAdviser<INamedType> builderType)
20 {
21 this._builderProperty = builderType.IntroduceAutomaticProperty(
22 this.SourceProperty.Name,
23 this.SourceProperty.Type,
24 IntroductionScope.Instance,
25 buildProperty: p =>
26 {
27 p.Accessibility = Accessibility.Public;
28 p.InitializerExpression = this.SourceProperty.InitializerExpression;
29 })
30 .Declaration;
31 }
32
33 public override bool TryImportBuilderArtifactsFromBaseType(INamedType baseType,
34 ScopedDiagnosticSink diagnosticSink)
35
36 {
37 return this.TryFindBuilderPropertyInBaseType(baseType, diagnosticSink,
38 out this._builderProperty);
39 }
40
41 public override void SetBuilderPropertyValue(IExpression expression,
42 IExpression builderInstance)
43 {
44 this._builderProperty!.With(builderInstance).Value = expression.Value;
45 }
46}
The TryFindBuilderPropertyInBaseType
helper method is defined here:
9protected bool TryFindBuilderPropertyInBaseType(INamedType baseType,
10 ScopedDiagnosticSink diagnosticSink, [NotNullWhen(true)] out IProperty? baseProperty)
11{
12 baseProperty =
13 baseType.AllProperties.OfName(this.SourceProperty.Name)
14 .SingleOrDefault();
15
16 if (baseProperty == null)
17 {
18 diagnosticSink.Report(
19 BuilderDiagnosticDefinitions.BaseBuilderMustContainProperty.WithArguments((
20 baseType, this.SourceProperty.Name)));
21 return false;
22 }
23
24 return true;
25}
Step 2. Updating the aspect
Both the BuildAspect
method and the templates must call the abstract methods and templates of PropertyMapping
.
Let's look, for instance, at the code that used to create the builder properties in the Builder
nested type. You can see how the implementation-specific logic was moved to PropertyMapping.ImplementBuilderArtifacts
and PropertyMapping.TryImportBuilderArtifactsFromBaseType
:
133// Add builder properties and update the mapping.
134foreach (var property in properties)
135{
136 if (property.SourceProperty.DeclaringType == sourceType)
137 {
138 // For properties of the current type, introduce a new property.
139 property.ImplementBuilderArtifacts(builderType);
140 }
141 else if (baseBuilderType != null)
142 {
143 // For properties of the base type, import them.
144 if (!property.TryImportBuilderArtifactsFromBaseType(baseBuilderType,
145 builder.Diagnostics))
146 {
147 hasError = true;
148 }
149 }
150}
151
152if (hasError)
153{
154 return;
155}
The aspect has been updated in several other locations. For details, please refer to the source code by following the link to GitHub at the top of this article.
Step 3. Adding the logic specific to immutable collections
At this point, we can run the same unit tests as for the previous article, and they should execute without any differences.
Let's now focus on implementing support for properties whose type is an immutable collection.
As always, we should first design a pattern at a conceptual level, and then switch to its implementation.
To make things more complex with immutable collections, we must address the requirement that collection builders should not be allocated until the user evaluates the public property of the Builder
type. When this property is required, we must create a collection builder from the initial collection value, which can be either empty or, if the ToBuilder()
method was used, the current value in the source object.
Each property will be implemented by four artifacts:
- A field containing the property initial value, which can be either empty or, when initialized from the copy constructor, set to the value in the source object.
- A nullable field containing the collection builder.
- The public property representing the collection, which lazily instantiates the collection builder.
- A method returning the immutable collection from the collection builder if it has been defined, or returning the initial value if undefined (which means that there can be no change).
These artifacts are built by the ImplementBuilderArtifacts
method of the ImmutableCollectionPropertyMapping
class and then used in other methods and templates.
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Metalama.Framework.Diagnostics;
6
7namespace Metalama.Samples.Builder3;
8
9internal class ImmutableCollectionPropertyMapping : PropertyMapping
10{
11 private IField? _collectionBuilderField;
12 private IField? _initialValueField;
13 private IProperty? _collectionBuilderProperty;
14 private IMethod? _getImmutableValueMethod;
15 private readonly IType _collectionBuilderType;
16
17 public ImmutableCollectionPropertyMapping(IProperty sourceProperty, bool isRequired,
18 bool isInherited) : base(sourceProperty, isRequired, isInherited)
19 {
20 this._collectionBuilderType =
21 ((INamedType)sourceProperty.Type).Types.OfName("Builder").Single();
22 }
23
24 private IType ImmutableCollectionType => this.SourceProperty.Type;
25
26 public override void ImplementBuilderArtifacts(IAdviser<INamedType> builderType)
27 {
28 builderType = builderType.WithTemplateProvider(this);
29
30 this._collectionBuilderField = builderType
31 .IntroduceField(NameHelper.ToFieldName(this.SourceProperty.Name + "Builder"),
32 this._collectionBuilderType.ToNullableType(),
33 buildField: f => f.Accessibility = Accessibility.Private)
34 .Declaration;
35
36 this._initialValueField = builderType
37 .IntroduceField(NameHelper.ToFieldName(this.SourceProperty.Name),
38 this.ImmutableCollectionType,
39 buildField: f =>
40 {
41 f.Accessibility = Accessibility.Private;
42
43 if (!this.IsRequired)
44 {
45 // Unless the field is required, we must initialize it to a value representing
46 // a valid but empty collection, except if we are given a different
47 // initializer expression.
48 if (this.SourceProperty.InitializerExpression != null)
49 {
50 f.InitializerExpression = this.SourceProperty.InitializerExpression;
51 }
52 else
53 {
54 var initializerExpressionBuilder = new ExpressionBuilder();
55 initializerExpressionBuilder.AppendTypeName(
56 this.ImmutableCollectionType);
57 initializerExpressionBuilder.AppendVerbatim(".Empty");
58 f.InitializerExpression = initializerExpressionBuilder.ToExpression();
59 }
60 }
61 })
62 .Declaration;
63
64 this._collectionBuilderProperty = builderType
65 .IntroduceProperty(nameof(this.BuilderPropertyTemplate), buildProperty: p =>
66 {
67 p.Name = this.SourceProperty.Name;
68 p.Accessibility = Accessibility.Public;
69 p.GetMethod!.Accessibility = Accessibility.Public;
70 p.Type = this._collectionBuilderType;
71 }).Declaration;
72
73 this._getImmutableValueMethod = builderType.IntroduceMethod(
74 nameof(this.BuildPropertyMethodTemplate),
75 buildMethod: m =>
76 {
77 m.Name = "GetImmutable" + this.SourceProperty.Name;
78 m.Accessibility = Accessibility.Protected;
79 m.ReturnType = this.ImmutableCollectionType;
80 }).Declaration;
81 }
82
83 [Template]
84 public dynamic BuilderPropertyTemplate
85 => this._collectionBuilderField!.Value ??= this._initialValueField!.Value!.ToBuilder();
86
87 [Template]
88 public dynamic BuildPropertyMethodTemplate()
89 {
90 if (this._collectionBuilderField!.Value == null)
91 {
92 return this._initialValueField!.Value!;
93 }
94 else
95 {
96 return this._collectionBuilderProperty!.Value!.ToImmutable();
97 }
98 }
99
100
101 public override IExpression GetBuilderPropertyValue()
102 => this._getImmutableValueMethod!.CreateInvokeExpression([]);
103
104 public override bool TryImportBuilderArtifactsFromBaseType(INamedType baseType,
105 ScopedDiagnosticSink diagnosticSink)
106 {
107 // Find the property containing the collection builder.
108 if (!this.TryFindBuilderPropertyInBaseType(baseType, diagnosticSink,
109 out this._collectionBuilderProperty))
110 {
111 return false;
112 }
113
114 // Find the method GetImmutable* method.
115 this._getImmutableValueMethod =
116 baseType.AllMethods.OfName("GetImmutable" + this.SourceProperty.Name)
117 .SingleOrDefault();
118
119 if (this._getImmutableValueMethod == null)
120 {
121 diagnosticSink.Report(
122 BuilderDiagnosticDefinitions.BaseBuilderMustContainGetImmutableMethod.WithArguments(
123 (baseType, this.SourceProperty.Name)));
124 return false;
125 }
126
127 return true;
128 }
129
130 public override void SetBuilderPropertyValue(IExpression expression,
131 IExpression builderInstance)
132 {
133 this._initialValueField!.With(builderInstance).Value = expression.Value;
134 }
135}
Conclusion
Handling different kinds of properties led us to use more abstraction in our aspect. As you can see, meta-programming, like other forms of programming, requires a strict definition of concepts and the right level of abstraction.
Our aspect now correctly handles not only derived types but also immutable collections.