【EF Core】自動生成的欄位值

东邪独孤發表於2024-08-04

自動生成欄位值,咱們首先想到的是主鍵列(帶 IDENTITY 的主鍵)。EF Core 預設的主鍵配置也是啟用 Identity 自增長的,而且可以自動標識主鍵。前提是代表主鍵的實體屬性名要符合以下規則:

1、名字叫 ID、id、或 Id,就是不分大小寫;

2、名字由實體類名 + Id 構成。比如,Car 實體類,包含一個屬性叫 CarID 或 CarId;

3、屬性型別是整數型別(int、long、ushort 等,但不是 byte)或 GUID。

這些識別主鍵的規則是由一種叫“約定”(Convension)的東西實現的,具體來說,是一個叫 KeyDiscoveryConvention 的類。老周放一小段原始碼給各位瞧瞧。

public class KeyDiscoveryConvention :
    IEntityTypeAddedConvention,
    IPropertyAddedConvention,
    IKeyRemovedConvention,
    IEntityTypeBaseTypeChangedConvention,
    IEntityTypeMemberIgnoredConvention,
    IForeignKeyAddedConvention,
    IForeignKeyRemovedConvention,
    IForeignKeyPropertiesChangedConvention,
    IForeignKeyUniquenessChangedConvention,
    IForeignKeyOwnershipChangedConvention,
    ISkipNavigationForeignKeyChangedConvention
{
    private const string KeySuffix = "Id";

    ……

    public static IEnumerable<IConventionProperty> DiscoverKeyProperties(
        IConventionEntityType entityType,
        IEnumerable<IConventionProperty> candidateProperties)
    {
        Check.NotNull(entityType, nameof(entityType));

        // ReSharper disable PossibleMultipleEnumeration
        var keyProperties = candidateProperties.Where(p => string.Equals(p.Name, KeySuffix, StringComparison.OrdinalIgnoreCase));
        if (!keyProperties.Any())
        {
            var entityTypeName = entityType.ShortName();
            keyProperties = candidateProperties.Where(
                p => p.Name.Length == entityTypeName.Length + KeySuffix.Length
                    && p.Name.StartsWith(entityTypeName, StringComparison.OrdinalIgnoreCase)
                    && p.Name.EndsWith(KeySuffix, StringComparison.OrdinalIgnoreCase));
        }

        return keyProperties;
        // ReSharper restore PossibleMultipleEnumeration
    }
   ……
}

這幾個邏輯 And 其實就是查詢 <類名>Id 格式的屬性名,如 StudentID、CarId、OrderID…… 外來鍵的發現原理也跟主鍵一樣。

用 Sqlite 資料舉一個簡單的例子。下面是實體類(假設它用來表示輸入法資訊):

public class InputMethod
{
    public ushort RecoId { get; set; }
    public string? MethodDisplay { get; set; }
    public string? Description { get; set; }
    public string? Culture { get; set; }
}

如你所見,這個類作為主鍵的屬性是 RecoId,但是,它的命名是無法被自動識別的,咱們必須明確地告訴 EF,它是主鍵。方法有二:

1、批註法。直接在屬性上應用相關的特性類。如

public class InputMethod
{
    [Key]
    public ushort RecoId { get; set; }
    ……
}

2、重寫 DbContext 類的 OnModelCreating 方法。如

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId);
}

如果使用了上面重寫 OnModelCreating 方法,那麼,你的 DbContext 派生類已經能識別 InputMethod 實體類了。但如果你用的是在屬性上應用 [Key] 特性的方式,那麼 DbContext 的派生類是識別不到實體類的,你需要將它的集合宣告為 DbContext 的屬性。

internal class TestDBContext : DbContext
{
    // 建構函式
    public TestDBContext(DbContextOptions<TestDBContext> opt)
        : base(opt)
    { }

    // 將實體集合宣告為屬性
    public DbSet<InputMethod> InputMethods { get; set; }
}

