Metalama//Commented Examples/Change Tracking/Step 4.​ Reverting changes
Open sandboxFocus

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:

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, 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
Source Code


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}
Transformed Code
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.