At this point, we have a TrackChanges
aspect that implements the 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 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:
1[TrackChanges]
2[NotifyPropertyChanged]
3public partial class Comment
4{
5 public Guid Id { get; }
6
7 public string Author { get; set; }
8
9 public string Content { get; set; }
10
11 public Comment( Guid id, string author, string content )
12 {
13 this.Id = id;
14 this.Author = author;
15 this.Content = content;
16 }
17}
1using System;
2using System.ComponentModel;
3
4[TrackChanges]
5[NotifyPropertyChanged]
6public partial class Comment
7: INotifyPropertyChanged, ISwitchableChangeTracking, IChangeTracking
8{
9 public Guid Id { get; }
10private string _author = default!;
11
12 public string Author { get { return this._author; } set { if (value != this._author) { this._author = value; OnPropertyChanged("Author"); } return; } }
13 private string _content = default!;
14
15 public string Content { get { return this._content; } set { if (value != this._content) { this._content = value; OnPropertyChanged("Content"); } return; } }
16
17 public Comment( Guid id, string author, string content )
18 {
19 this.Id = id;
20 this.Author = author;
21 this.Content = content;
22 }
23private bool _isTrackingChanges;
24
25 public bool IsChanged { get; private set; }
26
27 public bool IsTrackingChanges
28 {
29 get
30 {
31 return _isTrackingChanges;
32 }
33 set
34 {
35 if (this._isTrackingChanges != value)
36 {
37 this._isTrackingChanges = value;
38 this.OnPropertyChanged("IsTrackingChanges");
39 }
40 }
41 }
42
43 public void AcceptChanges()
44 {
45 IsChanged = false;
46 }
47 protected void OnChange()
48 {
49 if (IsChanged == false)
50 {
51 IsChanged = true;
52 this.OnPropertyChanged("IsChanged");
53 }
54 }
55 protected virtual void OnPropertyChanged(string name)
56 {
57 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
58 if (name is not ("IsChanged" or "IsTrackingChanges"))
59 {
60 OnChange();
61 }
62 }
63 public event PropertyChangedEventHandler? PropertyChanged;
64}
1public class ModeratedComment : Comment
2{
3 public ModeratedComment( Guid id, string author, string content ) : base( id, author, content ) { }
4
5 public bool? IsApproved { get; set; }
6}
1public class ModeratedComment : Comment
2{
3 public ModeratedComment( Guid id, string author, string content ) : base( id, author, content ) { }
4private bool? _isApproved;
5
6 public bool? IsApproved { get { return this._isApproved; } set { if (value != this._isApproved) { this._isApproved = value; OnPropertyChanged("IsApproved"); } return; } }
7}
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[Inheritable]
7public class TrackChangesAttribute : TypeAspect
8{
9 private static readonly DiagnosticDefinition<INamedType> _mustHaveOnChangeMethod = new(
10 "MY001",
11 Severity.Error,
12 $"The '{nameof(ISwitchableChangeTracking)}' interface is implemented manually on type '{{0}}', but the type does not have an '{nameof(OnChange)}()' method." );
13
14 private static readonly DiagnosticDefinition _onChangeMethodMustBeProtected = new(
15 "MY002",
16 Severity.Error,
17 $"The '{nameof(OnChange)}()' method must be have the 'protected' accessibility." );
18
19 private static readonly DiagnosticDefinition<IMethod> _onPropertyChangedMustBeVirtual = new(
20 "MY003",
21 Severity.Error,
22 "The '{0}' method must be virtual." );
23
24 public override void BuildAspect( IAspectBuilder<INamedType> builder )
25 {
26 // Implement the ISwitchableChangeTracking interface.
27 var implementInterfaceResult = builder.Advice.ImplementInterface(
28 builder.Target,
29 typeof(ISwitchableChangeTracking),
30 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 var onPropertyChanged = this.GetOnPropertyChangedMethod( builder.Target );
55
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(
65 f =>
66 !f.IsImplicitlyDeclared && f.Writeability == Writeability.All &&
67 f.IsAutoPropertyOrField == true );
68
69 foreach ( var fieldOrProperty in fieldsOrProperties )
70 {
71 builder.Advice.OverrideAccessors(
72 fieldOrProperty,
73 null,
74 nameof(this.OverrideSetter) );
75 }
76 }
77
78 //
79 //
80 else if ( onPropertyChanged.DeclaringType.Equals( builder.Target ) )
81 {
82 // If the OnPropertyChanged method was declared in the current type, override it.
83 builder.Advice.Override( onPropertyChanged, nameof(this.OnPropertyChanged) );
84 }
85
86 //
87 //
88 else if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignore )
89 {
90 // If we have an OnPropertyChanged method but the type already implements ISwitchableChangeTracking,
91 // we assume that the type already hooked the OnPropertyChanged method, and
92 // there is nothing else to do.
93 }
94 else
95 {
96 // If the OnPropertyChanged method was defined in a base class, but not overridden
97 // in the current class, and if we implement ISwitchableChangeTracking ourselves,
98 // then we need to override OnPropertyChanged.
99
100 if ( !onPropertyChanged.IsVirtual )
101 {
102 builder.Diagnostics.Report( _onPropertyChangedMustBeVirtual.WithArguments( onPropertyChanged ) );
103 }
104 else
105 {
106 builder.Advice.IntroduceMethod(
107 builder.Target,
108 nameof(this.OnPropertyChanged),
109 whenExists: OverrideStrategy.Override );
110 }
111 }
112
113 //
114 }
115
116 private IMethod? GetOnPropertyChangedMethod( INamedType type )
117 => type.AllMethods
118 .OfName( "OnPropertyChanged" )
119 .SingleOrDefault( m => m.Parameters.Count == 1 );
120
121 [InterfaceMember]
122 public bool IsChanged { get; private set; }
123
124 [InterfaceMember]
125 public bool IsTrackingChanges
126 {
127 get => meta.This._isTrackingChanges;
128 set
129 {
130 if ( meta.This._isTrackingChanges != value )
131 {
132 meta.This._isTrackingChanges = value;
133
134 var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
135
136 if ( onPropertyChanged != null )
137 {
138 onPropertyChanged.Invoke( nameof(this.IsTrackingChanges) );
139 }
140 }
141 }
142 }
143
144 [InterfaceMember]
145 public void AcceptChanges() => this.IsChanged = false;
146
147 [Introduce( WhenExists = OverrideStrategy.Ignore )]
148 protected void OnChange()
149 {
150 if ( this.IsChanged == false )
151 {
152 this.IsChanged = true;
153
154 var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
155
156 if ( onPropertyChanged != null )
157 {
158 onPropertyChanged.Invoke( nameof(this.IsChanged) );
159 }
160 }
161 }
162
163 [Template]
164 private void OverrideSetter( dynamic? value )
165 {
166 meta.Proceed();
167
168 if ( value != meta.Target.Property.Value )
169 {
170 this.OnChange();
171 }
172 }
173
174 [Template]
175 protected virtual void OnPropertyChanged( string name )
176 {
177 meta.Proceed();
178
179 if ( name is not (nameof(this.IsChanged) or nameof(this.IsTrackingChanges)) )
180 {
181 this.OnChange();
182 }
183 }
184}
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
.
116private IMethod? GetOnPropertyChangedMethod( INamedType type )
117 => type.AllMethods
118 .OfName( "OnPropertyChanged" )
119 .SingleOrDefault( m => m.Parameters.Count == 1 );
120
We call GetOnPropertyChangedMethod
from BuildAspect
.
If we do not find any OnPropertyChanged
, we have to override all fields and automatic properties ourselves:
57if ( 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(
65 f =>
66 !f.IsImplicitlyDeclared && f.Writeability == Writeability.All &&
67 f.IsAutoPropertyOrField == true );
68
69 foreach ( var fieldOrProperty in fieldsOrProperties )
70 {
71 builder.Advice.OverrideAccessors(
72 fieldOrProperty,
73 null,
74 nameof(this.OverrideSetter) );
75 }
76}
77
However, if the closest OnPropertyChanged
method is in the base type, the logic is more complex:
57if ( 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(
65 f =>
66 !f.IsImplicitlyDeclared && f.Writeability == Writeability.All &&
67 f.IsAutoPropertyOrField == true );
68
69 foreach ( var fieldOrProperty in fieldsOrProperties )
70 {
71 builder.Advice.OverrideAccessors(
72 fieldOrProperty,
73 null,
74 nameof(this.OverrideSetter) );
75 }
76}
77
If the closest OnPropertyChanged
is in the current type, we override it:
88else if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignore )
89{
90 // If we have an OnPropertyChanged method but the type already implements ISwitchableChangeTracking,
91 // we assume that the type already hooked the OnPropertyChanged method, and
92 // there is nothing else to do.
93}
94else
95{
96 // If the OnPropertyChanged method was defined in a base class, but not overridden
97 // in the current class, and if we implement ISwitchableChangeTracking ourselves,
98 // then we need to override OnPropertyChanged.
99
100 if ( !onPropertyChanged.IsVirtual )
101 {
102 builder.Diagnostics.Report( _onPropertyChangedMustBeVirtual.WithArguments( onPropertyChanged ) );
103 }
104 else
105 {
106 builder.Advice.IntroduceMethod(
107 builder.Target,
108 nameof(this.OnPropertyChanged),
109 whenExists: OverrideStrategy.Override );
110 }
111}
112
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
:
147[Introduce( WhenExists = OverrideStrategy.Ignore )]
148protected void OnChange()
149{
150 if ( this.IsChanged == false )
151 {
152 this.IsChanged = true;
153
154 var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
155
156 if ( onPropertyChanged != null )
157 {
158 onPropertyChanged.Invoke( nameof(this.IsChanged) );
159 }
160 }
161}
162
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.