Open sandboxFocusImprove this doc

Builder example, step 3: Handling immutable collection properties

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.

Source Code
1using Metalama.Samples.Builder3;
2using System.Collections.Immutable;
3
4namespace Metalama.Samples.Builder3.Tests.SimpleExample._ImmutableArray;
5
6#pragma warning disable CS8618 //  Non-nullable property must contain a non-null value when exiting constructor.
7
8[GenerateBuilder]
9public partial class ColorWheel
10{
11    public ImmutableArray<string> Colors { get; }



















































12}
Transformed Code
1using Metalama.Samples.Builder3;
2using System.Collections.Immutable;
3
4namespace Metalama.Samples.Builder3.Tests.SimpleExample._ImmutableArray;
5
6#pragma warning disable CS8618 //  Non-nullable property must contain a non-null value when exiting constructor.
7
8[GenerateBuilder]
9public partial class ColorWheel
10{
11    public ImmutableArray<string> Colors { get; }
12
13    protected ColorWheel(ImmutableArray<string> colors)
14    {
15        Colors = colors;
16    }
17
18    public virtual Builder ToBuilder()
19    {
20        return new Builder(this);
21    }
22
23    public class Builder
24    {
25        private ImmutableArray<string> _colors = ImmutableArray<string>.Empty;
26        private ImmutableArray<string>.Builder? _colorsBuilder;
27
28        public Builder()
29        {
30        }
31
32        protected internal Builder(ColorWheel source)
33        {
34            _colors = source.Colors;
35        }
36
37        public ImmutableArray<string>.Builder Colors
38        {
39            get
40            {
41                return _colorsBuilder ??= _colors.ToBuilder();
42            }
43        }
44
45        public ColorWheel Build()
46        {
47            var instance = new ColorWheel(GetImmutableColors());
48            return instance;
49        }
50
51        protected ImmutableArray<string> GetImmutableColors()
52        {
53            if (_colorsBuilder == null)
54            {
55                return _colors;
56            }
57            else
58            {
59                return Colors.ToImmutable();
60            }
61        }
62    }
63}

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 the Builder 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 the Builder 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:

132// Add builder properties and update the mapping.
133foreach (var property in properties)
134{
135    if (property.SourceProperty.DeclaringType == sourceType)
136    {
137        // For properties of the current type, introduce a new property.
138        property.ImplementBuilderArtifacts(builderType);
139    }
140    else if (baseBuilderType != null)
141    {
142        // For properties of the base type, import them.
143        if (!property.TryImportBuilderArtifactsFromBaseType(baseBuilderType,
144                builder.Diagnostics))
145        {
146            hasError = true;
147        }
148    }
149}
150
151if (hasError)
152{
153    return;
154}

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.