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



















































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

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.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Builder3;
6
7[CompileTime]
8internal abstract partial class PropertyMapping : ITemplateProvider
9{
10    protected PropertyMapping( IProperty sourceProperty, bool isRequired, bool isInherited )
11    {
12        this.SourceProperty = sourceProperty;
13        this.IsRequired = isRequired;
14        this.IsInherited = isInherited;
15    }
16
17    public IProperty SourceProperty { get; }
18
19    public bool IsRequired { get; }
20
21    public bool IsInherited { get; }
22
23    public int? SourceConstructorParameterIndex { get; set; }
24
25    public int? BuilderConstructorParameterIndex { get; set; }
26
27    /// <summary>
28    /// Gets an expression that contains the value of the Builder property. The type of the
29    /// expression must be the type of the property in the <i>source</i> type, not in the builder
30    /// type.
31    /// </summary>
32    public abstract IExpression GetBuilderPropertyValue();
33
34    /// <summary>
35    /// Adds the properties, fields and methods required to implement this property.
36    /// </summary>
37    public abstract void ImplementBuilderArtifacts( IAdviser<INamedType> builderType );
38
39    /// <summary>
40    /// Imports, from the base type, the properties, field and methods required for
41    /// the current property. 
42    /// </summary>
43    public abstract bool TryImportBuilderArtifactsFromBaseType(
44        INamedType baseType,
45        ScopedDiagnosticSink diagnosticSink );
46
47    /// <summary>
48    /// A template for the code that sets the relevant data in the Builder type for the current property. 
49    /// </summary>
50    [Template]
51    public virtual void SetBuilderPropertyValue( IExpression expression, IExpression builderInstance )
52    {
53        // Abstract templates are not supported, so we must create a virtual method and override it.
54        throw new NotSupportedException();
55    }
56}

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.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Builder3;
6
7internal class StandardPropertyMapping : PropertyMapping
8{
9    private IProperty? _builderProperty;
10
11    public StandardPropertyMapping( IProperty sourceProperty, bool isRequired, bool isInherited )
12        : base( sourceProperty, isRequired, isInherited ) { }
13
14    public override IExpression GetBuilderPropertyValue() => this._builderProperty!;
15
16    public override void ImplementBuilderArtifacts( IAdviser<INamedType> builderType )
17    {
18        this._builderProperty = builderType.IntroduceAutomaticProperty(
19                this.SourceProperty.Name,
20                this.SourceProperty.Type,
21                IntroductionScope.Instance,
22                buildProperty: p =>
23                {
24                    p.Accessibility = Accessibility.Public;
25                    p.InitializerExpression = this.SourceProperty.InitializerExpression;
26                } )
27            .Declaration;
28    }
29
30    public override bool TryImportBuilderArtifactsFromBaseType(
31        INamedType baseType,
32        ScopedDiagnosticSink diagnosticSink )
33
34    {
35        return this.TryFindBuilderPropertyInBaseType(
36            baseType,
37            diagnosticSink,
38            out this._builderProperty );
39    }
40
41    public override void SetBuilderPropertyValue(
42        IExpression expression,
43        IExpression builderInstance )
44    {
45        this._builderProperty!.WithObject( builderInstance ).Value = expression.Value;
46    }
47}

The TryFindBuilderPropertyInBaseType helper method is defined here:

