MetalamaCommented examplesChange TrackingStep 3.​ Integrating with INotify­Property­Changed
Open sandboxFocusImprove this doc

Change Tracking example, step 3: integrating with INotifyPropertyChanged

At this point, we have a TrackChanges aspect that implements the <xref:System.ComponentModel.IChangeTracking> interface, supports hand-written base implementations of this interface and reports errors if the pattern contract is not respected. However, we have built this aspect in pure isolation. In practice, the TrackChanges aspect must interact with the NotifyPropertyChanged pattern. When the <xref:System.ComponentModel.IChangeTracking.IsChanged> property changes, the PropertyChanged event must be raised.

It is essential to understand that the two concepts interacting with each other are not aspects or interfaces but patterns. Aspects, by definition, are executable artifacts that automate the implementation and verification patterns, but patterns can also be implemented manually. Patterns define extension points. The OnPropertyChanged method is a part of the pattern we chose to implement the INotifyPropertyChanged interface, but not a part of the interface itself. Patterns are essentially conventions, and a different implementation pattern can rely on a different triggering mechanism than the OnPropertyChanged method.

Therefore, when you design an aspect, you should first reason about the pattern, think about how the different patterns combine, and how they work with inherited classes or parent-child relationships.

For this example, we decide (and we insist this is a design pattern decision) to invoke the OnChange method from the OnPropertyChanged method. Why? There are two reasons for this. First, the setters of all mutable properties are already supposed to call the OnPropertyChanged method, so adding a new call to OnChange everywhere would be a double pain. This argument is valid if we implement the pattern by hand, but what if we use an aspect? Here comes the second reason: the code generated by Metalama is much less readable when two aspects are added to one property.

Let's see this pattern in action:

Source Code



1[TrackChanges]
2[NotifyPropertyChanged]
3public partial class Comment
4{
5    public Guid Id { get; }



6    public string Author { get; set; }



















7    public string Content { get; set; }

















8
9    public Comment( Guid id, string author, string content )
10    {
11        this.Id = id;
12        this.Author = author;
13        this.Content = content;
14    }




















15}



























Transformed Code
1using System;
2using System.ComponentModel;
3
4[TrackChanges]
5[NotifyPropertyChanged]
6public partial class Comment: INotifyPropertyChanged, ISwitchableChangeTracking, IChangeTracking
7{
8    public Guid Id { get; }
9
10
11    private string _author = default!;
12    public string Author
13    {
14        get
15        {
16            return this._author;
17        }
18
19        set
20        {
21            if (value != this._author)
22            {
23                this._author = value;
24                this.OnPropertyChanged("Author");
25            }
26
27            return;
28        }
29    }
30
31    private string _content = default!;
32    public string Content
33    {
34        get
35        {
36            return this._content;
37        }
38
39        set
40        {
41            if (value != this._content)
42            {
43                this._content = value;
44                this.OnPropertyChanged("Content");
45            }
46
47            return;
48        }
49    }
50
51    public Comment( Guid id, string author, string content )
52    {
53        this.Id = id;
54        this.Author = author;
55        this.Content = content;
56    }
57
58    private bool _isTrackingChanges;
59
60    public bool IsChanged { get; private set; }
61
62    public bool IsTrackingChanges
63    {
64        get
65        {
66            return _isTrackingChanges;
67        }
68
69        set
70        {
71            if (this._isTrackingChanges != value)
72            {
73                this._isTrackingChanges = value;
74                this.OnPropertyChanged("IsTrackingChanges");
75            }
76        }
77    }
78
79    public void AcceptChanges()
80    {
81        this.IsChanged = false;
82    }
83
84    protected void OnChange()
85    {
86        if (this.IsChanged == false)
87        {
88            this.IsChanged = true;
89            this.OnPropertyChanged("IsChanged");
90        }
91    }
92
93    protected virtual void OnPropertyChanged(string name)
94    {
95        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
96        if (name is not ("IsChanged" or "IsTrackingChanges"))
97        {
98            this.OnChange();
99        }
100    }
101
102    public event PropertyChangedEventHandler? PropertyChanged;
103}
104
Source Code
1public class ModeratedComment : Comment
2{
3    public ModeratedComment( Guid id, string author, string content ) : base( id, author, content )
4    {
5    }
6



7    public bool? IsApproved { get; set; }

















8}
Transformed Code
1public class ModeratedComment : Comment
2{
3    public ModeratedComment( Guid id, string author, string content ) : base( id, author, content )
4    {
5    }
6
7
8    private bool? _isApproved;
9
10    public bool? IsApproved
11    {
12        get
13        {
14            return this._isApproved;
15        }
16
17        set
18        {
19            if (value != this._isApproved)
20            {
21                this._isApproved = value;
22                this.OnPropertyChanged("IsApproved");
23            }
24
25            return;
26        }
27    }
28}

Aspect implementation

The new aspect implementation is the following:

