前言
會寫這篇是因為最近開始大量使用 SQL Server Trigger 來維護冗余 (也不清楚這路對不對).
EF Core migrations 沒有支持 Trigger Github Issue, 能找到相關的 Laraue.EfCoreTriggers, 但 star 太少, 不敢用.
於是計划自己實現一個簡單版本符合自己用就好.
更新 09-11-2022: EF Core 7.0 breaking changes 提醒:
EF7 以后, 所有使用 Trigger 的 Table 都需要 Config 聲明哦.
主要參考:
Add support for managing Triggers with EF migration
How to customize migration generation in EF Core Code First?
Design-time DbContext Creation
EF Core Add Migration Debugging
How EF Core Migrations Work?
要搞底層東西, 首先要摸清楚它怎么 work 的.
首先是 build model, 數據庫表結構
然后運行 migrations command
dotnet ef migrations add init
Ef Core Design 會創建出 migrations file (我們熟悉的 Up, Down)
如果想做一些調整, 可以直接修改這個 file. 比如 migrationBuilder.Sql()
然后運行 update database command
dotnet ef database update
Ef Core Design 會依據不同的 SQL Provider 生產出對應的 SQL Command 去 update database.
The Official Way
在了解 migrations 的流程后, 下一步就是要知道如何擴展它.
這一篇就教了如果去寫自己的 Operations 來擴展 Migrations.
首先創建一個 migrationBuilder 擴展方法, 里頭調用 migrationBuilder.Sql("SQL command here...");
然后在 migration file (就是那個 Up Down 的 class) 里調用
呃...這不就是直接修改 migrations file, 寫上 SQL Command 嗎... (也算擴展 ?)
文章里還說到, 要支持多個 SQL Provider 所以必須寫多種 SQL Command.
除了上面這種直接的方法, 文章也給出另一種沒有那么直接的方式
首先做一個 MigrationOperation
然后不直接調用 SQL Command, 只把 operation add 進去 builder
最后通過來擴展 SqlServerMigrationsSqlGenerator 來實現 operations to SQL command.

