Open sandboxFocusImprove this doc

Builder example, step 2: Handling derived types

In the previous article, we assumed the type hierarchy was flat. Now, we will consider type inheritance, handling cases where the base type already has a builder.

Our objective is to generate code like in the following example, where the WebArticle class derives from Article. Notice how WebArticle.Builder derives from Article.Builder and how WebArticle constructors call the base constructors of Article.

Source Code
1using System.ComponentModel.DataAnnotations;
2
3namespace Metalama.Samples.Builder2.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]
11    public string Url { get; }
12
13    [Required]
14    public string Name { get; }







































15}
16























17public class WebArticle : Article
18{

























19    public string Keywords { get; }


















20}
Transformed Code
1using System.ComponentModel.DataAnnotations;
2
3namespace Metalama.Samples.Builder2.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]
11    public string Url { get; }
12
13    [Required]
14    public string Name { get; }
15
16    protected Article(string url, string name)
17    {
18        Url = url;
19        Name = name;
20    }
21
22    public virtual Builder ToBuilder()
23    {
24        return new Builder(this);
25    }
26
27    public class Builder
28    {
29        public Builder(string url, string name)
30        {
31            Url = url;
32            Name = name;
33        }
34
35        protected internal Builder(Article source)
36        {
37            Url = source.Url;
38            Name = source.Name;
39        }
40
41        private string _name = default!;
42
43        public string Name
44        {
45            get
46            {
47                return _name;
48            }
49
50            set
51            {
52                _name = value;
53            }
54        }
55
56        private string _url = default!;
57
58        public string Url
59        {
60            get
61            {
62                return _url;
63            }
64
65            set
66            {
67                _url = value;
68            }
69        }
70
71        public Article Build()
72        {
73            var instance = new Article(Url, Name);
74            return instance;
75        }
76    }
77}
78
79public class WebArticle : Article
80{
81    public string Keywords { get; }
82
83    protected WebArticle(string keywords, string url, string name) : base(url, name)
84    {
85        Keywords = keywords;
86    }
87
88    public override Builder ToBuilder()
89    {
90        return new Builder(this);
91    }
92
93    public new class Builder : Article.Builder
94    {
95        public Builder(string url, string name) : base(url, name)
96        {
97        }
98
99        protected internal Builder(WebArticle source) : base(source)
100        {
101            Keywords = source.Keywords;
102        }
103
104        private string _keywords = default!;
105
106        public string Keywords
107        {
108            get
109            {
110                return _keywords;
111            }
112
113            set
114            {
115                _keywords = value;
116            }
117        }
118
119        public new WebArticle Build()
120        {
121            var instance = new WebArticle(Keywords, Url, Name);
122            return instance;
123        }
124    }
125}

Step 1. Preparing to report errors

A general best practice when implementing patterns using an aspect is to consider the case where the pattern has been implemented manually on the base type and to report errors when hand-written code does not adhere to the conventions we have set for the patterns. For instance, the previous article set some rules regarding the generation of constructors. In this article, the aspect will assume that the base types (both the base source type and the base builder type) define the expected constructors. Otherwise, we will report an error. It's always better for the user than throwing an exception.

Before reporting any error, we must declare a DiagnosticDefinition static field for each type of error.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Builder2;
6
7[CompileTime]
8internal static class BuilderDiagnosticDefinitions
9{
10    public static readonly DiagnosticDefinition<INamedType>
11        BaseTypeCannotContainMoreThanOneBuilderType
12            = new(
13                "BUILDER01",
14                Severity.Error,
15                "The type '{0}' cannot contain more than one nested type named 'Builder'.",
16                "The base type cannot contain more than one nested type named 'Builder'." );
17
18    public static readonly DiagnosticDefinition<INamedType> BaseTypeMustContainABuilderType
19        = new(
20            "BUILDER02",
21            Severity.Error,
22            "The type '{0}' must contain a 'Builder' nested type.",
23            "The base type cannot contain more than one builder type." );
24
25    public static readonly DiagnosticDefinition<(INamedType, string)> BaseBuilderMustContainProperty
26        = new(
27            "BUILDER03",
28            Severity.Error,
29            "The '{0}' type must contain a property named '{1}'.",
30            "The base builder type must contain properties for all properties of the base built type." );
31
32    public static readonly DiagnosticDefinition<(INamedType, int)> BaseTypeMustContainOneConstructor
33        = new(
34            "BUILDER04",
35            Severity.Error,
36            "The '{0}' type must contain a single constructor but has {1}.",
37            "The base type must contain a single constructor." );
38
39    public static readonly DiagnosticDefinition<(IConstructor, string)>
40        BaseTypeConstructorHasUnexpectedParameter
41            = new(
42                "BUILDER05",
43                Severity.Error,
44                "The '{1}' parameter of '{0}' cannot be mapped to a property.",
45                "A parameter of the base type cannot be mapped to a property." );
46
47    public static readonly DiagnosticDefinition<(INamedType BuilderType, INamedType SourceType)>
48        BaseBuilderMustContainCopyConstructor
49            = new(
50                "BUILDER06",
51                Severity.Error,
52                "The '{0}' type must contain a constructor, called the copy constructor, with a single parameter of type '{1}'.",
53                "The base type must contain a copy constructor." );
54
55    public static readonly DiagnosticDefinition<(INamedType, int)>
56        BaseBuilderMustContainOneNonCopyConstructor
57            = new(
58                "BUILDER07",
59                Severity.Error,
60                "The '{0}' type must contain exactly two constructors but has {1}.",
61                "The base builder type must contain exactly two constructors." );
62}