1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5
6#pragma warning disable IDE0031, IDE1005
7
8[Inheritable]
9public class TrackChangesAttribute : TypeAspect
10{
11    private static readonly DiagnosticDefinition<INamedType> _mustHaveOnChangeMethod = new(
12        "MY001",
13        Severity.Error,
14        $"The '{nameof(ISwitchableChangeTracking)}' interface is implemented manually on type '{{0}}', but the type does not have an '{nameof(OnChange)}()' method.");
15
16    private static readonly DiagnosticDefinition _onChangeMethodMustBeProtected = new(
17        "MY002",
18        Severity.Error,
19        $"The '{nameof(OnChange)}()' method must be have the 'protected' accessibility.");
20
21    private static readonly DiagnosticDefinition<IMethod> _onPropertyChangedMustBeVirtual = new(
22        "MY003",
23        Severity.Error,
24        "The '{0}' method must be virtual.");
25
26    public override void BuildAspect( IAspectBuilder<INamedType> builder )
27    {
28        // Implement the ISwitchableChangeTracking interface.         
29        var implementInterfaceResult = builder.Advice.ImplementInterface( builder.Target,
30            typeof(ISwitchableChangeTracking), OverrideStrategy.Ignore );
31
32        if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignore )
33        {
34            // If the type already implements ISwitchableChangeTracking, it must have a protected method called OnChanged, without parameters, otherwise
35            // this is a contract violation, so we report an error.
36
37            var onChangeMethod = builder.Target.AllMethods.OfName( nameof(this.OnChange) )
38                .SingleOrDefault( m => m.Parameters.Count == 0 );
39
40            if ( onChangeMethod == null )
41            {
42                builder.Diagnostics.Report( _mustHaveOnChangeMethod.WithArguments( builder.Target ) );
43            }
44            else if ( onChangeMethod.Accessibility != Accessibility.Protected )
45            {
46                builder.Diagnostics.Report( _onChangeMethodMustBeProtected );
47            }
48        }
49        else
50        {
51            builder.Advice.IntroduceField( builder.Target, "_isTrackingChanges", typeof(bool) );
52        }
53
54
55        var onPropertyChanged = this.GetOnPropertyChangedMethod( builder.Target );
56
57        if ( onPropertyChanged == null ) 
58        {
59            // If the type has an OnPropertyChanged method, we assume that all properties
60            // and fields already call it, and we hook into OnPropertyChanged instead of
61            // overriding each setter.
62
63            var fieldsOrProperties = builder.Target.FieldsAndProperties
64                .Where( f =>
65                    !f.IsImplicitlyDeclared && f.Writeability == Writeability.All && f.IsAutoPropertyOrField == true );
66
67            foreach ( var fieldOrProperty in fieldsOrProperties )
68            {
69                builder.Advice.OverrideAccessors( fieldOrProperty, null, nameof(this.OverrideSetter) );
70            }
71        } 
72        else if ( onPropertyChanged.DeclaringType.Equals( builder.Target ) ) 
73        {
74            // If the OnPropertyChanged method was declared in the current type, override it.
75            builder.Advice.Override( onPropertyChanged, nameof(this.OnPropertyChanged) );
76        } 
77        else if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignore ) 
78        {
79            // If we have an OnPropertyChanged method but the type already implements ISwitchableChangeTracking,
80            // we assume that the type already hooked the OnPropertyChanged method, and
81            // there is nothing else to do.
82        }
83        else
84        {
85            // If the OnPropertyChanged method was defined in a base class, but not overridden
86            // in the current class, and if we implement ISwitchableChangeTracking ourselves,
87            // then we need to override OnPropertyChanged.
88
89            if ( !onPropertyChanged.IsVirtual )
90            {
91                builder.Diagnostics.Report( _onPropertyChangedMustBeVirtual.WithArguments( onPropertyChanged ) );
92            }
93            else
94            {
95                builder.Advice.IntroduceMethod( builder.Target, nameof(this.OnPropertyChanged),
96                    whenExists: OverrideStrategy.Override );
97            }
98        } 
99    }
100
101
102    private IMethod? GetOnPropertyChangedMethod( INamedType type )
103        => type.AllMethods
104            .OfName( "OnPropertyChanged" )
105            .SingleOrDefault( m => m.Parameters.Count == 1 );
106
107    [InterfaceMember]
108    public bool IsChanged { get; private set; }
109
110
111    [InterfaceMember]
112    public bool IsTrackingChanges
113    {
114        get => meta.This._isTrackingChanges;
115        set
116        {
117            if ( meta.This._isTrackingChanges != value )
118            {
119                meta.This._isTrackingChanges = value;
120
121                var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
122
123                if ( onPropertyChanged != null )
124                {
125                    onPropertyChanged.Invoke( nameof(this.IsTrackingChanges) );
126                }
127            }
128        }
129    }
130
131    [InterfaceMember]
132    public void AcceptChanges() => this.IsChanged = false;
133
134
135    [Introduce( WhenExists = OverrideStrategy.Ignore )]
136    protected void OnChange()
137    {
138        if ( this.IsChanged == false )
139        {
140            this.IsChanged = true;
141
142            var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
143
144            if ( onPropertyChanged != null )
145            {
146                onPropertyChanged.Invoke( nameof(this.IsChanged) );
147            }
148        }
149    }
150
151    [Template]
152    private void OverrideSetter( dynamic? value )
153    {
154        meta.Proceed();
155
156        if ( value != meta.Target.Property.Value )
157        {
158            this.OnChange();
159        }
160    }
161
162    [Template]
163    protected virtual void OnPropertyChanged( string name )
164    {
165        meta.Proceed();
166
167        if ( name is not (nameof(this.IsChanged) or nameof(this.IsTrackingChanges)) )
168        {
169            this.OnChange();
170        }
171    }
172}

