We have this big project we're working on (which I have written about before) and one of the things we need to do on this project is automatic logging of changes made to model objects. I've worked out a way to do this generically, for any object, and I think others might find it just as useful as we have.

Requirements

We needed to be able to compare two objects of the same type, examine their properties, and log which properties have changed value. Further, we needed to be able to mark which property represents the primary key for the instance of the object (which is not expected to change value), and we needed to be able to specify fields whose changes won't be logged. Finally, we wanted to do this generically; for any two objects of the same type, we should be able to compare them and log changes.

In short, we needed this log generator to be:

  • Able to compare two objects of any type, provided they are the same type as each other
  • Able to exclude certain properties
  • Able to determine the primary key field and record that value

Let's set up an environment where we can achieve these goals.

Program Setup

Let's say we have a model object that looks like this:

public class RetailLocation  
{
    public int Id { get; set; }
    public DateTime DateOpened { get; set; }
    public string ManagerFirstName { get; set; }
    public string ManagerLastName { get; set; }
    public bool HasLimitedMenu { get; set; }
}

Every time a change is made to this object, we want to automatically record what changes occurred.

Further, we also have the log object itself:

public class ChangeLog  
{
    public string ClassName { get; set; }
    public string PropertyName { get; set; }
    public int PrimaryKey { get; set; }
    public string OldValue { get; set; }
    public string NewValue { get; set; }
    public DateTime DateChanged { get; set; }
}

What this means is that if multiple properties change (between two objects) then we'll get multiple records of ChangeLog.

Let's set up a command-line program that will test this.

class Program  
{
    static void Main(string[] args)
    {
        //Create the old entry
        RetailLocation oldLocation = new RetailLocation()
        {
            Id = 1,
            DateOpened = new DateTime(2009, 12, 3),
            ManagerFirstName = "Steve",
            ManagerLastName = "Harkonnen",
            HasLimitedMenu = true
        };

        //Create the new entry
        RetailLocation newLocation = new RetailLocation()
        {
            Id = 1,
            DateOpened = new DateTime(2009, 12, 3),
            ManagerFirstName = "Kelly",
            ManagerLastName = "Nishimura",
            HasLimitedMenu = false
        };

        ChangeLogService service = new ChangeLogService();
        List<ChangeLog> logs = service.GetChanges(oldLocation, newLocation); //What does this do?

        foreach(var log in logs)
        {
            Console.WriteLine("Primary Key: " + log.PrimaryKey.ToString() + ", Class Name:" + log.ClassName + ", Property Name: " + log.PropertyName + ", Old Value = " + log.OldValue + ", New Value = " + log.NewValue);
        }
    }
}

Whenever we instantiate ChangeLogService and call GetChanges, we should get back a List of ChangeLogs that will contain all the changes found between the two objects. But exactly how can we get these changes?

Making the Comparison

Let's start with our GetChanges method declaration:

public List<ChangeLog> GetChanges(object oldEntry, object newEntry)  
{
    List<ChangeLog> logs = new List<ChangeLog>();
}

We'll need several steps to get all of the changes.

Step 1: Do the Types Match?

First off, we won't compare objects if they are not of the same type. So let's check the type of the two objects and return a blank list of changes if the types are different.

var oldType = oldEntry.GetType();  
var newType = newEntry.GetType();  
if(oldType != newType)  
{
    return logs; //Types don't match, cannot log changes
}

Step 2: Find the Primary Key Property

Now we come to the first real issue we have to solve: we need to record the primary key value for the record that changed.

