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

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    public override IExpression GetBuilderPropertyValue() => this._builderProperty!;
16
17    public override void ImplementBuilderArtifacts( IAdviser<INamedType> builderType )
18    {
19        this._builderProperty = builderType.IntroduceAutomaticProperty(
20                this.SourceProperty.Name,
21                this.SourceProperty.Type,
22                IntroductionScope.Instance,
23                buildProperty: p =>
24                {
25                    p.Accessibility = Accessibility.Public;
26                    p.InitializerExpression = this.SourceProperty.InitializerExpression;
27                } )
28            .Declaration;
29    }
30
31    public override bool TryImportBuilderArtifactsFromBaseType(
32        INamedType baseType,
33        ScopedDiagnosticSink diagnosticSink )
34
35    {
36        return this.TryFindBuilderPropertyInBaseType(
37            baseType,
38            diagnosticSink,
39            out this._builderProperty );
40    }
41
42    public override void SetBuilderPropertyValue(
43        IExpression expression,
44        IExpression builderInstance )
45    {
46        this._builderProperty!.With( builderInstance ).Value = expression.Value;
47    }
48}

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:

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

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

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.