Click here to Skip to main content
15,868,141 members
Articles / Web Development / HTML
Tip/Trick

Entity Framework DiagnosticsContext: Get Detailed Information on ChangeTracker Entries

Rate me:
Please Sign up or sign in to vote.
5.00/5 (5 votes)
13 Nov 2015CPOL3 min read 31.4K   157   17   1
An EF base DbContext that provides detailed information on current and totalized changes

Sample Image

Introduction

DBContext.SaveChanges() returns the number of state entries written to the underlying database. If your code behaves unexpectedly or throws a DbUpdateException, that is not of much help. You can easily see the contents of DbSet.Local in a watch window, but finding the state of entities on a break in DbChangeTracker.Entries() is cumbersome.

The presented DiagnosticsContext exposes dictionary properties, that can be inspected on a break. Apart from debugging and logging, you can use it to present the user more information on lengthy updates. The dictionaries are keyed by entity type, the values consist of triple tuples (added, modified & deleted state) of int? or IList element type. An element is null, if there are no changes for the state, and a dictionary entry is only present, if the entity has any changes.

C#
//                 (added, modified, deleted)
using StateTuple = System.Tuple<int?, int?, int?>;
using DetailsTuple = System.Tuple<IList, IList, IList>;

CurrentChanges and CurrentChangeDetails are updated on every SaveChanges[Async](), while TotalChanges and TotalChangeDetails accumulate the changes during context lifetime. Note that CurrentChanges[Details] is already valid, when SaveChanges[Async]() fails. Even with diagnostics turned off, DiagnosticsContext can be a lifesaver (see Points of Interest).

C#
public enum DiagnosticsContextMode { None, Current, Total, CurrentDetails, TotalDetails}

public abstract class DiagnosticsContext : DbContext
{
    protected DiagnosticsContext(string nameOrConnectionString)
        : base(nameOrConnectionString) {}

    // Default is DiagnosticsContextMode.None.
    public DiagnosticsContextMode EnableDiagnostics { get; set; }

    // Optionally prints change information on SaveChanges in debug mode.
    public DiagnosticsContextMode AutoDebugPrint { get; set; }

    // Has one entry (added, modified, deleted) for every monitored type, that has unsaved changes.
    // Available after SaveChanges returns, valid until next call to SaveChanges.
    public Dictionary<Type, StateTuple> CurrentChanges { get; private set; }

    // Holds accumulated CurrentChanges contents during context lifetime.
    // Available after first SaveChanges returns, otherwise null.
    public Dictionary<Type, StateTuple> TotalChanges { get; private set; }

    // Has one entry (added, modified, deleted) for every monitored type, that has unsaved changes.
    // Available after SaveChanges returns, valid until next call to SaveChanges.
    public Dictionary<Type, DetailsTuple> CurrentChangeDetails { get; private set; }

    // Holds accumulated TotalChangeDetails contents during context lifetime.
    // Available after first SaveChanges returns, otherwise null.
    public Dictionary<Type, DetailsTuple> TotalChangeDetails { get; private set; }
}

Background

On the first SaveChanges() call, DiagnosticsContext acquires once all the entity types defined for the derived context. This includes (abstract) base and derived types, ordered as EF discovers them.

C#
private IEnumerable<EntityType> GetEntityTypes()
{
    MetadataWorkspace metadata = ((IObjectContextAdapter)this).ObjectContext.MetadataWorkspace;
    return metadata.GetItemCollection(DataSpace.OSpace).GetItems<EntityType>();
}

The EntityType objects are converted to System.Type instances, provided that DiagnosticsContext resides in the same assembly, where your entities are defined. Otherwise, inheritors must provide assembly-qualified type conversion.

C#
protected virtual IList<Type> GetMonitoredTypes(IEnumerable<EntityType> entityTypes)
{
    return entityTypes.Select(x => Type.GetType(x.FullName, true /* throwOnError */)).ToList();
}

Inheritors should override GetMonitoredTypes() to remove types, that will not be monitored (i.e., either base or its derived types) and re-order monitored types as fit, as this is the order of dictionary entries and debugging output.

On calling SaveChanges(), the changed entries of the DbChangeTracker are obtained. DiagnosticsContext takes care of doing this only once, as it results in an extra DetectChanges() call, i.e., in details mode count of changes is obtained from detail collection. (My first implementation merrily called ChangeTracker.Entries() for every monitored type.)

C#
private IList<DbEntityEntry> getChangeTrackerEntries()
{
    return ChangeTracker.Entries()
        .Where(x => x.State != EntityState.Unchanged && x.State != EntityState.Detached)
        .ToArray();
}

To leverage Linq, I use a generic helper class with non generic interface:

C#
private interface IHelper
{
    StateTuple GetChange(IList<DbEntityEntry> dbEntityEntries);
    DetailsTuple GetChangeDetails(IList<DbEntityEntry> dbEntityEntries);
}

private class Helper<T> : IHelper where T : class {}

Non generic code can now call generic version via IHelper. Helper instances are constructed per changed monitored type and are cached in a static dictionary.

