Skip to content

Commit 8f95f80

Browse files
committed
Allow opting out of RETURNING/OUTPUT clauses in SaveChanges
Fixes dotnet#29916
1 parent 16c1380 commit 8f95f80

File tree

35 files changed

+1073
-147
lines changed

35 files changed

+1073
-147
lines changed

src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -1458,8 +1458,7 @@ public static bool IsTableExcludedFromMigrations(this IReadOnlyEntityType entity
14581458
}
14591459

14601460
var ownership = entityType.FindOwnership();
1461-
if (ownership != null
1462-
&& ownership.IsUnique)
1461+
if (ownership is { IsUnique: true })
14631462
{
14641463
return ownership.PrincipalEntityType.IsTableExcludedFromMigrations();
14651464
}

src/EFCore.SqlServer/Extensions/SqlServerEntityTypeExtensions.cs

+56
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,60 @@ public static void SetHistoryTableSchema(this IMutableEntityType entityType, str
271271
/// <returns>The configuration source for the temporal history table schema setting.</returns>
272272
public static ConfigurationSource? GetHistoryTableSchemaConfigurationSource(this IConventionEntityType entityType)
273273
=> entityType.FindAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema)?.GetConfigurationSource();
274+
275+
/// <summary>
276+
/// Returns a value indicating whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is
277+
/// incompatible with certain SQL Server features, such as tables with triggers.
278+
/// </summary>
279+
/// <param name="entityType">The entity type.</param>
280+
/// <returns><see langword="true" /> if the SQL OUTPUT clause is used to save changes to the table.</returns>
281+
public static bool GetIsSqlOutputClauseUsed(this IReadOnlyEntityType entityType)
282+
{
283+
if (entityType.FindAnnotation(SqlServerAnnotationNames.UseSqlOutputClause) is { Value: bool useSqlOutputClause } )
284+
{
285+
return useSqlOutputClause;
286+
}
287+
288+
if (entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy
289+
&& entityType.BaseType is not null)
290+
{
291+
return entityType.GetRootType().GetIsSqlOutputClauseUsed();
292+
}
293+
294+
return true;
295+
}
296+
297+
/// <summary>
298+
/// Sets a value indicating whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible
299+
/// with certain SQL Server features, such as tables with triggers.
300+
/// </summary>
301+
/// <param name="entityType">The entity type.</param>
302+
/// <param name="useSqlOutputClause">The value to set.</param>
303+
public static void UseSqlOutputClause(this IMutableEntityType entityType, bool? useSqlOutputClause)
304+
=> entityType.SetOrRemoveAnnotation(SqlServerAnnotationNames.UseSqlOutputClause, useSqlOutputClause);
305+
306+
/// <summary>
307+
/// Sets a value indicating whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible
308+
/// with certain SQL Server features, such as tables with triggers.
309+
/// </summary>
310+
/// <param name="entityType">The entity type.</param>
311+
/// <param name="useSqlOutputClause">The value to set.</param>
312+
/// <param name="fromDataAnnotation">Indicates whether the configuration was specified using a data annotation.</param>
313+
/// <returns>The configured value.</returns>
314+
public static bool? UseSqlOutputClause(
315+
this IConventionEntityType entityType,
316+
bool? useSqlOutputClause,
317+
bool fromDataAnnotation = false)
318+
=> (bool?)entityType.SetOrRemoveAnnotation(
319+
SqlServerAnnotationNames.UseSqlOutputClause,
320+
useSqlOutputClause,
321+
fromDataAnnotation)?.Value;
322+
323+
/// <summary>
324+
/// Gets the configuration source for whether to use the SQL OUTPUT clause when saving changes to the table.
325+
/// </summary>
326+
/// <param name="entityType">The entity type.</param>
327+
/// <returns>The configuration source for the memory-optimized setting.</returns>
328+
public static ConfigurationSource? GetUseSqlOutputClauseConfigurationSource(this IConventionEntityType entityType)
329+
=> entityType.FindAnnotation(SqlServerAnnotationNames.UseSqlOutputClause)?.GetConfigurationSource();
274330
}

src/EFCore.SqlServer/Extensions/SqlServerTableBuilderExtensions.cs

+98
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ namespace Microsoft.EntityFrameworkCore;
1010
/// </summary>
1111
public static class SqlServerTableBuilderExtensions
1212
{
13+
#region IsTemporal
14+
1315
/// <summary>
1416
/// Configures the table as temporal.
1517
/// </summary>
@@ -183,6 +185,10 @@ public static OwnedNavigationTableBuilder<TOwnerEntity, TDependentEntity> IsTemp
183185
return tableBuilder;
184186
}
185187

