Open sandboxFocusImprove this doc

Generating view-model wrappers for enums without boilerplate

This example shows how to build an aspect that generates view-model classes for enum types. For each enum member Foo, the aspect will generate an IsFoo property.

For instance, let's take the following input enum:

1internal enum Visibility
2{
3    Visible,
4    Hidden,
5    Collapsed
6}

The aspect will generate the following output:

1using System;
2
3namespace ViewModels
4{
5    internal sealed class VisibilityViewModel
6    {
7        private readonly Visibility _value;
8
9        public VisibilityViewModel(Visibility value)
10        {
11            this._value = value;
12        }
13        public bool IsCollapsed
14        {
15            get
16            {
17                return _value == Visibility.Collapsed;
18            }
19        }
20
21        public bool IsHidden
22        {
23            get
24            {
25                return _value == Visibility.Hidden;
26            }
27        }
28
29        public bool IsVisible
30        {
31            get
32            {
33                return _value == Visibility.Visible;
34            }
35        }
36    }
37}

Step 1. Creating the aspect class and its properties

We want to use the aspect as an assembly-level custom attribute, as follows:

1[assembly: GenerateEnumViewModel( typeof(StringOptions), "ViewModels" )]
2[assembly: GenerateEnumViewModel( typeof(Visibility), "ViewModels" )]

To create an assembly-level aspect, we need to derive the CompilationAspect class.

We add two properties to the aspect class: EnumType and TargetNamespace. We initialize them from the constructor:

