Skip to content

Commit 9e9c44d

Browse files
committed
"Scaffold" triggers for SQL Server
HasTrigger with trigger name only, to make the SQL Server SaveChanges work out of the box. Closes #28185
1 parent 335bddf commit 9e9c44d

File tree

6 files changed

+206
-41
lines changed

6 files changed

+206
-41
lines changed

src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,16 @@ protected virtual ModelBuilder VisitTables(ModelBuilder modelBuilder, ICollectio
337337
VisitUniqueConstraints(builder, table.UniqueConstraints);
338338
VisitIndexes(builder, table.Indexes);
339339

340+
if (table.FindAnnotation(RelationalAnnotationNames.Triggers) is { Value: HashSet<string> triggers })
341+
{
342+
foreach (var triggerName in triggers)
343+
{
344+
builder.ToTable(table.Name, table.Schema, tb => tb.HasTrigger(triggerName));
345+
}
346+
347+
table.RemoveAnnotation(RelationalAnnotationNames.Triggers);
348+
}
349+
340350
builder.Metadata.AddAnnotations(table.GetAnnotations());
341351

342352
return builder;

src/EFCore.Relational/Design/AnnotationCodeGenerator.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ public class AnnotationCodeGenerator : IAnnotationCodeGenerator
2828
private static readonly ISet<string> IgnoredRelationalAnnotations = new HashSet<string>
2929
{
3030
RelationalAnnotationNames.CheckConstraints,
31-
RelationalAnnotationNames.Triggers,
3231
RelationalAnnotationNames.Sequences,
3332
RelationalAnnotationNames.DbFunctions,
3433
RelationalAnnotationNames.MappingFragments,
@@ -61,6 +60,13 @@ private static readonly MethodInfo EntityTypeUseTptMappingStrategyMethodInfo
6160
= typeof(RelationalEntityTypeBuilderExtensions).GetRuntimeMethod(
6261
nameof(RelationalEntityTypeBuilderExtensions.UseTptMappingStrategy), new[] { typeof(EntityTypeBuilder) })!;
6362

63+
private static readonly MethodInfo EntityTypeToTableMethodInfo
64+
= typeof(RelationalEntityTypeBuilderExtensions).GetRuntimeMethod(
65+
nameof(RelationalEntityTypeBuilderExtensions.ToTable), new[] { typeof(EntityTypeBuilder), typeof(string) })!;
66+
67+
private static readonly MethodInfo TableHasTriggerMethodInfo
68+
= typeof(TableBuilder).GetRuntimeMethod(nameof(TableBuilder.HasTrigger), new[] { typeof(string) })!;
69+
6470
private static readonly MethodInfo PropertyHasColumnNameMethodInfo
6571
= typeof(RelationalPropertyBuilderExtensions).GetRuntimeMethod(
6672
nameof(RelationalPropertyBuilderExtensions.HasColumnName), new[] { typeof(PropertyBuilder), typeof(string) })!;
@@ -239,6 +245,18 @@ public virtual IReadOnlyList<MethodCallCodeFragment> GenerateFluentApiCalls(
239245
}
240246
}
241247

248+
if (TryGetAndRemove(annotations, RelationalAnnotationNames.Triggers, out IDictionary<string, ITrigger>? triggers))
249+
{
250+
// ToTable(tb => tb.HasTrigger("Trigger1"))
251+
methodCallCodeFragments.Add(
252+
new MethodCallCodeFragment(
253+
EntityTypeToTableMethodInfo,
254+
new NestedClosureCodeFragment(
255+
"tb", triggers.Values
256+
.Select(t => new MethodCallCodeFragment(TableHasTriggerMethodInfo, t.Name))
257+
.ToList())));
258+
}
259+
242260
methodCallCodeFragments.AddRange(GenerateFluentApiCallsHelper(entityType, annotations, GenerateFluentApi));
243261

244262
return methodCallCodeFragments;

src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ FROM [sys].[views] AS [v]
648648
GetColumns(connection, tables, filter, viewFilter, typeAliases, databaseCollation);
649649
GetIndexes(connection, tables, filter);
650650
GetForeignKeys(connection, tables, filter);
651+
GetTriggers(connection, tables, filter);
651652

652653
foreach (var table in tables)
653654
{
@@ -1295,6 +1296,48 @@ FROM [sys].[foreign_keys] AS [f]
12951296
}
12961297
}
12971298