9protected bool TryFindBuilderPropertyInBaseType(
10    INamedType baseType,
11    ScopedDiagnosticSink diagnosticSink,
12    [NotNullWhen( true )] out IProperty? baseProperty )
13{
14    baseProperty =
15        baseType.AllProperties.OfName( this.SourceProperty.Name )
16            .SingleOrDefault();
17
18    if ( baseProperty == null )
19    {
20        diagnosticSink.Report(
21            BuilderDiagnosticDefinitions.BaseBuilderMustContainProperty.WithArguments(
22                (
23                    baseType, this.SourceProperty.Name) ) );
24
25        return false;
26    }
27
28    return true;
29}

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:

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

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.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Code.SyntaxBuilders;
4using Metalama.Framework.Diagnostics;
5
6namespace Metalama.Samples.Builder3;
7
8internal class ImmutableCollectionPropertyMapping : PropertyMapping
9{
10    private IField? _collectionBuilderField;
11    private IField? _initialValueField;
12    private IProperty? _collectionBuilderProperty;
13    private IMethod? _getImmutableValueMethod;
14    private readonly IType _collectionBuilderType;
15
16    public ImmutableCollectionPropertyMapping(
17        IProperty sourceProperty,
18        bool isRequired,
19        bool isInherited ) : base( sourceProperty, isRequired, isInherited )
20    {
21        this._collectionBuilderType =
22            ((INamedType) sourceProperty.Type).Types.OfName( "Builder" ).Single();
23    }
24
25    private IType ImmutableCollectionType => this.SourceProperty.Type;
26
27    public override void ImplementBuilderArtifacts( IAdviser<INamedType> builderType )
28    {
29        builderType = builderType.WithTemplateProvider( this );
30
31        this._collectionBuilderField = builderType
32            .IntroduceField(
33                NameHelper.ToFieldName( this.SourceProperty.Name + "Builder" ),
34                this._collectionBuilderType.ToNullable(),
35                buildField: f => f.Accessibility = Accessibility.Private )
36            .Declaration;
37
38        this._initialValueField = builderType
39            .IntroduceField(
40                NameHelper.ToFieldName( this.SourceProperty.Name ),
41                this.ImmutableCollectionType,
42                buildField: f =>
43                {
44                    f.Accessibility = Accessibility.Private;
45
46                    if ( !this.IsRequired )
47                    {
48                        // Unless the field is required, we must initialize it to a value representing
49                        // a valid but empty collection, except if we are given a different
50                        // initializer expression.
51                        if ( this.SourceProperty.InitializerExpression != null )
52                        {
53                            f.InitializerExpression = this.SourceProperty.InitializerExpression;
54                        }
55                        else
56                        {
57                            var initializerExpressionBuilder = new ExpressionBuilder();
58                            initializerExpressionBuilder.AppendTypeName( this.ImmutableCollectionType );
59                            initializerExpressionBuilder.AppendVerbatim( ".Empty" );
60                            f.InitializerExpression = initializerExpressionBuilder.ToExpression();
61                        }
62                    }
63                } )
64            .Declaration;
65
66        this._collectionBuilderProperty = builderType
67            .IntroduceProperty(
68                nameof(this.BuilderPropertyTemplate),
69                buildProperty: p =>
70                {
71                    p.Name = this.SourceProperty.Name;
72                    p.Accessibility = Accessibility.Public;
73                    p.GetMethod!.Accessibility = Accessibility.Public;
74                    p.Type = this._collectionBuilderType;
75                } )
76            .Declaration;
77
78        this._getImmutableValueMethod = builderType.IntroduceMethod(
79                nameof(this.BuildPropertyMethodTemplate),
80                buildMethod: m =>
81                {
82                    m.Name = "GetImmutable" + this.SourceProperty.Name;
83                    m.Accessibility = Accessibility.Protected;
84                    m.ReturnType = this.ImmutableCollectionType;
85                } )
86            .Declaration;
87    }
88
89    [Template]
90    public dynamic BuilderPropertyTemplate
91        => this._collectionBuilderField!.Value ??= this._initialValueField!.Value!.ToBuilder();
92
93    [Template]
94    public dynamic BuildPropertyMethodTemplate()
95    {
96        if ( this._collectionBuilderField!.Value == null )
97        {
98            return this._initialValueField!.Value!;
99        }
100        else
101        {
102            return this._collectionBuilderProperty!.Value!.ToImmutable();
103        }
104    }
105
106    public override IExpression GetBuilderPropertyValue()
107        => this._getImmutableValueMethod!.CreateInvokeExpression();
108
109    public override bool TryImportBuilderArtifactsFromBaseType(
110        INamedType baseType,
111        ScopedDiagnosticSink diagnosticSink )
112    {
113        // Find the property containing the collection builder.
114        if ( !this.TryFindBuilderPropertyInBaseType(
115                baseType,
116                diagnosticSink,
117                out this._collectionBuilderProperty ) )
118        {
119            return false;
120        }
121
122        // Find the method GetImmutable* method.
123        this._getImmutableValueMethod =
124            baseType.AllMethods.OfName( "GetImmutable" + this.SourceProperty.Name )
125                .SingleOrDefault();
126
127        if ( this._getImmutableValueMethod == null )
128        {
129            diagnosticSink.Report(
130                BuilderDiagnosticDefinitions.BaseBuilderMustContainGetImmutableMethod.WithArguments(
131                    (baseType, this.SourceProperty.Name) ) );
132
133            return false;
134        }
135
136        return true;
137    }
138
139    public override void SetBuilderPropertyValue(
140        IExpression expression,
141        IExpression builderInstance )
142    {
143        this._initialValueField!.WithObject( builderInstance ).Value = expression.Value;
144    }
145}

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.