diff --git a/src/EFCore/DbContext.cs b/src/EFCore/DbContext.cs index 22a58e30af2..2af5f7d74e1 100644 --- a/src/EFCore/DbContext.cs +++ b/src/EFCore/DbContext.cs @@ -486,6 +486,8 @@ public virtual int SaveChanges(bool acceptAllChangesOnSuccess) { CheckDisposed(); + SavingChanges?.Invoke(this, new SavingChangesEventArgs(acceptAllChangesOnSuccess)); + var interceptionResult = DbContextDependencies.UpdateLogger.SaveChangesStarting(this); TryDetectChanges(); @@ -496,18 +498,26 @@ public virtual int SaveChanges(bool acceptAllChangesOnSuccess) ? interceptionResult.Result : DbContextDependencies.StateManager.SaveChanges(acceptAllChangesOnSuccess); - return DbContextDependencies.UpdateLogger.SaveChangesCompleted(this, entitiesSaved); + var result = DbContextDependencies.UpdateLogger.SaveChangesCompleted(this, entitiesSaved); + + SavedChanges?.Invoke(this, new SavedChangesEventArgs(acceptAllChangesOnSuccess, result)); + + return result; } catch (DbUpdateConcurrencyException exception) { DbContextDependencies.UpdateLogger.OptimisticConcurrencyException(this, exception); + SaveChangesFailed?.Invoke(this, new SaveChangesFailedEventArgs(acceptAllChangesOnSuccess, exception)); + throw; } catch (Exception exception) { DbContextDependencies.UpdateLogger.SaveChangesFailed(this, exception); + SaveChangesFailed?.Invoke(this, new SaveChangesFailedEventArgs(acceptAllChangesOnSuccess, exception)); + throw; } } @@ -595,6 +605,8 @@ public virtual async Task SaveChangesAsync( { CheckDisposed(); + SavingChanges?.Invoke(this, new SavingChangesEventArgs(acceptAllChangesOnSuccess)); + var interceptionResult = await DbContextDependencies.UpdateLogger .SaveChangesStartingAsync(this, cancellationToken).ConfigureAwait(false); @@ -608,13 +620,20 @@ public virtual async Task SaveChangesAsync( .SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken) .ConfigureAwait(false); - return await DbContextDependencies.UpdateLogger + var result = await DbContextDependencies.UpdateLogger .SaveChangesCompletedAsync(this, entitiesSaved, cancellationToken) .ConfigureAwait(false); + + SavedChanges?.Invoke(this, new SavedChangesEventArgs(acceptAllChangesOnSuccess, result)); + + return result; } catch (DbUpdateConcurrencyException exception) { - DbContextDependencies.UpdateLogger.OptimisticConcurrencyException(this, exception); + await DbContextDependencies.UpdateLogger.OptimisticConcurrencyExceptionAsync(this, exception, cancellationToken) + .ConfigureAwait(false); + + SaveChangesFailed?.Invoke(this, new SaveChangesFailedEventArgs(acceptAllChangesOnSuccess, exception)); throw; } @@ -622,10 +641,27 @@ public virtual async Task SaveChangesAsync( { await DbContextDependencies.UpdateLogger.SaveChangesFailedAsync(this, exception, cancellationToken).ConfigureAwait(false); + SaveChangesFailed?.Invoke(this, new SaveChangesFailedEventArgs(acceptAllChangesOnSuccess, exception)); + throw; } } + /// + /// An event fired at the beginning of a call to or + /// + public event EventHandler SavingChanges; + + /// + /// An event fired at the end of a call to or + /// + public event EventHandler SavedChanges; + + /// + /// An event fired if a call to or fails with an exception. + /// + public event EventHandler SaveChangesFailed; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -705,6 +741,10 @@ void IResettableService.ResetState() service.ResetState(); } + SavingChanges = null; + SavedChanges = null; + SaveChangesFailed = null; + _disposed = true; } diff --git a/src/EFCore/Diagnostics/CoreLoggerExtensions.cs b/src/EFCore/Diagnostics/CoreLoggerExtensions.cs index 249dbcf472f..95673e2200d 100644 --- a/src/EFCore/Diagnostics/CoreLoggerExtensions.cs +++ b/src/EFCore/Diagnostics/CoreLoggerExtensions.cs @@ -64,10 +64,7 @@ public static void SaveChangesFailed( diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled); - if (interceptor != null) - { - interceptor.SaveChangesFailed(eventData); - } + interceptor?.SaveChangesFailed(eventData); } } @@ -140,18 +137,63 @@ public static void OptimisticConcurrencyException( definition.Log(diagnostics, exception); } - if (diagnostics.NeedsEventData(definition, out var diagnosticSourceEnabled, out var simpleLogEnabled)) + if (diagnostics.NeedsEventData(definition, + out var interceptor, out var diagnosticSourceEnabled, out var simpleLogEnabled)) { - var eventData = new DbContextErrorEventData( - definition, - OptimisticConcurrencyException, - context, - exception); + var eventData = CreateDbContextErrorEventData(context, exception, definition); diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled); + + interceptor?.SaveChangesFailed(eventData); } } + /// + /// Logs for the event. + /// + /// The diagnostics logger to use. + /// The context in use. + /// The exception that caused this event. + /// The cancellation token. + /// A for the async result. + public static ValueTask OptimisticConcurrencyExceptionAsync( + [NotNull] this IDiagnosticsLogger diagnostics, + [NotNull] DbContext context, + [NotNull] Exception exception, + CancellationToken cancellationToken = default) + { + var definition = CoreResources.LogOptimisticConcurrencyException(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log(diagnostics, exception); + } + + if (diagnostics.NeedsEventData( + definition, + out var interceptor, out var diagnosticSourceEnabled, out var simpleLogEnabled)) + { + var eventData = CreateDbContextErrorEventData(context, exception, definition); + + diagnostics.DispatchEventData(definition, eventData, diagnosticSourceEnabled, simpleLogEnabled); + + if (interceptor != null) + { + return interceptor.SaveChangesFailedAsync(eventData, cancellationToken); + } + } + + return new ValueTask(); + } + + private static DbContextErrorEventData CreateDbContextErrorEventData( + DbContext context, Exception exception, EventDefinition definition) + => new DbContextErrorEventData( + definition, + OptimisticConcurrencyException, + context, + exception); + private static string OptimisticConcurrencyException(EventDefinitionBase definition, EventData payload) { var d = (EventDefinition)definition; diff --git a/src/EFCore/SaveChangesEventArgs.cs b/src/EFCore/SaveChangesEventArgs.cs new file mode 100644 index 00000000000..577c1757b41 --- /dev/null +++ b/src/EFCore/SaveChangesEventArgs.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Base event arguments for the and events. + /// + public abstract class SaveChangesEventArgs : EventArgs + { + /// + /// Creates a base event arguments instance for + /// or events. + /// + /// The value passed to SaveChanges. + protected SaveChangesEventArgs(bool acceptAllChangesOnSuccess) + { + AcceptAllChangesOnSuccess = acceptAllChangesOnSuccess; + } + + /// + /// The value passed to or . + /// + public virtual bool AcceptAllChangesOnSuccess { get; } + } +} diff --git a/src/EFCore/SaveChangesFailedEventArgs.cs b/src/EFCore/SaveChangesFailedEventArgs.cs new file mode 100644 index 00000000000..e83c29317b9 --- /dev/null +++ b/src/EFCore/SaveChangesFailedEventArgs.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using JetBrains.Annotations; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Event arguments for the event. + /// + public class SaveChangesFailedEventArgs : SaveChangesEventArgs + { + /// + /// Creates a new instance with the exception that was thrown. + /// + /// The value passed to SaveChanges. + /// The exception thrown. + public SaveChangesFailedEventArgs(bool acceptAllChangesOnSuccess, [NotNull] Exception exception) + : base(acceptAllChangesOnSuccess) + { + Exception = exception; + } + + /// + /// The exception thrown during or . + /// + public virtual Exception Exception { get; } + } +} diff --git a/src/EFCore/SavedChangesEventArgs.cs b/src/EFCore/SavedChangesEventArgs.cs new file mode 100644 index 00000000000..cbbce9ecdc9 --- /dev/null +++ b/src/EFCore/SavedChangesEventArgs.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Event arguments for the event. + /// + public class SavedChangesEventArgs : SaveChangesEventArgs + { + /// + /// Creates a new instance with the given number of entities saved. + /// + /// The value passed to SaveChanges. + /// The number of entities saved. + public SavedChangesEventArgs(bool acceptAllChangesOnSuccess, int entitiesSavedCount) + : base(acceptAllChangesOnSuccess) + { + EntitiesSavedCount = entitiesSavedCount; + } + + /// + /// The number of entities saved. + /// + public virtual int EntitiesSavedCount { get; } + } +} diff --git a/src/EFCore/SavingChangesEventArgs.cs b/src/EFCore/SavingChangesEventArgs.cs new file mode 100644 index 00000000000..6aa94bee972 --- /dev/null +++ b/src/EFCore/SavingChangesEventArgs.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Event arguments for the event. + /// + public class SavingChangesEventArgs : SaveChangesEventArgs + { + /// + /// Creates event arguments for the event. + /// + /// The value passed to SaveChanges. + public SavingChangesEventArgs(bool acceptAllChangesOnSuccess) + : base(acceptAllChangesOnSuccess) + { + } + } +} diff --git a/test/EFCore.InMemory.FunctionalTests/InterceptionInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/InterceptionInMemoryTest.cs index 9413af3b88a..59932446217 100644 --- a/test/EFCore.InMemory.FunctionalTests/InterceptionInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/InterceptionInMemoryTest.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.TestUtilities; using Microsoft.Extensions.DependencyInjection; using Xunit; +using Xunit.Sdk; namespace Microsoft.EntityFrameworkCore { @@ -16,6 +17,8 @@ protected SaveChangesInterceptionInMemoryTestBase(InterceptionInMemoryFixtureBas { } + protected override bool SupportsOptimisticConcurrency => false; + public abstract class InterceptionInMemoryFixtureBase : InterceptionFixtureBase { protected override string StoreName => "SaveChangesInterception"; diff --git a/test/EFCore.Specification.Tests/SaveChangesInterceptionTestBase.cs b/test/EFCore.Specification.Tests/SaveChangesInterceptionTestBase.cs index 29aa18b2dae..c6d3df214e3 100644 --- a/test/EFCore.Specification.Tests/SaveChangesInterceptionTestBase.cs +++ b/test/EFCore.Specification.Tests/SaveChangesInterceptionTestBase.cs @@ -32,6 +32,28 @@ public virtual async Task Intercept_SaveChanges_passively(bool async, bool injec using var _ = context; + var savingEventCalled = false; + var resultFromEvent = 0; + Exception exceptionFromEvent = null; + + context.SavingChanges += (sender, args) => + { + Assert.Same(context, sender); + savingEventCalled = true; + }; + + context.SavedChanges += (sender, args) => + { + Assert.Same(context, sender); + resultFromEvent = args.EntitiesSavedCount; + }; + + context.SaveChangesFailed += (sender, args) => + { + Assert.Same(context, sender); + exceptionFromEvent = args.Exception; + }; + context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); using var transaction = context.Database.BeginTransaction(); @@ -48,6 +70,10 @@ public virtual async Task Intercept_SaveChanges_passively(bool async, bool injec Assert.Equal(1, savedCount); + Assert.True(savingEventCalled); + Assert.Equal(savedCount, resultFromEvent); + Assert.Null(exceptionFromEvent); + AssertNormalOutcome(context, interceptor, async); listener.AssertEventsInOrder( @@ -76,6 +102,28 @@ public virtual async Task Intercept_SaveChanges_to_suppress_save(bool async, boo using var _ = context; + var savingEventCalled = false; + var resultFromEvent = 0; + Exception exceptionFromEvent = null; + + context.SavingChanges += (sender, args) => + { + Assert.Same(context, sender); + savingEventCalled = true; + }; + + context.SavedChanges += (sender, args) => + { + Assert.Same(context, sender); + resultFromEvent = args.EntitiesSavedCount; + }; + + context.SaveChangesFailed += (sender, args) => + { + Assert.Same(context, sender); + exceptionFromEvent = args.Exception; + }; + context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); using var transaction = context.Database.BeginTransaction(); @@ -92,6 +140,10 @@ public virtual async Task Intercept_SaveChanges_to_suppress_save(bool async, boo Assert.Equal(-1, savedCount); + Assert.True(savingEventCalled); + Assert.Equal(savedCount, resultFromEvent); + Assert.Null(exceptionFromEvent); + AssertNormalOutcome(context, interceptor, async); listener.AssertEventsInOrder( @@ -134,6 +186,28 @@ public virtual async Task Intercept_SaveChanges_to_change_result(bool async, boo using var _ = context; + var savingEventCalled = false; + var resultFromEvent = 0; + Exception exceptionFromEvent = null; + + context.SavingChanges += (sender, args) => + { + Assert.Same(context, sender); + savingEventCalled = true; + }; + + context.SavedChanges += (sender, args) => + { + Assert.Same(context, sender); + resultFromEvent = args.EntitiesSavedCount; + }; + + context.SaveChangesFailed += (sender, args) => + { + Assert.Same(context, sender); + exceptionFromEvent = args.Exception; + }; + context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); using var transaction = context.Database.BeginTransaction(); @@ -150,6 +224,10 @@ public virtual async Task Intercept_SaveChanges_to_change_result(bool async, boo Assert.Equal(777, savedCount); + Assert.True(savingEventCalled); + Assert.Equal(savedCount, resultFromEvent); + Assert.Null(exceptionFromEvent); + AssertNormalOutcome(context, interceptor, async); listener.AssertEventsInOrder( @@ -178,27 +256,67 @@ public override async ValueTask SavedChangesAsync( } [ConditionalTheory] - [InlineData(false, false, false)] - [InlineData(true, false, false)] - [InlineData(false, true, false)] - [InlineData(true, true, false)] - [InlineData(false, false, true)] - [InlineData(true, false, true)] - [InlineData(false, true, true)] - [InlineData(true, true, true)] - public virtual async Task Intercept_SaveChanges_failed(bool async, bool inject, bool noAcceptChanges) + [InlineData(false, false, false, false)] + [InlineData(true, false, false, false)] + [InlineData(false, true, false, false)] + [InlineData(true, true, false, false)] + [InlineData(false, false, true, false)] + [InlineData(true, false, true, false)] + [InlineData(false, true, true, false)] + [InlineData(true, true, true, false)] + [InlineData(false, false, false, true)] + [InlineData(true, false, false, true)] + [InlineData(false, true, false, true)] + [InlineData(true, true, false, true)] + [InlineData(false, false, true, true)] + [InlineData(true, false, true, true)] + [InlineData(false, true, true, true)] + [InlineData(true, true, true, true)] + public virtual async Task Intercept_SaveChanges_failed(bool async, bool inject, bool noAcceptChanges, bool concurrencyError) { + if (concurrencyError + && !SupportsOptimisticConcurrency) + { + return; + } + var (context, interceptor) = CreateContext(inject); using var _ = context; using var transaction = context.Database.BeginTransaction(); - context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); - var ___ = async ? await context.SaveChangesAsync() : context.SaveChanges(); - context.ChangeTracker.Clear(); + if (!concurrencyError) + { + context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); + var ___ = async ? await context.SaveChangesAsync() : context.SaveChanges(); + context.ChangeTracker.Clear(); + } - context.Add(new Singularity { Id = 35, Type = "Red Dwarf" }); + var savingEventCalled = false; + var resultFromEvent = -1; + Exception exceptionFromEvent = null; + + context.SavingChanges += (sender, args) => + { + Assert.Same(context, sender); + savingEventCalled = true; + }; + + context.SavedChanges += (sender, args) => + { + Assert.Same(context, sender); + resultFromEvent = args.EntitiesSavedCount; + }; + + context.SaveChangesFailed += (sender, args) => + { + Assert.Same(context, sender); + exceptionFromEvent = args.Exception; + }; + + context.Entry(new Singularity { Id = 35, Type = "Red Dwarf" }).State + = concurrencyError ? EntityState.Modified : EntityState.Added; using var listener = Fixture.SubscribeToDiagnosticListener(context.ContextId); @@ -226,9 +344,22 @@ public virtual async Task Intercept_SaveChanges_failed(bool async, bool inject, Assert.Same(context, interceptor.Context); Assert.Same(thrown, interceptor.Exception); - listener.AssertEventsInOrder( - CoreEventId.SaveChangesStarting.Name, - CoreEventId.SaveChangesFailed.Name); + Assert.True(savingEventCalled); + Assert.Equal(-1, resultFromEvent); + Assert.Same(thrown, exceptionFromEvent); + + if (concurrencyError) + { + listener.AssertEventsInOrder( + CoreEventId.SaveChangesStarting.Name, + CoreEventId.OptimisticConcurrencyException.Name); + } + else + { + listener.AssertEventsInOrder( + CoreEventId.SaveChangesStarting.Name, + CoreEventId.SaveChangesFailed.Name); + } } [ConditionalTheory] @@ -369,5 +500,7 @@ private static void AssertNormalOutcome(DbContext context, SaveChangesIntercepto Assert.False(interceptor.FailedCalled); Assert.Same(context, interceptor.Context); } + + protected virtual bool SupportsOptimisticConcurrency => true; } } diff --git a/test/EFCore.Tests/DbContextTest.cs b/test/EFCore.Tests/DbContextTest.cs index c9097dc1fab..89c223f765d 100644 --- a/test/EFCore.Tests/DbContextTest.cs +++ b/test/EFCore.Tests/DbContextTest.cs @@ -743,7 +743,7 @@ public async Task It_throws_object_disposed_exception(bool async) await Assert.ThrowsAsync(() => context.FindAsync(typeof(Random), 77).AsTask()); var methodCount = typeof(DbContext).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Count(); - var expectedMethodCount = 42 + 2; + var expectedMethodCount = 42 + 8; Assert.True( methodCount == expectedMethodCount, userMessage: $"Expected {expectedMethodCount} methods on DbContext but found {methodCount}. "