注意,資料記錄的集合要用 DbSet<>,其他型別的集合是不行的喲。比如,你改成這樣,就會報錯。

public List<InputMethod> InputMethods { get; set; }

說明人家只認 DbSet 集合,其他集合無效。

這裡老周選用服務容器來配置。

static void Main(string[] args)
{
    IServiceCollection services = new ServiceCollection();
    // 構建連線字串
    SqliteConnectionStringBuilder constrbd = new();
    constrbd.DataSource = "abc.db";
    // 新增 Sqlite 功能
    services.AddSqlite<TestDBContext>(
            connectionString:    constrbd.ToString(),
            optionsAction:       dcopt =>
            {
                dcopt.LogTo(msg => Console.WriteLine(msg), LogLevel.Information);
            }
        );
    // 生成服務列表
    var svcProd = services.BuildServiceProvider();
    if(svcProd == null)
    {
        return;
    }

    // 訪問資料上下文
    using TestDBContext dbc = svcProd.GetRequiredService<TestDBContext>();
    ……
}

連線字串你可以直接用字串寫,不用 ConnectionStringBuilder。預設的 SQLite 庫是不支援密碼的,所以老周就不設定密碼了。在呼叫 AddSqlite 方法時,有一個名為 optionsAction 的引數,咱們可以用它配置日誌輸出。LogTo 方法配置簡單,只要提供一個委託,它繫結的方法只要有一個 string 型別的輸入引數就行,這個字串引數就是日誌文字。

配置日誌功能後,執行程式時,控制檯能看到執行的 SQL 語句。

下面咱們來建立資料庫,然後插入兩條 InputMethod 記錄。

// 訪問資料上下文
using TestDBContext dbc = svcProd.GetRequiredService<TestDBContext>();
// 刪除資料庫
dbc.Database.EnsureDeleted();
// 建立資料庫
dbc.Database.EnsureCreated();

// 嘗試插入兩條記錄
InputMethod[] ents = [
        new(){MethodDisplay = "雙拼輸入", Description="按兩個鍵完成一個音節",Culture="zh-CN"},
        new() {MethodDisplay = "六指輸入", Description="專供六個指頭的人使用",Culture="zh-CN"}
    ];
dbc.Set<InputMethod>().AddRange(ents);
int result = dbc.SaveChanges();
Console.WriteLine($"更新記錄數:{result}");

// 列印插入的記錄
foreach(InputMethod im in dbc.Set<InputMethod>())
{
    Console.WriteLine($"ID={im.RecoId}, Display={im.MethodDisplay}, Culture={im.Culture}");
}

這裡是為了測試,呼叫了 EnsureDeleted 方法,實際應用時一般不要呼叫。因為這個方法的功能是把現存的資料庫刪除。如果呼叫了此方法,那應用程式每次啟動都會刪掉資料庫,那使用者肯定會投訴你的。EnsureCreated 方法可以使用,它的功能是如果資料庫不存在,就建立新資料庫;如果資料庫存在,那啥也不做。所以,呼叫 EnsureCreated 方法不會造成資料丟失,放心用。

插入資料和呼叫 SaveChanges 方法儲存到資料庫的程式碼,相信大夥都很熟了,老周就不介紹了。

程式執行之後,將得到這樣的日誌:

info: 2024/8/4 12:48:11.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      PRAGMA journal_mode = 'wal';
info: 2024/8/4 12:48:11.582 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "tb_ims" (
          "RecoId" INTEGER NOT NULL CONSTRAINT "PK_tb_ims""MethodDisplay""Description""Culture" TEXT NULL
      );