188+
#endregion IsTemporal
189+
190+
#region IsMemoryOptimized
191+
186192
/// <summary>
187193
/// Configures the table that the entity maps to when targeting SQL Server as memory-optimized.
188194
/// </summary>
@@ -264,4 +270,96 @@ public static OwnedNavigationTableBuilder<TOwnerEntity, TDependentEntity> IsMemo
264270

265271
return tableBuilder;
266272
}
273+
274+
#endregion IsMemoryOptimized
275+
276+
#region UseSqlOutputClause
277+
278+
/// <summary>
279+
/// Configures whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible with
280+
/// certain SQL Server features, such as tables with triggers.
281+
/// </summary>
282+
/// <remarks>
283+
/// See <see href="https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause">Using the SQL OUTPUT clause with SQL Server</see>
284+
/// for more information and examples.
285+
/// </remarks>
286+
/// <param name="tableBuilder">The builder for the table being configured.</param>
287+
/// <param name="useSqlOutputClause">A value indicating whether to use the OUTPUT clause when saving changes to the table.</param>
288+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
289+
public static TableBuilder UseSqlOutputClause(
290+
this TableBuilder tableBuilder,
291+
bool useSqlOutputClause = true)
292+
{
293+
tableBuilder.Metadata.UseSqlOutputClause(useSqlOutputClause);
294+
295+
return tableBuilder;
296+
}
297+
298+
/// <summary>
299+
/// Configures whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible with
300+
/// certain SQL Server features, such as tables with triggers.
301+
/// </summary>
302+
/// <remarks>
303+
/// See <see href="https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause">Using the SQL OUTPUT clause with SQL Server</see>
304+
/// for more information and examples.
305+
/// </remarks>
306+
/// <typeparam name="TEntity">The entity type being configured.</typeparam>
307+
/// <param name="tableBuilder">The builder for the table being configured.</param>
308+
/// <param name="useSqlOutputClause">A value indicating whether to use the OUTPUT clause when saving changes to the table.</param>
309+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
310+
public static TableBuilder<TEntity> UseSqlOutputClause<TEntity>(
311+
this TableBuilder<TEntity> tableBuilder,
312+
bool useSqlOutputClause = true)
313+
where TEntity : class
314+
{
315+
tableBuilder.Metadata.UseSqlOutputClause(useSqlOutputClause);
316+
317+
return tableBuilder;
318+
}
319+
320+
/// <summary>
321+
/// Configures whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible with
322+
/// certain SQL Server features, such as tables with triggers.
323+
/// </summary>
324+
/// <remarks>
325+
/// See <see href="https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause">Using the SQL OUTPUT clause with SQL Server</see>
326+
/// for more information and examples.
327+
/// </remarks>
328+
/// <param name="tableBuilder">The builder for the table being configured.</param>
329+
/// <param name="useSqlOutputClause">A value indicating whether to use the OUTPUT clause when saving changes to the table.</param>
330+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
331+
public static OwnedNavigationTableBuilder UseSqlOutputClause(
332+
this OwnedNavigationTableBuilder tableBuilder,
333+
bool useSqlOutputClause = true)
334+
{
335+
tableBuilder.Metadata.UseSqlOutputClause(useSqlOutputClause);
336+
337+
return tableBuilder;
338+
}
339+
340+
/// <summary>
341+
/// Configures whether to use the SQL OUTPUT clause when saving changes to the table. The OUTPUT clause is incompatible with
342+
/// certain SQL Server features, such as tables with triggers.
343+
/// </summary>
344+
/// <remarks>
345+
/// See <see href="https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause">Using the SQL OUTPUT clause with SQL Server</see>
346+
/// for more information and examples.
347+
/// </remarks>
348+
/// <typeparam name="TOwnerEntity">The entity type owning the relationship.</typeparam>
349+
/// <typeparam name="TDependentEntity">The dependent entity type of the relationship.</typeparam>
350+
/// <param name="tableBuilder">The builder for the table being configured.</param>
351+
/// <param name="useSqlOutputClause">A value indicating whether to use the OUTPUT clause when saving changes to the table.</param>
352+
/// <returns>The same builder instance so that multiple calls can be chained.</returns>
353+
public static OwnedNavigationTableBuilder<TOwnerEntity, TDependentEntity> UseSqlOutputClause<TOwnerEntity, TDependentEntity>(
354+
this OwnedNavigationTableBuilder<TOwnerEntity, TDependentEntity> tableBuilder,
355+
bool useSqlOutputClause = true)
356+
where TOwnerEntity : class
357+
where TDependentEntity : class
358+
{
359+
tableBuilder.Metadata.UseSqlOutputClause(useSqlOutputClause);
360+
361+
return tableBuilder;
362+
}
363+
364+
#endregion UseSqlOutputClause
267365
}

