查詢與規約
QueryService
IQueryService<TEntity> 提供唯讀查詢,以投影選擇器回傳結果型別,避免將實體洩漏至上層。
| 方法 | 語意 |
|---|---|
AnyAsync / CountAsync |
存在性與計數 |
GetSingleAsync / GetSingleOrDefaultAsync |
必須剛好一筆 |
GetFirstAsync / GetFirstOrDefaultAsync |
依排序取第一筆 |
GetListAsync |
清單投影 |
GetPagedListAsync |
分頁投影 |
GetGroupedListAsync |
分組、彙總、Having 與排序投影 |
GetSingleAsync 在多於一筆時拋例外。「依排序取第一筆」請改用 GetFirstAsync:
string nextName = await queryService.GetFirstAsync(
customer => customer.Name,
customer => !customer.IsDisabled,
query => query.OrderBy(customer => customer.DisplayOrder),
ct
).ConfigureAwait(false);
查無資料但不想拋例外時,改用 GetFirstOrDefaultAsync,無資料時回傳 null。
建立查詢服務
兩個泛型參數的 QueryService<TDbContext, TEntity> 提供一般投影查詢。三個泛型參數的版本再加入 Domain Key 查詢。
public sealed class CustomerQueryService
: QueryService<AppDbContext, Customer, string> {
public CustomerQueryService(AppDbContext dbContext)
: base(dbContext) {
}
}
實體實作 IHasExternalKey<string> 時,AnyAsync(key)、GetAsync(selector, key) 與 FindAsync(selector, key) 會依 ExternalKey 查詢。其餘實體使用 EF Core model 的單一主鍵。複合主鍵情境須覆寫 CreateKeyPredicate。
規約 Specifications
Specification<T> 封裝可命名、可組合的查詢條件,PagedSpecification<T> 再加上排序與分頁。
ISpecification<Customer> active = new Specification<Customer>(customer => !customer.IsDisabled);
ISpecification<Customer> taipei = new Specification<Customer>(customer => customer.City == "Taipei");
ISpecification<Customer> spec = active.And(taipei);
And、Or 與 Not 構成完整的組合運算,And 與 Or 各有接受規約與接受條件運算式的多載,不需為了組合而先把運算式包成規約。組合結果一律是新的 ISpecification<T>,來源規約不受影響,可安全重複使用同一個具名規約。
ISpecification<Customer> outsideTaipei = taipei.Not();
ISpecification<Customer> reachable = active.Or(customer => customer.HasContactEmail);
規約可直接交給 IQueryService<TEntity> 的規約多載:
IPagedSpecification<Customer> paged = PagedSpecification<Customer>.Create(
spec,
query => query.OrderBy(customer => customer.Name),
1,
20
);
PagedList<CustomerListItem> page = await queryService.GetPagedListAsync(
customer => new CustomerListItem(customer.Id, customer.Name),
paged,
ct
).ConfigureAwait(false);
規約只承載查詢條件與排序分頁,不含投影。投影由 selector 顯式指定。查詢經導覽屬性的條件與投影由 EF Core 自動 join,不需要 Include;載入聚合圖供領域行為操作屬於 Repository 的職責,見持久化與模型設定。習慣以離散參數呼叫時,可透過 QueryServiceSpecificationExtensions 組成規約後委派核心多載。
條件式組合
搜尋畫面的條件多半是選填的,SpecificationExtensions 提供條件成立才組合的擴充方法,讓拼裝流程維持單一運算式,不必用 if 逐段改寫規約變數。
| 擴充方法 | 組合時機 |
|---|---|
AndIf / OrIf |
傳入的 bool 為 true |
AndIfHasValue / OrIfHasValue |
傳入的值存在 |
ISpecification<Customer> specification = new Specification<Customer>(customer => !customer.IsDisabled)
.AndIfHasValue(criteria.City, city => customer => customer.City == city)
.AndIfHasValue(criteria.RegisteredAfter, date => customer => customer.CreatedTime >= date)
.AndIf(criteria.OnlyContactable, customer => customer.HasContactEmail);
AndIfHasValue 與 OrIfHasValue 把 null 視為無值,字串另外把空字串與純空白也視為無值,因此文字搜尋欄位不需要在呼叫端先做一次 IsNullOrWhiteSpace。其他型別只要不是 null 就算有值,0 與 false 都會觸發組合。條件不成立時原樣回傳來源規約,不會產生恆真條件。
工廠委派只在值存在時執行,可直接使用該值的非 null 形式建立條件運算式。
分頁結果
GetPagedListAsync 回傳 PagedList<TItem>,Items 是本頁投影結果,Metadata 是分頁統計。
| 成員 | 說明 |
|---|---|
PageNumber / PageSize |
查詢時指定的頁碼與每頁筆數 |
TotalCount |
套用條件後的總筆數 |
TotalPages |
依總筆數與每頁筆數計算的總頁數,無資料時為 0 |
HasPreviousPage / HasNextPage |
供分頁控制項判斷上下頁 |
頁碼與每頁筆數必須為正整數,傳入 0 或負數會拋出 ArgumentOutOfRangeException。頁碼從 1 起算。
查詢無法由 IQueryService<TEntity> 表達而需自行組 IQueryable 時,用 ToPagedListAsync 取得同樣形狀的結果,回傳型別與 QueryService 一致,上層不必分辨資料來自哪一種查詢方式。
PagedList<CustomerListItem> page = await dbContext.Customers
.Where(customer => !customer.IsDisabled)
.OrderBy(customer => customer.Name)
.Select(customer => new CustomerListItem(customer.Id, customer.Name))
.ToPagedListAsync(pageNumber, pageSize, ct)
.ConfigureAwait(false);
它會先執行一次 CountAsync 再取本頁資料,共兩次資料庫往返。排序須在呼叫前完成,未排序的來源分頁結果不穩定。
分組查詢
GetGroupedListAsync 先套用實體篩選,再執行 GroupBy 與彙總投影。havingPredicate 套用在彙總投影之後。
IReadOnlyList<CustomerCountByCity> counts = await queryService.GetGroupedListAsync(
customer => customer.City,
group => new CustomerCountByCity(group.Key, group.Count()),
customer => !customer.IsDisabled,
result => result.Count >= 10,
query => query.OrderByDescending(result => result.Count),
ct
).ConfigureAwait(false);
彙總投影成員只能來自分組鍵或 SQL 可轉譯的彙總計算。自訂 .NET 方法通常無法由 EF Core 轉譯,應在查詢物化後另行處理。
審計使用者投影
AuditedQueryService<TDbContext, TEntity, TUserEntity> 提供建立者與更新者的 LEFT JOIN 基底。衍生類別指定使用者來源與鍵選擇器,再從 AuditedQueryEntity.Entity、Creator 與 Updater 投影公開結果。
public sealed class CustomerAuditQueryService
: AuditedQueryService<AppDbContext, Customer, AppUser> {
public CustomerAuditQueryService(AppDbContext dbContext)
: base(dbContext) {
}
protected override IQueryable<AppUser> Users => DbContext.Users;
protected override Expression<Func<Customer, Guid>> CreatorIdSelector => customer => customer.CreatorId;
protected override Expression<Func<Customer, Guid?>>? UpdaterIdSelector => customer => customer.UpdaterId;
protected override Expression<Func<AppUser, Guid>> UserIdSelector => user => user.Id;
}
選擇器必須直接指向屬性,不能是運算式或方法呼叫。
軟刪除篩選
QueryService 預設使用 AsNoTracking 並保留 EF Core 全域 query filter。IgnoreSoftDeletedFilter = true 會呼叫 IgnoreQueryFilters(),因此不只略過軟刪除條件,也會略過同一實體的租戶等其他全域條件。此設定只能用於受控的管理流程,並由呼叫端補上必要的隔離條件。