We can make the assumption that the object will have at least one of those fields represent a primary key value. (In the real world, of course, you could have multiple fields represent the key, but in this tutorial we're assuming that the database has a single-column primary key for all tables). But, even with this assumption, how can we identify which property in the object (i.e. which column in the database) is that primary key?

We will use an attribute:

public class LoggingPrimaryKeyAttribute : Attribute  
{
}

We can then use this to decorate the RetailLocation object from earlier:

public class RetailLocation  
{
    [LoggingPrimaryKey]
    public int Id { get; set; }
    public DateTime DateOpened { get; set; }
    public string ManagerFirstName { get; set; }
    public string ManagerLastName { get; set; }
    public bool HasLimitedMenu { get; set; }
}

Step 3: Initialize Change Log Data and Get All Properties

We now need some data about each change log that we're going to create. Specifically, we need the primary key value, the date changed, and the type name. We're using the LoggingPrimaryKeyAttribute to get the first item, and the other two can be gathered from other data, like so:

var oldProperties = oldType.GetProperties();  
var newProperties = newType.GetProperties();

var dateChanged = DateTime.Now;  
var primaryKey = (int)oldProperties.Where(x => Attribute.IsDefined(x, typeof(LoggingPrimaryKeyAttribute))).First().GetValue(oldEntry);  
var className = oldEntry.GetType().Name;  

Notice the call to Attribute.IsDefined. This returns true if the given property (represented by x in this call) has an attribute of the given type defined upon it. This is how we can check if a particular attribute is defined on a particular property (and we'll be using this again in just a bit).

Better still, we now have lists of properties from the two changed objects. We can use these lists to determine which of their properties have changed.

Step 4: Ignore Specified Properties

We have now reached the step where we can compare the properties of the two objects. However, we first need a way to exclude the properties that we don't want to log changes for. To do this, we create another attribute:

public class IgnoreLoggingAttribute : Attribute  
{
}

In our sample, we'll pretend that we don't want to record changes made to the Manager's First Name, so we'll decorate RetailLocation like so:

public class RetailLocation  
{
    [LoggingPrimaryKey]
    public int Id { get; set; }
    public DateTime DateOpened { get; set; }
    [IgnoreLogging]
    public string ManagerFirstName { get; set; }
    public string ManagerLastName { get; set; }
    public bool HasLimitedMenu { get; set; }

}

Step 5: Compare the Properties

We want to walk through each of the properties on one of the objects and to see if the other object's value for that property has changed.

foreach(var oldProperty in oldProperties)  
{

}

Inside this foreach loop, we check to see if the new instance has the property:

foreach(var oldProperty in oldProperties)  
{
    var matchingProperty = newProperties.Where(x => !Attribute.IsDefined(x, typeof(IgnoreLoggingAttribute)) 
                                                    && x.Name == oldProperty.Name 
                                                    && x.PropertyType == oldProperty.PropertyType)
                                        .FirstOrDefault();
    if(matchingProperty == null)
    {
        continue; //If we don't find a matching property, move on to the next property.
    }
}

Once again we have a call to Attribute.IsDefined, only this time we want the properties that don't have the attribute [IgnoreLogging] defined upon them.

Finally, we need to compare the values of the properties and, if they are different, create a new entry of ChangeLog to record the differences. You might think about doing this:

var oldValue = oldProperty.GetValue(oldEntry);  
var newValue = matchingProperty.GetValue(newEntry);  
if(matchingProperty != null && oldValue != newValue) { //Create ChangeLog }  

However, if you do this, you'll find that the IF clause is always true. In other words, oldValue will always not be equal to newValue. This is because, in C# the == and != operators perform a reference value check on the objects being compared, which will never be equal (see Jon Skeet's answer on StackOverflow). Instead, what we need to do is change the values to string to directly compare them:

var oldValue = oldProperty.GetValue(oldEntry).ToString();  
var newValue = matchingProperty.GetValue(newEntry).ToString();  
if(matchingProperty != null && oldValue != newValue) { //Create ChangeLog }  

The Complete Method

Here is the complete code for the GetChanges method:

public List<ChangeLog> GetChanges(object oldEntry, object newEntry)  
{
    List<ChangeLog> logs = new List<ChangeLog>();

    var oldType = oldEntry.GetType();
    var newType = newEntry.GetType();
    if(oldType != newType)
    {
        return logs; //Types don't match, cannot log changes
    }

    var oldProperties = oldType.GetProperties();
    var newProperties = newType.GetProperties();

    var dateChanged = DateTime.Now;
    var primaryKey = (int)oldProperties.Where(x => Attribute.IsDefined(x, typeof(LoggingPrimaryKeyAttribute))).First().GetValue(oldEntry);
    var className = oldEntry.GetType().Name;

    foreach(var oldProperty in oldProperties)
    {
        var matchingProperty = newProperties.Where(x => !Attribute.IsDefined(x, typeof(IgnoreLoggingAttribute)) 
                                                        && x.Name == oldProperty.Name 
                                                        && x.PropertyType == oldProperty.PropertyType)
                                            .FirstOrDefault();
        if(matchingProperty == null)
        {
            continue;
        }
        var oldValue = oldProperty.GetValue(oldEntry).ToString();
        var newValue = matchingProperty.GetValue(newEntry).ToString();
        if(matchingProperty != null && oldValue != newValue)
        {
            logs.Add(new ChangeLog()
            {
                PrimaryKey = primaryKey,
                DateChanged = dateChanged,
                ClassName = className,
                PropertyName = matchingProperty.Name,
                OldValue = oldProperty.GetValue(oldEntry).ToString(),
                NewValue = matchingProperty.GetValue(newEntry).ToString()
            });
        }
    }

    return logs;
}

The completed method fulfills all of our requirements:

  • It will get and record the primary key value.
  • It will ignore specified columns.
  • It will compare the properties of two objects of the same type and return the properties that have changed values.

Check out my sample project on Github, which has all of the code we've written in this post. As always, if you notice something I could've done better with this code, let me know in the comments!

Happy Coding!