1299+
private void GetTriggers(DbConnection connection, IReadOnlyList<DatabaseTable> tables, string tableFilter)
1300+
{
1301+
using var command = connection.CreateCommand();
1302+
command.CommandText = @"
1303+
SELECT
1304+
SCHEMA_NAME([t].[schema_id]) AS [table_schema],
1305+
[t].[name] AS [table_name],
1306+
[tr].[name] AS [trigger_name]
1307+
FROM [sys].[triggers] AS [tr]
1308+
JOIN [sys].[tables] AS [t] ON [tr].[parent_id] = [t].[object_id]
1309+
WHERE "
1310+
+ tableFilter
1311+
+ @"
1312+
ORDER BY [table_schema], [table_name], [tr].[name]";
1313+
1314+
using var reader = command.ExecuteReader();
1315+
var tableGroups = reader.Cast<DbDataRecord>()
1316+
.GroupBy(
1317+
ddr => (tableSchema: ddr.GetValueOrDefault<string>("table_schema"),
1318+
tableName: ddr.GetFieldValue<string>("table_name")));
1319+
1320+
foreach (var tableGroup in tableGroups)
1321+
{
1322+
var tableSchema = tableGroup.Key.tableSchema;
1323+
var tableName = tableGroup.Key.tableName;
1324+
1325+
var table = tables.Single(t => t.Schema == tableSchema && t.Name == tableName);
1326+
1327+
var triggers = new HashSet<string>();
1328+
table[RelationalAnnotationNames.Triggers] = triggers;
1329+
1330+
foreach (var triggerRecord in tableGroup)
1331+
{
1332+
var triggerName = triggerRecord.GetFieldValue<string>("trigger_name");
1333+
1334+
// We don't actually scaffold anything beyond the fact that there's a trigger with a given name.
1335+
// This is to modify the SaveChanges logic to not use OUTPUT without INTO, which is incompatible with triggers.
1336+
triggers.Add(triggerName);
1337+
}
1338+
}
1339+
}
1340+
12981341
private bool SupportsTemporalTable()
12991342
=> _compatibilityLevel >= 130 && _engineEdition != 6;
13001343

test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Microsoft.EntityFrameworkCore.Scaffolding;
1313

14+
#nullable enable
15+
1416
public class SqlServerDatabaseModelFactoryTest : IClassFixture<SqlServerDatabaseModelFactoryTest.SqlServerDatabaseModelFixture>
1517
{
1618
protected SqlServerDatabaseModelFixture Fixture { get; }
@@ -633,7 +635,7 @@ CONSTRAINT [PK_Blogs] PRIMARY KEY NONCLUSTERED ([Id])
633635
var table = Assert.Single(dbModel.Tables.Where(t => t.Name == "Blogs"));
634636

635637
// ReSharper disable once PossibleNullReferenceException
636-
Assert.True((bool)table[SqlServerAnnotationNames.MemoryOptimized]);
638+
Assert.True((bool)table[SqlServerAnnotationNames.MemoryOptimized]!);
637639
},
638640
"DROP TABLE [Blogs]");
639641

