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