This article will create an aspect that automatically implements the IChangeTracking interface of the .NET Framework.
Before implementing any aspect, we must first discuss the implementation design of the pattern. The main design decision
is that objects will not track their changes by default. Instead, they will have an IsTrackingChanges
property to
control this behavior. Why? Because if we enable change tracking by default, we will need to reset the changes after
object initialization has been completed, and we have no way to automate this. So, instead of asking users to write code
that calls the AcceptChanges method after each object initialization, we
ask them to set the IsTrackingChanges
property to true
if they are interested in change tracking. Therefore, we can
define the ISwitchableChangeTracking
interface as follows:
1using System.ComponentModel;
2
3public interface ISwitchableChangeTracking : IChangeTracking
4{
5 /// <summary>
6 /// Gets or sets a value indicating whether the current object
7 /// is tracking its changes.
8 /// </summary>
9 bool IsTrackingChanges { get; set; }
10}
We want our aspect to generate the following code. In this example, we have a base class named Comment
and a derived
class named ModeratedComment
.
1[TrackChanges]
2public partial class Comment
3{
4 public Guid Id { get; }
5
6 public string Author { get; set; }
7
8 public string Content { get; set; }
9
10 public Comment( Guid id, string author, string content )
11 {
12 this.Id = id;
13 this.Author = author;
14 this.Content = content;
15 }
16}
1using System;
2using System.ComponentModel;
3
4[TrackChanges]
5public partial class Comment
6: ISwitchableChangeTracking, IChangeTracking
7{
8 public Guid Id { get; }
9private string _author = default!;
10
11 public string Author { get { return this._author; } set { if (value != this._author) { this._author = value; OnChange(); } } }
12 private string _content = default!;
13
14 public string Content { get { return this._content; } set { if (value != this._content) { this._content = value; OnChange(); } } }
15
16 public Comment( Guid id, string author, string content )
17 {
18 this.Id = id;
19 this.Author = author;
20 this.Content = content;
21 }
22public bool IsChanged { get; private set; }
23 public bool IsTrackingChanges { get; set; }
24
25 public void AcceptChanges()
26 {
27 IsChanged = false;
28 }
29 protected void OnChange()
30 {
31 if (IsTrackingChanges)
32 {
33 IsChanged = true;
34 }
35 }
36}
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}
1[TrackChanges]
2public class ModeratedComment : Comment
3{
4 public ModeratedComment( Guid id, string author, string content ) : base( id, author, content ) { }
5private bool? _isApproved;
6
7 public bool? IsApproved { get { return this._isApproved; } set { if (value != this._isApproved) { this._isApproved = value; OnChange(); } } }
8}
Aspect Implementation
Our aspect implementation needs to perform two operations:
- Implement the
ISwitchableChangeTracking
interface (including the IChangeTracking system interface) unless the type already implements it; - Add an
OnChange
method that sets the IsChanged property totrue
if change tracking is enabled unless the type already contains such a method; - Override the setter of all fields and automatic properties to call
OnChange
.
Here is the complete implementation:
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4[Inheritable]
5public class TrackChangesAttribute : TypeAspect
6{
7 public override void BuildAspect( IAspectBuilder<INamedType> builder )
8 {
9 // Implement the ISwitchableChangeTracking interface.
10 builder.Advice.ImplementInterface(
11 builder.Target,
12 typeof(ISwitchableChangeTracking),
13 OverrideStrategy.Ignore );
14
15 // Override all writable fields and automatic properties.
16 var fieldsOrProperties = builder.Target.FieldsAndProperties
17 .Where(
18 f => !f.IsImplicitlyDeclared &&
19 f.IsAutoPropertyOrField == true &&
20 f.Writeability == Writeability.All );
21
22 foreach ( var fieldOrProperty in fieldsOrProperties )
23 {
24 builder.Advice.OverrideAccessors( fieldOrProperty, null, nameof(this.OverrideSetter) );
25 }
26 }
27
28 [InterfaceMember]
29 public bool IsChanged { get; private set; }
30
31 [InterfaceMember]
32 public bool IsTrackingChanges { get; set; }
33
34 [InterfaceMember]
35 public void AcceptChanges() => this.IsChanged = false;
36
37 [Introduce( WhenExists = OverrideStrategy.Ignore )]
38 protected void OnChange()
39 {
40 if ( this.IsTrackingChanges )
41 {
42 this.IsChanged = true;
43 }
44 }
45
46 [Template]
47 private void OverrideSetter( dynamic? value )
48 {
49 if ( value != meta.Target.Property.Value )
50 {
51 meta.Proceed();
52
53 this.OnChange();
54 }
55 }
56}
The TrackChangesAttribute
class is a type-level aspect, so it must derive from
the TypeAspect class, which itself derives from Attribute.
The [Inheritable] at the top of the class indicates that the aspect should be inherited from the base class to derived classes. For further details, see Applying aspects to derived types.
The entry point of the aspect is the BuildAspect
method. Our implementation has two parts, two of the three operations
that our aspect has to perform.
First, the BuildAspect
method calls the ImplementInterface
method
to add the ISwitchableChangeTracking
interface to the target type. It specifies
the OverrideStrategy to Ignore
, indicating that the operation should be ignored if
the target type already implements the interface.
The ImplementInterface method requires the aspect class to contain
the interface members, which should be annotated with
the [InterfaceMember] custom attribute. The
implementation of these members is trivial. For details about adding interfaces to types,
see Implementing interfaces.
Then, the BuildAspect
method selects fields and automatic properties except readonly
fields and init
or get
-only
automatic properties (we apply this condition using the expression f.Writeability == Writeability.All
). For all these
fields and properties, we call the OverrideAccessors method
using OverridePropertySetter
as a template for the new setter. For further details,
see Overriding fields or properties.
Here is the template for the field/property setter. Note that it must be annotated with the [Template] custom attribute.
46[Template]
47private void OverrideSetter( dynamic? value )
48{
49 if ( value != meta.Target.Property.Value )
50 {
51 meta.Proceed();
52
53 this.OnChange();
54 }
55}
To introduce the OnChange
method (which is not part of the interface), we use the following code:
37[Introduce( WhenExists = OverrideStrategy.Ignore )]
38protected void OnChange()
39{
40 if ( this.IsTrackingChanges )
41 {
42 this.IsChanged = true;
43 }
44}
45
The OnChange
method has an [Introduce] custom attribute;
therefore, Metalama will add this method to the target type. We again assign the Ignore
value to
the WhenExists property to skip this step if the target type
already contains this method.
Summary
In this first article, we created the first version of the TrackChanged
aspect, which already does a good job. The
aspect also works with manual implementations of IChangeTracking as long as the
OnChangemethod is available. But what if there is no
OnChange` method? In the following article, we will see how to
report an error when this happens.