Note: see the link below for the English version of this article.
https://duongnt.com/datetime-net6-postgresql
Gần đây, tôi nâng cấp một project từ .NET Core 3.1 lên .NET 6. Project này dùng Entity Framework Core với provider npgsql efcore để tương tác với một database PostgreSQL. Việc nâng cấp diễn ra tương đối thuận lợi cho đến khi tôi nhận ra rằng nhiều integration test đang gặp lỗi như sau.
Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported. Note that it's not possible to mix DateTimes with different Kinds in an array/range. See the Npgsql.EnableLegacyTimestampBehavior AppContext switch to enable legacy behavior.
Điều đáng ngạc nhiên là tất cả unit test thì lại vẫn chạy thông. Trong bài hôm nay, chúng ta sẽ tìm hiểu nguyên nhân lỗi trên và tìm cách giải quyết.
Các bạn có thể tải về code ví dụ từ đường link dưới đây.
https://github.com/duongntbk/PostgreSql6IssueDemo
Thiết lập môi trường chạy thử
Ta cần có một database PostgreSQL để chạy project ví dụ ở trên. Cách đơn giản nhất là sử dụng Docker image. Ngoài ra các bạn cũng có thể cài PostgreSQL vào máy của mình, hoặc kết nối tới một server remote.
Sau khi đã có database, ta cần cập nhật thông tin tài khoản trong file này. Bước cuối cùng là chạy lệnh dưới đây để phục hồi lại database (các bạn có thể sẽ phải cài tool dotnet ef).
dotnet ef database restore.
Lệnh trên sẽ tạo database với một table đơn giản gọi là Person
.
Và thêm vào một số dữ liệu để test.
Tái hiện lại lỗi
Code cũ sử dụng .NET Core 3.1
Project Net3.1
chứa code sử dụng .NET Core 3.1. Như ta thấy ở đây, code của ta cập nhật trường NickName
thành một giá trị Guid
ngẫu nhiên.
var baby = await dbContext.People.SingleOrDefaultAsync(p => p.Name == "Baby Doe");
baby.NickName = Guid.NewGuid();
dbContext.Set<Person>().Update(baby);
await dbContext.SaveChangesAsync();
Ta chạy lệnh dưới đây trong folder Net3.1
.
dotnet run
Sau khi chạy lệnh, ta có thể xác nhận là giá trị NickName
của BabyDoe
đã được cập nhật.
Code lỗi sử dụng .NET 6
Project Net6
chứa code sử dụng .NET 6. Ta chạy nó bằng lệnh dưới đây.
dotnet run
Lúc này ta sẽ gặp lỗi Microsoft.EntityFrameworkCore.DbUpdateException
như ở dưới.
Vì sao ta lại gặp lỗi datetime?
Một thay đổi trong LINQ provider
Trong lúc cập nhật project lên .NET 6, tôi đã nâng cấp provider npgsql efcore lên bản mới nhất là 6.x. Một trong những thay đổi lớn nhất trong bản 6.x là cách xử lý kiểu dữ liệu timestamp
. Nếu database sử dụng timestamp with timezone
thì trong code C# ta phải map nó với object DateTime
có DateTime.Kind == DateTimeKind.Utc
. Ngược lại, ta phải map timestamp without timezone
với object DateTime
có DateTime.Kind == DateTimeKind.Local (hoặc DateTimeKind.Unspecified)
. Thoạt nhìn qua, tôi tưởng rằng mình đã ghi object có DateTimeKind.Local/DateTimeKind.Unspecified
vào một cột timestamp with timezone
trong database. Nhưng giả thuyết này có vẻ không ổn vì 2 nguyên nhân sau.
-
Tôi đã kiểm tra kỹ là database đang sử dụng
timestamp without time zone
. -
Tôi luôn chuyển
DateTime
sang kiểu Utc trước khi lưu chúng vào database. Bước này đảm bảo là tất cả các giá trịDateTimeKind
lẽ ra đều phải làUtc
rồi.
Tìm nguyên nhân thực tế
Manh mối đầu tiên tới từ việc kiểm tra một record trích xuất từ database. Dưới đây là giá trị của Dob
trong record với Name == "Baby Doe"
. Như ta thấy, DateTimeKind
ở đây là Unspecified
mặc dù nó phải là Utc
.
Sau đó tôi đọc được issue này trong repo của npgsql, trong đó đáng chú nhất là comment này.
the error message does not indicate that your PostgreSQL column type is wrong – Npgsql has no knowledge of the column type you’re inserting into, etc. It says that you’ve asked Npgsql to send an Unspecified DateTime as a PG timestamptz type, e.g. by setting NpgsqlDbType.TimestampTz on your parameter; so the mismatch isn’t between the value and the column, but rather between the value and your parameter type.
Và câu hỏi này trên Stackoverflow giúp tôi trả lời nốt những băn khoăn còn sót lại.
The EF type mapping – which manages the PG type of the parameter sent (timestamp vs. timestamptz) – is determined only by the CLR type of the parameter (DateTime), without looking at its contents (i.e. the Kind). And the default EF mapping for DateTime is timestamptz, not timestamp
Cuối cùng tôi cũng nắm được lý do dẫn đến lỗi ở trên.
- Vì cột
Dob
trong database làtimestamp without timezone
, khi npgsql đọc dữ liệu nó sẽ đặtDataTimeKind
thànhUnspecified
. - Khi ta ghi record đó lại vào database, vì
Person.Dob
có kiểuDateTime
, npqsql đặt parameter tương ứng là timestampz (tức làtimestamp with timezone
). - npqsql phát hiện ra nó đang đặt parameter của một property với
DateTimeKind == Unspecified
làtimestamp with timezone
. Mâu thuẫn này dẫn tới lỗi.
Điều này cũng giải thích vì sao unit test không phát hiện ra lỗi ở trên. Đó là vì tôi dùng EF Core In-Memory để thay cho database thật. Việc này cho phép unit test không bị phụ thuộc vào các phần khác trong hệ thống. Nhưng hệ quả là ta sẽ không phát hiện được những lỗi nhỏ như thế này.
Cách giải quyết lỗi
Bật lại chế độ legacy
Thông báo lỗi của ngpsql efcore đã chứa sẵn gợi ý cách sửa lỗi. Ta có thể dùng AppContext
bật flag Npgsql.EnableLegacyTimestampBehavior
để tạm bỏ qua lỗi.
public DomainContextLegacy(DbContextOptions<DomainContextLegacy> options)
: base(options)
{
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
Ta chạy lệnh sau đây trong thư mục Net6
để xác nhận là DomainContextLegacy
không gặp phải lỗi.
dotnet run legacy
Lệnh trên sẽ cập nhật giá trị NickName
của BabyDoe
.
Nhưng nếu ta không muốn bật chế độ legacy thì sao? Hãy cùng tìm hiểu một giải pháp khác ở phần dưới.
Use a conversion to set DateTimeKind for the DateTime object
Như đã nói ở trên, nguyên nhân sâu xa của lỗi là do giá trị DateTimeKind
của Person.Dob
đã bị npgsql đặt thành Unspecified
. Có nghĩa là nếu ta chuyển giá trị đó lại thành Utc
thì ta sẽ không gặp lỗi nữa. Code dưới đây sẽ chạy thông.
var baby = await dbContext.Set<Person>().SingleOrDefaultAsync(p => p.Name == "Baby Doe");
baby.NickName = Guid.NewGuid().ToString();
baby.Dob = DateTime.SpecifyKind(baby.Dob, DateTimeKind.Utc); // <-- đặt DateTimeKind của Dob thành Utc
dbContext.Set<Person>().Update(baby);
await dbContext.SaveChangesAsync();
Tuy nhiên việc tự thay đổi DateTimeKind
là tương đối nguy hiểm. Ta sẽ phải lặp lại code trên tại nhiều chỗ khác nhau. Và nếu không cẩn thận ta có thể quên bước chuyển đổi này. Thật may là DbContext
cho phép ta thêm hàm chuyển đổi cho property của object DTO. Hàm này sẽ được tự động áp dụng mỗi khi npqsql đọc hay ghi dữ liệu từ database. Ta có thể dùng đoạn code dưới đây để chuyển giá trị DateTimeKind
của Dob
thành Utc
.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Person>()
.Property(p => p.Dob)
.HasConversion
(
src => src.Kind == DateTimeKind.Utc ? src : DateTime.SpecifyKind(src, DateTimeKind.Utc),
dst => dst.Kind == DateTimeKind.Utc ? dst : DateTime.SpecifyKind(dst, DateTimeKind.Utc)
);
}
Ta dùng lệnh dưới đây để chạy thử DomainContextFixed
.
dotnet run fixed
Giá trị NickName
của BabyDoe
lại được cập nhật một lần nữa.
![Enable Legacy](/wp-content/uploads/2022/10/datetime-net6-postgresql-7.png)
Kết thúc
Việc nâng cấp project lên .NET 6 là phức tạp hơn tôi tưởng lúc đầu. Lỗi lần này một lần nữa cho thấy sự quan trọng của việc có cả unit test lẫn integration test. Mặc dù unit test không phát hiện được lỗi nhưng integration test thì lại phát hiện được. Đó là vì integration test của tôi dùng database PostgreSQL thực tế chứ không dùng EF Core In-Memory.