info: 2024/8/4 12:48:11.700 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (3ms) [Parameters=[@p0='?' (Size = 5), @p1='?' (Size = 10), @p2='?' (Size = 4)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_ims" ("Culture", "Description", "MethodDisplay")
      VALUES (@p0, @p1, @p2)
      RETURNING "RecoId";
info: 2024/8/4 12:48:11.712 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='?' (Size = 5), @p1='?' (Size = 10), @p2='?' (Size = 4)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_ims" ("Culture", "Description", "MethodDisplay")
      VALUES (@p0, @p1, @p2)
      RETURNING "RecoId";
更新記錄數:2
info: 2024/8/4 12:48:11.849 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT "t"."RecoId", "t"."Culture", "t"."Description", "t"."MethodDisplay"
      FROM "tb_ims" AS "t"
ID=1, Display=雙拼輸入, Culture=zh-CN
ID=2, Display=六指輸入, Culture=zh-CN

這樣你會發現,對於整數型別的主鍵,預設是自動生成遞增ID的。注意,這個是由資料庫生成的,而不是 EF Core 的生成器。不同資料庫的 SQL 語句會有差異。

為了對比,咱們不防改為 SQL Server,看看輸出的日誌。

// 構建連線字串
SqlConnectionStringBuilder constrbd = new();
constrbd.DataSource = ".\\SQLTEST";
constrbd.InitialCatalog = "CrazyDB";
constrbd.IntegratedSecurity = true;
// 不信任伺服器證書有時候會連不上
constrbd.TrustServerCertificate = true;
// 可讀可寫
constrbd.ApplicationIntent = ApplicationIntent.ReadWrite;

// 新增 SQL Server 功能
services.AddSqlServer<TestDBContext>(
        connectionString: constrbd.ToString(),
        optionsAction: opt =>
        {
            opt.LogTo(logmsg => Console.WriteLine(logmsg), LogLevel.Information);
        });

其他程式碼不變,再次執行。輸出的日誌如下:

info: 2024/8/4 13:01:06.087 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (115ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      CREATE DATABASE [CrazyDB];
info: 2024/8/4 13:01:06.122 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (31ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [CrazyDB] SET READ_COMMITTED_SNAPSHOT ON;
      END;
info: 2024/8/4 13:01:06.137 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: 2024/8/4 13:01:06.181 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (10ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [tb_ims] (
          [RecoId] int NOT NULL IDENTITY,
          [MethodDisplay] nvarchar(12) NOT NULL,
          [Description] nvarchar(max) NULL,
          [Culture] nvarchar(max) NULL,
          CONSTRAINT [PK_tb_ims] PRIMARY KEY ([RecoId])
      );
info: 2024/8/4 13:01:06.317 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (30ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 12), @p3='?' (Size = 4000), @p4='?' (Size = 4000), @p5='?' (Size = 12)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [tb_ims] USING (
      VALUES (@p0, @p1, @p2, 0),
      (@p3, @p4, @p5, 1)) AS i ([Culture], [Description], [MethodDisplay], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Culture], [Description], [MethodDisplay])
      VALUES (i.[Culture], i.[Description], i.[MethodDisplay])
      OUTPUT INSERTED.[RecoId], i._Position;
更新記錄數:2
info: 2024/8/4 13:01:06.438 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [t].[RecoId], [t].[Culture], [t].[Description], [t].[MethodDisplay]
      FROM [tb_ims] AS [t]
ID=1, Display=雙拼輸入, Culture=zh-CN
ID=2, Display=六指輸入, Culture=zh-CN

A、使用 Sqlite 資料庫時,生成的 CREATE TABLE 語句,主鍵列是 PRIMARY KEY AUTOINCREMENT;

B、使用 SQL Server 時,主鍵列使用的是 IDENTITY,預設以 1 為種子,增量是 1。所以插入記錄的鍵值是1和2。

有時候我們並不希望主鍵列自動生成值,同樣有兩種配置方法:

1、透過特性類來批註。如

public class InputMethod
{
    [Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
    public ushort RecoId { get; set; }
    public string? MethodDisplay { get; set; }
    public string? Description { get; set; }
    public string? Culture { get; set; }
}

將 DatabaseGeneratedOption 設定為 None,就取消列的自動生成了。

2、透過模型配置,即重寫 OnModelCreating 方法實現。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId);
    modelBuilder.Entity<InputMethod>()
        .Property(k => k.RecoId)
        .ValueGeneratedNever();
}

這種情況下,插入資料時主鍵列就需要咱們手動賦值了。

======================================================================================

上面的是熱身運動,是比較簡單的應用方案。下面老周給大夥伴解決一個問題。老周看到在 GitHub 等平臺上有人提問,但沒有得到解決。如果你看到老周這篇水文並且你有此困惑,那你運氣不錯。好,F話不多說,咱們看問題。

需求:主鍵不變,但是我不想讓它帶有 IDENTITY,插入記錄時用我自定義的方式生成主鍵的值。這個需要的本質就是:我不要資料庫給我生成遞增ID,我要在程式裡生成。

前面老周提過,預設行為下主鍵列如果是整數型別或 GUID,就會產生自增長的列。所以,咱們有一個很關鍵的步驟——就是怎麼禁止 EF 去產生 IDENTITY 列。如果你看到 EF Core SQL Server 的原始碼,可能你會知道有個約定類叫 SqlServerValueGenerationStrategyConvention。這個約定類預設會設定主鍵列的自動生成策略為 IdentityColumn。

 public virtual void ProcessModelInitialized(
     IConventionModelBuilder modelBuilder,
     IConventionContext<IConventionModelBuilder> context)
     => modelBuilder.HasValueGenerationStrategy(SqlServerValueGenerationStrategy.IdentityColumn);

於是,有大夥伴可能會想到,那我從 SqlServerValueGenerationStrategyConvention 派生出一個類,重寫 ProcessModelInitialized 方法,把自動生成策略改為 None,然後在約定集合中替換掉 SqlServerValueGenerationStrategyConvention。

這個思路不是不行,就是工作量大一些。你不僅要定義個新類,還要把它註冊到服務容器中替換 SqlServerValueGenerationStrategyConvention 。畢竟 EF Core 框架內部也是使用了服務容器和依賴注入的方式來組織各種元件的。具體做法是在初始化 DbContext 類(包括你派生的類)時會傳遞一個 DbContextOptions<TContext> 物件,它有一個 ReplaceService 方法,可以替換容器中的服務。在呼叫 AddSqlServer 方法時就可以配置。

 public static IServiceCollection AddSqlServer<TContext>(
     this IServiceCollection serviceCollection,
     string? connectionString,
     Action<SqlServerDbContextOptionsBuilder>? sqlServerOptionsAction = null,
     Action<DbContextOptionsBuilder>? optionsAction = null)
     where TContext : DbContext

上述方案太麻煩,故老周未採用。其實,就算服務初始化時設定了生成策略是 Identity,可我們可以在構建模型時修改它呀。做法就是重寫 DbContext 類的 OnModelCreating 方法,然後透過 IConventionModelBuilder.HasValueGenerationStrategy 方法就能修改生成策略。當然,這裡頭是有點波折的,我們不能在 ModelBuilder 例項上呼叫,因為這貨並不是直接實現 IConventionModelBuilder 介面的,它是這麼搞的:

public class ModelBuilder : IInfrastructure<IConventionModelBuilder>

IInfrastructure<T> 介面的作用是把 T 隱藏,不希望程式程式碼訪問型別T。DbContext 類也實現這個介面,但它隱藏的是 IServiceProvider 物件,不想讓咱們訪問裡面註冊的服務。也就是說,IConventionModelBuilder 的實現者被隱藏了。不過,EF Core 並沒有把事情做得太絕,好歹給了一個擴充套件方法 GetInfrastructure。用這個擴充套件方法我們能得到 IConventionModelBuilder 型別的引用。

弄清楚這個原理,程式碼就好寫了。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    IConventionModelBuilder cvbd = modelBuilder.GetInfrastructure();
    if (cvbd.CanSetValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None))
    {
        cvbd.HasValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None);
    }

    ……
}

