昨天提到 EF Core 新增或更新資料 (UPSERT) 的簡便寫法,有讀者朋友提到是否能用 Attach() 跟 Entry().State = EntityState.Modified; 取代 Entry().CurrentValues.SetValues()?

感覺這得配合範例比較好解釋,所以關於這個議題,就專門寫一篇文章跟大家講解好了。(老高上身)

先說 Entry(),Entry(資料庫Entity物件) 會傳回該 Entity 的 EntityEntry 物件,方便我們存取它的變更追蹤資訊(Change Tracking Information),EntityEntry 最常用的屬性是 State,共有 Added、Deleted、Detached、Modified、Unchanged 等幾種狀態,我們可以透過設定 State 控制 EF Core 在 SaveChanges() 時要執行 INSERT、DELETE 還是 UPDATE 動作。

因此,Entry(record).State = EntityState.Modified 等同將 record 物件列為追蹤對象,並標註為物件已被修改,之後 SaveChanges() 時便會用它對資料執行 UPDATE。由於整顆物件被標為己修改,故 UPDATE 時每個屬性都要 SET 覆寫。(前篇文章提到的 CurrentValues.SetValues() 則會追蹤哪些欄位有改,哪些沒變,UPDATE 時只更新有異動的欄位)

至於 Attach(record),等同 Entry(record).State = EntityState.Unchanged,會將 record 列入追蹤對象。若 Primary Key 採自動跳號,Sate 可能是 Added 或 Unchanged,未指定 Primary Key 時 State 為 Added,否則為 Unchanged。當 State 為 Unchanged 時,EF Core 會假裝 Entity 所有屬性跟資料庫端一致,未被修改過,故呼叫 SaveChanges() 將不會觸發資料庫動作,因為從 EF Core 角度,Entity 沒被改何需更新?Attach(record) 後若修改 record.SomeProp,則 EF Core 會追蹤到 SomeProp 屬性改變,下次 SaveChanges() 會 UPDATE SET 更新 SomeProp。

以上就是使用 Entry(record).State = EntityState.Modified、Attach(record) 預期會發生的事。讓我們做幾個實驗來驗證一下。

先準備一個公用函式類別,提供測試所需的功能,包含建立使用 SQLite 記憶體資料庫作為資料來源的 JournalDbContext、加掛可動態開啟關閉的 Log 輸出以觀察 SQL 語法、可指定顏色 Console.WriteLine 的顯示函式:

using CRUDExample.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace efcore_attach
{
    public static class DbCtxHelper
    {
        // 使用記憶體中的 SQLite 資料庫,來去不留痕跡
        // https://blog.darkthread.net/blog/ef-core-test-with-in-memory-db/
        const string cnStr = "Data Source=InMemoryDbName;Mode=Memory;Cache=Shared";
        static SqliteConnection _persistConn;
        static DbCtxHelper() {
            _persistConn = new SqliteConnection(cnStr);
            _persistConn.Open();
            new JournalDbContext(new DbContextOptionsBuilder<JournalDbContext>()
                .UseSqlite(_persistConn)
                .Options)
                .Database.EnsureCreated();
        }
        static bool enableLog = false;
        public static JournalDbContext CreateDbContext()
        {
            var dbOpt = new DbContextOptionsBuilder<JournalDbContext>()
                .UseSqlite(cnStr)
                // 設定可動態開關的 Log 輸出,並限定觀察 SQL 語法
                .LogTo(s =>
                {
                    if (enableLog && s.Contains("Microsoft.EntityFrameworkCore.Database.Command"))
                         Console.WriteLine(s);
                }, Microsoft.Extensions.Logging.LogLevel.Information)
                // 連同寫入資料庫的參數一起顯示,正式環境需留意個資或敏感資料寫入Log
                .EnableSensitiveDataLogging()
                .Options;
            return new JournalDbContext(dbOpt);
        }
        public static void WriteLog(string msg, ConsoleColor color) {
            Console.ForegroundColor = color;
            Console.WriteLine(msg);
            Console.ResetColor();
        }
        public static void WriteRemark(string msg)
            => WriteLog(msg, ConsoleColor.Yellow);
        public static void WriteError(string msg) 
            => WriteLog(msg, ConsoleColor.Red);
        public static void WriteError(Exception ex)
            => WriteError(ex.InnerException?.Message ?? ex.Message);
        // 啟用 Log 輸出並執行 SaveChanges()
        public static void SaveChangesWithLogging(this JournalDbContext dbCtx) 
        {
            try
            {
                enableLog = true;
                dbCtx.SaveChanges();
                enableLog = false;
            }
            catch (Exception ex)
            {
                WriteError(ex.InnerException?.Message ?? ex.Message);
            }
        }
    }
}