For details, see Reporting and suppressing diagnostics.

Step 2. Finding the base type and its members

We can now inspect the base type and look for artifacts we will need: the constructors, the Builder type, and the constructors of the Builder type. If we don't find them, we report an error and quit.

27// Find the Builder nested type in the base type.
28INamedType? baseBuilderType = null;
29
30IConstructor? baseConstructor = null,
31              baseBuilderConstructor = null,
32              baseBuilderCopyConstructor = null;
33
34if ( sourceType.BaseType != null && sourceType.BaseType.SpecialType != SpecialType.Object )
35{
36    // We need to filter parameters to work around a bug where the Constructors collection
37    // contains the implicit constructor.
38    var baseTypeConstructors =
39        sourceType.BaseType.Constructors.Where( c => c.Parameters.Count > 0 ).ToList();
40
41    if ( baseTypeConstructors.Count != 1 )
42    {
43        builder.Diagnostics.Report(
44            BuilderDiagnosticDefinitions.BaseTypeMustContainOneConstructor.WithArguments(
45                (
46                    sourceType.BaseType, baseTypeConstructors.Count) ) );
47
48        hasError = true;
49    }
50    else
51    {
52        baseConstructor = baseTypeConstructors[0];
53    }
54
55    var baseBuilderTypes =
56        sourceType.BaseType.Types.OfName( "Builder" ).ToList();
57
58    switch ( baseBuilderTypes.Count )
59    {
60        case 0:
61            builder.Diagnostics.Report(
62                BuilderDiagnosticDefinitions.BaseTypeMustContainABuilderType.WithArguments(
63                    sourceType.BaseType ) );
64
65            return;
66
67        case > 1:
68            builder.Diagnostics.Report(
69                BuilderDiagnosticDefinitions.BaseTypeCannotContainMoreThanOneBuilderType
70                    .WithArguments( sourceType.BaseType ) );
71
72            return;
73
74        default:
75            baseBuilderType = baseBuilderTypes[0];
76
77            // Check that we have exactly two constructors.
78            if ( baseBuilderType.Constructors.Count != 2 )
79            {
80                builder.Diagnostics.Report(
81                    BuilderDiagnosticDefinitions.BaseBuilderMustContainOneNonCopyConstructor
82                        .WithArguments(
83                            (baseBuilderType,
84                             baseBuilderType.Constructors.Count) ) );
85
86                return;
87            }
88
89            // Find the copy constructor.
90            baseBuilderCopyConstructor = baseBuilderType.Constructors
91                .SingleOrDefault(
92                    c =>
93                        c.Parameters.Count == 1 &&
94                        c.Parameters[0].Type.Equals( sourceType.BaseType ) );
95
96            if ( baseBuilderCopyConstructor == null )
97            {
98                builder.Diagnostics.Report(
99                    BuilderDiagnosticDefinitions.BaseBuilderMustContainCopyConstructor
100                        .WithArguments(
101                            (baseBuilderType,
102                             sourceType.BaseType) ) );
103
104                return;
105            }
106
107            // The normal constructor is the other constructor.
108            baseBuilderConstructor =
109                baseBuilderType.Constructors.Single( c => c != baseBuilderCopyConstructor );
110
111            break;
112    }
113}
114
115if ( hasError )
116{
117    return;
118}
119

Step 3. Creating the Builder type

Now that we have found the artifacts in the base type, we can update the rest of the BuildAspect method to use them.

In the snippet that creates the Builder type, we specify the Builder of the base type as the base type of the new Builder:

142// Introduce the Builder nested type.
143var builderType = builder.IntroduceClass(
144    "Builder",
145    OverrideStrategy.New,
146    t =>
147    {
148        t.Accessibility = Accessibility.Public;
149        t.BaseType = baseBuilderType;
150        t.IsSealed = sourceType.IsSealed;
151    } );
152

Note that we set the whenExist parameter to OverrideStrategy.New. This means we will generate a new class if the base type already contains a Builder class.

