Change Tracking example, step 4: reverting changes
In this article, we will implement the ability to revert the object to the last-accepted version. The .NET Framework exposes this ability as the IRevertibleChangeTracking interface. It adds a new RejectChanges method. This method must revert any changes performed since the last call to the AcceptChanges method.
We need to duplicate each field or automatic property: one copy will contain the current value, and the second will contain the accepted value. The AcceptChanges method copies the current values to the accepted ones, while the RejectChanges method copies the accepted values to the current ones.
Let's see this pattern in action:
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}
1using System;
2using System.ComponentModel;
3
4[TrackChanges]
5[NotifyPropertyChanged]
6public partial class Comment: INotifyPropertyChanged, ISwitchableChangeTracking, IRevertibleChangeTracking, 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 string _acceptedAuthor;
59 private string _acceptedContent;
60 private bool _isTrackingChanges;
61
62 public bool IsChanged { get; private set; }
63
64 public bool IsTrackingChanges
65 {
66 get
67 {
68 return _isTrackingChanges;
69 }
70
71 set
72 {
73 if (this._isTrackingChanges != value)
74 {
75 this._isTrackingChanges = value;
76 this.OnPropertyChanged("IsTrackingChanges");
77 if (value)
78 {
79 this.AcceptChanges();
80 }
81 }
82 }
83 }
84
85 public virtual void AcceptChanges()
86 {
87 this.IsChanged = false;
88 this._acceptedAuthor = this.Author;
89 this._acceptedContent = this.Content;
90 }
91
92 protected void OnChange()
93 {
94 if (this.IsChanged == false)
95 {
96 this.IsChanged = true;
97 this.OnPropertyChanged("IsChanged");
98 }
99 }
100
101 protected virtual void OnPropertyChanged(string name)
102 {
103 this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
104 if (name is not ("IsChanged" or "IsTrackingChanges"))
105 {
106 this.OnChange();
107 }
108 }
109
110 public virtual void RejectChanges()
111 {
112 this.IsChanged = false;
113 this.Author = this._acceptedAuthor;
114 this.Content = this._acceptedContent;
115 }
116
117 public event PropertyChangedEventHandler? PropertyChanged;
118}
119
1[TrackChanges]
2public class ModeratedComment : Comment
3{
4 public ModeratedComment( Guid id, string author, string content ) : base( id, author, content )
5 {
6 }
7
8 public bool? IsApproved { get; set; }
9}
1using System;
2
3[TrackChanges]
4public class ModeratedComment : Comment
5{
6 public ModeratedComment( Guid id, string author, string content ) : base( id, author, content )
7 {
8 }
9
10 private bool? _isApproved;
11
12 public bool? IsApproved
13 {
14 get
15 {
16 return this._isApproved;
17 }
18
19 set
20 {
21 if (value != this._isApproved)
22 {
23 this._isApproved = value;
24 this.OnPropertyChanged("IsApproved");
25 }
26
27 return;
28 }
29 }
30
31 private bool? _acceptedIsApproved;
32}
Aspect implementation
Here is the complete code of the new version of the TrackChanges
aspect:
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5using System.ComponentModel;
6using System.Globalization;
7
8
9#pragma warning disable IDE1005
10
11
12[Inheritable]
13public class TrackChangesAttribute : TypeAspect
14{
15 private static readonly DiagnosticDefinition<INamedType> _mustHaveOnChangeMethod = new(
16 "MY001",
17 Severity.Error,
18 $"The '{nameof(ISwitchableChangeTracking)}' interface is implemented manually on type '{{0}}', but the type does not have an '{nameof(OnChange)}()' method.");
19
20 private static readonly DiagnosticDefinition _onChangeMethodMustBeProtected = new(
21 "MY002",
22 Severity.Error,
23 $"The '{nameof(OnChange)}()' method must be have the 'protected' accessibility.");
24
25 private static readonly DiagnosticDefinition<IMethod> _onPropertyChangedMustBeVirtual = new(
26 "MY003",
27 Severity.Error,
28 "The '{0}' method must be virtual.");
29
30 public override void BuildAspect( IAspectBuilder<INamedType> builder )
31 {
32 // Select fields and automatic properties that can be changed.
33 var fieldsOrProperties = builder.Target.FieldsAndProperties
34 .Where( f =>
35 !f.IsImplicitlyDeclared && f.Writeability == Writeability.All && f.IsAutoPropertyOrField == true );
36
37
38 var introducedFields = new Dictionary<IFieldOrProperty, IField>();
39
40 // Create a field for each mutable field or property. These fields
41 // will contain the accepted values.
42 foreach ( var fieldOrProperty in fieldsOrProperties )
43 {
44 var upperCaseName = fieldOrProperty.Name.TrimStart( '_' );
45 upperCaseName = upperCaseName.Substring( 0, 1 ).ToUpper( CultureInfo.InvariantCulture ) +
46 upperCaseName.Substring( 1 );
47 var acceptedField =
48 builder.Advice.IntroduceField( builder.Target, "_accepted" + upperCaseName, fieldOrProperty.Type );
49 introducedFields[fieldOrProperty] = acceptedField.Declaration;
50 }
51
52 // Implement the ISwitchableChangeTracking interface.
53 var implementInterfaceResult = builder.Advice.ImplementInterface( builder.Target,
54 typeof(ISwitchableChangeTracking), OverrideStrategy.Ignore,
55 new { IntroducedFields = introducedFields } );
56
57 // If the type already implements ISwitchableChangeTracking, it must have a protected method called OnChanged, without parameters, otherwise
58 // this is a contract violation, so we report an error.
59 if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignored )
60 {
61 var onChangeMethod = builder.Target.AllMethods.OfName( nameof(this.OnChange) )
62 .Where( m => m.Parameters.Count == 0 ).SingleOrDefault();
63
64 if ( onChangeMethod == null )
65 {
66 builder.Diagnostics.Report( _mustHaveOnChangeMethod.WithArguments( builder.Target ) );
67 }
68 else if ( onChangeMethod.Accessibility != Accessibility.Protected )
69 {
70 builder.Diagnostics.Report( _onChangeMethodMustBeProtected );
71 }
72 }
73 else
74 {
75 builder.Advice.IntroduceField( builder.Target, "_isTrackingChanges", typeof(bool) );
76 }
77
78 // Override all writable fields and automatic properties.
79 // If the type has an OnPropertyChanged method, we assume that all properties
80 // and fields already call it, and we hook into OnPropertyChanged instead of
81 // overriding each setter.
82 var onPropertyChanged = this.GetOnPropertyChangedMethod( builder.Target );
83
84 if ( onPropertyChanged == null )
85 {
86 // If the type has an OnPropertyChanged method, we assume that all properties
87 // and fields already call it, and we hook into OnPropertyChanged instead of
88 // overriding each setter.
89 foreach ( var fieldOrProperty in fieldsOrProperties )
90 {
91 builder.Advice.OverrideAccessors( fieldOrProperty, null, nameof(this.OverrideSetter) );
92 }
93 }
94 else if ( onPropertyChanged.DeclaringType.Equals( builder.Target ) )
95 {
96 builder.Advice.Override( onPropertyChanged, nameof(this.OnPropertyChanged) );
97 }
98 else if ( implementInterfaceResult.Outcome == AdviceOutcome.Ignored )
99 {
100 // If we have an OnPropertyChanged method but the type already implements ISwitchableChangeTracking,
101 // we assume that the type already hooked the OnPropertyChanged method, and
102 // there is nothing else to do.
103 }
104 else
105 {
106 // If the OnPropertyChanged method was defined in a base class, but not overridden
107 // in the current class, and if we implement ISwitchableChangeTracking ourselves,
108 // then we need to override OnPropertyChanged.
109
110 if ( !onPropertyChanged.IsVirtual )
111 {
112 builder.Diagnostics.Report( _onPropertyChangedMustBeVirtual.WithArguments( onPropertyChanged ) );
113 }
114 else
115 {
116 builder.Advice.IntroduceMethod( builder.Target, nameof(this.OnPropertyChanged),
117 whenExists: OverrideStrategy.Override );
118 }
119 }
120 }
121
122 private IMethod? GetOnPropertyChangedMethod( INamedType type )
123 => type.AllMethods
124 .OfName( "OnPropertyChanged" )
125 .Where( m => m.Parameters.Count == 1 )
126 .SingleOrDefault();
127
128 [InterfaceMember( WhenExists = InterfaceMemberOverrideStrategy.Ignore )]
129 public bool IsChanged { get; private set; }
130
131 [InterfaceMember( WhenExists = InterfaceMemberOverrideStrategy.Ignore )]
132 public bool IsTrackingChanges
133 {
134 get => meta.This._isTrackingChanges;
135 set
136 {
137 if ( meta.This._isTrackingChanges != value )
138 {
139 meta.This._isTrackingChanges = value;
140
141 var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
142
143 if ( onPropertyChanged != null )
144 {
145 onPropertyChanged.Invoke( nameof(this.IsTrackingChanges) );
146 }
147
148 if ( value )
149 {
150 this.AcceptChanges();
151 }
152 }
153 }
154 }
155
156 [InterfaceMember]
157 public virtual void AcceptChanges()
158 {
159 if ( meta.Target.Method.IsOverride )
160 {
161 meta.Proceed();
162 }
163 else
164 {
165 this.IsChanged = false;
166 }
167
168 var introducedFields = (Dictionary<IFieldOrProperty, IField>) meta.Tags["IntroducedFields"]!;
169
170 foreach ( var field in introducedFields )
171 {
172 field.Value.Value = field.Key.Value;
173 }
174 }
175
176 [InterfaceMember]
177 public virtual void RejectChanges()
178 {
179 if ( meta.Target.Method.IsOverride )
180 {
181 meta.Proceed();
182 }
183 else
184 {
185 this.IsChanged = false;
186 }
187
188
189 var introducedFields = (Dictionary<IFieldOrProperty, IField>) meta.Tags["IntroducedFields"]!;
190
191 foreach ( var field in introducedFields )
192 {
193 field.Key.Value = field.Value.Value;
194 }
195 }
196
197
198 [Introduce( WhenExists = OverrideStrategy.Ignore )]
199 protected void OnChange()
200 {
201 if ( this.IsChanged == false )
202 {
203 this.IsChanged = true;
204
205 var onPropertyChanged = this.GetOnPropertyChangedMethod( meta.Target.Type );
206
207 if ( onPropertyChanged != null )
208 {
209 onPropertyChanged.Invoke( nameof(this.IsChanged) );
210 }
211 }
212 }
213
214 [Template]
215 private void OverrideSetter( dynamic? value )
216 {
217 meta.Proceed();
218
219 if ( value != meta.Target.Property.Value )
220 {
221 this.OnChange();
222 }
223 }
224
225 [Template]
226 protected virtual void OnPropertyChanged( string name )
227 {
228 meta.Proceed();
229
230 if ( name is not (nameof(this.IsChanged) or nameof(this.IsTrackingChanges)) )
231 {
232 this.OnChange();
233 }
234 }
235}
Let's focus on the following part of the BuildAspect
method for the moment.
38 var introducedFields = new Dictionary<IFieldOrProperty, IField>();
39
40 // Create a field for each mutable field or property. These fields
41 // will contain the accepted values.
42 foreach ( var fieldOrProperty in fieldsOrProperties )
43 {
44 var upperCaseName = fieldOrProperty.Name.TrimStart( '_' );
45 upperCaseName = upperCaseName.Substring( 0, 1 ).ToUpper( CultureInfo.InvariantCulture ) +
46 upperCaseName.Substring( 1 );
47 var acceptedField =
48 builder.Advice.IntroduceField( builder.Target, "_accepted" + upperCaseName, fieldOrProperty.Type );
49 introducedFields[fieldOrProperty] = acceptedField.Declaration;
50 }
51
52 // Implement the ISwitchableChangeTracking interface.
53 var implementInterfaceResult = builder.Advice.ImplementInterface( builder.Target,
54 typeof(ISwitchableChangeTracking), OverrideStrategy.Ignore,
55 new { IntroducedFields = introducedFields } );
First, the method introduces new fields into the type for each mutable field or automatic property using the IntroduceField method. For details about this practice, see Introducing members.
Note that we are building the introducedFields
dictionary, which maps the current-value field or property to the accepted-value field. This dictionary will be passed to the ImplementInterface call as a tag. The collection of tags is an anonymous object. For more details about this technique, see Sharing state with advice.
The field dictionary is read from the implementation of AcceptChanges
and RejectChanges
:
156 [InterfaceMember]
157 public virtual void AcceptChanges()
158 {
159 if ( meta.Target.Method.IsOverride )
160 {
161 meta.Proceed();
162 }
163 else
164 {
165 this.IsChanged = false;
166 }
167
168 var introducedFields = (Dictionary<IFieldOrProperty, IField>) meta.Tags["IntroducedFields"]!;
169
170 foreach ( var field in introducedFields )
171 {
172 field.Value.Value = field.Key.Value;
173 }
174 }
176 [InterfaceMember]
177 public virtual void RejectChanges()
178 {
179 if ( meta.Target.Method.IsOverride )
180 {
181 meta.Proceed();
182 }
183 else
184 {
185 this.IsChanged = false;
186 }
187
188
189 var introducedFields = (Dictionary<IFieldOrProperty, IField>) meta.Tags["IntroducedFields"]!;
190
191 foreach ( var field in introducedFields )
192 {
193 field.Key.Value = field.Value.Value;
194 }
195 }
As you can see, the (Dictionary<IFieldOrProperty, IField>) meta.Tags["IntroducedFields"]
expression gets the IntroducedFields
tag, which was passed to the ImplementInterface method. We cast it back to its original type and iterate it. We use the Value property to generate the run-time expression that represents the field or property. In the AcceptChanges
method, we copy the current values to the accepted ones and do the opposite in the RejectChanges
method.