src/EFCore.SqlServer/Metadata/Conventions/SqlServerConventionSetBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public override ConventionSet CreateConventionSet()
5454
conventionSet.Add(new SqlServerIndexConvention(Dependencies, RelationalDependencies, _sqlGenerationHelper));
5555
conventionSet.Add(new SqlServerMemoryOptimizedTablesConvention(Dependencies, RelationalDependencies));
5656
conventionSet.Add(new SqlServerDbFunctionConvention(Dependencies, RelationalDependencies));
57+
conventionSet.Add(new SqlServerOutputClauseConvention(Dependencies, RelationalDependencies));
5758

5859
conventionSet.Replace<CascadeDeleteConvention>(
5960
new SqlServerOnDeleteConvention(Dependencies, RelationalDependencies));

src/EFCore.SqlServer/Metadata/Conventions/SqlServerIndexConvention.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,7 @@ private void SetIndexFilter(IConventionIndexBuilder indexBuilder, bool columnNam
161161
var index = indexBuilder.Metadata;
162162
if (index.IsUnique
163163
&& index.IsClustered() != true
164-
&& GetNullableColumns(index) is List<string> nullableColumns
165-
&& nullableColumns.Count > 0)
164+
&& GetNullableColumns(index) is { Count: > 0 } nullableColumns)
166165
{
167166
if (columnNameChanged
168167
|| index.GetFilter() == null)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
// ReSharper disable once CheckNamespace
5+
namespace Microsoft.EntityFrameworkCore.Metadata.Conventions;
6+
7+
/// <summary>
8+
/// A convention that configures tables with triggers to not use the OUTPUT clause when saving changes.
9+
/// </summary>
10+
/// <remarks>
11+
/// See <see href="https://aka.ms/efcore-docs-conventions">Model building conventions</see>, and
12+
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and SQL Azure databases with EF Core</see>
13+
/// for more information and examples.
14+
/// </remarks>
15+
public class SqlServerOutputClauseConvention
16+
: ITriggerAddedConvention, ITriggerRemovedConvention, IEntityTypeBaseTypeChangedConvention
17+
{
18+
/// <summary>
19+
/// Creates a new instance of <see cref="SqlServerDbFunctionConvention" />.
20+
/// </summary>
21+
/// <param name="dependencies">Parameter object containing dependencies for this convention.</param>
22+
/// <param name="relationalDependencies"> Parameter object containing relational dependencies for this convention.</param>
23+
public SqlServerOutputClauseConvention(
24+
ProviderConventionSetBuilderDependencies dependencies,
25+
RelationalConventionSetBuilderDependencies relationalDependencies)
26+
{
27+
Dependencies = dependencies;
28+
RelationalDependencies = relationalDependencies;
29+
}
30+
31+
/// <summary>
32+
/// Dependencies for this service.
33+
/// </summary>
34+
protected virtual ProviderConventionSetBuilderDependencies Dependencies { get; }
35+
36+
/// <summary>
37+
/// Relational provider-specific dependencies for this service.
38+
/// </summary>
39+
protected virtual RelationalConventionSetBuilderDependencies RelationalDependencies { get; }
40+
41+
/// <inheritdoc />
42+
public virtual void ProcessTriggerAdded(IConventionTriggerBuilder triggerBuilder, IConventionContext<IConventionTriggerBuilder> context)
43+
{
44+
var entityType = triggerBuilder.Metadata.EntityType;
45+
46+
if (entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy)
47+
{
48+
entityType = entityType.GetRootType();
49+
}
50+
51+
entityType.UseSqlOutputClause(false);
52+
}
53+
54+
/// <inheritdoc />
55+
public virtual void ProcessTriggerRemoved(
56+
IConventionEntityTypeBuilder entityTypeBuilder,
57+
IConventionTrigger trigger,
58+
IConventionContext<IConventionTrigger> context)
59+
{
60+
var entityType = entityTypeBuilder.Metadata;
61+
62+
if (entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy
63+
&& entityType.GetRootType() is { } rootEntityType
64+
&& !HasAnyTriggers(rootEntityType))
65+
{
66+
rootEntityType.UseSqlOutputClause(null);
67+
}
68+
else if (!HasAnyTriggers(entityType))
69+
{
70+
entityType.UseSqlOutputClause(null);
71+
}
72+
}
73+
74+
/// <inheritdoc />
75+
public void ProcessEntityTypeBaseTypeChanged(
76+
IConventionEntityTypeBuilder entityTypeBuilder,
77+
IConventionEntityType? newBaseType,
78+
IConventionEntityType? oldBaseType,
79+
IConventionContext<IConventionEntityType> context)
80+
{
81+
var entityType = entityTypeBuilder.Metadata;
82+
83+
if (entityTypeBuilder.Metadata.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy)
84+
{
85+
// Update the old TPH root, removing or adding the setting if triggers are found anywhere in the hierarchy
86+
if (oldBaseType?.GetRootType() is { } oldRootEntityType
87+
&& !oldRootEntityType.GetIsSqlOutputClauseUsed()
88+
&& !HasAnyTriggers(oldRootEntityType))
89+
{
90+
oldRootEntityType.UseSqlOutputClause(null);
91+
}
92+
93+
if (newBaseType?.GetRootType() is { } newRootEntityType
94+
&& newRootEntityType.GetIsSqlOutputClauseUsed()
95+
&& HasAnyTriggers(entityType))
96+
{
97+
newRootEntityType.UseSqlOutputClause(false);
98+
}
99+
}
100+
101+
// If the entity was removed from a TPH hierarchy, we may need to add the setting to the entity itself
102+
if (newBaseType is null)
103+
{
104+
entityType.UseSqlOutputClause(HasAnyTriggers(entityType) ? false : null);
105+
}
106+
}
107+
108+
private bool HasAnyTriggers(IConventionEntityType entityType)
109+
{
110+
if (entityType.GetDeclaredTriggers().Any())
111+
{
112+
return true;
113+
}
114+
115+
if (entityType.GetMappingStrategy() == RelationalAnnotationNames.TphMappingStrategy
116+
&& entityType.GetDerivedTypes().Any(t => t.GetDeclaredTriggers().Any()))
117+
{
118+
return true;
119+
}
120+
121+
return false;
122+
}
123+
}

src/EFCore.SqlServer/Metadata/Conventions/SqlServerValueGenerationConvention.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ public override void ProcessEntityTypeAnnotationChanged(
7171
IConventionAnnotation? oldAnnotation,
7272
IConventionContext<IConventionAnnotation> context)
7373
{
74-
if ((name == SqlServerAnnotationNames.TemporalPeriodStartPropertyName
75-
|| name == SqlServerAnnotationNames.TemporalPeriodEndPropertyName)
74+
if (name is SqlServerAnnotationNames.TemporalPeriodStartPropertyName or SqlServerAnnotationNames.TemporalPeriodEndPropertyName
7675
&& annotation?.Value is string propertyName)
7776
{
7877
var periodProperty = entityTypeBuilder.Metadata.FindProperty(propertyName);

src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs

+8
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,12 @@ public static class SqlServerAnnotationNames
258258
/// doing so can result in application failures when updating to a new Entity Framework Core release.
259259
/// </summary>
260260
public const string ValueGenerationStrategy = Prefix + "ValueGenerationStrategy";
261+
262+
/// <summary>
263+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
264+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
265+
/// any release. You should only use it directly in your code with extreme caution and knowing that
266+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
267+
/// </summary>
268+
public const string UseSqlOutputClause = Prefix + "UseSqlOutputClause";
261269
}

src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/EFCore.SqlServer/Properties/SqlServerStrings.resx

+2-2
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,10 @@
282282
<value>SQL Server does not support releasing a savepoint.</value>
283283
</data>
284284
<data name="SaveChangesFailedBecauseOfComputedColumnWithFunction" xml:space="preserve">
285-
<value>Could not save changes because the target table has computed column with a function that performs data access. Please configure your entity type accordingly, see https://aka.ms/efcore-docs-sqlserver-save-changes-and-computed-columns for more information.</value>
285+
<value>Could not save changes because the target table has computed column with a function that performs data access. Please configure your table accordingly, see https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause for more information.</value>
286286
</data>
287287
<data name="SaveChangesFailedBecauseOfTriggers" xml:space="preserve">
288-
<value>Could not save changes because the target table has database triggers. Please configure your entity type accordingly, see https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers for more information.</value>
288+
<value>Could not save changes because the target table has database triggers. Please configure your table accordingly, see https://aka.ms/efcore-docs-sqlserver-save-changes-and-output-clause for more information.</value>
289289
</data>
290290
<data name="SequenceBadType" xml:space="preserve">
291291
<value>SQL Server sequences cannot be used to generate values for the property '{property}' on entity type '{entityType}' because the property type is '{propertyType}'. Sequences can only be used with integer properties.</value>

0 commit comments

Comments
 (0)