# 淺談 Entity Framework 中的預設值行為
[](https://hackmd.io/dsKavEibTKGfN58w1L3DJA)
## 前言
事情的起因是,我發現專案中大家對於字串型別空值的處理方式不一致,因此決定統一為 `NOT NULL` 並存空字串。不久之後,我看到同事寫了一段類似這樣的程式碼:`entity.other = input.item == 3 ? input.other : null;`。這讓我感到疑惑,因為早上才決定統一存空字串,為什麼他存的是 `null`?
我問了我同事,他回覆說他有測試過,資料庫欄位設置為 `NOT NULL Default ''`,即使 Entity 的 Property 存的是 `null`,在新增的時候,資料庫會寫入空字串。如果是更新操作,他會明確設置為空字串。
我當時回應他,應該是要看有沒有觸發設值行為才對。他說他要下班,隔天會再測給我看。不過其實我也不確定我的認知是否正確,因為我知道 Entity Framework 在更新資料時,如果新值與舊值相同,`SaveChanges()` 的回傳值會是 `0`。所以,有可能不是依靠 Entity 屬性是否設值來判定是否有變更,而是通過新值與舊值是否相同來判定。
後來我也進行了測試,結果和他相同,但最終還是告訴他,不管怎樣,他的程式碼還是需要調整。否則,其他人看程式碼會以為存的是 `null`,實際上靠 Entity Framework 和資料庫特性存成了空字串,這樣太不直觀了。
## 實際測試
以 SQL Server 來測試,先使用以下 SQL 來初始化資料表。
```sql
CREATE TABLE [dbo].[Test](
[Id] [int] IDENTITY(1,1) NOT NULL,
[Name] [varchar](50) NOT NULL, -- 為了測試不給值的情境而建立的無用欄位
[TestVarchar] [varchar](50) NULL,
[TestInt] [int] NULL,
CONSTRAINT [PK_Test] PRIMARY KEY CLUSTERED (
[Id] ASC
) WITH (
PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF
) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Test] ADD CONSTRAINT [DF_Test_TestVarchar] DEFAULT ('TestVarchar') FOR [TestVarchar]
GO
ALTER TABLE [dbo].[Test] ADD CONSTRAINT [DF_Test_TestInt] DEFAULT ((1234)) FOR [TestInt]
GO
```
### SQL 測試
執行以下 SQL 指令:
```sql
INSERT INTO Test (Name, TestVarchar, TestInt) VALUES ('Name', default, default);
INSERT INTO Test (Name, TestVarchar, TestInt) VALUES ('Name', null, null);
INSERT INTO Test (Name) VALUES ('Name');
```
產生 SQL 如下:
| Name | TestVarchar | TestInt |
| --- | --- | --- |
| Name | TestVarchar | 1234 |
| Name | NULL | NULL |
| Name | TestVarchar | 1234 |
由以上結果可以看出當 SQL 未給值,或是給予 `default` 時,會給予 SQL 預設值,但直接給 `null` 則不會,因此不會是 SQL 的機制造成的。
### Entity Framework 測試
使用 .NET 8,安裝 Microsoft.EntityFrameworkCore 8.06,並使用反向工程建立 Entity Framework。產生的程式如下:
```csharp
public partial class TestContext : DbContext {
public TestContext(DbContextOptions<TestContext> options)
: base(options) {
}
public virtual DbSet<Test> Tests { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<Test>(entity =>
{
entity.ToTable("Test");
entity.Property(e => e.Name)
.IsRequired()
.HasMaxLength(50)
.IsUnicode(false);
entity.Property(e => e.TestInt).HasDefaultValue(1234);
entity.Property(e => e.TestVarchar)
.HasMaxLength(50)
.IsUnicode(false)
.HasDefaultValue("TestVarchar");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}
public partial class Test {
public int Id { get; set; }
public string Name { get; set; }
public string TestVarchar { get; set; }
public int? TestInt { get; set; }
}
```
執行以下程式。
```csharp
using (TestContext context = new(dbContextOptions)) {
context.Tests.Add(new() {
Name = "Name"
});
context.SaveChanges();
context.Tests.Add(new() {
Name = "Name",
TestVarchar = null,
TestInt = null
});
context.SaveChanges();
}
```
產生 SQL 如下:
```sql
INSERT INTO [Test] ([Name])
OUTPUT INSERTED.[Id], INSERTED.[TestInt], INSERTED.[TestVarchar]
VALUES (@p0);
INSERT INTO [Test] ([Name])
OUTPUT INSERTED.[Id], INSERTED.[TestInt], INSERTED.[TestVarchar]
VALUES (@p0);
```
執行結果如下:
| Name | TestVarchar | TestInt |
| --- | --- | --- |
| Name | TestVarchar | 1234 |
| Name | TestVarchar | 1234 |
由以上結果可以發現,不論是沒有設值,還是給予 `null`,最終產生的 INSERT 語法都會忽略該欄位。推測原因是 Entity Framework 不是用 Property 設值的行為來判定異動,而是比對新舊值。而 `string` 和 `int?` 的預設值都是 `null`,所以不管是沒設值還是設為 `null`,初始值都沒變,因此INSERT 語法忽略此欄位。
當時測到這邊,馬上想到一件很可怕的事,C# `int` 的預設值是 `0`,`bool` 的預設值是 `false`,如果初始值設定別的值,不就會造成設值與結果不相符的情況?
### struct 型別測試
使用以下 SQL 建立第二張資料表來測試結果。
```sql
CREATE TABLE [dbo].[Test2](
[Id] [int] IDENTITY(1,1) NOT NULL,
[TestInt] [int] NOT NULL,
[TestBit] [bit] NOT NULL,
[TestDateTime] [datetime2](7) NOT NULL,
[TestGuid] [uniqueidentifier] NOT NULL,
CONSTRAINT [PK_Test2] PRIMARY KEY CLUSTERED (
[Id] ASC
) WITH (
PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON,
OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF
) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Test2] ADD CONSTRAINT [DF_Test2_TestInt] DEFAULT ((1234)) FOR [TestInt]
GO
ALTER TABLE [dbo].[Test2] ADD CONSTRAINT [DF_Test2_TestBit] DEFAULT ((1)) FOR [TestBit]
GO
ALTER TABLE [dbo].[Test2] ADD CONSTRAINT [DF_Test2_TestDateTime] DEFAULT ('2024-01-01 12:00:00') FOR [TestDateTime]
GO
ALTER TABLE [dbo].[Test2] ADD CONSTRAINT [DF_Test2_TestGuid] DEFAULT ('21EC2020-3AEA-1069-A2DD-08002B30309D') FOR [TestGuid]
GO
```
Entity 相關程式碼如下:
```csharp
// DbContext
modelBuilder.Entity<Test2>(entity =>
{
entity.ToTable("Test2");
entity.Property(e => e.TestBit).HasDefaultValue(true);
entity.Property(e => e.TestDateTime).HasDefaultValue(new DateTime(2024, 1, 1, 12, 0, 0, 0, DateTimeKind.Unspecified));
entity.Property(e => e.TestGuid).HasDefaultValue(new Guid("21ec2020-3aea-1069-a2dd-08002b30309d"));
entity.Property(e => e.TestInt).HasDefaultValue(1234);
});
// Entity
public partial class Test2 {
public int Id { get; set; }
public int TestInt { get; set; }
public bool TestBit { get; set; }
public DateTime TestDateTime { get; set; }
public Guid TestGuid { get; set; }
}
```
執行以下程式:
```csharp
using (TestContext context = new(dbContextOptions)) {
context.Test2s.Add(new Test2());
context.SaveChanges();
context.Test2s.Add(new() {
TestInt = default,
TestBit = default,
TestDateTime = default,
TestGuid = default,
});
context.SaveChanges();
}
```
產生 SQL 如下:
```sql
INSERT INTO [Test2] ([TestBit])
OUTPUT INSERTED.[Id], INSERTED.[TestDateTime], INSERTED.[TestGuid], INSERTED.[TestInt]
VALUES (@p0);
INSERT INTO [Test2] ([TestBit])
OUTPUT INSERTED.[Id], INSERTED.[TestDateTime], INSERTED.[TestGuid], INSERTED.[TestInt]
VALUES (@p0);
```
執行結果如下:
| Name | TestInt | TestBit | TestDateTime | TestGuid |
| --- | --- | --- | --- | --- |
| Name | 1234 | 0 | 2024-01-01 12:00:00.0000000 | 21EC2020-3AEA-1069-A2DD-08002B30309D |
| Name | 1234 | 0 | 2024-01-01 12:00:00.0000000 | 21EC2020-3AEA-1069-A2DD-08002B30309D |
看了以上結果稍微鬆了一口氣,至少 `TestBit` 並沒有被忽略,這代表新增時,是否會忽略欄位,還是會根據型別來判定。不然如果遇到預設值 為`true` 的 SQL 欄位,要寫入 `false`,結果實際寫成 `true`,那這個畫面太美,我不敢看。
不過測到這時,我有另一個疑惑,`TestInt` 會寫入 `1234`,是因為 SQL 有給預設值 `1234`,那如果沒給預設值,是會寫入 `0`,還是無法寫入資料呢?
這邊我把 SQL Server 資料表 `Test2` 欄位的預設值都拿掉,執行反向工程後,重新執行寫入程式,產生 SQL 如下:
```csharp
INSERT INTO [Test2] ([TestBit], [TestDateTime], [TestGuid], [TestInt])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3);
INSERT INTO [Test2] ([TestBit], [TestDateTime], [TestGuid], [TestInt])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3);
```
執行結果如下:
| Name | TestInt | TestBit | TestDateTime | TestGuid |
| --- | --- | --- | --- | --- |
| Name | 0 | 0 | 0001-01-01 00:00:00.0000000 | 00000000-0000-0000-0000-000000000000 |
| Name | 0 | 0 | 0001-01-01 00:00:00.0000000 | 00000000-0000-0000-0000-000000000000 |
由以上結果可以得知,當未設定 SQL 預設值時,當 Entity 屬性或給值,或是給與 C# 初始值相同的值時,產生的 INSERT 語法,並***不會***忽略該欄位。
### 測試 EF 6 的情境
如果使用 EF Core Power Tools 來做反向工程,當選擇 .NET 6 和 .NET 7 的版本,針對 `bit` 欄位產生結果不同,Entity 屬性型別會是 `bool?`,然後指定 `Required`。
```csharp
// DbContext
modelBuilder.Entity<Test2>(entity => {
entity.ToTable("Test2");
entity.Property(e => e.TestBit)
.IsRequired()
.HasDefaultValueSql("((1))");
entity.Property(e => e.TestDateTime).HasDefaultValueSql("('2024-01-01 12:00:00')");
entity.Property(e => e.TestGuid).HasDefaultValueSql("('21EC2020-3AEA-1069-A2DD-08002B30309D')");
entity.Property(e => e.TestInt).HasDefaultValueSql("((1234))");
});
public partial class Test2 {
public int Id { get; set; }
public int TestInt { get; set; }
public bool? TestBit { get; set; }
public DateTime TestDateTime { get; set; }
public Guid TestGuid { get; set; }
}
```
產生出來的 SQL 會發現,`TestBit`,這邊看起來算合理。
```sql
INSERT INTO [Test2]
OUTPUT INSERTED.[Id], INSERTED.[TestBit], INSERTED.[TestDateTime], INSERTED.[TestGuid], INSERTED.[TestInt]
DEFAULT VALUES;
INSERT INTO [Test2]
OUTPUT INSERTED.[Id], INSERTED.[TestBit], INSERTED.[TestDateTime], INSERTED.[TestGuid], INSERTED.[TestInt]
DEFAULT VALUES;
```
如果我將 `TestBit` 的型別從 `bool?` 改為 `bool` 會發生什麼事呢?以下是產生的結果。由於這裡將 EF 8 降版到 EF 6,所以產生的 SQL 會有些不同:
```sql
INSERT INTO [Test2]
DEFAULT VALUES;
SELECT [Id], [TestBit], [TestDateTime], [TestGuid], [TestInt]
FROM [Test2]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
INSERT INTO [Test2]
DEFAULT VALUES;
SELECT [Id], [TestBit], [TestDateTime], [TestGuid], [TestInt]
FROM [Test2]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
```
可以發現 `TestBit` 欄位寫入被忽略了,也就是說,在 EF 6 中,即使設為 `NOT NULL` 並給予預設值,`bit` 型別的欄位仍會被忽略。EF Core Power Tools 可能是覺得到這種行為太坑了,故意將其轉為 `bool?`。之後,EF 8 也注意到這個問題,因此修正了這種行為。
## 結論
* 當 SQL Server 欄位有設定預設值時,Entity Framework 在新增資料時,如果 Entity 屬性值與該型別的預設值一致,某些型別的欄位在產生 INSERT 語法時會被忽略。
* 使用 Entity Framework 時,盡量不要使用 SQL 預設值,如果使用,也應確保 SQL 預設值與 C# 預設值一致,以避免發生預期外結果。但對於字串型別,可將其設為 `NOT NULL Default ''`,以統一處理 `null` 和空字串。
## 同場加映
### 更新 Entity 但其值不變
前言有提到,當更新資料時,即使 Entity 屬性已設值但其值不變,`SaveChanges()` 的回傳值會是 `0`。以下是測試範例:
首先使用以下 SQL 建立新的資料表
```sql
CREATE TABLE [dbo].[Test3](
[Id] [int] IDENTITY(1,1) NOT NULL,
[TestVarchar] [varchar](50) NOT NULL,
[TestInt] [int] NOT NULL,
[TestBit] [bit] NOT NULL,
CONSTRAINT [PK_Test3] PRIMARY KEY CLUSTERED (
[Id] ASC
) WITH (
PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF
) ON [PRIMARY]
) ON [PRIMARY]
GO
```
建立三筆一樣的資料,資料值如下:
TestVarchar | TestInt | TestBit |
| --- | --- | --- |
TestVarchar | 1234 | 1 |
TestVarchar | 1234 | 1 |
TestVarchar | 1234 | 1 |
```csharp
using (TestContext context = new(dbContextOptions)) {
Test3 entity = context.Test3s.Single(x => x.Id == 1);
entity.TestVarchar = entity.TestVarchar;
entity.TestInt = entity.TestInt;
entity.TestBit = entity.TestBit;
int changedCount = context.SaveChanges();
Console.WriteLine("EntityState:" + context.Entry(entity).State);
Console.WriteLine("ChangedCount:" + changedCount);
}
```
Console 結果如下:
```
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (18ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT TOP(2) [t].[Id], [t].[TestBit], [t].[TestInt], [t].[TestVarchar]
FROM [Test3] AS [t]
WHERE [t].[Id] = 1
EntityState:Unchanged
ChangedCount:0
```
從以上結果可以看到,即使 Entity 有設值,`EntityState` 仍然保持為 `Unchanged`,且並未執行任 Update 語法,導致 `SaveChanges()` 的結果為 `0`。因此,在 Business Service 的 Update 方法中,判斷執行結果應使用 `context.Entry(entity).State == EntityState.Unchanged || context.SaveChanges() > 0`,避免當其值不變時,導致誤判。
### 正確使用 AsNoTracking() 避免 EntityState 不必要修改
在我目前的公司,有些同事常常犯的錯誤是沒弄清楚 `AsNoTracking()` 的正確使用時機,導致在執行UPDATE 前,使用 SELECT 取得資料時,也使用了 `AsNoTracking()`,這就要求額外設置 `context.Entry(entity).State = EntityState.Modified;` 才能正確執行更新操作。雖然最終結果是一致的,但產生的 SQL 語法卻有所不同。
這裡使用以下程式碼進行了測試,這三筆資料的 `Name` 欄位值都為 `Name`,並使用以下程式改為 `NewName`。
```csharp
using (TestContext context = new(dbContextOptions)) {
Test entity1 = context.Tests.Single(x => x.Id == 1);
entity1.Name = "NewName";
context.SaveChanges();
Test entity2 = context.Tests.Single(x => x.Id == 2);
entity2.Name = "NewName";
context.Entry(entity2).State = EntityState.Modified;
context.SaveChanges();
Test entity3 = context.Tests.AsNoTracking().Single(x => x.Id == 3);
entity3.Name = "NewName";
context.Entry(entity3).State = EntityState.Modified;
context.SaveChanges();
}
```
這些程式碼所產生的 SQL 語法如下:
```sql
-- 沒有使用 AsNoTracking(),且沒手動設置 EntityState.Modified
SELECT TOP(2) [t].[Id], [t].[Name], [t].[TestInt], [t].[TestVarchar]
FROM [Test] AS [t]
WHERE [t].[Id] = 1
UPDATE [Test] SET [Name] = @p0
OUTPUT 1
WHERE [Id] = @p1;
-- 沒有使用 AsNoTracking(),但手動設置 EntityState.Modified
SELECT TOP(2) [t].[Id], [t].[Name], [t].[TestInt], [t].[TestVarchar]
FROM [Test] AS [t]
WHERE [t].[Id] = 2
UPDATE [Test] SET [Name] = @p0, [TestInt] = @p1, [TestVarchar] = @p2
OUTPUT 1
WHERE [Id] = @p3;
-- 使用了 AsNoTracking(),並手動設置 EntityState.Modified
SELECT TOP(2) [t].[Id], [t].[Name], [t].[TestInt], [t].[TestVarchar]
FROM [Test] AS [t]
WHERE [t].[Id] = 3
UPDATE [Test] SET [Name] = @p0, [TestInt] = @p1, [TestVarchar] = @p2
OUTPUT 1
WHERE [Id] = @p3;
```
從這些結果可以看出,正常的 UPDATE 語法只會更新已設定值的欄位,然而當手動設置 `context.Entry(entity).State = EntityState.Modified;` 時,則會導致所有欄位被更新。
## 異動歷程
* 2024-07-08 新增。
* 2024-09-03 調整並補充 EF 6 的情境說明。
###### tags `.NET` `.NET Core & .NET 5+` `Entity Framework`