diff --git a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionManager.cs b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionManager.cs index cf10358d87f..79784f94a5c 100644 --- a/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionManager.cs +++ b/src/EFCore.Cosmos/Storage/Internal/CosmosTransactionManager.cs @@ -50,7 +50,7 @@ public virtual Task BeginTransactionAsync( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual Task CommitTransactionAsync(CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + => throw new NotSupportedException(); /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.InMemory/Storage/Internal/InMemoryTransactionManager.cs b/src/EFCore.InMemory/Storage/Internal/InMemoryTransactionManager.cs index 77f393e5c86..1d37af41c4a 100644 --- a/src/EFCore.InMemory/Storage/Internal/InMemoryTransactionManager.cs +++ b/src/EFCore.InMemory/Storage/Internal/InMemoryTransactionManager.cs @@ -114,6 +114,42 @@ public virtual Task RollbackTransactionAsync(CancellationToken cancellationToken return Task.CompletedTask; } + /// + public virtual void CreateSavepoint(string savepointName) + => _logger.TransactionIgnoredWarning(); + + /// + public virtual Task CreateSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + _logger.TransactionIgnoredWarning(); + return Task.CompletedTask; + } + + /// + public virtual void RollbackSavepoint(string savepointName) + => _logger.TransactionIgnoredWarning(); + + /// + public virtual Task RollbackSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + _logger.TransactionIgnoredWarning(); + return Task.CompletedTask; + } + + /// + public virtual void ReleaseSavepoint(string savepointName) + => _logger.TransactionIgnoredWarning(); + + /// + public virtual Task ReleaseSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + _logger.TransactionIgnoredWarning(); + return Task.CompletedTask; + } + + /// + public virtual bool AreSavepointsSupported => true; + /// /// 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 diff --git a/src/EFCore.Relational/Storage/RelationalConnection.cs b/src/EFCore.Relational/Storage/RelationalConnection.cs index 2a28fcac086..7ebcec6f835 100644 --- a/src/EFCore.Relational/Storage/RelationalConnection.cs +++ b/src/EFCore.Relational/Storage/RelationalConnection.cs @@ -509,6 +509,86 @@ public virtual Task RollbackTransactionAsync(CancellationToken cancellationToken return CurrentTransaction.RollbackAsync(cancellationToken); } + /// + public virtual void CreateSavepoint(string savepointName) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + CurrentTransaction.Save(savepointName); + } + + /// + public virtual Task CreateSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + return CurrentTransaction.SaveAsync(savepointName, cancellationToken); + } + + /// + public virtual void RollbackSavepoint(string savepointName) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + CurrentTransaction.Rollback(savepointName); + } + + /// + public virtual Task RollbackSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + return CurrentTransaction.RollbackAsync(savepointName, cancellationToken); + } + + /// + public virtual void ReleaseSavepoint(string savepointName) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + CurrentTransaction.Release(savepointName); + } + + /// + public virtual Task ReleaseSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + return CurrentTransaction.ReleaseAsync(savepointName, cancellationToken); + } + + /// + public virtual bool AreSavepointsSupported + { + get + { + if (CurrentTransaction == null) + { + throw new InvalidOperationException(RelationalStrings.NoActiveTransaction); + } + + return CurrentTransaction.AreSavepointsSupported; + } + } + /// /// Opens the connection to the database. /// diff --git a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs index 1f0b4809152..6e3c5577fdd 100644 --- a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs +++ b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs @@ -28,6 +28,8 @@ namespace Microsoft.EntityFrameworkCore.Update.Internal /// public class BatchExecutor : IBatchExecutor { + private const string SavepointName = "__EFSavePoint"; + /// /// 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 @@ -58,19 +60,28 @@ public virtual int Execute( IRelationalConnection connection) { var rowsAffected = 0; - IDbContextTransaction startedTransaction = null; + var transaction = connection.CurrentTransaction; + var beganTransaction = false; + var createdSavepoint = false; try { - if (connection.CurrentTransaction == null + if (transaction == null && (connection as ITransactionEnlistmentManager)?.EnlistedTransaction == null && Transaction.Current == null && CurrentContext.Context.Database.AutoTransactionsEnabled) { - startedTransaction = connection.BeginTransaction(); + transaction = connection.BeginTransaction(); + beganTransaction = true; } else { connection.Open(); + + if (transaction?.AreSavepointsSupported == true) + { + transaction.Save(SavepointName); + createdSavepoint = true; + } } foreach (var batch in commandBatches) @@ -79,13 +90,29 @@ public virtual int Execute( rowsAffected += batch.ModificationCommands.Count; } - startedTransaction?.Commit(); + if (beganTransaction) + { + transaction.Commit(); + } + } + catch + { + if (createdSavepoint) + { + transaction.Rollback(SavepointName); + } + + throw; } finally { - if (startedTransaction != null) + if (createdSavepoint) { - startedTransaction.Dispose(); + transaction.Release(SavepointName); + } + else if (beganTransaction) + { + transaction.Dispose(); } else { @@ -108,19 +135,28 @@ public virtual async Task ExecuteAsync( CancellationToken cancellationToken = default) { var rowsAffected = 0; - IDbContextTransaction startedTransaction = null; + var transaction = connection.CurrentTransaction; + var beganTransaction = false; + var createdSavepoint = false; try { - if (connection.CurrentTransaction == null + if (transaction == null && (connection as ITransactionEnlistmentManager)?.EnlistedTransaction == null && Transaction.Current == null && CurrentContext.Context.Database.AutoTransactionsEnabled) { - startedTransaction = await connection.BeginTransactionAsync(cancellationToken); + transaction = await connection.BeginTransactionAsync(cancellationToken); + beganTransaction = true; } else { await connection.OpenAsync(cancellationToken); + + if (transaction?.AreSavepointsSupported == true) + { + await transaction.SaveAsync(SavepointName, cancellationToken); + createdSavepoint = true; + } } foreach (var batch in commandBatches) @@ -129,16 +165,29 @@ public virtual async Task ExecuteAsync( rowsAffected += batch.ModificationCommands.Count; } - if (startedTransaction != null) + if (beganTransaction) { - await startedTransaction.CommitAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); } } + catch + { + if (createdSavepoint) + { + await transaction.RollbackAsync(SavepointName, cancellationToken); + } + + throw; + } finally { - if (startedTransaction != null) + if (createdSavepoint) + { + await transaction.ReleaseAsync(SavepointName, cancellationToken); + } + else if (beganTransaction) { - await startedTransaction.DisposeAsync(); + await transaction.DisposeAsync(); } else { diff --git a/src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs index 24f6efbb311..f7ec66ce574 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerServiceCollectionExtensions.cs @@ -64,6 +64,7 @@ public static IServiceCollection AddEntityFrameworkSqlServer([NotNull] this ISer .TryAdd() .TryAdd() .TryAdd(p => p.GetService()) + .TryAdd() .TryAdd() .TryAdd() .TryAdd(p => p.GetService()) diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerTransaction.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerTransaction.cs new file mode 100644 index 00000000000..f8ac5860c68 --- /dev/null +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerTransaction.cs @@ -0,0 +1,72 @@ +// 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 System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal +{ + public class SqlServerTransaction : RelationalTransaction, IDbContextTransaction + { + private readonly DbTransaction _dbTransaction; + + /// + /// 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 + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqlServerTransaction( + [NotNull] IRelationalConnection connection, + [NotNull] DbTransaction transaction, + Guid transactionId, + [NotNull] IDiagnosticsLogger logger, + bool transactionOwned) + : base(connection, transaction, transactionId, logger, transactionOwned) + => _dbTransaction = transaction; + + /// + public virtual void Save(string savepointName) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "SAVE TRANSACTION " + savepointName; + command.ExecuteNonQuery(); + } + + /// + public virtual async Task SaveAsync(string savepointName, CancellationToken cancellationToken = default) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "SAVE TRANSACTION " + savepointName; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// + public virtual void Rollback(string savepointName) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "ROLLBACK TRANSACTION " + savepointName; + command.ExecuteNonQuery(); + } + + /// + public virtual async Task RollbackAsync(string savepointName, CancellationToken cancellationToken = default) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "ROLLBACK TRANSACTION " + savepointName; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// + public virtual bool AreSavepointsSupported => true; + } +} diff --git a/src/EFCore.SqlServer/Storage/Internal/SqlServerTransactionFactory.cs b/src/EFCore.SqlServer/Storage/Internal/SqlServerTransactionFactory.cs new file mode 100644 index 00000000000..d5fd9fda540 --- /dev/null +++ b/src/EFCore.SqlServer/Storage/Internal/SqlServerTransactionFactory.cs @@ -0,0 +1,23 @@ +// 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 System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal +{ + public class SqlServerTransactionFactory : IRelationalTransactionFactory + { + /// + /// 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 + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual RelationalTransaction Create( + IRelationalConnection connection, DbTransaction transaction, Guid transactionId, IDiagnosticsLogger logger, bool transactionOwned) + => new SqlServerTransaction(connection, transaction, transactionId, logger, transactionOwned); + } +} diff --git a/src/EFCore.Sqlite.Core/Extensions/SqliteServiceCollectionExtensions.cs b/src/EFCore.Sqlite.Core/Extensions/SqliteServiceCollectionExtensions.cs index c93ff1f6db2..8ba5a14d4af 100644 --- a/src/EFCore.Sqlite.Core/Extensions/SqliteServiceCollectionExtensions.cs +++ b/src/EFCore.Sqlite.Core/Extensions/SqliteServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ public static IServiceCollection AddEntityFrameworkSqlite([NotNull] this IServic .TryAdd() .TryAdd() .TryAdd() + .TryAdd() .TryAdd() .TryAdd(p => p.GetService()) .TryAdd() diff --git a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransaction.cs b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransaction.cs new file mode 100644 index 00000000000..817012ae393 --- /dev/null +++ b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransaction.cs @@ -0,0 +1,90 @@ +// 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 System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal +{ + public class SqliteTransaction : RelationalTransaction, IDbContextTransaction + { + private readonly DbTransaction _dbTransaction; + + /// + /// 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 + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SqliteTransaction( + [NotNull] IRelationalConnection connection, + [NotNull] DbTransaction transaction, + Guid transactionId, + [NotNull] IDiagnosticsLogger logger, + bool transactionOwned) + : base(connection, transaction, transactionId, logger, transactionOwned) + => _dbTransaction = transaction; + + /// + public virtual void Save(string savepointName) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "SAVEPOINT " + savepointName; + command.ExecuteNonQuery(); + } + + /// + public virtual async Task SaveAsync(string savepointName, CancellationToken cancellationToken = default) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "SAVEPOINT " + savepointName; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// + public virtual void Rollback(string savepointName) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "ROLLBACK TO " + savepointName; + command.ExecuteNonQuery(); + } + + /// + public virtual async Task RollbackAsync(string savepointName, CancellationToken cancellationToken = default) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "ROLLBACK TO " + savepointName; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// + public virtual void Release(string savepointName) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "RELEASE " + savepointName; + command.ExecuteNonQuery(); + } + + /// + public virtual async Task ReleaseAsync(string savepointName, CancellationToken cancellationToken = default) + { + using var command = Connection.DbConnection.CreateCommand(); + command.Transaction = _dbTransaction; + command.CommandText = "RELEASE " + savepointName; + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// + public virtual bool AreSavepointsSupported => true; + } +} diff --git a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransactionFactory.cs b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransactionFactory.cs new file mode 100644 index 00000000000..ca92dbed8d4 --- /dev/null +++ b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTransactionFactory.cs @@ -0,0 +1,23 @@ +// 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 System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal +{ + public class SqliteTransactionFactory : IRelationalTransactionFactory + { + /// + /// 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 + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual RelationalTransaction Create( + IRelationalConnection connection, DbTransaction transaction, Guid transactionId, IDiagnosticsLogger logger, bool transactionOwned) + => new SqliteTransaction(connection, transaction, transactionId, logger, transactionOwned); + } +} diff --git a/src/EFCore/Infrastructure/DatabaseFacade.cs b/src/EFCore/Infrastructure/DatabaseFacade.cs index 05743f8cb75..9072138ef4f 100644 --- a/src/EFCore/Infrastructure/DatabaseFacade.cs +++ b/src/EFCore/Infrastructure/DatabaseFacade.cs @@ -182,6 +182,73 @@ public virtual void RollbackTransaction() public virtual Task RollbackTransactionAsync(CancellationToken cancellationToken = default) => Dependencies.TransactionManager.RollbackTransactionAsync(cancellationToken); + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + public virtual void CreateSavepoint([NotNull] string savepointName) + => Dependencies.TransactionManager.CreateSavepoint(savepointName); + + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + /// The cancellation token. + /// A representing the asynchronous operation. + public virtual Task CreateSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => Dependencies.TransactionManager.CreateSavepointAsync(savepointName, cancellationToken); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + public virtual void RollbackSavepoint([NotNull] string savepointName) + => Dependencies.TransactionManager.RollbackSavepoint(savepointName); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + /// The cancellation token. + /// A representing the asynchronous operation. + public virtual Task RollbackSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => Dependencies.TransactionManager.RollbackSavepointAsync(savepointName, cancellationToken); + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + public virtual void ReleaseSavepoint([NotNull] string savepointName) + => Dependencies.TransactionManager.ReleaseSavepoint(savepointName); + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + /// The cancellation token. + /// A representing the asynchronous operation. + public virtual Task ReleaseSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => Dependencies.TransactionManager.ReleaseSavepointAsync(savepointName, cancellationToken); + + /// + /// Gets a value that indicates whether this instance supports + /// database savepoints. If false, the methods , + /// + /// and as well as their synchronous counterparts are expected to throw + /// . + /// + /// + /// true if this instance supports database savepoints; + /// otherwise, false. + /// + public virtual bool AreSavepointsSupported => Dependencies.TransactionManager.AreSavepointsSupported; + /// /// Creates an instance of the configured . /// diff --git a/src/EFCore/Storage/IDbContextTransaction.cs b/src/EFCore/Storage/IDbContextTransaction.cs index 9a03a219753..78bffe008cf 100644 --- a/src/EFCore/Storage/IDbContextTransaction.cs +++ b/src/EFCore/Storage/IDbContextTransaction.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Infrastructure; namespace Microsoft.EntityFrameworkCore.Storage @@ -47,5 +48,69 @@ public interface IDbContextTransaction : IDisposable, IAsyncDisposable /// The cancellation token. /// A representing the asynchronous operation. Task RollbackAsync(CancellationToken cancellationToken = default); + + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + void Save([NotNull] string savepointName) => throw new NotSupportedException(); + + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + /// The cancellation token. + /// A representing the asynchronous operation. + Task SaveAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + void Rollback([NotNull] string savepointName) => throw new NotSupportedException(); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + /// The cancellation token. + /// A representing the asynchronous operation. + Task RollbackAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + void Release([NotNull] string savepointName) { } + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + /// The cancellation token. + /// A representing the asynchronous operation. + Task ReleaseAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + /// + /// Gets a value that indicates whether this instance supports + /// database savepoints. If false, the methods , + /// + /// and as well as their synchronous counterparts are expected to throw + /// . + /// + /// + /// true if this instance supports database savepoints; + /// otherwise, false. + /// + bool AreSavepointsSupported => false; } } diff --git a/src/EFCore/Storage/IDbContextTransactionManager.cs b/src/EFCore/Storage/IDbContextTransactionManager.cs index ffd36a8bd50..5641fc2c31e 100644 --- a/src/EFCore/Storage/IDbContextTransactionManager.cs +++ b/src/EFCore/Storage/IDbContextTransactionManager.cs @@ -1,8 +1,10 @@ // 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 System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -68,6 +70,70 @@ public interface IDbContextTransactionManager : IResettableService /// Task RollbackTransactionAsync(CancellationToken cancellationToken = default); + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + void CreateSavepoint([NotNull] string savepointName) => throw new NotSupportedException(); + + /// + /// Creates a savepoint in the transaction. This allows all commands that are executed after the savepoint + /// was established to be rolled back, restoring the transaction state to what it was at the time of the + /// savepoint. + /// + /// The name of the savepoint to be created. + /// The cancellation token. + /// A representing the asynchronous operation. + Task CreateSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + void RollbackSavepoint([NotNull] string savepointName) => throw new NotSupportedException(); + + /// + /// Rolls back all commands that were executed after the specified savepoint was established. + /// + /// The name of the savepoint to roll back to. + /// The cancellation token. + /// A representing the asynchronous operation. + Task RollbackSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + void ReleaseSavepoint([NotNull] string savepointName) => throw new NotSupportedException(); + + /// + /// Destroys a savepoint previously defined in the current transaction. This allows the system to + /// reclaim some resources before the transaction ends. + /// + /// The name of the savepoint to release. + /// The cancellation token. + /// A representing the asynchronous operation. + Task ReleaseSavepointAsync([NotNull] string savepointName, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + /// + /// Gets a value that indicates whether this instance supports + /// database savepoints. If false, the methods , + /// + /// and as well as their synchronous counterparts are expected to throw + /// . + /// + /// + /// true if this instance supports database savepoints; + /// otherwise, false. + /// + bool AreSavepointsSupported => false; + /// /// Gets the current transaction. /// diff --git a/test/EFCore.Relational.Specification.Tests/TransactionTestBase.cs b/test/EFCore.Relational.Specification.Tests/TransactionTestBase.cs index 6f992bea433..9af44c9db07 100644 --- a/test/EFCore.Relational.Specification.Tests/TransactionTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/TransactionTestBase.cs @@ -649,34 +649,67 @@ public virtual async Task SaveChanges_false_uses_explicit_transaction_without_co [InlineData(true, false)] [InlineData(false, true)] [InlineData(false, false)] - public virtual async Task SaveChanges_uses_explicit_transaction_and_does_not_rollback_on_failure(bool async, bool autoTransaction) + public virtual async Task SaveChanges_uses_explicit_transaction_with_failure_behavior(bool async, bool autoTransaction) { - using var context = CreateContext(); - context.Database.AutoTransactionsEnabled = autoTransaction; - - using (var transaction = context.Database.BeginTransaction()) + using (var context = CreateContext()) { - var firstEntry = context.Entry(context.Set().OrderBy(c => c.Id).First()); - firstEntry.State = EntityState.Deleted; + context.Database.AutoTransactionsEnabled = autoTransaction; + + using var transaction = context.Database.BeginTransaction(); + var firstEntry = context.Entry(context.Set().OrderBy(c => c.Id).First()); var lastEntry = context.Entry(context.Set().OrderBy(c => c.Id).Last()); - lastEntry.State = EntityState.Added; if (async) { + firstEntry.State = EntityState.Deleted; + lastEntry.State = EntityState.Added; await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + + lastEntry.State = EntityState.Unchanged; + firstEntry.Entity.Name = "John"; + firstEntry.State = EntityState.Modified; + if (AreSavepointsSupported) + { + await context.SaveChangesAsync(); + } + else + { + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } } else { + firstEntry.State = EntityState.Deleted; + lastEntry.State = EntityState.Added; Assert.Throws(() => context.SaveChanges()); + + lastEntry.State = EntityState.Unchanged; + firstEntry.Entity.Name = "John"; + firstEntry.State = EntityState.Modified; + if (AreSavepointsSupported) + { + context.SaveChanges(); + } + else + { + Assert.Throws(() => context.SaveChanges()); + } } - Assert.Equal(EntityState.Deleted, firstEntry.State); - Assert.Equal(EntityState.Added, lastEntry.State); Assert.NotNull(transaction.GetDbTransaction().Connection); + + transaction.Commit(); + + context.Database.AutoTransactionsEnabled = true; } - context.Database.AutoTransactionsEnabled = true; + if (AreSavepointsSupported) + { + using var context = CreateContext(); + Assert.Equal(Customers.Count, context.Set().Count()); + Assert.Equal("John", context.Set().OrderBy(c => c.Id).First().Name); + } } [ConditionalTheory] @@ -1190,6 +1223,8 @@ protected virtual void AssertStoreInitialState() protected virtual bool DirtyReadsOccur => true; + protected virtual bool AreSavepointsSupported => true; + protected DbContext CreateContext() => Fixture.CreateContext(); protected abstract DbContext CreateContextWithConnectionString(); diff --git a/test/EFCore.Relational.Tests/RelationalEventIdTest.cs b/test/EFCore.Relational.Tests/RelationalEventIdTest.cs index 82b1b853b6b..15bba5f126b 100644 --- a/test/EFCore.Relational.Tests/RelationalEventIdTest.cs +++ b/test/EFCore.Relational.Tests/RelationalEventIdTest.cs @@ -169,6 +169,7 @@ public Task OpenAsync(CancellationToken cancellationToken, bool errorsExpe public void RollbackTransaction() => throw new NotImplementedException(); public Task RollbackTransactionAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public IDbContextTransaction UseTransaction(DbTransaction transaction) => throw new NotImplementedException(); public Task UseTransactionAsync( diff --git a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs index a39088de36d..4b23d0ead52 100644 --- a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs @@ -48,41 +48,6 @@ await c.Database.CreateExecutionStrategy().ExecuteAsync( }); } - [ConditionalFact] - public Task Database_concurrency_token_value_is_discarded_for_non_conflicting_entities() - { - byte[] firstVersion = null; - byte[] secondVersion = null; - return ConcurrencyTestAsync( - c => c.Drivers.Single(d => d.CarNumber == 2).Podiums = StorePodiums, - c => - { - var driver = c.Drivers.Single(d => d.CarNumber == 1); - driver.Podiums = ClientPodiums; - firstVersion = c.Entry(driver).Property("Version").CurrentValue; - - var secondDriver = c.Drivers.Single(d => d.CarNumber == 2); - secondDriver.Podiums = ClientPodiums; - secondVersion = c.Entry(secondDriver).Property("Version").CurrentValue; - }, - (c, ex) => - { - Assert.IsType(ex); - - var firstDriverEntry = c.Entry(c.Drivers.Local.Single(d => d.CarNumber == 1)); - Assert.Equal(firstVersion, firstDriverEntry.Property("Version").CurrentValue); - var databaseValues = firstDriverEntry.GetDatabaseValues(); - Assert.NotEqual(firstVersion, databaseValues["Version"]); - firstDriverEntry.OriginalValues.SetValues(databaseValues); - - var secondDriverEntry = ex.Entries.Single(); - Assert.Equal(secondVersion, secondDriverEntry.Property("Version").CurrentValue); - secondDriverEntry.OriginalValues.SetValues(secondDriverEntry.GetDatabaseValues()); - ResolveConcurrencyTokens(secondDriverEntry); - }, - c => Assert.Equal(ClientPodiums, c.Drivers.Single(d => d.CarNumber == 2).Podiums)); - } - [ConditionalFact] public async Task Database_concurrency_token_value_is_updated_for_all_sharing_entities() { diff --git a/test/EFCore.Tests/DatabaseFacadeTest.cs b/test/EFCore.Tests/DatabaseFacadeTest.cs index a5434c26cc7..c845972f555 100644 --- a/test/EFCore.Tests/DatabaseFacadeTest.cs +++ b/test/EFCore.Tests/DatabaseFacadeTest.cs @@ -151,6 +151,10 @@ public FakeDbContextTransactionManager(FakeDbContextTransaction transaction) public int CommitCalls; public int RollbackCalls; + public int CreateSavepointCalls; + public int RollbackSavepointCalls; + public int ReleaseSavepointCalls; + public int AreSavepointsSupportedCalls; public IDbContextTransaction BeginTransaction() => _transaction; @@ -174,6 +178,39 @@ public Task RollbackTransactionAsync(CancellationToken cancellationToken = defau return Task.CompletedTask; } + public void CreateSavepoint(string savepointName) => CreateSavepointCalls++; + + public Task CreateSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + CreateSavepointCalls++; + return Task.CompletedTask; + } + + public void RollbackSavepoint(string savepointName) => RollbackSavepointCalls++; + + public Task RollbackSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + RollbackSavepointCalls++; + return Task.CompletedTask; + } + + public void ReleaseSavepoint(string savepointName) => ReleaseSavepointCalls++; + + public Task ReleaseSavepointAsync(string savepointName, CancellationToken cancellationToken = default) + { + ReleaseSavepointCalls++; + return Task.CompletedTask; + } + + public bool AreSavepointsSupported + { + get + { + AreSavepointsSupportedCalls++; + return true; + } + } + public IDbContextTransaction CurrentTransaction => _transaction; public Transaction EnlistedTransaction { get; } public void EnlistTransaction(Transaction transaction) => throw new NotImplementedException(); @@ -245,6 +282,97 @@ public async Task Can_roll_back_transaction_async() Assert.Equal(1, manager.RollbackCalls); } + [ConditionalFact] + public void Can_create_savepoint() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + context.Database.CreateSavepoint("foo"); + + Assert.Equal(1, manager.CreateSavepointCalls); + } + + [ConditionalFact] + public async Task Can_create_savepoint_async() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + await context.Database.CreateSavepointAsync("foo"); + + Assert.Equal(1, manager.CreateSavepointCalls); + } + + [ConditionalFact] + public void Can_rollback_savepoint() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + context.Database.RollbackSavepoint("foo"); + + Assert.Equal(1, manager.RollbackSavepointCalls); + } + + [ConditionalFact] + public async Task Can_rollback_savepoint_async() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + await context.Database.RollbackSavepointAsync("foo"); + + Assert.Equal(1, manager.RollbackSavepointCalls); + } + + [ConditionalFact] + public void Can_release_savepoint() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + context.Database.ReleaseSavepoint("foo"); + + Assert.Equal(1, manager.ReleaseSavepointCalls); + } + + [ConditionalFact] + public async Task Can_release_savepoint_async() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + await context.Database.ReleaseSavepointAsync("foo"); + + Assert.Equal(1, manager.ReleaseSavepointCalls); + } + + [ConditionalFact] + public void Can_check_if_checkpoints_are_supported() + { + var manager = new FakeDbContextTransactionManager(new FakeDbContextTransaction()); + + var context = InMemoryTestHelpers.Instance.CreateContext( + new ServiceCollection().AddSingleton(manager)); + + _ = context.Database.AreSavepointsSupported; + + Assert.Equal(1, manager.AreSavepointsSupportedCalls); + } + [ConditionalFact] public void Can_get_current_transaction() {