Notice the new GetOnPropertyChangedMethod method. It looks for the OnPropertyChanged method in the AllMethods collection. This collection contains methods defined by the current type and the non-private ones of the base classes. Therefore, GetOnPropertyChangedMethod may return an IMethod from the current type, from the base class, or null.

102    private IMethod? GetOnPropertyChangedMethod( INamedType type )
103        => type.AllMethods
104            .OfName( "OnPropertyChanged" )
105            .SingleOrDefault( m => m.Parameters.Count == 1 );

We call GetOnPropertyChangedMethod from BuildAspect.

If we do not find any OnPropertyChanged, we have to override all fields and automatic properties ourselves:

57        if ( onPropertyChanged == null ) 
58        {
59            // If the type has an OnPropertyChanged method, we assume that all properties
60            // and fields already call it, and we hook into OnPropertyChanged instead of
61            // overriding each setter.
62
63            var fieldsOrProperties = builder.Target.FieldsAndProperties
64                .Where( f =>
65                    !f.IsImplicitlyDeclared && f.Writeability == Writeability.All && f.IsAutoPropertyOrField == true );
66
67            foreach ( var fieldOrProperty in fieldsOrProperties )
68            {
69                builder.Advice.OverrideAccessors( fieldOrProperty, null, nameof(this.OverrideSetter) );
70            }
71        } 

However, if the closest OnPropertyChanged method is in the base type, the logic is more complex:

57        if ( onPropertyChanged == null ) 
58        {
59            // If the type has an OnPropertyChanged method, we assume that all properties
60            // and fields already call it, and we hook into OnPropertyChanged instead of
61            // overriding each setter.
62
63            var fieldsOrProperties = builder.Target.FieldsAndProperties
64                .Where( f =>
65                    !f.IsImplicitlyDeclared && f.Writeability == Writeability.All && f.IsAutoPropertyOrField == true );
66
67            foreach ( var fieldOrProperty in fieldsOrProperties )
68            {
69                builder.Advice.OverrideAccessors( fieldOrProperty, null, nameof(this.OverrideSetter) );
70            }
71        } 

If the closest OnPropertyChanged is in the current type, we override it:

77        else if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignore ) 
78        {
79            // If we have an OnPropertyChanged method but the type already implements ISwitchableChangeTracking,
80            // we assume that the type already hooked the OnPropertyChanged method, and
81            // there is nothing else to do.
82        }
83        else
84        {
85            // If the OnPropertyChanged method was defined in a base class, but not overridden
86            // in the current class, and if we implement ISwitchableChangeTracking ourselves,
87            // then we need to override OnPropertyChanged.
88
89            if ( !onPropertyChanged.IsVirtual )
90            {
91                builder.Diagnostics.Report( _onPropertyChangedMustBeVirtual.WithArguments( onPropertyChanged ) );
92            }
93            else
94            {
95                builder.Advice.IntroduceMethod( builder.Target, nameof(this.OnPropertyChanged),
96                    whenExists: OverrideStrategy.Override );
97            }
98        } 

If both the OnPropertyChanged method and the ISwitchableChangeTracking interface are defined in the base type, we do not have to hook OnPropertyChanged because it is the responsibility of the base type. We rely on the outcome of the ImplementInterface method to know if ISwitchableChangeTracking was already implemented.

However, if the base type defines an OnPropertyChanged method but no ISwitchableChangeTracking interface, we need to override the OnPropertyChanged method. It's only possible if the base method is virtual. Otherwise, we report an error. To override a base class method, we need to use IntroduceMethod instead of Override.

Finally, we also need to change the implementations of IsTrackingChanges and OnChange to call OnPropertyChanged. Let's see, for instance, OnChange:

135    [Introduce( WhenExists = OverrideStrategy.Ignore )]
136    protected void OnChange()
137    {
138        if ( this.IsChanged == false )
139        {
140            this.IsChanged = true;
141
142            var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
143
144            if ( onPropertyChanged != null )
145            {
146                onPropertyChanged.Invoke( nameof(this.IsChanged) );
147            }
148        }
149    }

If the OnPropertyChanged method is present, we invoke it using the Invoke method. Note, to be precise, that Invoke does not invoke really the method because the code runs at compile time. What it actually does is generate the code that will invoke the method at run time. Note also that we cannot use the conditional ?. operator in this case. We must use an if statement to check if the OnPropertyChanged method is present.

Summary

In this article, we briefly discussed the philosophy of pattern interactions. We then integrated the TrackChanges and the NotifyPropertyChanges pattern. In the following article, we will add the ability to revert changes done to the object.