Step 4. Mapping properties

To discover properties, we now use the AllProperties collection which, unlike Properties, includes properties defined by base types. We added an IsInherited property into the PropertyMapping field.

Here is how we updated the code that discovers properties:

123// Create a list of PropertyMapping items for all properties that we want to build using the Builder.
124var properties = sourceType.AllProperties.Where(
125        p => p.Writeability != Writeability.None &&
126             !p.IsStatic )
127    .Select(
128        p =>
129        {
130            var isRequired = p.Attributes.OfAttributeType( typeof(RequiredAttribute) )
131                .Any();
132
133            var isInherited = !p.DeclaringType.Equals( sourceType );
134
135            return new PropertyMapping( p, isRequired, isInherited );
136        } )
137    .ToList();
138

The code that creates properties must be updated too. We don't have to create builder properties for properties of the base type since these properties should already be defined in the base builder type. If we don't find such a property, we report an error.

156// Add builder properties and update the mapping.  
157foreach ( var property in properties )
158{
159    if ( property.IsInherited )
160    {
161        // For properties of the base type, find the matching property.
162        var baseProperty =
163            baseBuilderType!.AllProperties.OfName( property.SourceProperty.Name )
164                .SingleOrDefault();
165
166        if ( baseProperty == null )
167        {
168            builder.Diagnostics.Report(
169                BuilderDiagnosticDefinitions.BaseBuilderMustContainProperty.WithArguments(
170                    (
171                        baseBuilderType, property.SourceProperty.Name) ) );
172
173            hasError = true;
174        }
175        else
176        {
177            property.BuilderProperty = baseProperty;
178        }
179    }
180    else
181    {
182        // For properties of the current type, introduce a new property.
183        property.BuilderProperty =
184            builderType.IntroduceAutomaticProperty(
185                    property.SourceProperty.Name,
186                    property.SourceProperty.Type,
187                    IntroductionScope.Instance,
188                    buildProperty: p =>
189                    {
190                        p.Accessibility = Accessibility.Public;
191
192                        p.InitializerExpression =
193                            property.SourceProperty.InitializerExpression;
194                    } )
195                .Declaration;
196    }
197}
198

Note that we could do more validation, such as checking the property type and its visibility.

Step 5. Updating constructors

All constructors must be updated to call the base constructor. Let's demonstrate the technique with the public constructor of the Builder class.

Here is the updated code:

207// Add a builder constructor accepting the required properties and update the mapping.
208builderType.IntroduceConstructor(
209    nameof(this.BuilderConstructorTemplate),
210    buildConstructor: c =>
211    {
212        c.Accessibility = Accessibility.Public;
213
214        // Adding parameters.
215        foreach ( var property in properties.Where( m => m.IsRequired ) )
216        {
217            var parameter = c.AddParameter(
218                NameHelper.ToParameterName( property.SourceProperty.Name ),
219                property.SourceProperty.Type );
220
221            property.BuilderConstructorParameterIndex = parameter.Index;
222        }
223
224        // Calling the base constructor.
225        if ( baseBuilderConstructor != null )
226        {
227            c.InitializerKind = ConstructorInitializerKind.Base;
228
229            foreach ( var baseConstructorParameter in baseBuilderConstructor.Parameters )
230            {
231                var thisParameter =
232                    c.Parameters.SingleOrDefault(
233                        p =>
234                            p.Name == baseConstructorParameter.Name );
235
236                if ( thisParameter != null )
237                {
238                    c.AddInitializerArgument( thisParameter );
239                }
240                else
241                {
242                    builder.Diagnostics.Report(
243                        BuilderDiagnosticDefinitions
244                            .BaseTypeConstructorHasUnexpectedParameter.WithArguments(
245                                (
246                                    baseBuilderConstructor,
247                                    baseConstructorParameter.Name) ) );
248
249                    hasError = true;
250                }
251            }
252        }
253    } );
254

The first part of the logic is unchanged: we add a parameter for each required property, including inherited ones. Then, when we have a base class, we call the base constructor. First, we set the InitializerKind of the new constructor to Base. Then, for each parameter of the base constructor, we find the corresponding parameter in the new constructor, and we call the <xrefMMetalama.Framework.Code.DeclarationBuilders.IConstructorBuilder.AddInitializerArgument*> method to add an argument to the call to the base() constructor. If we don't find this parameter, we report an error.

Step 6. Other changes

Other parts of the BuildAspect method and most templates must be updated to take inherited properties into account. Please refer to the source code of the example on GitHub for details (see the links at the top of this article).

Conclusion

Handling type inheritance is generally not a trivial task because you have to consider the possibility that the base type does not define the expected declarations. Reporting errors is always better than failing with an exception, and certainly better than generating invalid code.

In the next article, we will see how to handle properties whose type is an immutable collection.