把生成策略改為 None 後,生成主鍵列時就不會有 IDENTITY 了。

如果你樂意,可以在插入記錄時手動給主鍵列賦值也行的。不過,為了能自動生成值,我們應該寫一個自己的生成類。

public class MyValueGenerator : ValueGenerator<int>
{
    // 返回false表示這個生成的值不是臨時,它最終要存入資料庫的
    public override bool GeneratesTemporaryValues => false;

    private static readonly Random rand = new((int)DateTime.Now.Ticks);

    public override int Next(EntityEntry entry)
    {
        // 獲取所有實體
        DbSet<InputMethod> ents = entry.Context.Set<InputMethod>();
        int newID = default;
        do
        {
            // 生成隨機ID
            newID = rand.Next();
        }
        // 保證不重複
        while (ents.Any(x => x.RecoId == newID));
        // 返回新值
        return newID;
    }
}

我這裡的邏輯是這樣的,值是隨機生成的,但要用一個迴圈去檢查這個值是不是已存在資料庫中,如果存在,繼續生成,直到數值不重複。

實現自定義生成器,有兩個抽象類可供選擇:

1、如果你生成的值,型別不確定(可能是int,可能是 long,可能是……),那就實現 ValueGenerator 類;

2、如果要生成的值是明確型別的,比如這裡是 int,那就實現帶泛型引數的 ValueGenerator<TValue> 類。

