Friday, March 14, 2008

Implementing Change Tracking when disconnected.

If you have a look at the LINQ to SQL Entity Base source code on codeplex, you'll see that the way I've implemented change tracking is by putting a few flags on the base class IsNew, IsModified, IsDeleted.

I've been able to get IsNew & IsModified to set automacally, here's how they work:

IsNew
This can be established by checking the entities RowVersion (TimeStamp) field (which BTW is a requirement to have for this to work). If the RowVersion is null, it's never been applied to the database (as the database sets this value not the developer) and hence we can tell with absolute certainty that it's a new object.
But it's in the child class how did I accomplish this?
Since the RowVersion property is in the class, there's a few options we can use to achieve this:

(1) Write extra code in the child entity class
Nope, this is out of the question! We are trying to avoid coding here!

(2) Create an interface (OK)
This is probably the best action for performance, but you need to make sure that all entities use the same column name for the RowVersion. If you use this method, you could cast the current object to this an interface and get the value that way. Of course, to get the entity to implement this, you'll need to force it to implement the interface by adding it to the DataContext dbml file (just like described here) .
However, as I'm writing something to share with one and all, and no doubt everyones gonna want to name it differently, this isn't the appropriate option (however it's still a damn good one!).

(3) Use a virtual property and override it in the entity (OK)
This is also good for performance, however it also means that you need to set every RowVersion field property property to have it's access modifier set to "override" which is a bit annoying. Personally I'm impressed that you can do this in the DBML model viewer, but it's still a little hard to maintain when you are adding tables - just another thing to remember, and again everthing has to be set to the same name for it to work.

(4) Use reflection (OK)
This is not so bad, we can simple get the properties using reflection and find which property is marked with ColumnAttribute.IsVersion = true. Seeing there can only be 1 of these per table (enforce by SQL Server) this is pretty safe. It also means i can throw a custom exception with a message if I discover that there is now RowVersion field and let the developer know.

So, after considering the options, I went with the later option being reflection mainly because it's the most flexible for this situation, but all things being equal I think the best option if you can control it is to use an interface as in (2) above.



IsModified
This ones easy, there's already an interface supplied called INotifyPropertyChanged that each entity implements, which you can then use and attach to the childs PropertyChangedEventHandler in your parent class. Whenever this event is raised, we know a column has been changed and we know to set the IsModified Flag to true.
Interestingly enough too, if the event is raised and it's the field that is the RowVersion (TimeStamp) property that's being updated, we know that the data has just been applied to the database, and hence we know we can reset the IsNew and IsModified and IsDeleted to false. So this is something we definately want to do, if after committing the data we want to keep working with our Entity Tree.
One propblem is though, I noticed that this event is also raised for child entities and entity collections not just columns. I need to avoid these non-column events because they are not the type of property changes I am looking for. So, with a little reflection, I can find out if the property has an AssociationAttribue applied to it and ignore the change events raised for these. So that's solved too.

IsDeleted
*** UPDATE --> I've come up with a solution to this problem, see this link for more details ***

I'm still looking for a good way to do this. Unfortantely, the one draw back with the way the entities are organised when disconnected, is there's no good place to handle this because if you remove the entity, you remove the entity - it's gone - not much use setting a flag if you can no longer find it!

One Idea I have is to store the object in the parent, but I haven't got around to working this one out. It's definately the trickiest of the lot.

So for now, there's just a simple flag indicating that the object needs to be deleted, which is not ideal at this stage, but it mostly works, apart from where you have a single child entity (not a collection of entities) and you want to delete it and replace it with a diffent object - currenlty you have to commit to the database in between otherwise, again you'll loose the original object.

Cheers

Matt.

6 comments:

Anonymous said...

Hey Matt,

I've got say: You're the one !

I've been struggling with LINQ regardind the disconnected world but you just saved my life !

Thanks for that ! Really appreciated !

Your blog and project are bookmarked !

All the best !

Cheers,

Andre.

Matthew Hunter said...

Thanks Andre,

Glag to see someone getting something out of my late nights figuring this stuff out.

Still have to get deletions under control (which I have some ideas for)!

Cheers

Matt.

Anonymous said...

Hey Matt,

Something funny just happened with the SynchroniseWithDataContext():

When I try to update an entity which has no _entityAssociationProperties it goes fine :)

But when I try to update an entity which has _entityAssociationProperties I got an error saying that I can't add an entity that already exists :(

