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.
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).
public enum DiagnosticsContextMode { None, Current, Total, CurrentDetails, TotalDetails}
public abstract class DiagnosticsContext : DbContext
{
protected DiagnosticsContext(string nameOrConnectionString)
: base(nameOrConnectionString) {}
public DiagnosticsContextMode EnableDiagnostics { get; set; }
public DiagnosticsContextMode AutoDebugPrint { get; set; }
public Dictionary<Type, StateTuple> CurrentChanges { get; private set; }
public Dictionary<Type, StateTuple> TotalChanges { get; private set; }
public Dictionary<Type, DetailsTuple> CurrentChangeDetails { get; private set; }
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.
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.
protected virtual IList<Type> GetMonitoredTypes(IEnumerable<EntityType> entityTypes)
{
return entityTypes.Select(x => Type.GetType(x.FullName, true )).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.)
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:
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.
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:
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:
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);
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.
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>();
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.
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()