Note: see the link below for the English version of this article.

https://duongnt.com/datetime-net6-postgresql

Datetime error with .NET 6 and 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.

Person table

Và thêm vào một số dữ liệu để test.

Person table test data

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.

Person table test data

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.

DbUpdateException

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 DateTimeDateTime.Kind == DateTimeKind.Utc. Ngược lại, ta phải map timestamp without timezone với object DateTimeDateTime.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.

    Timezone type

  • 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.

Object value on the C# side

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ẽ đặt DataTimeKind thành Unspecified.
  • Khi ta ghi record đó lại vào database, vì Person.Dob có kiểu DateTime, 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 == Unspecifiedtimestamp 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.

Enable Legacy

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.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply