Các bộ lọc truy vấn toàn cục (Global Query Filters) đã được cập nhật trong EF Core 10

Global query filters (bộ lọc truy vấn toàn cục) trong Entity Framework Core (EF Core) là một tính năng mạnh mẽ có thể được sử dụng hiệu quả để quản lý các mẫu truy cập dữ liệu (data access patterns).

Global query filters là các vị từ truy vấn LINQ được áp dụng cho các entity model trong EF Core. Các bộ lọc này được tự động áp dụng cho tất cả các truy vấn liên quan đến các entity tương ứng. Điều này đặc biệt hữu ích trong các ứng dụng multi-tenant (đa khách hàng) hoặc các tình huống yêu cầu soft deletion (xóa mềm).

Trong EF Core 10, Global Query Filters đã được đổi tên thành Named Query Filters (bộ lọc truy vấn có tên).

Bản cập nhật này giải quyết một hạn chế lớn mà EF Core trước đây gặp phải: hỗ trợ nhiều global query filters trên một entity duy nhất.

Trong bài viết này, chúng ta sẽ khám phá:

  • Query Filters cho use case Soft Delete (xóa mềm)
  • Query Filters cho ứng dụng Multi-Tenant (đa khách hàng)
  • Named Query Filters

Bắt đầu ngay thôi.

Query filters cho use case soft delete (xóa mềm)

Hãy cùng khám phá một use case mà global query filters đặc biệt hữu ích – entity soft deletion (xóa mềm entity). Trong một số ứng dụng, các entity không thể bị xóa hoàn toàn khỏi cơ sở dữ liệu mà phải được giữ lại phục vụ cho mục đích thống kê và lưu trữ lịch sử. Hoặc để đảm bảo dữ liệu liên quan không bị thay đổi, tức là vẫn được tham chiếu bởi các khóa ngoại. Giải pháp cho use case này chính là soft deletion.

Soft deletion được triển khai bằng cách thêm cột is_deleted vào bảng cơ sở dữ liệu cho các entity cần thiết. Bất cứ khi nào một entity được coi là đã xóa, cột này sẽ được đặt thành true. Trong hầu hết các truy vấn cơ sở dữ liệu của ứng dụng, các entity “đã xóa” nên được bỏ qua trong các thao tác đọc và không hiển thị cho người dùng cuối.

Hãy xem xét ví dụ với các entity sau:

public class Author
{
    public required Guid Id { get; set; }
    public required string Name { get; set; }
    public required string Country { get; set; }
    public required List<Book> Books { get; set; } = [];
}

public class Book
{
    public required Guid Id { get; set; }
    public required string Title { get; set; }
    public required int Year { get; set; }
    public required bool IsDeleted { get; set; }
    public required Guid TenantId { get; set; }
    public required Author Author { get; set; }
}

Chúng ta cần tạo và cấu hình DbContext với một global query filter:

public class ApplicationDbContext : DbContext
{
    public DbSet<Author> Authors { get; set; } = default!;
    
    public DbSet<Book> Books { get; set; } = default!;
    
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(x => !x.IsDeleted);
        
        base.OnModelCreating(modelBuilder);
        
        modelBuilder.Entity<Author>(entity =>
        {
            entity.ToTable("authors");

            entity.HasKey(x => x.Id);
            entity.HasIndex(x => x.Name);
            
            entity.Property(x => x.Id).IsRequired();
            entity.Property(x => x.Name).IsRequired();
            entity.Property(x => x.Country).IsRequired();
            
            entity.HasMany(x => x.Books)
                .WithOne(x => x.Author);
        });
        
        modelBuilder.Entity<Book>(entity =>
        {
            entity.ToTable("books");

            entity.HasKey(x => x.Id);
            entity.HasIndex(x => x.Title);
            
            entity.Property(x => x.Id).IsRequired();
            entity.Property(x => x.Title).IsRequired();
            entity.Property(x => x.Year).IsRequired();
            entity.Property(x => x.IsDeleted).IsRequired();

            entity.HasOne(x => x.Author)
                .WithMany(x => x.Books);
        });
    }
}