10public class GenerateEnumViewModelAttribute : CompilationAspect
11{
12public Type EnumType { get; }
13
14public string TargetNamespace { get; }
15
16public GenerateEnumViewModelAttribute( Type enumType, string targetNamespace )
17{
18    this.EnumType = enumType;
19    this.TargetNamespace = targetNamespace;
20}
21

Step 2. Coping with several instances

By default, Metalama supports only a single instance of each aspect class per target declaration. To allow for several instances, we must first add the [AttributeUsage] custom attribute to our aspect.

6[AttributeUsage( AttributeTargets.Assembly, AllowMultiple = true )]
7

Regardless of the number of [assembly: GenerateEnumViewModel] attributes found in the project, Metalama will call BuildAspect, the aspect entry point method, for only one of these instances. The other instances are known as secondary instances and are available under builder.AspectInstance.SecondaryInstances.

Therefore, we will add most of the implementation in a local function named ImplementViewModel and invoke this method for both the primary and the secondary methods. Our BuildAspect method starts like this:

25public override void BuildAspect( IAspectBuilder<ICompilation> builder )
26{
27    ImplementViewModel( this );
28
29    foreach ( var secondaryInstance in builder.AspectInstance.SecondaryInstances )
30    {
31        ImplementViewModel( (GenerateEnumViewModelAttribute) secondaryInstance.Aspect );
32    }
33
34    void ImplementViewModel( GenerateEnumViewModelAttribute aspectInstance )
35

The next steps will show how to build the ImplementViewModel local function.

Step 3. Validating inputs

It's good practice to verify all assumptions and report clear error messages when the aspect user provides unexpected inputs. Here, we verify that the given type is indeed an enum type.

First, we declare the error messages as a static field of a compile-time class:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5[CompileTime]
6internal static class DiagnosticDefinitions
7{
8    public static readonly DiagnosticDefinition<INamedType> NotAnEnumError =
9        new(
10            "ENUM01",
11            Severity.Error,
12            "The type '{0}' is not an enum." );
13}

Then, we check the code and report the error if something is incorrect:

40var enumType =
41    (INamedType) TypeFactory.GetType( aspectInstance.EnumType );
42
43if ( enumType.TypeKind != TypeKind.Enum )
44{
45    builder.Diagnostics.Report( DiagnosticDefinitions.NotAnEnumError.WithArguments( enumType ) );
46    builder.SkipAspect();
47
48    return;
49}
50

For details on reporting errors, see Reporting and suppressing diagnostics.

Step 4. Introducing the class, the value field, and the constructor

We can now introduce the view-model class using the IntroduceClass method. This returns an object that we can use to add members to the value field (using IntroduceField) and the constructor (using IntroduceConstructor).

54// Introduce the ViewModel type.
55var viewModelType = builder
56    .WithNamespace( this.TargetNamespace )
57    .IntroduceClass(
58        enumType.Name + "ViewModel",
59        buildType:
60        type =>
61        {
62            type.Accessibility = enumType.Accessibility;
63            type.IsSealed = true;
64        } );
65
66// Introduce the _value field.
67viewModelType.IntroduceField(
68    "_value",
69    enumType,
70    IntroductionScope.Instance,
71    buildField: field => { field.Writeability = Writeability.ConstructorOnly; } );
72
73// Introduce the constructor.
74viewModelType.IntroduceConstructor(
75    nameof(this.ConstructorTemplate),
76    args: new { T = enumType } );
77

For details, see Introducing types and Introducing members.

Here is the T# template of the constructor:

120[Template]
121public void ConstructorTemplate<[CompileTime] T>( T value ) => meta.This._value = value!;
122

Note that this template accepts a compile-time generic parameter T, which represents the enum type. The value of this parameter is set in the call to IntroduceConstructor by setting the args parameter.

In this template, meta.This._value compiles to this._value. The C# compiler does not complain because meta.This returns a dynamic value, so we can have anything on the right hand of this expression. Metalama then just replaces meta.This with this.

Step 5. Introducing the view-model properties

We can finally add the IsFoo properties. Depending on whether the enum is a multi-value bit map (i.e., [Flags]) or a single-value type, we need different strategies.

Here is the code that adds the properties:

81// Get the field type and decides the template.
82var isFlags =
83    enumType.Attributes.Any( a => a.Type.IsConvertibleTo( typeof(FlagsAttribute) ) );
84
85var template = isFlags ? nameof(this.IsFlagTemplate) : nameof(this.IsMemberTemplate);
86
87// Introduce a property into the view-model type for each enum member.
88foreach ( var member in enumType.Fields )
89{
90    viewModelType.IntroduceProperty(
91        template,
92        tags: new { member },
93        buildProperty: p => p.Name = "Is" + member.Name );
94}
95

The code first selects the proper template depending on the nature of the enum type.

Then, it enumerates the enum members, and for each member, calls the IntroduceProperty method. Note that we are passing a member tag, which will be used by the templates.

Here is the template for the non-flags variant:

100// Template for the non-flags enum member.
101[Template]
102public bool IsMemberTemplate => meta.This._value == ((IField) meta.Tags["member"]!).Value;
103

Here is the template for the flags variant:

104// Template for a flag enum member.
105[Template]
106public bool IsFlagTemplate
107{
108    get
109    {
110        var field = (IField) meta.Tags["member"]!;
111
112        // Note that the next line does not work for the "zero" flag, but currently Metalama
113        // does not expose the constant value of the enum member so we cannot test its
114        // value at compile time.
115        return (meta.This._value & field.Value) == ((IField) meta.Tags["member"]!).Value;
116    }
117}
118

In this template, meta.Tags["member"] refers to the member tag passed by BuildAspect.

Complete aspect

Putting all the pieces together, here is the complete code of the aspect:

1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4
5// 
6[AttributeUsage( AttributeTargets.Assembly, AllowMultiple = true )]
7
8// 
9// 
10public class GenerateEnumViewModelAttribute : CompilationAspect
11{
12    public Type EnumType { get; }
13
14    public string TargetNamespace { get; }
15
16    public GenerateEnumViewModelAttribute( Type enumType, string targetNamespace )
17    {
18        this.EnumType = enumType;
19        this.TargetNamespace = targetNamespace;
20    }
21
22// 
23
24// 
25    public override void BuildAspect( IAspectBuilder<ICompilation> builder )
26    {
27        ImplementViewModel( this );
28
29        foreach ( var secondaryInstance in builder.AspectInstance.SecondaryInstances )
30        {
31            ImplementViewModel( (GenerateEnumViewModelAttribute) secondaryInstance.Aspect );
32        }
33
34        void ImplementViewModel( GenerateEnumViewModelAttribute aspectInstance )
35
36// 
37
38        {
39            // 
40            var enumType =
41                (INamedType) TypeFactory.GetType( aspectInstance.EnumType );
42
43            if ( enumType.TypeKind != TypeKind.Enum )
44            {
45                builder.Diagnostics.Report( DiagnosticDefinitions.NotAnEnumError.WithArguments( enumType ) );
46                builder.SkipAspect();
47
48                return;
49            }
50
51            // 
52
53            // 
54            // Introduce the ViewModel type.
55            var viewModelType = builder
56                .WithNamespace( this.TargetNamespace )
57                .IntroduceClass(
58                    enumType.Name + "ViewModel",
59                    buildType:
60                    type =>
61                    {
62                        type.Accessibility = enumType.Accessibility;
63                        type.IsSealed = true;
64                    } );
65
66            // Introduce the _value field.
67            viewModelType.IntroduceField(
68                "_value",
69                enumType,
70                IntroductionScope.Instance,
71                buildField: field => { field.Writeability = Writeability.ConstructorOnly; } );
72
73            // Introduce the constructor.
74            viewModelType.IntroduceConstructor(
75                nameof(this.ConstructorTemplate),
76                args: new { T = enumType } );
77
78            // 
79
80            // 
81            // Get the field type and decides the template.
82            var isFlags =
83                enumType.Attributes.Any( a => a.Type.IsConvertibleTo( typeof(FlagsAttribute) ) );
84
85            var template = isFlags ? nameof(this.IsFlagTemplate) : nameof(this.IsMemberTemplate);
86
87            // Introduce a property into the view-model type for each enum member.
88            foreach ( var member in enumType.Fields )
89            {
90                viewModelType.IntroduceProperty(
91                    template,
92                    tags: new { member },
93                    buildProperty: p => p.Name = "Is" + member.Name );
94            }
95
96            // 
97        }
98    }
99
100    // Template for the non-flags enum member.
101    [Template]
102    public bool IsMemberTemplate => meta.This._value == ((IField) meta.Tags["member"]!).Value;
103
104    // Template for a flag enum member.
105    [Template]
106    public bool IsFlagTemplate
107    {
108        get
109        {
110            var field = (IField) meta.Tags["member"]!;
111
112            // Note that the next line does not work for the "zero" flag, but currently Metalama
113            // does not expose the constant value of the enum member so we cannot test its
114            // value at compile time.
115            return (meta.This._value & field.Value) == ((IField) meta.Tags["member"]!).Value;
116        }
117    }
118
119    // 
120    [Template]
121    public void ConstructorTemplate<[CompileTime] T>( T value ) => meta.This._value = value!;
122
123    // 
124}