diff --git a/src/EFCore.Relational/Update/SelectingUpdateSqlGenerator.cs b/src/EFCore.Relational/Update/SelectingUpdateSqlGenerator.cs new file mode 100644 index 00000000000..441aaf51b7e --- /dev/null +++ b/src/EFCore.Relational/Update/SelectingUpdateSqlGenerator.cs @@ -0,0 +1,308 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; + +namespace Microsoft.EntityFrameworkCore.Update; + +/// +/// +/// A base class for the service that is typically inherited from by database providers. +/// The implementation uses a separate SELECT query after the update SQL to retrieve any database-generated values or for +/// concurrency checking. +/// +/// +/// This type is typically used by database providers; it is generally not used in application code. +/// +/// +/// +/// +/// The service lifetime is . This means a single instance is used by many +/// instances. The implementation must be thread-safe. This service cannot depend on services registered +/// as . +/// +/// +/// See Implementation of database providers and extensions for more +/// information and examples. +/// +/// +public abstract class SelectingUpdateSqlGenerator : UpdateSqlGenerator +{ + /// + /// Initializes a new instance of the this class. + /// + /// Parameter object containing dependencies for this service. + protected SelectingUpdateSqlGenerator(UpdateSqlGeneratorDependencies dependencies) + : base(dependencies) + { + } + + /// + public override ResultSetMapping AppendInsertOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + => AppendInsertAndSelectOperations(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for inserting a row to the commands being built, via an INSERT followed by an optional SELECT to retrieve any + /// database-generated values. + /// + /// 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. + protected virtual ResultSetMapping AppendInsertAndSelectOperations( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + { + var name = command.TableName; + var schema = command.Schema; + var operations = command.ColumnModifications; + + var writeOperations = operations.Where(o => o.IsWrite).ToList(); + var readOperations = operations.Where(o => o.IsRead).ToList(); + + AppendInsertCommand(commandStringBuilder, name, schema, writeOperations, readOperations: Array.Empty()); + + if (readOperations.Count > 0) + { + 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); + } + + /// + public override ResultSetMapping AppendUpdateOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + => AppendUpdateAndSelectOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for updating a row to the commands being built, via an UPDATE followed by a SELECT to retrieve any + /// database-generated values or for concurrency checking. + /// + /// 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. + protected virtual ResultSetMapping AppendUpdateAndSelectOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + { + var name = command.TableName; + var schema = command.Schema; + var operations = command.ColumnModifications; + + var writeOperations = operations.Where(o => o.IsWrite).ToList(); + var conditionOperations = operations.Where(o => o.IsCondition).ToList(); + var readOperations = operations.Where(o => o.IsRead).ToList(); + + AppendUpdateCommand(commandStringBuilder, name, schema, writeOperations, Array.Empty(), conditionOperations); + + if (readOperations.Count > 0) + { + 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); + } + + /// + public override ResultSetMapping AppendDeleteOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + => AppendDeleteAndSelectOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for updating a row to the commands being built, via a DELETE followed by a SELECT for concurrency checking. + /// + /// 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. + protected virtual ResultSetMapping AppendDeleteAndSelectOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) + { + var name = command.TableName; + var schema = command.Schema; + var operations = command.ColumnModifications; + + var conditionOperations = operations.Where(o => o.IsCondition).ToList(); + + requiresTransaction = false; + + AppendDeleteCommand(commandStringBuilder, name, schema, Array.Empty(), conditionOperations); + + return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); + } + + /// + /// Appends a SQL command for selecting affected data. + /// + /// The builder to which the SQL should be appended. + /// The name of the table. + /// The table schema, or to use the default schema. + /// The operations representing the data to be read. + /// The operations used to generate the WHERE clause for the select. + /// The ordinal of the command for which rows affected it being returned. + /// The for this command. + protected virtual ResultSetMapping AppendSelectAffectedCommand( + StringBuilder commandStringBuilder, + string name, + string? schema, + IReadOnlyList readOperations, + IReadOnlyList conditionOperations, + int commandPosition) + { + AppendSelectCommandHeader(commandStringBuilder, readOperations); + AppendFromClause(commandStringBuilder, name, schema); + AppendWhereAffectedClause(commandStringBuilder, conditionOperations); + commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator) + .AppendLine(); + + return ResultSetMapping.LastInResultSet; + } + + /// + /// Appends a SQL fragment for starting a SELECT. + /// + /// The builder to which the SQL should be appended. + /// The operations representing the data to be read. + protected virtual void AppendSelectCommandHeader( + StringBuilder commandStringBuilder, + IReadOnlyList operations) + => commandStringBuilder + .Append("SELECT ") + .AppendJoin( + operations, + SqlGenerationHelper, + (sb, o, helper) => helper.DelimitIdentifier(sb, o.ColumnName)); + + /// + /// Appends a SQL fragment for starting a FROM clause. + /// + /// The builder to which the SQL should be appended. + /// The name of the table. + /// The table schema, or to use the default schema. + protected virtual void AppendFromClause( + StringBuilder commandStringBuilder, + string name, + string? schema) + { + commandStringBuilder + .AppendLine() + .Append("FROM "); + SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, name, schema); + } + + /// + /// Appends a WHERE clause involving rows affected. + /// + /// The builder to which the SQL should be appended. + /// The operations from which to build the conditions. + protected virtual void AppendWhereAffectedClause( + StringBuilder commandStringBuilder, + IReadOnlyList operations) + { + commandStringBuilder + .AppendLine() + .Append("WHERE "); + + AppendRowsAffectedWhereCondition(commandStringBuilder, 1); + + if (operations.Count > 0) + { + commandStringBuilder + .Append(" AND ") + .AppendJoin( + operations, (sb, v) => + { + if (v.IsKey) + { + if (!v.IsRead) + { + AppendWhereCondition(sb, v, v.UseOriginalValueParameter); + return true; + } + } + + if (IsIdentityOperation(v)) + { + AppendIdentityWhereCondition(sb, v); + return true; + } + + return false; + }, " AND "); + } + } + + /// + /// Returns a value indicating whether the given modification represents an auto-incrementing column. + /// + /// The column modification. + /// if the given modification represents an auto-incrementing column. + protected virtual bool IsIdentityOperation(IColumnModification modification) + => modification.IsKey && modification.IsRead; + + /// + /// Appends a WHERE condition checking rows affected. + /// + /// The builder to which the SQL should be appended. + /// The expected number of rows affected. + protected abstract void AppendRowsAffectedWhereCondition( + StringBuilder commandStringBuilder, + int expectedRowsAffected); + + /// + /// Appends a WHERE condition for the identity (i.e. key value) of the given column. + /// + /// The builder to which the SQL should be appended. + /// The column for which the condition is being generated. + protected abstract void AppendIdentityWhereCondition( + StringBuilder commandStringBuilder, + IColumnModification columnModification); + + /// + /// Appends a SQL command for selecting the number of rows affected. + /// + /// The builder to which the SQL should be appended. + /// The name of the table. + /// The table schema, or to use the default schema. + /// The ordinal of the command for which rows affected it being returned. + /// The for this command. + protected abstract ResultSetMapping AppendSelectAffectedCountCommand( + StringBuilder commandStringBuilder, + string name, + string? schema, + int commandPosition); +} diff --git a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs index 0d18aff8a37..3c03360075d 100644 --- a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs @@ -7,8 +7,8 @@ namespace Microsoft.EntityFrameworkCore.Update; /// /// -/// A base class for the service that is typically inherited from -/// by database providers. +/// A base class for the service that is typically inherited from by database providers. +/// The implementation uses a SQL RETURNING clause to retrieve any database-generated values or for concurrency checking. /// /// /// This type is typically used by database providers; it is generally not used in application code. @@ -16,13 +16,13 @@ namespace Microsoft.EntityFrameworkCore.Update; /// /// /// -/// The service lifetime is . This means a single instance -/// is used by many instances. The implementation must be thread-safe. -/// This service cannot depend on services registered as . +/// The service lifetime is . This means a single instance is used by many +/// instances. The implementation must be thread-safe. This service cannot depend on services registered +/// as . /// /// -/// See Implementation of database providers and extensions -/// for more information and examples. +/// See Implementation of database providers and extensions for more +/// information and examples. /// /// public abstract class UpdateSqlGenerator : IUpdateSqlGenerator @@ -60,6 +60,22 @@ public virtual ResultSetMapping AppendInsertOperation( IReadOnlyModificationCommand command, int commandPosition, out bool requiresTransaction) + => AppendInsertReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for inserting a row to the commands being built, via an INSERT containing an optional RETURNING clause to retrieve + /// any database-generated values. + /// + /// 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 AppendInsertReturningOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -88,6 +104,22 @@ public virtual ResultSetMapping AppendUpdateOperation( IReadOnlyModificationCommand command, int commandPosition, out bool requiresTransaction) + => AppendUpdateReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for updating a row to the commands being built, via an UPDATE containing an RETURNING clause to retrieve any + /// database-generated values or for concurrency checking. + /// + /// 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. + protected virtual ResultSetMapping AppendUpdateReturningOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -119,6 +151,21 @@ public virtual ResultSetMapping AppendDeleteOperation( IReadOnlyModificationCommand command, int commandPosition, out bool requiresTransaction) + => AppendDeleteReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + + /// + /// Appends SQL for deleting a row to the commands being built, via a DELETE containing a RETURNING clause for concurrency checking. + /// + /// 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. + protected virtual ResultSetMapping AppendDeleteReturningOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -202,33 +249,6 @@ protected virtual void AppendDeleteCommand( commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator); } - /// - /// Appends a SQL command for selecting affected data. - /// - /// The builder to which the SQL should be appended. - /// The name of the table. - /// The table schema, or to use the default schema. - /// The operations representing the data to be read. - /// The operations used to generate the WHERE clause for the select. - /// The ordinal of the command for which rows affected it being returned. - /// The for this command. - protected virtual ResultSetMapping AppendSelectAffectedCommand( - StringBuilder commandStringBuilder, - string name, - string? schema, - IReadOnlyList readOperations, - IReadOnlyList conditionOperations, - int commandPosition) - { - AppendSelectCommandHeader(commandStringBuilder, readOperations); - AppendFromClause(commandStringBuilder, name, schema); - AppendWhereAffectedClause(commandStringBuilder, conditionOperations); - commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator) - .AppendLine(); - - return ResultSetMapping.LastInResultSet; - } - /// /// Appends a SQL fragment for starting an INSERT. /// @@ -307,38 +327,6 @@ protected virtual void AppendUpdateCommandHeader( }); } - /// - /// Appends a SQL fragment for starting a SELECT. - /// - /// The builder to which the SQL should be appended. - /// The operations representing the data to be read. - protected virtual void AppendSelectCommandHeader( - StringBuilder commandStringBuilder, - IReadOnlyList operations) - => commandStringBuilder - .Append("SELECT ") - .AppendJoin( - operations, - SqlGenerationHelper, - (sb, o, helper) => helper.DelimitIdentifier(sb, o.ColumnName)); - - /// - /// Appends a SQL fragment for starting a FROM clause. - /// - /// The builder to which the SQL should be appended. - /// The name of the table. - /// The table schema, or to use the default schema. - protected virtual void AppendFromClause( - StringBuilder commandStringBuilder, - string name, - string? schema) - { - commandStringBuilder - .AppendLine() - .Append("FROM "); - SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, name, schema); - } - /// /// Appends a SQL fragment for a VALUES. /// @@ -446,65 +434,6 @@ protected virtual void AppendWhereClause( } } - /// - /// Appends a WHERE clause involving rows affected. - /// - /// The builder to which the SQL should be appended. - /// The operations from which to build the conditions. - protected virtual void AppendWhereAffectedClause( - StringBuilder commandStringBuilder, - IReadOnlyList operations) - { - commandStringBuilder - .AppendLine() - .Append("WHERE "); - - AppendRowsAffectedWhereCondition(commandStringBuilder, 1); - - if (operations.Count > 0) - { - commandStringBuilder - .Append(" AND ") - .AppendJoin( - operations, (sb, v) => - { - if (v.IsKey) - { - if (!v.IsRead) - { - AppendWhereCondition(sb, v, v.UseOriginalValueParameter); - return true; - } - } - - if (IsIdentityOperation(v)) - { - AppendIdentityWhereCondition(sb, v); - return true; - } - - return false; - }, " AND "); - } - } - - /// - /// Returns a value indicating whether the given modification represents an auto-incrementing column. - /// - /// The column modification. - /// if the given modification represents an auto-incrementing column. - protected virtual bool IsIdentityOperation(IColumnModification modification) - => modification.IsKey && modification.IsRead; - - /// - /// Appends a WHERE condition checking rows affected. - /// - /// The builder to which the SQL should be appended. - /// The expected number of rows affected. - protected abstract void AppendRowsAffectedWhereCondition( - StringBuilder commandStringBuilder, - int expectedRowsAffected); - /// /// Appends a WHERE condition for the given column. /// @@ -545,15 +474,6 @@ protected virtual void AppendWhereCondition( } } - /// - /// Appends a WHERE condition for the identity (i.e. key value) of the given column. - /// - /// The builder to which the SQL should be appended. - /// The column for which the condition is being generated. - protected abstract void AppendIdentityWhereCondition( - StringBuilder commandStringBuilder, - IColumnModification columnModification); - /// /// Appends SQL text that defines the start of a batch. /// @@ -596,7 +516,15 @@ public virtual void AppendNextSequenceValueOperation(StringBuilder commandString SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, name, schema); } - private static void AppendSqlLiteral( + /// + /// Appends the literal value for to the command being built by + /// . + /// + /// The builder to which the SQL fragment should be appended. + /// The column modification whose literal should get appended. + /// The table name of the column, used when an exception is thrown. + /// The schema of the column, used when an exception is thrown. + protected static void AppendSqlLiteral( StringBuilder commandStringBuilder, IColumnModification modification, string? tableName, diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs index c633fc85db5..c5c3624ee25 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs @@ -12,7 +12,7 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Update.Internal; /// 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 class SqlServerUpdateSqlGenerator : UpdateSqlGenerator, ISqlServerUpdateSqlGenerator +public class SqlServerUpdateSqlGenerator : SelectingUpdateSqlGenerator, ISqlServerUpdateSqlGenerator { /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -49,7 +49,7 @@ public override ResultSetMapping AppendInsertOperation( // (without INTO), which is also the default behavior, doesn't require a transaction and is the most efficient. if (command.ColumnModifications.All(o => !o.IsRead) || !HasAnyTriggers(command)) { - return base.AppendInsertOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); + return AppendInsertReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } // SQL Server doesn't allow INSERT ... OUTPUT on tables with triggers. @@ -107,33 +107,9 @@ public override ResultSetMapping AppendUpdateOperation( { // We normally do a simple UPDATE with an OUTPUT clause (either for the generated columns, or for "1" for concurrency checking). // However, if there are triggers defined, OUTPUT (without INTO) is not supported, so we do UPDATE+SELECT. - if (!HasAnyTriggers(command)) - { - return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); - } - - var name = command.TableName; - var schema = command.Schema; - var operations = command.ColumnModifications; - - var writeOperations = operations.Where(o => o.IsWrite).ToList(); - var conditionOperations = operations.Where(o => o.IsCondition).ToList(); - var readOperations = operations.Where(o => o.IsRead).ToList(); - - AppendUpdateCommand(commandStringBuilder, name, schema, writeOperations, Array.Empty(), conditionOperations); - - if (readOperations.Count > 0) - { - 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); + return HasAnyTriggers(command) + ? AppendUpdateAndSelectOperation(commandStringBuilder, command, commandPosition, out requiresTransaction) + : AppendUpdateReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } /// @@ -172,22 +148,9 @@ public override ResultSetMapping AppendDeleteOperation( { // We normally do a simple DELETE, with an OUTPUT clause emitting "1" for concurrency checking. // However, if there are triggers defined, OUTPUT (without INTO) is not supported, so we do UPDATE+SELECT. - if (!HasAnyTriggers(command)) - { - return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); - } - - var name = command.TableName; - var schema = command.Schema; - var operations = command.ColumnModifications; - - var conditionOperations = operations.Where(o => o.IsCondition).ToList(); - - requiresTransaction = false; - - AppendDeleteCommand(commandStringBuilder, name, schema, Array.Empty(), conditionOperations); - - return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); + return HasAnyTriggers(command) + ? AppendDeleteAndSelectOperation(commandStringBuilder, command, commandPosition, out requiresTransaction) + : AppendDeleteReturningOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } /// @@ -352,35 +315,6 @@ public virtual ResultSetMapping AppendBulkInsertOperation( out 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. - /// - protected virtual ResultSetMapping AppendInsertAndSelectOperations( - StringBuilder commandStringBuilder, - IReadOnlyModificationCommand command, - int commandPosition, - out bool requiresTransaction) - { - var name = command.TableName; - var schema = command.Schema; - var operations = command.ColumnModifications; - - var writeOperations = operations.Where(o => o.IsWrite).ToList(); - var readOperations = operations.Where(o => o.IsRead).ToList(); - var keyOperations = operations.Where(o => o.IsKey).ToList(); - - Check.DebugAssert(readOperations.Count > 0, "AppendInsertAndSelectOperations called without any read operations"); - - requiresTransaction = true; - - AppendInsertCommand(commandStringBuilder, name, schema, writeOperations, readOperations: Array.Empty()); - - return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition); - } - private ResultSetMapping AppendInsertMultipleRows( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, @@ -845,7 +779,7 @@ private ResultSetMapping AppendSelectCommand( /// 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 virtual ResultSetMapping AppendSelectAffectedCountCommand( + protected override ResultSetMapping AppendSelectAffectedCountCommand( StringBuilder commandStringBuilder, string name, string? schema,