Trong đoạn mã này, chúng ta áp dụng một query filter cho entity Book dựa trên thuộc tính IsDeleted:

modelBuilder.Entity<Book>()
    .HasQueryFilter(x => !x.IsDeleted);

Ở đây chúng ta đang lọc ra tất cả các cuốn sách đã bị xóa mềm khỏi kết quả truy vấn. Khi truy vấn sách từ DbContext, query filter này được áp dụng tự động. Hãy xem xét endpoint minimal API sau:

app.MapGet("/api/books", async (ApplicationDbContext dbContext) =>
{
    var nonDeletedBooks = await dbContext.Books.ToListAsync();
    return Results.Ok(nonDeletedBooks);
});

Mỗi lần truy vấn sách, chúng ta chỉ nhận được những cuốn chưa bị xóa, do đó chúng ta không cần phải sử dụng câu lệnh LINQ Where trong tất cả các truy vấn DbContext.

Trong một số trường hợp, chúng ta có thể cần truy cập tất cả các entity và bỏ qua query filter. EF Core có một phương thức đặc biệt tên là IgnoreQueryFilters dành cho trường hợp này:

app.MapGet("/api/all-books", async (ApplicationDbContext dbContext) =>
{
    var allBooks = await dbContext.Books
        .IgnoreQueryFilters()
        .Where(x => x.IsDeleted)
        .ToListAsync();
    
    return Results.Ok(allBooks);
});

Bằng cách đó, tất cả các cuốn sách sẽ được lấy từ cơ sở dữ liệu và query filter trên entity Book hoàn toàn bị bỏ qua.

Query filters cho ứng dụng multi-tenant (đa khách hàng)

Một use case thực tế khác của global query filters là multi-tenancy (đa khách hàng). Một ứng dụng multi-tenant chia sẻ một phiên bản phần mềm duy nhất cho nhiều khách hàng. Dữ liệu của mọi khách hàng không được hiển thị cho các khách hàng khác.

Hãy cùng khám phá cách triển khai multi-tenancy đơn giản nhất: lưu trữ tất cả dữ liệu trong một cơ sở dữ liệu và một bảng duy nhất.

Đầu tiên, chúng ta cần thêm thuộc tính TenantId vào entity Books:

public class Book
{
    // Other properties ...
    
    public required Guid TenantId { get; set; }
}

Thứ hai, chúng ta cần cập nhật DbContext:

public class ApplicationDbContext : DbContext
{
    private readonly Guid? _currentTenantId;
    
    public DbSet<Author> Authors { get; set; } = default!;
    
    public DbSet<Book> Books { get; set; } = default!;
    
    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ITenantService tenantService) : base(options)
    {
        _currentTenantId = tenantService.GetCurrentTenantId();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(x => x.TenantId == _currentTenantId);
        
        base.OnModelCreating(modelBuilder);
        
        // Rest of the code remains unchanged
    }
}

Trong đoạn mã này, chúng ta đã cập nhật query filter và thêm điều kiện x.TenantId == _currentTenantId. Vì chúng ta tạo DbContext cho mỗi request, chúng ta có thể tiêm (inject) tenant id hiện tại từ request (định danh khách hàng đang truy cập dữ liệu trong ứng dụng của chúng ta).

Dưới đây là một triển khai tenant service đơn giản lấy tenant id từ header của HTTP request:

public interface ITenantService
{
    Guid? GetCurrentTenantId();
}

public class TenantService : ITenantService
{
    private readonly Guid? _currentTenantId;
    
    public TenantService(IHttpContextAccessor accessor)
    {
        var headers = accessor.HttpContext?.Request.Headers;

        _currentTenantId = headers.TryGetValue("Tenant-Id", out var value) is true
            ? Guid.Parse(value.ToString())
            : null;
    }