@@ -720,7 +722,7 @@ Id int PRIMARY KEY
720722
{
721723
var pk = dbModel.Tables.Single().PrimaryKey;
722724

723-
Assert.Equal("dbo", pk.Table.Schema);
725+
Assert.Equal("dbo", pk!.Table!.Schema);
724726
Assert.Equal("PrimaryKeyTable", pk.Table.Name);
725727
Assert.StartsWith("PK__PrimaryK", pk.Name);
726728
Assert.Null(pk[SqlServerAnnotationNames.Clustered]);
@@ -778,7 +780,7 @@ CREATE TABLE IndexTable (
778780
Assert.All(
779781
table.Indexes, c =>
780782
{
781-
Assert.Equal("dbo", c.Table.Schema);
783+
Assert.Equal("dbo", c.Table!.Schema);
782784
Assert.Equal("IndexTable", c.Table.Name);
783785
});
784786

@@ -808,7 +810,7 @@ IndexProperty int
808810
Assert.All(
809811
table.Indexes, c =>
810812
{
811-
Assert.Equal("dbo", c.Table.Schema);
813+
Assert.Equal("dbo", c.Table!.Schema);
812814
Assert.Equal("IndexTable", c.Table.Name);
813815
});
814816

@@ -880,6 +882,45 @@ FOREIGN KEY (Id) REFERENCES PrincipalTable(Id) ON DELETE NO ACTION,
880882
DROP TABLE FirstDependent;
881883
DROP TABLE PrincipalTable;");
882884

885+
[ConditionalFact]
886+
public void Triggers()
887+
=> Test(
888+
new[] {
889+
@"
890+
CREATE TABLE SomeTable (
891+
Id int IDENTITY PRIMARY KEY,
892+
Foo int,
893+
Bar int,
894+
Baz int
895+
);",
896+
@"
897+
CREATE TRIGGER Trigger1
898+
ON SomeTable
899+
AFTER INSERT AS
900+
BEGIN
901+
UPDATE SomeTable SET Bar=Foo WHERE Id IN (SELECT INSERTED.Id FROM INSERTED);
902+
END;",
903+
@"
904+
CREATE TRIGGER Trigger2
905+
ON SomeTable
906+
AFTER INSERT AS
907+
BEGIN
908+
UPDATE SomeTable SET Baz=Foo WHERE Id IN (SELECT INSERTED.Id FROM INSERTED);
909+
END;" },
910+
Enumerable.Empty<string>(),
911+
Enumerable.Empty<string>(),
912+
dbModel =>
913+
{
914+
var table = dbModel.Tables.Single();
915+
var triggers = (HashSet<string>)table[RelationalAnnotationNames.Triggers]!;
916+
917+
Assert.Collection(triggers.OrderBy(t => t),
918+
t => Assert.Equal("Trigger1", t),
919+
t => Assert.Equal("Trigger2", t));
920+
921+
},
922+
"DROP TABLE SomeTable;");
923+
883924
#endregion
884925

885926
#region ColumnFacets
@@ -1480,7 +1521,7 @@ CREATE TABLE RowVersionTable (
14801521
{
14811522
var columns = dbModel.Tables.Single().Columns;
14821523

1483-
Assert.True((bool)columns.Single(c => c.Name == "rowversionColumn")[ScaffoldingAnnotationNames.ConcurrencyToken]);
1524+
Assert.True((bool)columns.Single(c => c.Name == "rowversionColumn")[ScaffoldingAnnotationNames.ConcurrencyToken]!);
14841525
},
14851526
"DROP TABLE RowVersionTable;");
14861527

@@ -1539,7 +1580,7 @@ NonSparse nvarchar(max) NULL
15391580
{
15401581
var columns = dbModel.Tables.Single().Columns;
15411582

1542-
Assert.True((bool)columns.Single(c => c.Name == "Sparse")[SqlServerAnnotationNames.Sparse]);
1583+
Assert.True((bool)columns.Single(c => c.Name == "Sparse")[SqlServerAnnotationNames.Sparse]!);
15431584
Assert.Null(columns.Single(c => c.Name == "NonSparse")[SqlServerAnnotationNames.Sparse]);
15441585
},
15451586
"DROP TABLE ColumnsWithSparseness;");
@@ -1633,7 +1674,7 @@ PRIMARY KEY (Id2, Id1)
16331674
{
16341675
var pk = dbModel.Tables.Single().PrimaryKey;
16351676

1636-
Assert.Equal("dbo", pk.Table.Schema);
1677+
Assert.Equal("dbo", pk!.Table!.Schema);
16371678
Assert.Equal("CompositePrimaryKeyTable", pk.Table.Name);
16381679
Assert.StartsWith("PK__Composit", pk.Name);
16391680
Assert.Equal(
@@ -1655,10 +1696,10 @@ CREATE TABLE NonClusteredPrimaryKeyTable (
16551696
{
16561697
var pk = dbModel.Tables.Single().PrimaryKey;
16571698

1658-
Assert.Equal("dbo", pk.Table.Schema);
1699+
Assert.Equal("dbo", pk!.Table!.Schema);
16591700
Assert.Equal("NonClusteredPrimaryKeyTable", pk.Table.Name);
16601701
Assert.StartsWith("PK__NonClust", pk.Name);
1661-
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]);
1702+
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]!);
16621703
Assert.Equal(
16631704
new List<string> { "Id1" }, pk.Columns.Select(ic => ic.Name).ToList());
16641705
},
@@ -1680,10 +1721,10 @@ CREATE TABLE NonClusteredPrimaryKeyTableWithClusteredIndex (
16801721
{
16811722
var pk = dbModel.Tables.Single().PrimaryKey;
16821723

1683-
Assert.Equal("dbo", pk.Table.Schema);
1724+
Assert.Equal("dbo", pk!.Table!.Schema);
16841725
Assert.Equal("NonClusteredPrimaryKeyTableWithClusteredIndex", pk.Table.Name);
16851726
Assert.StartsWith("PK__NonClust", pk.Name);
1686-
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]);
1727+
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]!);
16871728
Assert.Equal(
16881729
new List<string> { "Id1" }, pk.Columns.Select(ic => ic.Name).ToList());
16891730
},
@@ -1704,10 +1745,10 @@ CONSTRAINT UK_Clustered UNIQUE CLUSTERED ( Id2 ),
17041745
{
17051746
var pk = dbModel.Tables.Single().PrimaryKey;
17061747

1707-
Assert.Equal("dbo", pk.Table.Schema);
1748+
Assert.Equal("dbo", pk!.Table!.Schema);
17081749
Assert.Equal("NonClusteredPrimaryKeyTableWithClusteredConstraint", pk.Table.Name);
17091750
Assert.StartsWith("PK__NonClust", pk.Name);
1710-
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]);
1751+
Assert.False((bool)pk[SqlServerAnnotationNames.Clustered]!);
17111752
Assert.Equal(
17121753
new List<string> { "Id1" }, pk.Columns.Select(ic => ic.Name).ToList());
17131754
},
@@ -1728,7 +1769,7 @@ CONSTRAINT MyPK PRIMARY KEY ( Id2 ),
17281769
{
17291770
var pk = dbModel.Tables.Single().PrimaryKey;
17301771

1731-
Assert.Equal("dbo", pk.Table.Schema);
1772+
Assert.Equal("dbo", pk!.Table!.Schema);
17321773
Assert.Equal("PrimaryKeyName", pk.Table.Name);
17331774
Assert.StartsWith("MyPK", pk.Name);
17341775
Assert.Null(pk[SqlServerAnnotationNames.Clustered]);
@@ -1783,7 +1824,7 @@ CREATE TABLE ClusteredUniqueConstraintTable (
17831824
Assert.Equal("dbo", uniqueConstraint.Table.Schema);
17841825
Assert.Equal("ClusteredUniqueConstraintTable", uniqueConstraint.Table.Name);
17851826
Assert.StartsWith("UQ__Clustere", uniqueConstraint.Name);
1786-
Assert.True((bool)uniqueConstraint[SqlServerAnnotationNames.Clustered]);
1827+
Assert.True((bool)uniqueConstraint[SqlServerAnnotationNames.Clustered]!);
17871828
Assert.Equal(
17881829
new List<string> { "Id2" }, uniqueConstraint.Columns.Select(ic => ic.Name).ToList());
17891830
},
@@ -1834,7 +1875,7 @@ CREATE TABLE CompositeIndexTable (
18341875
var index = Assert.Single(dbModel.Tables.Single().Indexes);
18351876

18361877
// ReSharper disable once PossibleNullReferenceException
1837-
Assert.Equal("dbo", index.Table.Schema);
1878+
Assert.Equal("dbo", index.Table!.Schema);
18381879
Assert.Equal("CompositeIndexTable", index.Table.Name);
18391880
Assert.Equal("IX_COMPOSITE", index.Name);
18401881
Assert.Equal(
@@ -1859,10 +1900,10 @@ CREATE TABLE ClusteredIndexTable (
18591900
var index = Assert.Single(dbModel.Tables.Single().Indexes);
18601901

18611902
// ReSharper disable once PossibleNullReferenceException
1862-
Assert.Equal("dbo", index.Table.Schema);
1903+
Assert.Equal("dbo", index.Table!.Schema);
18631904
Assert.Equal("ClusteredIndexTable", index.Table.Name);
18641905
Assert.Equal("IX_CLUSTERED", index.Name);
1865-
Assert.True((bool)index[SqlServerAnnotationNames.Clustered]);
1906+
Assert.True((bool)index[SqlServerAnnotationNames.Clustered]!);
18661907
Assert.Equal(
18671908
new List<string> { "Id2" }, index.Columns.Select(ic => ic.Name).ToList());
18681909
},
@@ -1885,7 +1926,7 @@ CREATE TABLE UniqueIndexTable (
18851926
var index = Assert.Single(dbModel.Tables.Single().Indexes);
18861927

18871928
// ReSharper disable once PossibleNullReferenceException
1888-
Assert.Equal("dbo", index.Table.Schema);
1929+
Assert.Equal("dbo", index.Table!.Schema);
18891930
Assert.Equal("UniqueIndexTable", index.Table.Name);
18901931
Assert.Equal("IX_UNIQUE", index.Name);
18911932
Assert.True(index.IsUnique);
@@ -1912,7 +1953,7 @@ CREATE TABLE FilteredIndexTable (
19121953
var index = Assert.Single(dbModel.Tables.Single().Indexes);
19131954

19141955
// ReSharper disable once PossibleNullReferenceException
1915-
Assert.Equal("dbo", index.Table.Schema);
1956+
Assert.Equal("dbo", index.Table!.Schema);
19161957
Assert.Equal("FilteredIndexTable", index.Table.Name);
19171958
Assert.Equal("IX_UNIQUE", index.Name);
19181959
Assert.Equal("([Id2]>(10))", index.Filter);
@@ -2368,13 +2409,26 @@ public void No_warning_missing_view_definition()
23682409
#endregion
23692410

23702411
private void Test(
2371-
string createSql,
2412+
string? createSql,
2413+
IEnumerable<string> tables,
2414+
IEnumerable<string> schemas,
2415+
Action<DatabaseModel> asserter,
2416+
string? cleanupSql)
2417+
=> Test(
2418+
string.IsNullOrEmpty(createSql) ? Array.Empty<string>() : new[] { createSql },
2419+
tables,
2420+
schemas,
2421+
asserter,
2422+
cleanupSql);
2423+
2424+
private void Test(
2425+
string[] createSqls,
23722426
IEnumerable<string> tables,
23732427
IEnumerable<string> schemas,
23742428
Action<DatabaseModel> asserter,
2375-
string cleanupSql)
2429+
string? cleanupSql)
23762430
{
2377-
if (!string.IsNullOrEmpty(createSql))
2431+
foreach (var createSql in createSqls)
23782432
{
23792433
Fixture.TestStore.ExecuteNonQuery(createSql);
23802434
}

test/EFCore.SqlServer.FunctionalTests/SqlServerQueryTriggersTest.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
5-
64
// ReSharper disable InconsistentNaming
75
namespace Microsoft.EntityFrameworkCore;
86

0 commit comments

Comments
 (0)