先看測試結果,我安排了八個實驗:

  1. 單純 Attach() 未指定 Id 值的 Entity,State 為 Added,SaveChanges() 時將觸發 INSERT,但因日期相同違反 UNIQUE 限制無法寫入
  2. 先用 dbCtx.Records.First(r => r.Date == recDate) 查詢,再 Attach() 會相同 Id 的資料會出錯
  3. Attach() 指定 Id 值的 Entity,State 為 Unchanged,SaveChanges() 什麼都沒發生
  4. Attach() 後修改 EventSummary,SaveChanges() 時跑 UPDATE SET EventSummary
  5. Entry().State = EntityState.Modified,會 UPDATE SET 所有欄位 (其實只有 EventySummary 需要更新)
  6. SetValues(),UPDATE 只更新有修改的欄位 EventSummary
  7. SetValues() 傳入對象可以是 ViewModel 或匿名類別,EF Core 會更新有對映且異動的欄位
  8. SetValues() 傳入對象也可以是 Dictionary<string, object>,EF Core 會更新有對映且異動的項目

測試程式在這邊:

using CRUDExample.Models;
using efcore_attach;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

Action<string> print = (s) =>
{
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine(s);
    Console.ResetColor();
};

var recDate = new DateTime(2022, 1, 1);
// 產生待更新資料 / EventSummary 含隨機內容
Func<DailyRecord> GenDailyRecord = () => new DailyRecord
{
    // Id 為 Primary Key,採自動編號,不需指定
    Date = recDate,
    EventSummary = $"Update - {new Random().NextDouble():n6}",
    Remark = "",
    User = "darkthread"
};

// 先新增一筆資料
using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    dbCtx.Records.Add(GenDailyRecord());
    dbCtx.SaveChanges();
}

using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    // 使用 Attach 相當於 Entry(...).State = EntityState.Unchanged
    print("實驗一 Attach / 不指定 Id");
    var record = GenDailyRecord();
    var entEntry = dbCtx.Attach(record);
    DbCtxHelper.WriteRemark("未指定自動跳號 Primary Key 時,State = " + entEntry.State);
    // 但日期相同,違反 Unique Index
    dbCtx.SaveChangesWithLogging();
}

int recId;
using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    print("實驗二 Attach / 查詢過同一筆無法 Attach");
    var record = GenDailyRecord();
    recId = dbCtx.Records.First(r => r.Date == recDate).Id;
    record.Id = recId;
    try
    {
        dbCtx.Attach(record);
    }
    catch (Exception ex)
    {
        DbCtxHelper.WriteError(ex);
    }
}

using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    print("實驗三 Attach / 指定 Id");
    var record = GenDailyRecord();
    record.Id = recId;
    var entEntry = dbCtx.Attach(record);
    DbCtxHelper.WriteRemark("指定自動跳號 Primary Key 時,State = " + entEntry.State);
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("純 Attach 未改屬性,無動作");
    print("實驗四 Attach 後修改 EventSummary");
    record.EventSummary = "Attach 後修改";
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("只更新異動欄位");
}

using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    print("實驗五 Entry().State = EntityState.Modified");
    var record = GenDailyRecord();
    record.Id = recId;
    dbCtx.Entry(record).State = EntityState.Modified;
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("更新所有欄位,不管是否與原來相同");
}

using (var dbCtx = DbCtxHelper.CreateDbContext())
{
    print("實驗六 SetValues()");
    var record = GenDailyRecord();
    var exist = dbCtx.Records.Find(recId);
    record.Id = recId;
    dbCtx.Entry(exist).CurrentValues.SetValues(record);
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("只會更新異動欄位 EventSummary");
}

using (var dbCtx = DbCtxHelper.CreateDbContext()) 
{
    print("實驗七 SetValues(任意物件)");
    var exist = dbCtx.Records.Find(recId);
    dbCtx.Entry(exist).CurrentValues.SetValues(new {
        EventSummary = "ViewModel 或匿名物件",
        Remark = "",
        NoMappingProp = 1234
    });
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("只更新有對映且異動的欄位");    
}

using (var dbCtx = DbCtxHelper.CreateDbContext()) 
{
    print("實驗八 SetValues(Dictionary<string, object>)");
    var exist = dbCtx.Records.Find(recId);
    dbCtx.Entry(exist).CurrentValues.SetValues(new Dictionary<string, object>() {
        ["EventSummary"] = "來自 Dictionary",
        ["Remark"] = "",
        ["NoMappingProp"] = 1234
    });
    dbCtx.SaveChangesWithLogging();
    DbCtxHelper.WriteRemark("只更新有對映且異動的欄位");    
}

希望以上測試能讓大家對 CurrentValues.SetValues()、Attach() 與 Entry().State = EntityState.Modified 更新行為及應用有更多認識。老樣子,專案我放上 Github 了,需要的同學請自取。

Tutorial of how to use Attach()/Entry().State for entity data updating.


Comments

# by Ted

謝謝黑大,太詳盡且易懂,

Post a comment