這兩個類有繼承關係,ValueGenerator<TValue> 派生自 ValueGenerator 類。需要實現的抽象成員:

A、GeneratesTemporaryValues 屬性:只讀,返回 bool 值。如果你生成的值是臨時的,返回 true,不是臨時的,返回 false。啥意思呢。臨時的值表示暫時賦值給屬性/欄位,但 INSERT、UPDATE 時,這個值不會存入資料庫;如果不是臨時的值,最終會存進資料庫。上面例子中,老周讓它返回 false,就說明生成的這個值,要寫入資料庫的。

B、如果繼承 ValueGenerator 類,請實現 NextValue 抽象方法,返回型別是 object,就是生成的值;如果繼承的是 ValueGenerator<TValue>,請實現 Next 方法,此方法返回的型別由泛型引數決定。上面例子中是 int。

寫好生成類後,要把它應用到實體模型中,同樣是重寫 DbContext 類的 OnModelCreating 方法。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    IConventionModelBuilder cvbd = modelBuilder.GetInfrastructure();
    if (cvbd.CanSetValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None))
    {
        cvbd.HasValueGenerationStrategy(Microsoft.EntityFrameworkCore.Metadata.SqlServerValueGenerationStrategy.None);
    }

    modelBuilder.Entity<InputMethod>().HasKey(e => e.RecoId);
    modelBuilder.Entity<InputMethod>()
        .Property(k => k.RecoId)
        .HasValueGenerator<MyValueGenerator>()
        .ValueGeneratedOnAdd();
    modelBuilder.Entity<InputMethod>().ToTable("tb_ims")
        .Property(x => x.MethodDisplay)
        .IsRequired()
        .HasMaxLength(12);
}

ValueGeneratedOnAdd 方法表示在記錄插入資料庫時自動生成值,HasValueGenerator 方法設定你自定義的生成器。

現在,有了自定義生成規則,在插入資料時,主鍵不能賦值。一旦賦值,生成器就無效了。

// 嘗試插入兩條記錄
InputMethod[] ents = [
        new(){ MethodDisplay = "雙拼輸入", Description="按兩個鍵完成一個音節",Culture="zh-CN"},
        new() { MethodDisplay = "六指輸入", Description="專供六個指頭的人使用",Culture="zh-CN"}
    ];
dbc.Set<InputMethod>().AddRange(ents);
int result = dbc.SaveChanges();

