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