internal class MyMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator { public MyMigrationsSqlGenerator( MigrationsSqlGeneratorDependencies dependencies, IRelationalAnnotationProvider migrationsAnnotations) : base(dependencies, migrationsAnnotations) { } protected override void Generate( MigrationOperation operation, IModel model, MigrationCommandListBuilder builder) { if (operation is CreateUserOperation createUserOperation) { Generate(createUserOperation, builder); } else { base.Generate(operation, model, builder); } } private void Generate( CreateUserOperation operation, MigrationCommandListBuilder builder) { var sqlHelper = Dependencies.SqlGenerationHelper; var stringMapping = Dependencies.TypeMappingSource.FindMapping(typeof(string)); builder .Append("CREATE USER ") .Append(sqlHelper.DelimitIdentifier(operation.Name)) .Append(" WITH PASSWORD = ") .Append(stringMapping.GenerateSqlLiteral(operation.Password)) .AppendLine(sqlHelper.StatementTerminator) .EndCommand(); } }
記得要把原本的 SqlServerMigrationsSqlGenerator 替換掉哦
實現思路
Official way 並不能解決我們的問題, 我們需要從 modelBuilder 階段開始去寫 Trigger. 然后 generate 出正確的 migration file, 而不是直接修改 migration file.
至於 migration file 里頭是直接寫 SQL Command 或者使用 operation 在交由 SqlServerMigrationsSqlGenerator 去實現 SQL command, 這區別不大.
在參考了 Laraue.EfCoreTriggers 源碼后, 發現它的擴展方式是 IMigrationsModelDiffer.
IMigrationsModelDiffer 是 modelBuilder to migration file 過程中會用到的一個功能. 它會判斷之前和之后的區別, 來生產 migration file.
通過擴展它就可以分析 modelBuilder 的結構, 然后生產 migration file.
modelBuilder 有一個擴展的方式是 AddAnnotation, 可以任意加入 key-value
然后在 IMigrationsModelDiffer 里頭通過識別加入的 Annotation, 就可以修改 migration file, migration file 能擴展的地方是 .Sql()
以上就是 Laraue.EfCoreTriggers 的擴展方式了.
還有一篇 How to customize migration generation in EF Core Code First? 也提到了如何擴展 EF Core Migrations.
答題人正是 MySQL provider for EF Core 的 Lead developer.
5 個步驟,
1. 添加自己的 annotation. (上面講過了. 沒問題)
2. 自定義 MIgrationOperation (Official way 講過了, 沒問題)
3. IMigrationsModelDiffer (和 Laraue.EfCoreTriggers 一樣, 沒問題, 提醒: 這個是 internal class 哦, EF Core 並沒有 public 讓我們擴展的意思, 但也沒有其它的 way 了)
4. ICSharpMigrationOperationGenerator
這個是新東西, 它就是負責把 modelBuilder 做成 migration file 的幕后黑手. 負責 generate C# code, 所以擴展它的話, 幾乎可以完全控制 migration file 里的所有代碼了.
5. SqlServerMigrationsSqlGenerator (Official way 講過了, 沒問題)
小總結
到這里我們搞清楚了幾個重要的東西.
modelBuilder 負責描述數據庫結構, 它可以通過 AddAnnotation key-value 來添加自定義的表述信息. (所以它負責表達而已)
ICSharpMigrationOperationGenerator 負責把 modelBuilder 解析, 然后生產 C# migration file. 間中還會用到 IMigrationsModelDiffer 來對比之前的 model 和之后的 model 哪里不同了.
migration file 里的 C# code 主要就是做一堆的 operation, 我們也可以自定義自己的 C# code 去做 operation (Official way)
最后 migration file 做出的 operations 被 SqlServerMigrationsSqlGenerator (或者其它 Provider 的 generator) 解析編譯成最終的 SQL Command.
逐個測試
我們先過一圈, 感受一下, 最后才決定如何實現 Trigger.
Custom Annotation
modelBuilder.Entity<Color>().HasAnnotation("Trigger", "SQL Command");
IMigrationsModelDiffer
#pragma warning disable EF1001 // Internal EF Core API usage. public class MyMigrationsModelDiffer : Microsoft.EntityFrameworkCore.Migrations.Internal.MigrationsModelDiffer { public MyMigrationsModelDiffer( IRelationalTypeMappingSource typeMappingSource, IMigrationsAnnotationProvider migrationsAnnotationProvider, IRowIdentityMapFactory rowIdentityMapFactory, CommandBatchPreparerDependencies commandBatchPreparerDependencies) : base(typeMappingSource, migrationsAnnotationProvider, rowIdentityMapFactory, commandBatchPreparerDependencies) { } public override IReadOnlyList<MigrationOperation> GetDifferences(IRelationalModel? source, IRelationalModel? target) { var x = source?.GetAnnotations(); var y = target?.GetAnnotations(); return base.GetDifferences(source, target); } } #pragma warning restore EF1001 // Internal EF Core API usage.
還要 ReplaceService 哦
services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer("Server=192.168.1.152;Database=TestEFCore;Trusted_Connection=True;MultipleActiveResultSets=true") .ReplaceService<IMigrationsModelDiffer, MyMigrationsModelDiffer>(); });
ICSharpMigrationOperationGenerator
public class MyCSharpMigrationOperationGenerator : CSharpMigrationOperationGenerator { public MyCSharpMigrationOperationGenerator(CSharpMigrationOperationGeneratorDependencies dependencies) : base(dependencies) { Console.Write("hello world"); } protected override void Generate(CreateTableOperation operation, IndentedStringBuilder builder) { base.Generate(operation, builder); var www = builder.ToString(); } }
這個 C# generator 是在 Design Time 時做的. 它不是用 ReplaceService 而是通過依賴注入去 override 的.
public class MyDesignTimeServices : IDesignTimeServices { public void ConfigureDesignTimeServices(IServiceCollection services) => services.AddSingleton<ICSharpMigrationOperationGenerator, MyCSharpMigrationOperationGenerator>(); }
順便說一下, 如果是做測試 Console App 的話. Design Time 要另外寫 Factory, 參考: Design-time DbContext Creation
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext> { public ApplicationDbContext CreateDbContext(string[] args) { Debugger.Launch(); var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>(); optionsBuilder .UseSqlServer("Server=192.168.1.152;Database=TestEFCore;Trusted_Connection=True;MultipleActiveResultSets=true") .ReplaceService<IMigrationsModelDiffer, MyMigrationsModelDiffer>(); return new ApplicationDbContext(optionsBuilder.Options); } }
注: Debugger.Launch(); 是為了調試用的. 參考: EF Core Add Migration Debugging,
這特調試不是一般的 F5 啟動那種, 而是要調試 ModelDiffer 這種 design time 的代碼, 通常是 cmd dotnet ef migrations add WhateverName 啟動的.
ISqlServerMigrationsSqlGenerator
public class MyMigrationsSqlGenerator : SqlServerMigrationsSqlGenerator { public MyMigrationsSqlGenerator( MigrationsSqlGeneratorDependencies dependencies, IRelationalAnnotationProvider migrationsAnnotations) : base(dependencies, migrationsAnnotations) { } protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder) { base.Generate(operation, model, builder); } }
這個也需要 ReplaceService
services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer("Server=192.168.1.152;Database=TestEFCore;Trusted_Connection=True;MultipleActiveResultSets=true") .ReplaceService<IMigrationsSqlGenerator, MyMigrationsSqlGenerator>() .ReplaceService<IMigrationsModelDiffer, MyMigrationsModelDiffer>(); });
注: 所有 ReplaceService 只能一次哦, 之前在 Library use EF 的時候有講過, 如果是封裝 Library 的話要注意了.
我怎么做?
回到我最初的目的, 想讓 migration 來維護 "我的 Trigger". 就目前看,一個非常完整的方案應該是
定義好 modelBuilder 的擴展. 自定義 C# generator 編輯並調用自定義的 opration 函數, 然后由不同的 SQL Provider 去解析 operation 生成 SQL command.
所以需要 Custom Annotation, IMigrationsModelDiffer, ICSharpMigrationOperationGenerator, ISqlServerMigrationsSqlGenerator, 全部用上.
很顯然我並不會這樣折騰自己...所以最簡單的方式就是像 Laraue.EfCoreTriggers 那樣, 只要 add custom annotation, 然后擴展 IMigrationsModelDiffer 里頭調用 build-in 的 SQL operation 函數
也就是 .Sql() 啦, 這樣就夠我自己用了. 主要參考: MigrationsModelDiffer.cs
實戰
關鍵就在 IMigrationsModelDiffer 如何解析自定義的 annotation.
source 是 previous, target 是 current. 通過各做對比就可以創建出不同的 SqlOperation, 比如 CREATE TRIGGER, DROP TRIGGER 等等.
#pragma warning disable EF1001 // Internal EF Core API usage. public class MyMigrationsModelDiffer : Microsoft.EntityFrameworkCore.Migrations.Internal.MigrationsModelDiffer { public MyMigrationsModelDiffer( IRelationalTypeMappingSource typeMappingSource, IMigrationsAnnotationProvider migrationsAnnotationProvider, IRowIdentityMapFactory rowIdentityMapFactory, CommandBatchPreparerDependencies commandBatchPreparerDependencies) : base(typeMappingSource, migrationsAnnotationProvider, rowIdentityMapFactory, commandBatchPreparerDependencies) { } public override IReadOnlyList<MigrationOperation> GetDifferences(IRelationalModel? source, IRelationalModel? target) { var sourceModel = source?.Model; var targetModel = target?.Model; var oldEntityTypeNames = sourceModel?.GetEntityTypes().Select(x => x.Name) ?? Enumerable.Empty<string>(); var newEntityTypeNames = targetModel?.GetEntityTypes().Select(x => x.Name) ?? Enumerable.Empty<string>(); var commonEntityTypeNames = oldEntityTypeNames.Intersect(newEntityTypeNames); if (targetModel != null) { // modelBuilder.Entity<Product>().Metadata.Model.AddAnnotation("n1", "n1"); var annotations = targetModel.GetAnnotations().Select(a => a.Name); // modelBuilder.Entity<Product>().HasAnnotation("n2", "n2"); var e = targetModel.GetEntityTypes().Single(e => e.Name == "TestEFCore.Product").GetAnnotations().Select(e => e.Name).ToList(); // modelBuilder.Entity<Product>().Property(e => e.Name).HasAnnotation("n3", "n3"); var p = targetModel.GetEntityTypes().Single(e => e.Name == "TestEFCore.Product").GetProperty(nameof(Product.Name)).GetAnnotations().Select(e => e.Name).ToList(); // modelBuilder.Entity<Product>().HasMany(e => e.Colors).WithOne().HasAnnotation("n6", "n6").HasForeignKey(e => e.ProductId).HasAnnotation("n5", "n5") // .OnDelete(DeleteBehavior.Cascade).HasAnnotation("n4", "n4"); var f = targetModel.GetEntityTypes().Single(e => e.Name == "TestEFCore.Product").GetReferencingForeignKeys().Select(k => k.GetAnnotations().Select(e => e.Name)).ToList(); // n4, n5, n6 } IReadOnlyList<MigrationOperation> migrationOperations = base.GetDifferences(source, target); var finalMigrationOperations = migrationOperations.Concat(new List<MigrationOperation> { new SqlOperation { // 要支持 multiple provider 的話參考: Laraue.EfCoreTriggers, 它是在 AddAnnotation 階段就已經 build 好 SQL command 了. Sql = "SQL command here ..." } }).ToList(); return finalMigrationOperations; } } #pragma warning restore EF1001 // Internal EF Core API usage.
目前遇到的局限
想在 IMigrationsModelDiffer | ISqlServerMigrationsSqlGenerator 注入 Service 是做不到的.
因為 EF Core 有 internal 的 provider for 這 2 個 service, 外部是擴展不了的. 或者至少目前是沒有 right way 去做到的.
EF cannot resolve custom IMigrationsSqlGenerator