    public Guid? GetCurrentTenantId() => _currentTenantId;
}

Bây giờ hãy tạo một endpoint minimal API tương ứng:

app.MapGet("/api/tenant-books", async (ApplicationDbContext dbContext) =>
{
    var tenantBooks = await dbContext.Books.ToListAsync();
    return Results.Ok(tenantBooks);
});

Trong mọi truy vấn đọc, một global query filter theo tenant ID được áp dụng để đảm bảo tính toàn vẹn dữ liệu. Kết quả là khi gọi endpoint này, mỗi khách hàng chỉ có thể lấy được những cuốn sách của chính mình.

Named Query Filters

Trong EF Core 10, Global Query Filters đã được đổi tên thành Named Query Filters (bộ lọc truy vấn có tên).

Bản cập nhật này giải quyết một hạn chế lớn mà EF Core trước đây gặp phải: hỗ trợ nhiều global query filters trên một entity duy nhất.

Trong ứng dụng, chúng ta có thể cần nhiều query filter cho cùng một entity:

  • Một filter cho soft deletion (xóa mềm)
  • Một filter cho multi-tenancy (đa khách hàng)

Hãy cùng khám phá cách tạo hai named query filters cho entity Book:

public class ApplicationDbContext : DbContext
{
    public const string SoftDeleteFilter = nameof(SoftDeleteFilter);
    public const string TenantFiler = nameof(TenantFiler);

    // ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(SoftDeleteFilter, x => !x.IsDeleted)
            .HasQueryFilter(TenantFiler, x => x.TenantId == _currentTenantId);
        
        // ...
    }
}

Mỗi lần truy vấn sách, cả hai filter đều được áp dụng tự động:

app.MapGet("/api/tenant-books", async (ApplicationDbContext dbContext) =>
{
    var tenantBooks = await dbContext.Books
        .ToListAsync();

    return Results.Ok(tenantBooks);
});

Endpoint này sẽ trả về tất cả các cuốn sách chưa bị xóa và thuộc về tenant hiện tại.

Để bỏ qua một filter cụ thể, chúng ta có thể sử dụng phương thức IgnoreQueryFilters và lấy tất cả sách của tenant, bao gồm cả những cuốn đã bị xóa mềm:

app.MapGet("/api/tenant-books", async (ApplicationDbContext dbContext) =>
{
    var tenantBooks = await dbContext.Books
        .IgnoreQueryFilters([ApplicationDbContext.SoftDeleteFilter])
        .ToListAsync();

    return Results.Ok(tenantBooks);
});

Một thói quen tốt là sử dụng các hằng số (constants) cho tên filter.

Tổng kết

Global query filters trong EF Core là một tính năng mạnh mẽ giúp thực thi các quy tắc truy cập dữ liệu một cách nhất quán trên toàn bộ ứng dụng.

Chúng đặc biệt hữu ích trong các kiến trúc multi-tenant và trong các tình huống như soft deletion, đảm bảo rằng các query filter được tự động áp dụng cho mọi thao tác đọc.

Bằng cách áp dụng các filter này vào entity model của EF Core, bạn có thể đơn giản hóa đáng kể mã truy cập dữ liệu, đảm bảo tính toàn vẹn dữ liệu và giảm nguy cơ quên áp dụng các filter quan trọng cho các thao tác đọc.

Trong EF Core 10, chúng đã nhận được một bản cập nhật tuyệt vời: giờ đây có thể áp dụng nhiều query filter cho cùng một entity. Bạn cần đặt tên cho các filter và bỏ qua một số filter khi cần thiết.

Hy vọng bài viết này hữu ích với bạn. Hẹn gặp lại ở bài viết tiếp theo.

Bạn có thể tải mã nguồn của bài viết này miễn phí tại: Download source code