執行應用程式,你會發現,這次生成的 CREATE TABLE 語句中,RecoId 列已經沒有 IDENTITY 關鍵字了。

info: 2024/8/4 18:41:24.956 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (12ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: 2024/8/4 18:41:24.982 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [CrazyDB] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
      END;
info: 2024/8/4 18:41:25.003 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (21ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      DROP DATABASE [CrazyDB];
info: 2024/8/4 18:41:25.104 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (82ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      CREATE DATABASE [CrazyDB];
info: 2024/8/4 18:41:25.137 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (32ms) [Parameters=[], CommandType='Text', CommandTimeout='60']
      IF SERVERPROPERTY('EngineEdition') <> 5
      BEGIN
          ALTER DATABASE [CrazyDB] SET READ_COMMITTED_SNAPSHOT ON;
      END;
info: 2024/8/4 18:41:25.142 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1
info: 2024/8/4 18:41:25.194 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (6ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [tb_ims] (
          [RecoId] int NOT NULL,
          [MethodDisplay] nvarchar(12) NOT NULL,
          [Description] nvarchar(max) NULL,
          [Culture] nvarchar(max) NULL,
          CONSTRAINT [PK_tb_ims] PRIMARY KEY ([RecoId])
      );
info: 2024/8/4 18:41:25.408 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (24ms) [Parameters=[@__newID_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT CASE
          WHEN EXISTS (
              SELECT 1
              FROM [tb_ims] AS [t]
              WHERE [t].[RecoId] = @__newID_0) THEN CAST(1 AS bit)
          ELSE CAST(0 AS bit)
      END
info: 2024/8/4 18:41:25.448 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@__newID_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT CASE
          WHEN EXISTS (
              SELECT 1
              FROM [tb_ims] AS [t]
              WHERE [t].[RecoId] = @__newID_0) THEN CAST(1 AS bit)
          ELSE CAST(0 AS bit)
      END
info: 2024/8/4 18:41:25.488 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='?' (DbType = Int32), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 12), @p4='?' (DbType = Int32), @p5='?' (Size = 4000), @p6='?' (Size = 4000), @p7='?' (Size = 12)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [tb_ims] ([RecoId], [Culture], [Description], [MethodDisplay])
      VALUES (@p0, @p1, @p2, @p3),
      (@p4, @p5, @p6, @p7);
更新記錄數:2
info: 2024/8/4 18:41:25.524 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [t].[RecoId], [t].[Culture], [t].[Description], [t].[MethodDisplay]
      FROM [tb_ims] AS [t]
ID=427211935, Display=六指輸入, Culture=zh-CN
ID=1993200136, Display=雙拼輸入, Culture=zh-CN

怎麼樣,這玩法是不是很高階?當然,如果主鍵是字串型別,你也可以生成字串的值,一切看你需求,反正原理是相同的。

最後,咱們順便聊聊如何自動更改日期時間的問題。這個在實際開發中也很常用,比如一個計劃表,其實體如下:

public class Plan
{
    /// <summary>
    /// 計劃ID
    /// </summary>
    public int ID { get; set; }
    /// <summary>
    /// 計劃簡述
    /// </summary>
    public string? PlanDesc { get; set; }
    /// <summary>
    /// 計劃級別
    /// </summary>
    public int Level { get; set; }
    /// <summary>
    /// 計劃建立時間
    /// </summary>
    public DateTime? CreateTime { get; set; }
    /// <summary>
    /// 總計劃量
    /// </summary>
    public float TotalTask { get; set; }
    /// <summary>
    /// 完成量
    /// </summary>
    public float Completed { get; set; }
    /// <summary>
    /// 更新時間
    /// </summary>
    public DateTime? UpdateTime { get; set; }
}

最後一個欄位 UpdateTime 表示在插入後更新的時間,所以在插入時這個欄位可以留 NULL。比如我修改計劃完成數 Completed,在寫入資料庫時自動給 UpdateTime 欄位賦當前時間。這個不能用值生成器來做,因為生成器只能在資料插入前或插入後產生一次值,後面更新資料時不會再生成新值,就做不到自動設定更新時間了。所以,這裡咱們可以換個思路:重寫 DbContext 類的 SaveChanges 方法,在命令傳送到資料庫之前找出哪些記錄被修改過,然後設定 UpdateTime 屬性,最後才傳送 SQL 語句。這樣也能達到自動記錄更新時間的功能。

public class MyDBContext : DbContext
{
    ……

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        var modifieds = from c in ChangeTracker.Entries()
                        where c.State == EntityState.Modified
                              && c.Entity is Plan
                        select c;
        foreach(var obj in modifieds)
        {
            obj.Property(nameof(Plan.UpdateTime)).CurrentValue = DateTime.Now;
        }
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }
}

Modified 表示實體被更改過的狀態。修改屬性值時,應賦值給 CurrentValue,它代表的是實體當前的值,不要改 OriginalValue 的值,它指的是從資料庫中讀到的值,多數情況下不用去改,除非你要把當前 DbContext 例項的資料複製到另一個 DbContext 例項。

這樣當 Plan 物件被修改後,在提交前會自動設定更新時間。下面是測試程式碼:

 // 建立上下文
 using var ctx  = new MyDBContext();
 // 測試用,確定刪除資料庫
 ctx.Database.EnsureDeleted();
 // 確定建立資料庫
 ctx.Database.EnsureCreated();

 // 建立三條記錄
 Plan p01 = new()
 {
     PlanDesc = "裝配電池",
     CreateTime = DateTime.Now,
     TotalTask = 100f,
     Completed = 0f,
 };
 Plan p02 = new Plan()
 {
     PlanDesc = "更換底板",
     CreateTime = DateTime.Now,
     Level = 4,
     TotalTask = 12.0f,
     Completed = 0f
 };
 Plan p03 = new()
 {
     PlanDesc = "清洗蓋板",
     TotalTask = 20.5f,
     Completed = 0f,
     CreateTime = DateTime.Now
 };
 ctx.Plans.Add(p01);
 ctx.Plans.Add(p02);
 ctx.Plans.Add(p03);
 // 更新到資料庫
 int n = ctx.SaveChanges();
 Console.WriteLine($"已插入{n}條記錄");

 // 列印資料
 Print(ctx.Plans);
 MODIFY:     // 這是個標籤
 Console.Write("請輸入要更新的記錄ID:");
 string? line = Console.ReadLine();
 if(line == null)
 {
     Console.WriteLine("你輸入了嗎?");
     goto MODIFY;    // 回到標籤處
 }
 if(!int.TryParse(line, out int id))
 {
     Console.WriteLine("你丫的輸入的是整數嗎?");
     goto MODIFY;    // 回到標籤處
 }
 UPDATE:     // 標籤
 Console.Write("請輸入計劃完成數:");
 line = Console.ReadLine();
 if (line == null)
 {
     Console.WriteLine("你確定你沒敲錯鍵盤?");
     goto UPDATE;
 }
 if(!float.TryParse(line, out float comp))
 {
     Console.WriteLine("浮點數,浮點數,浮點數");
     goto UPDATE;
 }
 // 查詢
 Plan? curPlan = ctx.Plans.FirstOrDefault(x => x.ID == id);
 if (curPlan == null)
 {
     Console.WriteLine("找不到記錄");
     goto MODIFY;
 }
 if(comp > curPlan.TotalTask)
 {
     Console.WriteLine("你是在異空間工作嗎?");
     goto UPDATE;
 }
 // 更新
 curPlan.Completed = comp;
 ctx.SaveChanges();

 // 再次列印
 Print(ctx.Plans);

先插入三條資料,然後輸入記錄ID來修改 Completed 的值。更改後會看到更新時間。

好了,今天咱們就水到這裡了。

相關文章