I have recently been working on an application which required tracking of changes to about half the objects in my domain model. I needed to track the following:
The trigger solution would look something like this:
However, I then started to think about second-level caching - particularly using MemCached. With the cache lifetime being based on the SessionFactory, then this mode of creating the change records was going to be a problem.
The standard, well documented approach to creating an audit log of changes to domain objects while using NHibernate involves the user of Interceptors or Event Listeners, as described well here. However, from what I can tell, this only allows you modify the object itself. Examples I saw involved a heavy change-oriented base class or just updating change dates. I needed whole objects to track a series of changes over a long period of time, and I did not want to have to reference a (potentionally large) lazy loaded collection to add a new change record.
It became clear that to have a robust solution which supported second-level caching, I would have to do the work in the domain layer. Conceptually this is not such a bad thing, as it is domain concern, but it would involve more intensive code than a simple trigger. I share my solution below:
First I created some interfaces to ensure required fields were present in change records and domain objects that require change tracking:
Next, I created some custom attributes to use when creating the change records:
Then I updated my domain objects to implement the interfaces and use the attributes:
Then comes the interesting part - creating the audit records. I added the following method to my domain object base class:
Some interesting work is done by the call to DynamicExpression.ParseLambda(). This extension to the framework is available with the code samples provided by Microsoft and can be downloaded here
All that remains is to invoke the method and save the change records:
I am sure that others will encounter this same change tracking problem. I have shared my solution in the hope that you may find it a useful launching pad for your own solution.
- The object that was changed
- When the object was changed
- Who made the change
- The property that was changed
- What the old value was before the change
The sort of data you expect really.
The system is built using MS.NET 4.0 with a WCF interface over a domain model being persisted to an MS Sql Server database using NHibernate. A pretty standard multi-tier approach.
My first thought was to use triggers to track and record the changes. It is easy to identify when the change is being performed and easy to create the corresponding change record. The changed records were modeled in the domain, but were read-only and were lazy loaded. Even with first level caching on, using either session-per-call or session-per-request, propagating these changes to the objects would not be a problem, and there is always the option of Session.Refresh().
The trigger solution would look something like this:
IF OBJECT_ID ('TRG_person_personchanged','TR') IS NOT NULL
DROP TRIGGER TRG_person_personchanged;
GO
CREATE TRIGGER TRG_person_personchanged
ON [dbo].[person]
AFTER UPDATE
AS
SET NOCOUNT ON
BEGIN
DECLARE @changeDate datetime
SET @changeDate = GETUTCDATE()
IF (UPDATE(dateOfBirth))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'dateOfBirth',
ISNULL(del.dateOfBirth, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.dateOfBirth <> del.dateOfBirth OR ins.dateOfBirth IS NULL OR del.dateOfBirth IS NULL
END
IF (UPDATE(lastName))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'lastName',
ISNULL(del.lastName, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.lastName <> del.lastName OR ins.lastName IS NULL OR del.lastName IS NULL
END
IF (UPDATE(givenNames))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'givenNames',
ISNULL(del.givenNames, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.givenNames <> del.givenNames OR ins.givenNames IS NULL OR del.givenNames IS NULL
END
END
GO
DROP TRIGGER TRG_person_personchanged;
GO
CREATE TRIGGER TRG_person_personchanged
ON [dbo].[person]
AFTER UPDATE
AS
SET NOCOUNT ON
BEGIN
DECLARE @changeDate datetime
SET @changeDate = GETUTCDATE()
IF (UPDATE(dateOfBirth))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'dateOfBirth',
ISNULL(del.dateOfBirth, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.dateOfBirth <> del.dateOfBirth OR ins.dateOfBirth IS NULL OR del.dateOfBirth IS NULL
END
IF (UPDATE(lastName))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'lastName',
ISNULL(del.lastName, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.lastName <> del.lastName OR ins.lastName IS NULL OR del.lastName IS NULL
END
IF (UPDATE(givenNames))
BEGIN
INSERT INTO persondatahistory
(personId,
propertyName,
valueReplaced,
modifiedBy,
dateModified)
SELECT ins.personId,
'givenNames',
ISNULL(del.givenNames, ''),
ins.lastModifiedBy,
@changeDate
FROM INSERTED ins INNER JOIN DELETED del ON ins.personId = del.personId
WHERE ins.givenNames <> del.givenNames OR ins.givenNames IS NULL OR del.givenNames IS NULL
END
END
GO
However, I then started to think about second-level caching - particularly using MemCached. With the cache lifetime being based on the SessionFactory, then this mode of creating the change records was going to be a problem.
The standard, well documented approach to creating an audit log of changes to domain objects while using NHibernate involves the user of Interceptors or Event Listeners, as described well here. However, from what I can tell, this only allows you modify the object itself. Examples I saw involved a heavy change-oriented base class or just updating change dates. I needed whole objects to track a series of changes over a long period of time, and I did not want to have to reference a (potentionally large) lazy loaded collection to add a new change record.
It became clear that to have a robust solution which supported second-level caching, I would have to do the work in the domain layer. Conceptually this is not such a bad thing, as it is domain concern, but it would involve more intensive code than a simple trigger. I share my solution below:
First I created some interfaces to ensure required fields were present in change records and domain objects that require change tracking:
public interface IAuditable
{
DateTime? DateLastModified { get; set; }
IPerson LastModifiedBy { get; set; }
}
public interface IAuditRecord
{
Guid EntityId { get; set; }
string PropertyName { get; set; }
string ValueReplaced { get; set; }
IPerson ModifiedBy { get; set; }
DateTime DateModified { get; set; }
}
{
DateTime? DateLastModified { get; set; }
IPerson LastModifiedBy { get; set; }
}
public interface IAuditRecord
{
Guid EntityId { get; set; }
string PropertyName { get; set; }
string ValueReplaced { get; set; }
IPerson ModifiedBy { get; set; }
DateTime DateModified { get; set; }
}
Next, I created some custom attributes to use when creating the change records:
[AttributeUsage(AttributeTargets.Class)]
public class AuditRecordAttribute : Attribute
{
Type _auditRecordType;
private string _entityIdPropertyName;
public string EntityIdPropertyName
{
get { return _entityIdPropertyName; }
set { _entityIdPropertyName = value; }
}
public Type AuditRecordType
{
get { return _auditRecordType; }
set { _auditRecordType = value; }
}
public AuditRecordAttribute(Type auditRecordType, string idPropertyName)
{
_auditRecordType = auditRecordType;
_entityIdPropertyName = idPropertyName;
}
}
[AttributeUsage(AttributeTargets.Property)]
public class AuditablePropertyAttribute : Attribute
{
public AuditablePropertyAttribute()
{ }
}
[AttributeUsage(AttributeTargets.Property)]
public class AuditableCollectionAttribute : Attribute
{
private string _itemIdPropertyName;
public string ItemIdPropertyName
{
get { return _itemIdPropertyName; }
set { _itemIdPropertyName = value; }
}
public AuditableCollectionAttribute(string itemIdPropertyName)
{
_itemIdPropertyName = itemIdPropertyName;
}
}
public class AuditRecordAttribute : Attribute
{
Type _auditRecordType;
private string _entityIdPropertyName;
public string EntityIdPropertyName
{
get { return _entityIdPropertyName; }
set { _entityIdPropertyName = value; }
}
public Type AuditRecordType
{
get { return _auditRecordType; }
set { _auditRecordType = value; }
}
public AuditRecordAttribute(Type auditRecordType, string idPropertyName)
{
_auditRecordType = auditRecordType;
_entityIdPropertyName = idPropertyName;
}
}
[AttributeUsage(AttributeTargets.Property)]
public class AuditablePropertyAttribute : Attribute
{
public AuditablePropertyAttribute()
{ }
}
[AttributeUsage(AttributeTargets.Property)]
public class AuditableCollectionAttribute : Attribute
{
private string _itemIdPropertyName;
public string ItemIdPropertyName
{
get { return _itemIdPropertyName; }
set { _itemIdPropertyName = value; }
}
public AuditableCollectionAttribute(string itemIdPropertyName)
{
_itemIdPropertyName = itemIdPropertyName;
}
}
Then I updated my domain objects to implement the interfaces and use the attributes:
public class PersonAuditRecord : IAuditRecord
{
public virtual int PersonAuditRecordId{ get; set; }
public virtual Guid EntityId{ get; set; }
public virtual string PropertyName{ get; set; }
public virtual string ValueReplaced{ get; set; }
public virtual DateTime DateModified{ get; set; }
public virtual IPerson ModifiedBy{ get; set; }
public PersonAuditRecord()
[AuditRecordAttribute(typeof(PersonAuditRecord), "PersonId")]
public class Person : DomainBase, IPerson, IAuditable
{
public virtual Guid PersonId { get; set; }
[AuditablePropertyAttribute()]
public virtual string GivenNames{ get; set; }
[AuditablePropertyAttribute()]
public virtual string LastName { get; set; }
public virtual IPerson LastModifiedBy { get; set; }
public virtual DateTime? DateLastModified { get; set; }
public Person()
{}
}
{
public virtual int PersonAuditRecordId{ get; set; }
public virtual Guid EntityId{ get; set; }
public virtual string PropertyName{ get; set; }
public virtual string ValueReplaced{ get; set; }
public virtual DateTime DateModified{ get; set; }
public virtual IPerson ModifiedBy{ get; set; }
public PersonAuditRecord()
{}
}[AuditRecordAttribute(typeof(PersonAuditRecord), "PersonId")]
public class Person : DomainBase, IPerson, IAuditable
{
public virtual Guid PersonId { get; set; }
[AuditablePropertyAttribute()]
public virtual string GivenNames{ get; set; }
[AuditablePropertyAttribute()]
public virtual string LastName { get; set; }
public virtual IPerson LastModifiedBy { get; set; }
public virtual DateTime? DateLastModified { get; set; }
public Person()
{}
}
Then comes the interesting part - creating the audit records. I added the following method to my domain object base class:
public static IList TrackChanges(T persistedObject, T modifiedObject) where T : IAuditable
{
AuditRecordAttribute changesAttribute = (AuditRecordAttribute)typeof(T).GetCustomAttributes(typeof(AuditRecordAttribute), false).FirstOrDefault();
Type auditRecordType = changesAttribute.AuditRecordType;
dynamic entityId = typeof(T).GetProperty(changesAttribute.EntityIdPropertyName).GetValue(persistedObject, null);
List changes = new List();
PropertyInfo[] properties = typeof(T).GetProperties();
foreach (PropertyInfo property in properties)
{
dynamic[] attributes = property.GetCustomAttributes(false);
foreach(dynamic att in attributes)
{
if (att is AuditablePropertyAttribute)
{
dynamic oldValue = property.GetValue(persistedObject, null);
dynamic newValue = property.GetValue(modifiedObject, null);
// record changes to properties which are instances of domain objects
if (oldValue != null && oldValue is DomainBase)
{
IList domainChanges = ((DomainBase)oldValue).TrackChanges(oldValue, newValue);
changes.AddRange(domainChanges);
}
else
{
// record changes to POCO properties
if (oldValue != newValue)
{
IAuditRecord auditRecord = (IAuditRecord)Activator.CreateInstance(auditRecordType);
auditRecord.PropertyName = property.Name;
auditRecord.ValueReplaced = oldValue.ToString();
auditRecord.DateModified = DateTime.UtcNow;
auditRecord.ModifiedBy = ((IAuditable)modifiedObject).LastModifiedBy;
auditRecord.EntityId = entityId;
changes.Add(auditRecord);
}
}
}
else if (att is AuditableCollectionAttribute)
{
// record changes to collections of domain objects, specifically IList implementations
List oldItems = (List)property.GetValue(persistedObject, null);
List newItems = (List)property.GetValue(modifiedObject, null);
string idPropertyName = (att as AuditableCollectionAttribute).ItemIdPropertyName;
Type itemType = null;
if (oldItems.Count > 0)
{
itemType = oldItems[0].GetType();
}
else if (newItems.Count > 0)
{
itemType = newItems[0].GetType();
}
if (itemType != null)
{
foreach(dynamic old in oldItems)
{
dynamic oldId = itemType.GetProperty(idPropertyName).GetValue(old, null);
System.Linq.Expressions.LambdaExpression idPropertyExpression = DynamicExpression.ParseLambda(idPropertyName + " == @0", oldId);
var result = newItems.AsQueryable().Provider.Execute(idPropertyExpression);
if (result != null)
{
IList itemChanges = ((DomainBase)old).TrackChanges(old, result);
changes.AddRange(itemChanges);
}
}
}
}
}
}
return changes;
}
{
AuditRecordAttribute changesAttribute = (AuditRecordAttribute)typeof(T).GetCustomAttributes(typeof(AuditRecordAttribute), false).FirstOrDefault();
Type auditRecordType = changesAttribute.AuditRecordType;
dynamic entityId = typeof(T).GetProperty(changesAttribute.EntityIdPropertyName).GetValue(persistedObject, null);
List
PropertyInfo[] properties = typeof(T).GetProperties();
foreach (PropertyInfo property in properties)
{
dynamic[] attributes = property.GetCustomAttributes(false);
foreach(dynamic att in attributes)
{
if (att is AuditablePropertyAttribute)
{
dynamic oldValue = property.GetValue(persistedObject, null);
dynamic newValue = property.GetValue(modifiedObject, null);
// record changes to properties which are instances of domain objects
if (oldValue != null && oldValue is DomainBase)
{
IList
changes.AddRange(domainChanges);
}
else
{
// record changes to POCO properties
if (oldValue != newValue)
{
IAuditRecord auditRecord = (IAuditRecord)Activator.CreateInstance(auditRecordType);
auditRecord.PropertyName = property.Name;
auditRecord.ValueReplaced = oldValue.ToString();
auditRecord.DateModified = DateTime.UtcNow;
auditRecord.ModifiedBy = ((IAuditable)modifiedObject).LastModifiedBy;
auditRecord.EntityId = entityId;
changes.Add(auditRecord);
}
}
}
else if (att is AuditableCollectionAttribute)
{
// record changes to collections of domain objects, specifically IList implementations
List
List
string idPropertyName = (att as AuditableCollectionAttribute).ItemIdPropertyName;
Type itemType = null;
if (oldItems.Count > 0)
{
itemType = oldItems[0].GetType();
}
else if (newItems.Count > 0)
{
itemType = newItems[0].GetType();
}
if (itemType != null)
{
foreach(dynamic old in oldItems)
{
dynamic oldId = itemType.GetProperty(idPropertyName).GetValue(old, null);
System.Linq.Expressions.LambdaExpression idPropertyExpression = DynamicExpression.ParseLambda
var result = newItems.AsQueryable().Provider.Execute(idPropertyExpression);
if (result != null)
{
IList
changes.AddRange(itemChanges);
}
}
}
}
}
}
return changes;
}
Some interesting work is done by the call to DynamicExpression.ParseLambda(). This extension to the framework is available with the code samples provided by Microsoft and can be downloaded here
All that remains is to invoke the method and save the change records:
using (ISession session = _factory.OpenSession())
{
using (ITransaction trans = session.BeginTransaction())
{
Person persisted = session.Get<Person>(modified.PersonId);
IList<dynamic> changes = Person.TrackChanges(persisted, modified as Person);
foreach (dynamic change in changes)
{
session.Save(change);
}
if (changes.Count > 0)
{
if (persisted == null)
{
modified = (Person)session.Save(modified);
}
else
{
modified = (Person)session.Merge(modified);
}
}
trans.Commit();
}
session.Close();
}
return modified;
{
using (ITransaction trans = session.BeginTransaction())
{
Person persisted = session.Get<Person>
IList<dynamic>
foreach (dynamic change in changes)
{
session.Save(change);
}
if (changes.Count > 0)
{
if (persisted == null)
{
modified = (Person)session.Save(modified);
}
else
{
modified = (Person)session.Merge(modified);
}
}
trans.Commit();
}
session.Close();
}
return modified;
I am sure that others will encounter this same change tracking problem. I have shared my solution in the hope that you may find it a useful launching pad for your own solution.
You should take a look at NHibernate Envers
ReplyDeleteIt works roughly the same way except you don't need to define a xxxAuditRecord type for every type that needs auditing…
Jan - thanks for posting. I was not aware of NHibernate Envers when I did this work. It certainly does look like a nice solution for this problem. If it does what you need then it would provide a good option to avoid 'rolling-your-own' implementation.
Delete