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 IsAsyncData = new[] { new object[] { false }, new object[] { true } }; + + protected virtual void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected virtual void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + protected enum GeneratedValues + { + Some, + None, + All + } +} diff --git a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs index 16607104898..3a45ae9629b 100644 --- a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs +++ b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs @@ -13,28 +13,31 @@ public FakeSqlGenerator(UpdateSqlGeneratorDependencies dependencies) public override ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendInsertOperationCalls++; - return base.AppendInsertOperation(commandStringBuilder, command, commandPosition); + return base.AppendInsertOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendUpdateOperationCalls++; - return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition); + return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendDeleteOperationCalls++; - return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition); + return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public int AppendBatchHeaderCalls { get; set; } diff --git a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs index 058c1356a52..b567116d4e8 100644 --- a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs +++ b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs @@ -25,7 +25,7 @@ public void BatchCommands_creates_valid_batch_for_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -66,7 +66,7 @@ public void BatchCommands_creates_valid_batch_for_modified_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -107,7 +107,7 @@ public void BatchCommands_creates_valid_batch_for_deleted_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -142,7 +142,7 @@ public void BatchCommands_sorts_related_added_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Added); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -165,7 +165,7 @@ public void BatchCommands_sorts_added_and_related_modified_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Modified); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -188,7 +188,7 @@ public void BatchCommands_sorts_unrelated_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { secondEntry, firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { secondEntry, firstEntry }, modelData); Assert.Equal( new[] { firstEntry, secondEntry }, @@ -216,8 +216,7 @@ public void BatchCommands_sorts_entities_when_reparenting() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, previousParent, newParent }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, previousParent, newParent }, modelData); Assert.Equal( new[] { newParent, relatedEntry, previousParent }, @@ -244,7 +243,7 @@ public void BatchCommands_sorts_when_reassigning_child() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { newChild, previousChild }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { newChild, previousChild }, modelData); Assert.Equal( new[] { previousChild, newChild }, @@ -279,8 +278,7 @@ public void BatchCommands_sorts_entities_while_reassigning_child_tree() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) + var sortedEntities = CreateBatches(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -348,8 +346,8 @@ public void Batch_command_does_not_order_non_unique_index_values() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) + var sortedEntities = + CreateBatches(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -404,8 +402,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies(bool sen Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -451,8 +448,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies_includin Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -490,9 +486,8 @@ FakeEntity [Deleted]" Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging).BatchCommands( - // Order is important for this test. Entry which is not part of cycle but tail should come first. - new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + // Order is important for this test. Entry which is not part of cycle but tail should come first. + () => CreateBatches(new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [ConditionalFact] @@ -513,10 +508,9 @@ public void BatchCommands_works_with_duplicate_values_for_unique_indexes() var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { fakeEntry, fakeEntry2 }, modelData).ToArray(); + var batches = CreateBatches(new[] { fakeEntry, fakeEntry2 }, modelData); - Assert.Equal(2, batches.Length); + Assert.Equal(2, batches.Count); } [ConditionalFact] @@ -534,9 +528,8 @@ public void BatchCommands_creates_valid_batch_for_shared_table_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); + Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -588,9 +581,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_modified_entities var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { entry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -645,8 +636,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_deleted_entities( var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -701,8 +691,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), "{Id: 42}", EntityState.Deleted, nameof(FakeEntity), "{Id: 42}", EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -711,8 +700,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), EntityState.Deleted, nameof(FakeEntity), EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } @@ -755,8 +743,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -765,8 +752,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } else @@ -778,8 +764,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -788,8 +773,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } } @@ -812,8 +796,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -896,8 +879,7 @@ public void BatchCommands_works_with_incomplete_updates_for_shared_table_no_leaf var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var batches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false); Assert.Single(batches); } @@ -920,8 +902,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -929,11 +910,11 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n Assert.Equal( "1", Assert.Throws( - () => Assert.Equal(2, commandBatches.Length)).Actual); + () => Assert.Equal(2, commandBatches.Count)).Actual); } else { - Assert.Equal(2, commandBatches.Length); + Assert.Equal(2, commandBatches.Count); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); var command = commandBatches.First().ModificationCommands.Single(); @@ -966,6 +947,14 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n private static IServiceProvider CreateContextServices(IModel model) => RelationalTestHelpers.Instance.CreateContextServices(model); + public List CreateBatches( + IUpdateEntry[] entries, + IUpdateAdapter updateAdapter, + bool sensitiveLogging = false) + => CreateCommandBatchPreparer(updateAdapter: updateAdapter, sensitiveLogging: sensitiveLogging) + .BatchCommands(entries, updateAdapter) + .ToList(); + public ICommandBatchPreparer CreateCommandBatchPreparer( IModificationCommandBatchFactory modificationCommandBatchFactory = null, IUpdateAdapter updateAdapter = null, diff --git a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs index e2f17161743..f4848969fe0 100644 --- a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs +++ b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs @@ -25,6 +25,7 @@ public void AddCommand_adds_command_if_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(2, batch.ModificationCommands.Count); Assert.Same(command, batch.ModificationCommands[0]); @@ -42,6 +43,7 @@ public void AddCommand_does_not_add_command_if_not_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -58,6 +60,7 @@ public void AddCommand_does_not_add_command_if_resulting_sql_is_invalid() batch.ShouldValidateSql = false; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -75,6 +78,7 @@ public void UpdateCommandText_compiles_inserts() RelationalTestHelpers.Instance.CreateContextServices().GetRequiredService()); var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); + batch.Complete(); batch.UpdateCachedCommandTextBase(0); @@ -96,6 +100,7 @@ public void UpdateCommandText_compiles_updates() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendUpdateOperationCalls); @@ -115,6 +120,7 @@ public void UpdateCommandText_compiles_deletes() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendDeleteOperationCalls); @@ -133,6 +139,7 @@ public void UpdateCommandText_compiles_multiple_commands() var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); batch.AddCommand(command); + batch.Complete(); Assert.Equal("..", batch.CommandText); @@ -153,6 +160,7 @@ public async Task ExecuteAsync_executes_batch_commands_and_consumes_reader() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -175,6 +183,7 @@ public async Task ExecuteAsync_saves_store_generated_values() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -198,6 +207,7 @@ public async Task ExecuteAsync_saves_store_generated_values_on_non_key_columns() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -220,6 +230,7 @@ public async Task ExecuteAsync_saves_store_generated_values_when_updating() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -243,6 +254,7 @@ public async Task Exception_not_thrown_for_more_than_one_row_returned_for_single var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -265,6 +277,7 @@ public async Task Exception_thrown_if_rows_returned_for_command_without_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -289,6 +302,7 @@ public async Task Exception_thrown_if_no_rows_returned_for_command_with_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -316,6 +330,7 @@ public async Task DbException_is_wrapped_with_DbUpdateException(bool async) var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -343,6 +358,7 @@ public async Task OperationCanceledException_is_not_wrapped_with_DbUpdateExcepti var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -393,6 +409,8 @@ public void CreateStoreCommand_creates_parameters_for_each_ModificationCommand() false, true, false, false, true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -430,6 +448,8 @@ public void PopulateParameters_creates_parameter_for_write_ModificationCommand() sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -465,6 +485,8 @@ public void PopulateParameters_creates_parameter_for_condition_ModificationComma sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -500,6 +522,8 @@ public void PopulateParameters_creates_parameters_for_write_and_condition_Modifi sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -537,6 +561,8 @@ public void PopulateParameters_does_not_create_parameter_for_read_ModificationCo sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(0, storeCommand.RelationalCommand.Parameters.Count); @@ -626,7 +652,7 @@ private static ModificationCommandBatchFactoryDependencies CreateDependencies( } public string CommandText - => GetCommandText(); + => CachedCommandText.ToString(); public bool ShouldAddCommand { get; set; } diff --git a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs index 5338e33bbb9..b2c5db89b7b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs @@ -164,6 +164,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0003-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; @@ -174,6 +175,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0002-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; diff --git a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs index bc860720142..469a2b2120b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs @@ -166,6 +166,7 @@ FROM [Engines] AS [e] @p4='47.64491' (Nullable = true) @p5='-122.128101' (Nullable = true) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Engines] SET [Name] = @p0 WHERE [Id] = @p1 AND [EngineSupplierId] = @p2 AND [Name] = @p3 AND [StorageLocation_Latitude] = @p4 AND [StorageLocation_Longitude] = @p5; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs index 26b289461a3..85a40748b6c 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs @@ -389,6 +389,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -400,6 +401,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -411,6 +413,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs index 155f1612578..3f58c6ef539 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs @@ -363,6 +363,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -374,6 +375,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -385,6 +387,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs index dee1a0b6105..6c45fc18098 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs @@ -102,6 +102,7 @@ FROM [Countries] AS [c] @p1='1' @p2='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);", @@ -110,6 +111,7 @@ INSERT INTO [Animals] ([Species], [CountryId], [Name]) @p4=NULL (Size = 100) @p5='True' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) VALUES (@p3, @p4, @p5);", @@ -117,6 +119,7 @@ INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) @"@p6='Apteryx owenii' (Nullable = false) (Size = 100) @p7='0' (Size = 1) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Kiwi] ([Species], [FoundOn]) VALUES (@p6, @p7);", @@ -130,6 +133,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Birds] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -143,6 +147,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Kiwi] WHERE [Species] = @p0; @@ -150,6 +155,7 @@ DELETE FROM [Kiwi] // @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Birds] WHERE [Species] = @p1; @@ -157,6 +163,7 @@ DELETE FROM [Birds] // @"@p2='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p2; @@ -527,6 +534,7 @@ FROM [Animals] AS [a] @p1='0' @p2='Bald eagle' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);"); diff --git a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs index 0181ac8adef..4f9bbb9011a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs @@ -187,6 +187,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Name] = @p0 WHERE [Name] = @p1; @@ -195,6 +196,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p2='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p3='Repair' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [LicensedOperators] ([VehicleName], [LicenseType]) VALUES (@p2, @p3);", @@ -228,6 +230,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs index a80051258f6..4fa4ba61080 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs @@ -210,6 +210,7 @@ public override async Task Can_change_dependent_instance_non_derived() @p1='Repair' (Size = 4000) @p2='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Discriminator] = @p0, [LicenseType] = @p1, [Operator_Name] = @p2 WHERE [Name] = @p3; @@ -233,6 +234,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosIdentitySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosIdentitySqlServerTest.cs new file mode 100644 index 00000000000..3d6db4e7887 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosIdentitySqlServerTest.cs @@ -0,0 +1,431 @@ +// 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.Update; + +public class SaveChangesScenariosIdentitySqlServerTest : SaveChangesScenariosTestBase< + SaveChangesScenariosIdentitySqlServerTest.SaveChangesScenariosIdentitySqlServerFixture> +{ + public SaveChangesScenariosIdentitySqlServerTest( + SaveChangesScenariosIdentitySqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated2] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class SaveChangesScenariosIdentitySqlServerFixture : SaveChangesScenariosFixtureBase + { + protected override string StoreName { get; } = "SaveChangesScenariosIdentityTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosSequenceSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosSequenceSqlServerTest.cs new file mode 100644 index 00000000000..20d7c4f838e --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/SaveChangesScenariosSequenceSqlServerTest.cs @@ -0,0 +1,474 @@ +// 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; + +public class SaveChangesScenariosSequenceSqlServerTest : SaveChangesScenariosTestBase< + SaveChangesScenariosSequenceSqlServerTest.SaveChangesScenariosSequenceSqlServerFixture> +{ + public SaveChangesScenariosSequenceSqlServerTest( + SaveChangesScenariosSequenceSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='6' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='6' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated2] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='8' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='8' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class SaveChangesScenariosSequenceSqlServerFixture : SaveChangesScenariosFixtureBase + { + protected override string StoreName { get; } = "SaveChangesScenariosSequenceTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + base.OnModelCreating(modelBuilder, context); + + modelBuilder.HasSequence("Ids"); + + foreach (var name in new[] + { + nameof(SaveChangesScenariosContext.WithSomeDatabaseGenerated), + nameof(SaveChangesScenariosContext.WithSomeDatabaseGenerated2), + nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated), + nameof(SaveChangesScenariosContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Id) + .HasDefaultValueSql("NEXT VALUE FOR [Ids]"); + } + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs index 4320cebe7ae..ac06c242721 100644 --- a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs @@ -44,6 +44,7 @@ public virtual void Save_with_shared_foreign_key() @p1=NULL (Size = 4000) @p2='777' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Categories] ([Id], [Name], [PrincipalId]) VALUES (@p0, @p1, @p2);", @@ -131,6 +132,7 @@ public override void Save_replaced_principal() @"@p1='78' @p0='New Category' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Categories] SET [Name] = @p0 WHERE [Id] = @p1; diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/SaveChangesScenariosSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/SaveChangesScenariosSqliteTest.cs new file mode 100644 index 00000000000..db6db3efe13 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Update/SaveChangesScenariosSqliteTest.cs @@ -0,0 +1,370 @@ +// 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.Update; + +#nullable enable + +public class SaveChangesScenariosSqliteTest : SaveChangesScenariosTestBase< + SaveChangesScenariosSqliteTest.SaveChangesScenariosSqliteFixture> +{ + public SaveChangesScenariosSqliteTest(SaveChangesScenariosSqliteFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + fixture.TestSqlLoggerFactory.Clear(); + fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated2"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated2"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated2"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated2"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated2"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated2"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with different entity types + + public class SaveChangesScenariosSqliteFixture : SaveChangesScenariosFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + } +}