diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
index d891f6405b1..a96438e2c9b 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
+++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
@@ -843,6 +843,18 @@ public static string MissingParameterValue(object? parameter)
GetString("MissingParameterValue", nameof(parameter)),
parameter);
+ ///
+ /// Cannot execute an ModificationCommandBatch which hasn't been completed.
+ ///
+ public static string ModificationCommandBatchAlreadyComplete
+ => GetString("ModificationCommandBatchAlreadyComplete");
+
+ ///
+ /// Cannot execute an ModificationCommandBatch which hasn't been completed.
+ ///
+ public static string ModificationCommandBatchNotComplete
+ => GetString("ModificationCommandBatchNotCompleted");
+
///
/// Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity.
///
diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx
index 57b5e40ab57..9ba10958462 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.resx
+++ b/src/EFCore.Relational/Properties/RelationalStrings.resx
@@ -669,6 +669,12 @@
No value was provided for the required parameter '{parameter}'.
+
+ Cannot add commands to a completed ModificationCommandBatch.
+
+
+ Cannot execute an ModificationCommandBatch which hasn't been completed.
+
Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity.
diff --git a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs
index 3022c1c7e29..9fbaca6a97f 100644
--- a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs
+++ b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs
@@ -54,6 +54,26 @@ void AppendNextSequenceValueOperation(
/// The builder to which the SQL fragment should be appended.
void AppendBatchHeader(StringBuilder commandStringBuilder);
+ ///
+ /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off.
+ ///
+ /// The builder to which the SQL should be prepended.
+ void PrependEnsureAutocommit(StringBuilder commandStringBuilder);
+
+ ///
+ /// Appends a SQL command for deleting a row to the commands being built.
+ ///
+ /// The builder to which the SQL should be appended.
+ /// The command that represents the delete operation.
+ /// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
+ /// The for the command.
+ ResultSetMapping AppendDeleteOperation(
+ StringBuilder commandStringBuilder,
+ IReadOnlyModificationCommand command,
+ int commandPosition,
+ out bool requiresTransaction);
+
///
/// Appends a SQL command for deleting a row to the commands being built.
///
@@ -64,7 +84,22 @@ void AppendNextSequenceValueOperation(
ResultSetMapping AppendDeleteOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition);
+ int commandPosition)
+ => AppendDeleteOperation(commandStringBuilder, command, commandPosition, out _);
+
+ ///
+ /// Appends a SQL command for inserting a row to the commands being built.
+ ///
+ /// The builder to which the SQL should be appended.
+ /// The command that represents the delete operation.
+ /// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
+ /// The for the command.
+ ResultSetMapping AppendInsertOperation(
+ StringBuilder commandStringBuilder,
+ IReadOnlyModificationCommand command,
+ int commandPosition,
+ out bool requiresTransaction);
///
/// Appends a SQL command for inserting a row to the commands being built.
@@ -76,7 +111,22 @@ ResultSetMapping AppendDeleteOperation(
ResultSetMapping AppendInsertOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition);
+ int commandPosition)
+ => AppendInsertOperation(commandStringBuilder, command, commandPosition, out _);
+
+ ///
+ /// Appends a SQL command for updating a row to the commands being built.
+ ///
+ /// The builder to which the SQL should be appended.
+ /// The command that represents the delete operation.
+ /// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
+ /// The for the command.
+ ResultSetMapping AppendUpdateOperation(
+ StringBuilder commandStringBuilder,
+ IReadOnlyModificationCommand command,
+ int commandPosition,
+ out bool requiresTransaction);
///
/// Appends a SQL command for updating a row to the commands being built.
@@ -88,5 +138,6 @@ ResultSetMapping AppendInsertOperation(
ResultSetMapping AppendUpdateOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition);
+ int commandPosition)
+ => AppendUpdateOperation(commandStringBuilder, command, commandPosition, out _);
}
diff --git a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs
index 0ac9a116e26..986b0271c94 100644
--- a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs
+++ b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs
@@ -52,6 +52,16 @@ public virtual int Execute(
IEnumerable commandBatches,
IRelationalConnection connection)
{
+ using var batchEnumerator = commandBatches.GetEnumerator();
+
+ if (!batchEnumerator.MoveNext())
+ {
+ return 0;
+ }
+
+ var currentBatch = batchEnumerator.Current;
+ var nextBatch = batchEnumerator.MoveNext() ? batchEnumerator.Current : null;
+
var rowsAffected = 0;
var transaction = connection.CurrentTransaction;
var beganTransaction = false;
@@ -62,7 +72,9 @@ public virtual int Execute(
if (transaction == null
&& transactionEnlistManager?.EnlistedTransaction is null
&& transactionEnlistManager?.CurrentAmbientTransaction is null
- && CurrentContext.Context.Database.AutoTransactionsEnabled)
+ && CurrentContext.Context.Database.AutoTransactionsEnabled
+ // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf.
+ && (nextBatch is not null || currentBatch.RequiresTransaction))
{
transaction = connection.BeginTransaction();
beganTransaction = true;
@@ -79,10 +91,13 @@ public virtual int Execute(
}
}
- foreach (var batch in commandBatches)
+ while (currentBatch is not null)
{
- batch.Execute(connection);
- rowsAffected += batch.ModificationCommands.Count;
+ currentBatch.Execute(connection);
+ rowsAffected += currentBatch.ModificationCommands.Count;
+
+ currentBatch = nextBatch;
+ nextBatch = batchEnumerator.MoveNext() ? batchEnumerator.Current : null;
}
if (beganTransaction)
@@ -147,6 +162,16 @@ public virtual async Task ExecuteAsync(
IRelationalConnection connection,
CancellationToken cancellationToken = default)
{
+ using var batchEnumerator = commandBatches.GetEnumerator();
+
+ if (!batchEnumerator.MoveNext())
+ {
+ return 0;
+ }
+
+ var currentBatch = batchEnumerator.Current;
+ var nextBatch = batchEnumerator.MoveNext() ? batchEnumerator.Current : null;
+
var rowsAffected = 0;
var transaction = connection.CurrentTransaction;
var beganTransaction = false;
@@ -157,7 +182,9 @@ public virtual async Task ExecuteAsync(
if (transaction == null
&& transactionEnlistManager?.EnlistedTransaction is null
&& transactionEnlistManager?.CurrentAmbientTransaction is null
- && CurrentContext.Context.Database.AutoTransactionsEnabled)
+ && CurrentContext.Context.Database.AutoTransactionsEnabled
+ // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf.
+ && (nextBatch is not null || currentBatch.RequiresTransaction))
{
transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
beganTransaction = true;
@@ -174,10 +201,13 @@ public virtual async Task ExecuteAsync(
}
}
- foreach (var batch in commandBatches)
+ while (currentBatch is not null)
{
- await batch.ExecuteAsync(connection, cancellationToken).ConfigureAwait(false);
- rowsAffected += batch.ModificationCommands.Count;
+ await currentBatch.ExecuteAsync(connection, cancellationToken).ConfigureAwait(false);
+ rowsAffected += currentBatch.ModificationCommands.Count;
+
+ currentBatch = nextBatch;
+ nextBatch = batchEnumerator.MoveNext() ? batchEnumerator.Current : null;
}
if (beganTransaction)
diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs
index 099058647df..e463bcc41e6 100644
--- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs
+++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs
@@ -83,6 +83,8 @@ public virtual IEnumerable BatchCommands(
batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count);
}
+ batch.Complete();
+
yield return batch;
}
else
@@ -92,7 +94,10 @@ public virtual IEnumerable BatchCommands(
foreach (var command in batch.ModificationCommands)
{
- yield return StartNewBatch(parameterNameGenerator, command);
+ batch = StartNewBatch(parameterNameGenerator, command);
+ batch.Complete();
+
+ yield return batch;
}
}
@@ -109,6 +114,8 @@ public virtual IEnumerable BatchCommands(
batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count);
}
+ batch.Complete();
+
yield return batch;
}
else
@@ -118,7 +125,10 @@ public virtual IEnumerable BatchCommands(
foreach (var command in batch.ModificationCommands)
{
- yield return StartNewBatch(parameterNameGenerator, command);
+ batch = StartNewBatch(parameterNameGenerator, command);
+ batch.Complete();
+
+ yield return batch;
}
}
}
diff --git a/src/EFCore.Relational/Update/ModificationCommandBatch.cs b/src/EFCore.Relational/Update/ModificationCommandBatch.cs
index b01c52628af..152e665be45 100644
--- a/src/EFCore.Relational/Update/ModificationCommandBatch.cs
+++ b/src/EFCore.Relational/Update/ModificationCommandBatch.cs
@@ -33,6 +33,16 @@ public abstract class ModificationCommandBatch
///
public abstract bool AddCommand(IReadOnlyModificationCommand modificationCommand);
+ ///
+ /// Indicates that no more commands will be added to this batch, and prepares it for execution.
+ ///
+ public abstract void Complete();
+
+ ///
+ /// Indicates whether the batch requires a transaction in order to execute correctly.
+ ///
+ public abstract bool RequiresTransaction { get; }
+
///
/// Sends insert/update/delete commands to the database.
///
diff --git a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs
index b84e25a6b57..e612a388587 100644
--- a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs
+++ b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs
@@ -21,6 +21,8 @@ namespace Microsoft.EntityFrameworkCore.Update;
public abstract class ReaderModificationCommandBatch : ModificationCommandBatch
{
private readonly List _modificationCommands = new();
+ private string? _finalCommandText;
+ private bool _requiresTransaction = true;
///
/// Creates a new instance.
@@ -74,6 +76,11 @@ public override IReadOnlyList ModificationCommands
///
public override bool AddCommand(IReadOnlyModificationCommand modificationCommand)
{
+ if (_finalCommandText is not null)
+ {
+ throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchAlreadyComplete);
+ }
+
if (ModificationCommands.Count == 0)
{
ResetCommandText();
@@ -103,15 +110,35 @@ public override bool AddCommand(IReadOnlyModificationCommand modificationCommand
///
protected virtual void ResetCommandText()
{
- if (CachedCommandText.Length > 0)
- {
- CachedCommandText = new StringBuilder();
- }
+ CachedCommandText.Clear();
UpdateSqlGenerator.AppendBatchHeader(CachedCommandText);
+ _batchHeaderLength = CachedCommandText.Length;
+
+ SetRequiresTransaction(true);
+
LastCachedCommandIndex = -1;
}
+ private int _batchHeaderLength;
+
+ ///
+ /// Whether any SQL has already been added to the batch command text.
+ ///
+ protected virtual bool IsCachedCommandTextEmpty
+ => CachedCommandText.Length == _batchHeaderLength;
+
+ ///
+ public override bool RequiresTransaction
+ => _requiresTransaction;
+
+ ///
+ /// Sets whether the batch requires a transaction in order to execute correctly.
+ ///
+ /// Whether the batch requires a transaction in order to execute correctly.
+ protected virtual void SetRequiresTransaction(bool requiresTransaction)
+ => _requiresTransaction = requiresTransaction;
+
///
/// Checks whether a new command can be added to the batch.
///
@@ -126,18 +153,15 @@ protected virtual void ResetCommandText()
protected abstract bool IsCommandTextValid();
///
- /// Gets the command text for all the commands in the current batch and also caches it
- /// on .
+ /// Processes all unprocessed commands in the batch, making sure their corresponding SQL is populated in
+ /// .
///
- /// The command text.
- protected virtual string GetCommandText()
+ protected virtual void UpdateCachedCommandText()
{
for (var i = LastCachedCommandIndex + 1; i < ModificationCommands.Count; i++)
{
UpdateCachedCommandText(i);
}
-
- return CachedCommandText.ToString();
}
///
@@ -149,22 +173,35 @@ protected virtual void UpdateCachedCommandText(int commandPosition)
{
var newModificationCommand = ModificationCommands[commandPosition];
+ bool requiresTransaction;
+
switch (newModificationCommand.EntityState)
{
case EntityState.Added:
CommandResultSet[commandPosition] =
- UpdateSqlGenerator.AppendInsertOperation(CachedCommandText, newModificationCommand, commandPosition);
+ UpdateSqlGenerator.AppendInsertOperation(
+ CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction);
break;
case EntityState.Modified:
CommandResultSet[commandPosition] =
- UpdateSqlGenerator.AppendUpdateOperation(CachedCommandText, newModificationCommand, commandPosition);
+ UpdateSqlGenerator.AppendUpdateOperation(
+ CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction);
break;
case EntityState.Deleted:
CommandResultSet[commandPosition] =
- UpdateSqlGenerator.AppendDeleteOperation(CachedCommandText, newModificationCommand, commandPosition);
+ UpdateSqlGenerator.AppendDeleteOperation(
+ CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction);
break;
+
+ default:
+ throw new InvalidOperationException(
+ RelationalStrings.ModificationCommandInvalidEntityState(
+ newModificationCommand.Entries[0].EntityType,
+ newModificationCommand.EntityState));
}
+ _requiresTransaction = commandPosition > 0 || requiresTransaction;
+
LastCachedCommandIndex = commandPosition;
}
@@ -175,15 +212,33 @@ protected virtual void UpdateCachedCommandText(int commandPosition)
protected virtual int GetParameterCount()
=> ModificationCommands.Sum(c => c.ColumnModifications.Count);
+ ///
+ public override void Complete()
+ {
+ UpdateCachedCommandText();
+
+ // Some database have a more where autocommit is off, and so executing a command outside of an explicit transaction implicitly
+ // creates a new transaction (which needs to be explicitly committed).
+ // The below is a hook for allowing providers to turn autocommit on, in case it's off.
+ if (!RequiresTransaction)
+ {
+ UpdateSqlGenerator.PrependEnsureAutocommit(CachedCommandText);
+ }
+
+ _finalCommandText = CachedCommandText.ToString();
+ }
+
///
/// Generates a for the batch.
///
/// The command.
protected virtual RawSqlCommand CreateStoreCommand()
{
+ Check.DebugAssert(_finalCommandText is not null, "_finalCommandText is not null, checked in Execute");
+
var commandBuilder = Dependencies.CommandBuilderFactory
.Create()
- .Append(GetCommandText());
+ .Append(_finalCommandText);
var parameterValues = new Dictionary(GetParameterCount());
@@ -229,6 +284,11 @@ protected virtual RawSqlCommand CreateStoreCommand()
/// The connection to the database to update.
public override void Execute(IRelationalConnection connection)
{
+ if (_finalCommandText is null)
+ {
+ throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete);
+ }
+
var storeCommand = CreateStoreCommand();
try
@@ -263,6 +323,11 @@ public override async Task ExecuteAsync(
IRelationalConnection connection,
CancellationToken cancellationToken = default)
{
+ if (_finalCommandText is null)
+ {
+ throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete);
+ }
+
var storeCommand = CreateStoreCommand();
try
diff --git a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs
index f18c7f3fc3b..f1768588955 100644
--- a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs
+++ b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs
@@ -53,11 +53,13 @@ protected virtual ISqlGenerationHelper SqlGenerationHelper
/// The builder to which the SQL should be appended.
/// The command that represents the delete operation.
/// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
/// The for the command.
public virtual ResultSetMapping AppendInsertOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition)
+ int commandPosition,
+ out bool requiresTransaction)
{
var name = command.TableName;
var schema = command.Schema;
@@ -72,9 +74,13 @@ public virtual ResultSetMapping AppendInsertOperation(
{
var keyOperations = operations.Where(o => o.IsKey).ToList();
+ requiresTransaction = true;
+
return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition);
}
+ requiresTransaction = false;
+
return ResultSetMapping.NoResultSet;
}
@@ -84,11 +90,13 @@ public virtual ResultSetMapping AppendInsertOperation(
/// The builder to which the SQL should be appended.
/// The command that represents the delete operation.
/// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
/// The for the command.
public virtual ResultSetMapping AppendUpdateOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition)
+ int commandPosition,
+ out bool requiresTransaction)
{
var name = command.TableName;
var schema = command.Schema;
@@ -104,9 +112,13 @@ public virtual ResultSetMapping AppendUpdateOperation(
{
var keyOperations = operations.Where(o => o.IsKey).ToList();
+ requiresTransaction = true;
+
return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition);
}
+ requiresTransaction = false;
+
return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition);
}
@@ -116,11 +128,13 @@ public virtual ResultSetMapping AppendUpdateOperation(
/// The builder to which the SQL should be appended.
/// The command that represents the delete operation.
/// The ordinal of this command in the batch.
+ /// Returns whether the SQL appended must be executed in a transaction to work correctly.
/// The for the command.
public virtual ResultSetMapping AppendDeleteOperation(
StringBuilder commandStringBuilder,
IReadOnlyModificationCommand command,
- int commandPosition)
+ int commandPosition,
+ out bool requiresTransaction)
{
var name = command.TableName;
var schema = command.Schema;
@@ -128,6 +142,8 @@ public virtual ResultSetMapping AppendDeleteOperation(
AppendDeleteCommand(commandStringBuilder, name, schema, conditionOperations);
+ requiresTransaction = false;
+
return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition);
}
@@ -530,6 +546,14 @@ public virtual void AppendBatchHeader(StringBuilder commandStringBuilder)
{
}
+ ///
+ /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off.
+ ///
+ /// The builder to which the SQL should be prepended.
+ public virtual void PrependEnsureAutocommit(StringBuilder commandStringBuilder)
+ {
+ }
+
///
/// Generates SQL that will obtain the next value in the given sequence.
///
diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
index 209e9261080..d63a68ce451 100644
--- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
+++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
@@ -1377,7 +1377,7 @@ protected override void Generate(
GenerateIdentityInsert(builder, operation, on: true, model);
var sqlBuilder = new StringBuilder();
- ((SqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation(
+ ((ISqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation(
sqlBuilder,
GenerateModificationCommands(operation, model).ToList(),
0);
diff --git a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs
index 4099dd73dec..c7f92fb002a 100644
--- a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs
+++ b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs
@@ -27,5 +27,18 @@ public interface ISqlServerUpdateSqlGenerator : IUpdateSqlGenerator
ResultSetMapping AppendBulkInsertOperation(
StringBuilder commandStringBuilder,
IReadOnlyList modificationCommands,
- int commandPosition);
+ int commandPosition,
+ out bool requiresTransaction);
+
+ ///
+ /// 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.
+ ///
+ ResultSetMapping AppendBulkInsertOperation(
+ StringBuilder commandStringBuilder,
+ IReadOnlyList modificationCommands,
+ int commandPosition)
+ => AppendBulkInsertOperation(commandStringBuilder, modificationCommands, commandPosition, out _);
}
diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs
index c775d2b83f0..18e4ee6d501 100644
--- a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs
+++ b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs
@@ -85,7 +85,8 @@ protected override bool IsCommandTextValid()
{
if (--_commandsLeftToLengthCheck < 0)
{
- var commandTextLength = GetCommandText().Length;
+ UpdateCachedCommandText();
+ var commandTextLength = CachedCommandText.Length;
if (commandTextLength >= MaxScriptLength)
{
return false;
@@ -138,28 +139,24 @@ private static int CountParameters(IReadOnlyModificationCommand modificationComm
protected override void ResetCommandText()
{
base.ResetCommandText();
+
_bulkInsertCommands.Clear();
}
- ///
- /// 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.
- ///
- protected override string GetCommandText()
- => base.GetCommandText() + GetBulkInsertCommandText(ModificationCommands.Count);
-
- private string GetBulkInsertCommandText(int lastIndex)
+ private void AppendBulkInsertCommandText(int lastIndex)
{
if (_bulkInsertCommands.Count == 0)
{
- return string.Empty;
+ return;
}
- var stringBuilder = new StringBuilder();
+ var wasCachedCommandTextEmpty = IsCachedCommandTextEmpty;
+
var resultSetMapping = UpdateSqlGenerator.AppendBulkInsertOperation(
- stringBuilder, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count);
+ CachedCommandText, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count, out var requiresTransaction);
+
+ SetRequiresTransaction(!wasCachedCommandTextEmpty || requiresTransaction);
+
for (var i = lastIndex - _bulkInsertCommands.Count; i < lastIndex; i++)
{
CommandResultSet[i] = resultSetMapping;
@@ -169,8 +166,19 @@ private string GetBulkInsertCommandText(int lastIndex)
{
CommandResultSet[lastIndex - 1] = ResultSetMapping.LastInResultSet;
}
+ }
- return stringBuilder.ToString();
+ ///
+ /// 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.
+ ///
+ protected override void UpdateCachedCommandText()
+ {
+ base.UpdateCachedCommandText();
+
+ AppendBulkInsertCommandText(ModificationCommands.Count);
}
///
@@ -188,7 +196,9 @@ protected override void UpdateCachedCommandText(int commandPosition)
if (_bulkInsertCommands.Count > 0
&& !CanBeInsertedInSameStatement(_bulkInsertCommands[0], newModificationCommand))
{
- CachedCommandText.Append(GetBulkInsertCommandText(commandPosition));
+ // The new Add command cannot be added to the pending bulk insert commands (e.g. different table).
+ // Write out the pending commands before starting a new pending chain.
+ AppendBulkInsertCommandText(commandPosition);
_bulkInsertCommands.Clear();
}
@@ -198,8 +208,14 @@ protected override void UpdateCachedCommandText(int commandPosition)
}
else
{
- CachedCommandText.Append(GetBulkInsertCommandText(commandPosition));
- _bulkInsertCommands.Clear();
+ // If we have any pending bulk insert commands, write them out before the next non-Add command
+ if (_bulkInsertCommands.Count > 0)
+ {
+ // Note that we don't care about the transactionality of the bulk insert SQL, since there's the additional non-Add
+ // command coming right afterwards, and so a transaction is required in any case.
+ AppendBulkInsertCommandText(commandPosition);
+ _bulkInsertCommands.Clear();
+ }
base.UpdateCachedCommandText(commandPosition);
}
diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs
index 709e7558e76..6d9420b90c0 100644
--- a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs
+++ b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs
@@ -35,7 +35,8 @@ public SqlServerUpdateSqlGenerator(
public virtual ResultSetMapping AppendBulkInsertOperation(
StringBuilder commandStringBuilder,
IReadOnlyList modificationCommands,
- int commandPosition)
+ int commandPosition,
+ out bool requiresTransaction)
{
var table = StoreObjectIdentifier.Table(modificationCommands[0].TableName, modificationCommands[0].Schema);
if (modificationCommands.Count == 1)
@@ -45,13 +46,16 @@ public virtual ResultSetMapping AppendBulkInsertOperation(
!o.IsKey
|| !o.IsRead
|| o.Property?.GetValueGenerationStrategy(table) == SqlServerValueGenerationStrategy.IdentityColumn)
- ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition)
+ // Do a regular INSERT+SELECT for IDENTITY, but not if there are any non-IDENTITY generated columns
+ ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition, out requiresTransaction)
+ // If we have a non-identity generated column, do INSERT ... OUTPUT INTO @inserted; SELECT ... FROM @inserted
: AppendInsertOperationWithServerKeys(
commandStringBuilder,
modificationCommands[0],
modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList(),
modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList(),
- commandPosition);
+ commandPosition,
+ out requiresTransaction);
}
var readOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList();
@@ -59,18 +63,22 @@ public virtual ResultSetMapping AppendBulkInsertOperation(
var keyOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList();
var defaultValuesOnly = writeOperations.Count == 0;
- var nonIdentityOperations = modificationCommands[0].ColumnModifications
- .Where(o => o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn)
+ var nonWriteableOperations = modificationCommands[0].ColumnModifications
+ .Where(o =>
+ o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn
+ && o.Property?.GetComputedColumnSql() is null)
.ToList();
if (defaultValuesOnly)
{
- if (nonIdentityOperations.Count == 0
+ if (nonWriteableOperations.Count == 0
|| readOperations.Count == 0)
{
+ requiresTransaction = false;
foreach (var modification in modificationCommands)
{
- AppendInsertOperation(commandStringBuilder, modification, commandPosition);
+ AppendInsertOperation(commandStringBuilder, modification, commandPosition, out var localRequiresTransaction);
+ requiresTransaction = requiresTransaction || localRequiresTransaction;
}
return readOperations.Count == 0
@@ -78,31 +86,36 @@ public virtual ResultSetMapping AppendBulkInsertOperation(
: ResultSetMapping.LastInResultSet;
}
- if (nonIdentityOperations.Count > 1)
+ if (nonWriteableOperations.Count > 1)
{
- nonIdentityOperations.RemoveRange(1, nonIdentityOperations.Count - 1);
+ nonWriteableOperations.RemoveRange(1, nonWriteableOperations.Count - 1);
}
}
if (readOperations.Count == 0)
{
- return AppendBulkInsertWithoutServerValues(commandStringBuilder, modificationCommands, writeOperations);
+ return AppendBulkInsertWithoutServerValues(
+ commandStringBuilder, modificationCommands, writeOperations, out requiresTransaction);
}
if (defaultValuesOnly)
{
return AppendBulkInsertWithServerValuesOnly(
- commandStringBuilder, modificationCommands, commandPosition, nonIdentityOperations, keyOperations, readOperations);
+ commandStringBuilder, modificationCommands, commandPosition, nonWriteableOperations, keyOperations, readOperations,
+ out requiresTransaction);
}
if (modificationCommands[0].Entries.SelectMany(e => e.EntityType.GetAllBaseTypesInclusive())
.Any(e => e.IsMemoryOptimized()))
{
- if (!nonIdentityOperations.Any(o => o.IsRead && o.IsKey))
+ requiresTransaction = false;
+
+ if (!nonWriteableOperations.Any(o => o.IsRead && o.IsKey))
{
foreach (var modification in modificationCommands)
{
- AppendInsertOperation(commandStringBuilder, modification, commandPosition++);
+ AppendInsertOperation(commandStringBuilder, modification, commandPosition++, out var localRequiresTransaction);
+ requiresTransaction = requiresTransaction || localRequiresTransaction;
}
}
else
@@ -110,7 +123,9 @@ public virtual ResultSetMapping AppendBulkInsertOperation(
foreach (var modification in modificationCommands)
{
AppendInsertOperationWithServerKeys(
- commandStringBuilder, modification, keyOperations, readOperations, commandPosition++);
+ commandStringBuilder, modification, keyOperations, readOperations, commandPosition++,
+ out var localRequiresTransaction);
+ requiresTransaction = requiresTransaction || localRequiresTransaction;
}
}
@@ -118,13 +133,15 @@ public virtual ResultSetMapping AppendBulkInsertOperation(
}
return AppendBulkInsertWithServerValues(
- commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations);
+ commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations,
+ out requiresTransaction);
}
private ResultSetMapping AppendBulkInsertWithoutServerValues(
StringBuilder commandStringBuilder,
IReadOnlyList modificationCommands,
- List writeOperations)
+ List writeOperations,
+ out bool requiresTransaction)
{
Check.DebugAssert(writeOperations.Count > 0, $"writeOperations.Count is {writeOperations.Count}");
@@ -143,6 +160,8 @@ private ResultSetMapping AppendBulkInsertWithoutServerValues(
commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator);
+ requiresTransaction = false;
+
return ResultSetMapping.NoResultSet;
}
@@ -158,7 +177,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues(
int commandPosition,
List writeOperations,
List keyOperations,
- List readOperations)
+ List readOperations,
+ out bool requiresTransaction)
{
AppendDeclareTable(
commandStringBuilder,
@@ -190,6 +210,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues(
commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema,
orderColumn: PositionColumnName);
+ requiresTransaction = true;
+
return ResultSetMapping.NotLastInResultSet;
}
@@ -199,7 +221,8 @@ private ResultSetMapping AppendBulkInsertWithServerValuesOnly(
int commandPosition,
List nonIdentityOperations,
List keyOperations,
- List readOperations)
+ List readOperations,
+ out bool requiresTransaction)
{
AppendDeclareTable(commandStringBuilder, InsertedTableBaseName, commandPosition, keyOperations);
@@ -219,6 +242,8 @@ private ResultSetMapping AppendBulkInsertWithServerValuesOnly(
AppendSelectCommand(commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema);
+ requiresTransaction = true;
+
return ResultSetMapping.NotLastInResultSet;
}
@@ -399,7 +424,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys(
IReadOnlyModificationCommand command,
IReadOnlyList keyOperations,
IReadOnlyList readOperations,
- int commandPosition)
+ int commandPosition,
+ out bool requiresTransaction)
{
var name = command.TableName;
var schema = command.Schema;
@@ -415,6 +441,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys(
AppendValues(commandStringBuilder, name, schema, writeOperations);
commandStringBuilder.Append(SqlGenerationHelper.StatementTerminator);
+ requiresTransaction = true;
+
return AppendSelectCommand(
commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema);
}
@@ -515,6 +543,19 @@ public override void AppendBatchHeader(StringBuilder commandStringBuilder)
.Append("SET NOCOUNT ON")
.AppendLine(SqlGenerationHelper.StatementTerminator);
+ ///
+ /// 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 override void PrependEnsureAutocommit(StringBuilder commandStringBuilder)
+ {
+ // SQL Server allows turning off autocommit via the IMPLICIT_TRANSACTIONS setting (see
+ // https://docs.microsoft.com/sql/t-sql/statements/set-implicit-transactions-transact-sql).
+ commandStringBuilder.Insert(0, $"SET IMPLICIT_TRANSACTIONS OFF{SqlGenerationHelper.StatementTerminator}{Environment.NewLine}");
+ }
+
///
/// 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/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesData.cs b/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesData.cs
new file mode 100644
index 00000000000..92aa4587322
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesData.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.TestModels.SaveChangesScenariosModel;
+
+#nullable enable
+
+public class SaveChangesData : IEquatable
+{
+ // Generated on add (except for WithNoDatabaseGenerated2)
+ public int Id { get; set; }
+
+ // Generated on update (except for WithNoDatabaseGenerated2)
+ public int Data1 { get; set; }
+
+ // Not generated, except for for WithAllDatabaseGenerated
+ public int Data2 { get; set; }
+
+ public bool Equals(SaveChangesData? other)
+ => other is not null
+ && (ReferenceEquals(this, other)
+ || (Id == other.Id
+ && Data1 == other.Data1
+ && Data2 == other.Data2));
+}
diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesScenariosContext.cs b/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesScenariosContext.cs
new file mode 100644
index 00000000000..b0947f30a00
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/TestModels/SaveChangesScenariosModel/SaveChangesScenariosContext.cs
@@ -0,0 +1,29 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.TestModels.SaveChangesScenariosModel;
+
+#nullable enable
+
+public class SaveChangesScenariosContext : PoolableDbContext
+{
+ public SaveChangesScenariosContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ public DbSet WithSomeDatabaseGenerated
+ => Set(nameof(WithSomeDatabaseGenerated));
+ public DbSet WithSomeDatabaseGenerated2
+ => Set(nameof(WithSomeDatabaseGenerated2));
+
+ public DbSet WithNoDatabaseGenerated
+ => Set(nameof(WithNoDatabaseGenerated));
+ public DbSet WithNoDatabaseGenerated2
+ => Set(nameof(WithNoDatabaseGenerated2));
+
+ public DbSet WithAllDatabaseGenerated
+ => Set(nameof(WithAllDatabaseGenerated));
+ public DbSet WithAllDatabaseGenerated2
+ => Set(nameof(WithAllDatabaseGenerated2));
+}
diff --git a/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosFixtureBase.cs
new file mode 100644
index 00000000000..eed24f13426
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosFixtureBase.cs
@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.EntityFrameworkCore.TestModels.SaveChangesScenariosModel;
+
+namespace Microsoft.EntityFrameworkCore.Update;
+
+#nullable enable
+
+public abstract class SaveChangesScenariosFixtureBase : SharedStoreFixtureBase
+{
+ protected override string StoreName { get; } = "SaveChangesScenariosTest";
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
+ {
+ foreach (var name in new[]
+ {
+ nameof(SaveChangesScenariosContext.WithNoDatabaseGenerated),
+ nameof(SaveChangesScenariosContext.WithNoDatabaseGenerated2)
+ })
+ {
+ modelBuilder
+ .SharedTypeEntity(name)
+ .Property(w => w.Id)
+ .ValueGeneratedNever();
+ }
+
+ foreach (var name in new[]
+ {
+ nameof(SaveChangesScenariosContext.WithSomeDatabaseGenerated),
+ nameof(SaveChangesScenariosContext.WithSomeDatabaseGenerated2),
+ nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated),
+ nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated2)
+ })
+ {
+ modelBuilder
+ .SharedTypeEntity(name)
+ .Property(w => w.Data1)
+ .HasComputedColumnSql("80");
+ }
+
+ foreach (var name in new[]
+ {
+ nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated),
+ nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated2)
+ })
+ {
+ modelBuilder
+ .SharedTypeEntity(name)
+ .Property(w => w.Data2)
+ .HasComputedColumnSql("81");
+ }
+ }
+
+ protected override void Seed(SaveChangesScenariosContext context)
+ {
+ context.WithSomeDatabaseGenerated.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 });
+ context.WithSomeDatabaseGenerated2.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 });
+
+ context.WithNoDatabaseGenerated.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 });
+ context.WithNoDatabaseGenerated2.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 });
+
+ context.WithAllDatabaseGenerated.AddRange(new(), new());
+ context.WithAllDatabaseGenerated2.AddRange(new(), new());
+
+ context.SaveChanges();
+ }
+
+ protected override bool ShouldLogCategory(string logCategory)
+ => logCategory == DbLoggerCategory.Database.Transaction.Name
+ || logCategory == DbLoggerCategory.Database.Command.Name;
+
+ public TestSqlLoggerFactory TestSqlLoggerFactory
+ => (TestSqlLoggerFactory)ListLoggerFactory;
+}
diff --git a/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosTestBase.cs
new file mode 100644
index 00000000000..fbb088662ed
--- /dev/null
+++ b/test/EFCore.Relational.Specification.Tests/Update/SaveChangesScenariosTestBase.cs
@@ -0,0 +1,379 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.EntityFrameworkCore.TestModels.SaveChangesScenariosModel;
+
+namespace Microsoft.EntityFrameworkCore.Update;
+
+#nullable enable
+
+public abstract class SaveChangesScenariosTestBase : IClassFixture
+ where TFixture : SaveChangesScenariosFixtureBase
+{
+ protected SaveChangesScenariosTestBase(TFixture fixture)
+ {
+ Fixture = fixture;
+
+ fixture.Reseed();
+
+ ClearLog();
+ }
+
+ #region Single operation
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_with_generated_values(bool async)
+ => Test(EntityState.Added, secondOperationType: null, GeneratedValues.Some, async);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_with_no_generated_values(bool async)
+ => Test(EntityState.Added, secondOperationType: null, GeneratedValues.None, async);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_with_all_generated_values(bool async)
+ => Test(EntityState.Added, secondOperationType: null, GeneratedValues.All, async);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_with_generated_values(bool async)
+ => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.Some, async);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_with_no_generated_values(bool async)
+ => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.None, async);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Delete(bool async)
+ => Test(EntityState.Deleted, secondOperationType: null, GeneratedValues.Some, async);
+
+ #endregion Single operation
+
+ #region Two operations with same entity type
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_same_entity_type_and_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_same_entity_type_and_no_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_same_entity_type_and_all_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_Modify_with_same_entity_type_and_generated_values(bool async)
+ => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async)
+ => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Delete_Delete_with_same_entity_type(bool async)
+ => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: true);
+
+ #endregion Two operations with same entity type
+
+ #region Two operations with different entity types
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_different_entity_types_and_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: false);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_different_entity_types_and_no_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: false);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Add_Add_with_different_entity_types_and_all_generated_values(bool async)
+ => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: false);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_Modify_with_different_entity_types_and_generated_values(bool async)
+ => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: false);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async)
+ => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: false);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Delete_Delete_with_different_entity_types(bool async)
+ => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: false);
+
+ #endregion Two operations with different entity types
+
+ protected virtual async Task Test(
+ EntityState firstOperationType,
+ EntityState? secondOperationType,
+ GeneratedValues generatedValues,
+ bool async,
+ bool withSameEntityType = true)
+ {
+ await using var context = CreateContext();
+
+ var firstDbSet = generatedValues switch
+ {
+ GeneratedValues.Some => context.WithSomeDatabaseGenerated,
+ GeneratedValues.None => context.WithNoDatabaseGenerated,
+ GeneratedValues.All => context.WithAllDatabaseGenerated,
+ _ => throw new ArgumentOutOfRangeException(nameof(generatedValues))
+ };
+
+ var secondDbSet = secondOperationType is null
+ ? null
+ : (generatedValues, withSameEntityType) switch
+ {
+ (GeneratedValues.Some, true) => context.WithSomeDatabaseGenerated,
+ (GeneratedValues.Some, false) => context.WithSomeDatabaseGenerated2,
+ (GeneratedValues.None, true) => context.WithNoDatabaseGenerated,
+ (GeneratedValues.None, false) => context.WithNoDatabaseGenerated2,
+ (GeneratedValues.All, true) => context.WithAllDatabaseGenerated,
+ (GeneratedValues.All, false) => context.WithAllDatabaseGenerated2,
+ _ => throw new ArgumentOutOfRangeException(nameof(generatedValues))
+ };
+
+ SaveChangesData first;
+ SaveChangesData? second;
+
+ switch (firstOperationType)
+ {
+ case EntityState.Added:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ first = new SaveChangesData { Data2 = 1000 };
+ firstDbSet.Add(first);
+ break;
+ case GeneratedValues.None:
+ first = new SaveChangesData { Id = 100, Data1 = 1000, Data2 = 1000 };
+ firstDbSet.Add(first);
+ break;
+ case GeneratedValues.All:
+ first = new SaveChangesData();
+ firstDbSet.Add(first);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ case EntityState.Modified:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ first = firstDbSet.OrderBy(w => w.Id).First();
+ first.Data2 = 1000;
+ break;
+ case GeneratedValues.None:
+ first = firstDbSet.OrderBy(w => w.Id).First();
+ (first.Data1, first.Data2) = (1000, 1000);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ case EntityState.Deleted:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ first = firstDbSet.OrderBy(w => w.Id).First();
+ context.Remove(first);
+ break;
+ case GeneratedValues.None:
+ first = firstDbSet.OrderBy(w => w.Id).First();
+ context.Remove(first);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(firstOperationType));
+ }
+
+ switch (secondOperationType)
+ {
+ case EntityState.Added:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ second = new SaveChangesData { Data2 = 1001 };
+ secondDbSet!.Add(second);
+ break;
+ case GeneratedValues.None:
+ second = new SaveChangesData { Id = 101, Data1 = 1001, Data2 = 1001 };
+ secondDbSet!.Add(second);
+ break;
+ case GeneratedValues.All:
+ second = new SaveChangesData();
+ secondDbSet!.Add(second);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ case EntityState.Modified:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First();
+ second.Data2 = 1001;
+ break;
+ case GeneratedValues.None:
+ second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First();
+ (second.Data1, second.Data2) = (1001, 1001);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ case EntityState.Deleted:
+ switch (generatedValues)
+ {
+ case GeneratedValues.Some:
+ second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First();
+ context.Remove(second);
+ break;
+ case GeneratedValues.None:
+ second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First();
+ context.Remove(second);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(generatedValues));
+ }
+ break;
+
+ case null:
+ second = null;
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(firstOperationType));
+ }
+
+ // Execute
+ Fixture.ListLoggerFactory.Clear();
+
+ if (async)
+ {
+ await context.SaveChangesAsync();
+ }
+ else
+ {
+ context.SaveChanges();
+ }
+
+ // Make sure a transaction was created (or not)
+ if (ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType))
+ {
+ Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted);
+ Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted);
+ }
+ else
+ {
+ Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted);
+ Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted);
+ }
+
+ // Make sure the updates executed in the expected number of commands
+ Assert.Equal(
+ ShouldExecuteInNumberOfCommands(firstOperationType, secondOperationType, generatedValues, withSameEntityType),
+ Fixture.ListLoggerFactory.Log.Count(l => l.Id == RelationalEventId.CommandExecuted));
+
+ // To make sure generated values have been propagated, re-load the rows from the database and compare
+ context.ChangeTracker.Clear();
+
+ using (Fixture.TestSqlLoggerFactory.SuspendRecordingEvents())
+ {
+ if (firstOperationType != EntityState.Deleted)
+ {
+ Assert.Equal(await firstDbSet.FindAsync(first.Id), first);
+ }
+
+ if (second is not null && secondOperationType != EntityState.Deleted)
+ {
+ Assert.Equal(await secondDbSet!.FindAsync(second.Id), second);
+ }
+ }
+ }
+
+ ///
+ /// Providers can override this to specify when should create a transaction, and when not.
+ /// By default, it's assumed that multiple updates always require a transaction, whereas a single update never does.
+ ///
+ protected virtual bool ShouldCreateImplicitTransaction(
+ EntityState firstOperationType,
+ EntityState? secondOperationType,
+ GeneratedValues generatedValues,
+ bool withSameEntityType)
+ {
+ // By default, two changes require a transaction
+ if (secondOperationType is not null)
+ {
+ return true;
+ }
+
+ // Deletes don't ever need to bring back database-generated values
+ if (firstOperationType == EntityState.Deleted)
+ {
+ return false;
+ }
+
+ // By default, assume that fetching back database-generated values requires a transaction
+ return generatedValues != GeneratedValues.None;
+ }
+
+ ///
+ /// Providers can override this to specify how many commands (batches) are used to execute the update.
+ /// By default, it's assumed all operations are batched in one command.
+ ///
+ protected virtual int ShouldExecuteInNumberOfCommands(
+ EntityState firstOperationType,
+ EntityState? secondOperationType,
+ GeneratedValues generatedValues,
+ bool withSameEntityType)
+ => 1;
+
+ protected TFixture Fixture { get; }
+
+ protected SaveChangesScenariosContext CreateContext()
+ => Fixture.CreateContext();
+
+ public static IEnumerable