C#
private StateTuple getChange(Type type, IList<DbEntityEntry> dbEntityEntries)
{
    return getHelper(type).GetChange(dbEntityEntries);
}

private static IHelper getHelper(Type type)
{
    constructedHelpers = constructedHelpers ?? new Dictionary<Type, IHelper>();

    IHelper helper;
    if (constructedHelpers.TryGetValue(type, out helper))
    {
        return helper;
    }

    Type helperType = typeof(Helper<>).MakeGenericType(type);
    constructedHelpers.Add(type, helper = (IHelper)Activator.CreateInstance(helperType));
    return helper;
}

The generic implementation of GetChange() for a single type:

C#
public StateTuple GetChange(IList<DbEntityEntry> dbEntityEntries)
{
    dbEntityEntries = dbEntityEntries
        .Where(x => x.Entity is T)
        .ToArray();

    var countPerState = dbEntityEntries.GroupBy(x => x.State,
        (state, entries) => new
        {
            state,
            count = entries.Count()
        })
        .ToArray();

    var added = countPerState.SingleOrDefault(x => x.state == EntityState.Added);
    var modified = countPerState.SingleOrDefault(x => x.state == EntityState.Modified);
    var deleted = countPerState.SingleOrDefault(x => x.state == EntityState.Deleted);

    StateTuple tuple = new StateTuple(
        added != null ? added.count : (int?)null,
        modified != null ? modified.count : (int?)null,
        deleted != null ? deleted.count : (int?)null);

    return tuple.Item1 == null && tuple.Item2 == null && tuple.Item3 == null ? null : tuple;
}

And finally creating a dictionary with entries for each changed monitored type:

C#
private Dictionary<Type, StateTuple> getChanges(IEnumerable<Type> types )
{
    IList<DbEntityEntry> dbEntityEntries = getChangeTrackerEntries();

    Dictionary<Type, StateTuple> dic = types
        .Select(x => new { type = x, tuple = getChange(x, dbEntityEntries) })
        .Where(x => x.tuple != null)
        .ToDictionary(x => x.type, x => x.tuple);

    // empty dic: although ChangeTracker.HasChanges() there were no changes for the specified types
    return dic.Count != 0 ? dic : null;
}

Obtaining collections of changed items is pretty similar and not shown here.

Using the Code

Derive your concrete context or your common base context from DiagnosticsContext. Override GetMonitoredTypes() only in contexts that expose DbSet properties. Compile and run, hitting the Assert in GetMonitoredTypes(). Copy the generated Add statements to your GetMonitoredTypes() body and outcomment and re-order them as fit. Update the Assert statement, so that you will get notified, when your model changes in the distant future.

C#
public class YourContext : DiagnosticsContext
{
    public YourContext(string nameOrConnectionString) : base(nameOrConnectionString)

    protected override IList<Type> GetMonitoredTypes(IEnumerable<EntityType> entityTypes)
    {
        IList<Type> allTypes = base.GetMonitoredTypes(entityTypes);
        IList<Type> types = new List<Type>();

        // prints 'types.Add(allTypes.Single(x => x == typeof(a Type)));'
        Debug.Print(string.Join(Environment.NewLine,
            allTypes.Select(x => string.Format("types.Add(allTypes.Single
			(x => x == typeof({0})));", x.Name))));
        Debug.Assert(types.Count == allTypes.Count - 0);
        return types;
    }

    public DbSet<YourEntity> YourEntitySet { get; set; }
    ...
}

Play around with EnableDiagnostics and AutoDebugPrint properties. You probably want to modify DiagnosticsContext to use your ILogger service.

DiagnosticsContext exposes several methods, that can be called at any time independent of EnableDiagnostics property value. However be prudent, as these will invoke DetectChanges() every time.

C#
public Dictionary<Type, StateTuple> GetCurrentChanges()
public Dictionary<Type, StateTuple> GetCurrentChanges(IEnumerable<Type> types)

public Dictionary<Type, DetailsTuple> GetChangeDetails()
public Dictionary<Type, DetailsTuple> GetChangeDetails(IEnumerable<Type> types)
public Tuple<ICollection<T>, ICollection<T>,
ICollection<T>> GetChangeDetails<T>() where T : class

public void IgnoreNextChangeState(EntityState state, params Type[] ignoredTypes)

A common scenario is to add some entities, save them to get valid keys, then add related navigation entities and save.The second save shows the previously added entities correctly as modified. To ignore the modifications in logged output, instead of disabling and re-enabling EnableDiagnostics, precede the second save with IgnoreNextChangeState(EntityState.Modified, types).

Points of Interest

SaveChanges() throws and your context is running with DiagnosticsContextMode.None: all dictionary properties are null, bummer!

QuickWatch and context.GetChangeDetails() will rescue you.

History

  • 26 March 2015: published
  • 27 March 2015: Bugfix: NotSupportedException in addCollection()

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Germany Germany
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionTop Pin
Member 1125421627-Mar-15 4:12
Member 1125421627-Mar-15 4:12 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.