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; }
13public string TargetNamespace { get; }
14
15public GenerateEnumViewModelAttribute(Type enumType, string targetNamespace)
16{
17    this.EnumType = enumType;
18    this.TargetNamespace = targetNamespace;
19}

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.

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

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:

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

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("ENUM01",
10            Severity.Error,
11            "The type '{0}' is not an enum.");
12}

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

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

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).

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

For details, see Introducing types and Introducing members.

Here is the T# template of the constructor:

110[Template]
111public void ConstructorTemplate<[CompileTime] T>(T value) =>
112    meta.This._value = value!;

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:

74// Get the field type and decides the template.
75var isFlags = enumType.Attributes.Any(a => a.Type.Is(typeof(FlagsAttribute)));
76var template = isFlags ? nameof(this.IsFlagTemplate) : nameof(this.IsMemberTemplate);
77
78// Introduce a property into the view-model type for each enum member.
79foreach (var member in enumType.Fields)
80{
81    viewModelType.IntroduceProperty(
82        template,
83        tags: new { member },
84        buildProperty: p => p.Name = "Is" + member.Name);
85}

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:

90// Template for the non-flags enum member.
91[Template]
92public bool IsMemberTemplate => meta.This._value == ((IField)meta.Tags["member"]!).Value;
93

Here is the template for the flags variant:

94// Template for a flag enum member.
95[Template]
96public bool IsFlagTemplate
97{
98    get
99    {
100        var field = (IField)meta.Tags["member"]!;
101
102        // Note that the next line does not work for the "zero" flag, but currently Metalama
103        // does not expose the constant value of the enum member so we cannot test its
104        // value at compile time.
105        return (meta.This._value & field.Value) == ((IField)meta.Tags["member"]!).Value;
106    }
107}
108

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