I've checked my dbml file and all entities have the rowversion and the proper relationship....

Pretty odd isn't it ?

Any ideas ?

Thanks again !

Andre.

Matthew Hunter said...

Hi Andre,

That is a bit strange.

The example that I ship with the source code included an example of this (Order being updated when Order_Detail is associated property).

However, it's entirely possible there's a mistake in my logic.

If you could show the source code of your Update logic and screenshot/source of your DBML that would be great, I can probably figure it out.

One thing I'll get you to do to do is, use the ToEntityTree() method on your root, and dump out all the EntityGUID's to somewhere. If, after your update, one of the EntityGUID's is repeated twice then that's our culprit - it will attempt to attach it twice - which is easily fixed by removing duplicates in the ToEntityTree() output (before it's output).

I wasn't expecting this to be possible, but I may have missed something. Let me know what you find and I'll fix it ASAP.

Cheers

Matt.

Anonymous said...

Hey Matt,

I was really surprised with that error too :( but it seems to be a very strange error. I tried to change you code and got the same error.

That's what I did:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Data.Linq;
using System.Text;

namespace LINQEntityBaseExample
{
class Program
{
static void Main(string[] args)
{
Customer customer;

using (NorthWindDataContext db = new NorthWindDataContext())
{
db.DeferredLoadingEnabled = false;
Console.WriteLine("----------------------------");
Console.WriteLine("Reading the single customer");
Console.WriteLine("----------------------------");

customer = db.Customers.Where(c => c.CustomerID.Equals("ALFKI")).Single();
}

string originalFax = customer.Fax;
Console.WriteLine("");
Console.WriteLine("Original fax: {0}", originalFax);

customer.Fax = "999999";
Console.WriteLine("");
Console.WriteLine("New fax: {0}", customer.Fax);

using(NorthWindDataContext db = new NorthWindDataContext())
{
try
{
Console.WriteLine("");
Console.WriteLine("---------------");
Console.WriteLine("Changes Tracked");
Console.WriteLine("---------------");

Console.WriteLine("Fax changed? {0}", customer.IsModified);

customer.SynchroniseWithDataContext(db);
db.SubmitChanges();

Console.WriteLine("");
Console.WriteLine("----------------");
Console.WriteLine("Trying to commit");
Console.WriteLine("----------------");

Console.WriteLine("Any error after comitting?: {0}", "No :)");
}
catch (Exception ex)
{
Console.WriteLine("");
Console.WriteLine("-------------");
Console.WriteLine("Didn't Commit");
Console.WriteLine("-------------");
Console.WriteLine("Any error after committing?: {0}", "Yes :(");
Console.WriteLine("Error description: {0}", ex.Message);
}
}

using (NorthWindDataContext db = new NorthWindDataContext())
{
Console.WriteLine("");
Console.WriteLine("-----------------------------------------------------");
Console.WriteLine("Verifying whether the data has really been changed...");
Console.WriteLine("-----------------------------------------------------");

customer = db.Customers.Where(c => c.CustomerID.Equals("ALFKI")).Single();

Console.WriteLine("");
Console.WriteLine("Saved fax: {0}", customer.Fax);
}

Console.ReadKey();
}

}
}


But then, for my surprise, when I added the following line before the update it worked nicely !!!!

db.DeferredLoadingEnabled = false;

Why do we have to do that ?

Cheers,

Andre.

Matthew Hunter said...

Hi Andre,

Yes that would make sense! Thanks for the info.

Have a look at my here:

http://complexitykills.blogspot.com/2008/03/disconnected-linq-to-sql-tips-part-1.html

Under the section "The 'How to do Disconnected' Rules", it a requirement to load all objects that you are going to touch OR turn deferred loading off. Otherwise the entity won't be disconnected because when you use a child object or collection LINQ2SQL will try and hit the database again.

I think it's because of the way I'm exploring the "entity tree" when doing the syncronisation.

Exploring the entity tree means going through the associations, so it's attaching the entity the the datacontext, then exploring the associations. When it does this, the entitiy is now "connected" and hence goes looking in the db for it's childeren - infact it would probably do the same even if you already had got it's childeren before re-connecting it because it doesn't know any better.

Thanks for your input, what I've done is put an exception in the syncronisation method so that it stop people from trying to syncronise with a datacontext when deferred loading is enabled.

Grab the latest source code and you should be right!