![]()
單一方式來過濾、排序與分頁任何清單。
find()貫穿本文的範例是一個圖書館目錄。一本書會儲存以下內容:
// src/book/book.schema.ts
@Schema({ timestamps: true })
export class Book {
@Prop() title: string;
@Prop({ type: Types.ObjectId, ref: "Author" }) author: Types.ObjectId;
@Prop() publishedAt: Date;
@Prop() copies: number; // copies on the shelf
@Prop() available: boolean;
@Prop() acquisitionPrice: number; // what it cost us: internal, never published
}
Enter fullscreen mode
Exit fullscreen mode
作者的名字不在這裡:它存在於 authors 集合中,位於該參照的另一端。而使用目錄的畫面是一張帶有搜尋框、逐欄過濾器與分頁的表格。
提供資料的端點只寫一次,並透過累積方式成長。它一開始回傳固定排序的一頁資料,等到表格擁有所有過濾器時,它就變成這樣:
// src/book/book.controller.ts
@Controller("books")
export class BookController {
constructor(
@InjectModel(Book.name) private readonly model: Model<BookDocument>,
) {}
@Get()
async getAll(
@Query("title") title?: string,
@Query("available") available?: string,
@Query("minCopies") minCopies?: string,
@Query("sortBy") sortBy?: string,
@Query("page") page?: string,
) {
const filter: FilterQuery<BookDocument> = {};
if (title) {
filter.title = { $regex: title, $options: "i" };
}
if (available) {
filter.available = available === "true";
}
if (minCopies) {
filter.copies = { $gte: Number(minCopies) };
}
const current = Number(page ?? 1);
const [items, total] = await Promise.all([
this.model
.find(filter)
.sort({ [sortBy ?? "createdAt"]: -1 })
.skip((current - 1) * 20)
.limit(20),
this.model.countDocuments(filter),
]);
return { items: items, total: total, page: current };
}
}
Enter fullscreen mode
Exit fullscreen mode
該方法內有一些正確的決策:總數來自與項目相同的過濾器,因此分頁不會自相矛盾,而且兩個查詢是平行執行的。像這樣撰寫的清單可以穩定運行多年而不發生事故,其行為並非本文打算修正的內容。
值得衡量的是最終寫在檔案外面的東西。端點的簽名與呼叫它所需的 URL 共同構成一份合約:
GET /books?title=dune&available=true&minCopies=3&sortBy=publishedAt&page=2
Enter fullscreen mode
Exit fullscreen mode
這份合約沒有在任何地方宣告,卻已經在正式環境中使用:當有人在工單中分享該 URL 或將其寫入匯入腳本的那一刻,查詢字串中的五個名稱就有了儲存庫外部的消費者。而它所使用的詞彙並非目錄的詞彙,而是集合的詞彙:sortBy=publishedAt 完全依照資料庫的稱呼來命名文件欄位,minCopies 也鎖定了未出現在名稱中的運算子,而讀到 title=dune 的人無法判斷它是在尋找精確比對還是部分比對,因為這只寫在 if 裡面。
本文所建構的是 Criteria 模式:一個描述清單的物件——什麼被過濾、如何排序、哪一頁——從客戶端旅行到儲存庫,並被翻譯兩次,在每個邊界各一次。在分析之前先看看結果是值得的,因為接下來的一切都是為了證明為什麼是這種形狀而不是另一種。
同樣的表格,呼叫同樣的端點,會像這樣被請求:
GET /books
?filters[0][field]=title&filters[0][operator]=CONTAINS&filters[0][value][0]=dune
&filters[1][field]=authorName&filters[1][operator]=EQUAL&filters[1][value][0]=Herbert
&order[by]=publishedAt&order[type]=DESC
&page=2&pageSize=20
Enter fullscreen mode
Exit fullscreen mode
回應會帶有頁面以及繪製分頁器所需的資訊:
{ "items": [], "totalItems": 143, "totalPages": 8, "pageSize": 20 }
Enter fullscreen mode
Exit fullscreen mode
而控制器內不再有任何欄位名稱:
// src/book/infrastructure/nest/book.controller.ts
@Get()
async getAll(
@Query() request: CriteriaRequest,
): Promise<PaginationResponse<BookResponse>> {
const useCase = new GetAllBooks(this.repository, new BookCriteriaRequestMapper());
return await useCase.execute({ request: request });
}
Enter fullscreen mode
Exit fullscreen mode
將兩個 URL 並排在一起時,不用閱讀伺服器就能看出四個差異:
CONTAINS 會隨請求一起傳送,因此讀到 URL 的人知道 dune 是在尋找部分比對。在之前的版本中,這活在一個 if 裡面。authorName 不存在於任何文件中——作者在另一個集合中——但仍然可以像任何其他欄位一樣被過濾與排序。這些都不是免費的:達到這個目標每個實體需要四個檔案,外加每個資料庫引擎一個轉換器,而且有些專案並不值得這麼做。本文剩下的部分將說明為什麼是這種形狀、它的成本以及何時不值得。
端點與呼叫它的人被綁在一起的地方有五個,而且它們並非同一種類。前四個透過閱讀檔案就能看見;第五個只有在第二個清單出現時才會變得可見。
1. 運算子活在方法主體內。 title 是用 $regex 解析,而 minCopies 是用 $gte,但兩個名稱都沒有說出來,這表示過濾器的行為可以在不碰簽名的情況下改變:把那個 $regex 改成精確比對不會破壞任何編譯,唯一的訊號是回應開始帶回更少的列。
2. 參數名稱就是欄位名稱。 sortBy=publishedAt 能運作是因為該字串被直接傳給 .sort()。在 schema 中重新命名屬性會留下兩條出路:破壞已經在流通的 URL,或是在控制器內保留一張從舊名稱到新名稱的別名表——這正是該模式最終會正式化的翻譯對映,只是寫得太晚而且只針對移動的那個欄位。
3. 簽名隨著欄位乘以運算子而成長。 minCopies 涵蓋了 copies 其中一種可能的比較;最大值是另一個參數,而精確範圍是第三個。端點累積的不是每個欄位一個參數,而是每個有人想對某欄位提出的問題一個參數。
4. 可以被過濾的東西沒有寫在任何地方:它是 if 的殘餘。 要知道端點接受什麼,你必須閱讀整個方法並記住分支。在排序方面甚至沒有分支可讀,因為 sortBy 直接進入 .sort():文件中的任何路徑都是有效的排序,包括清單不會回傳的那些欄位。
5. 格式對這個端點來說是私有的。 下一個清單——作者、借閱、複本——會從頭開始再次決定一切:頁面是用 page 還是 offset 請求,排序是用 sortBy 加上 order 還是單一的 sort=-publishedAt,布林值是用 true、1 還是單純參數的存在來傳遞。在客戶端,每個畫面都寫自己的序列化器,而且沒有一個與前一個足夠相似到可以共用。
前四個是耦合的麻煩事:它們活在一個檔案內,透過編輯該檔案來修正,而修正它們的成本不取決於你等了多久。第五個是不同種類。它不活在任何檔案中,而是活在撰寫端點的人與消費它的人之間的協議中,而且它不會隨著欄位數量成長:它隨著清單數量乘以客戶端數量而成長。
只有單一清單、四個固定過濾器與一個畫面呼叫它時,這五個都沒有可觀察到的成本,而上面的方法是對問題的適當解答。它們在三個條件出現時變得可衡量,而且這三個條件往往一起出現:清單不再只有一個,客戶端不再只有一個,以及過濾器不再固定,因為使用者從表格標頭組合它們。
在查詢字串中傳遞的名稱是文件欄位的名稱,而已發佈的 URL 沒有版本也沒有棄用:只要有人繼續使用它,它就存在。當 publishedAt 變成 firstPublishedAt 的那一天,編譯器或測試都不會說任何話,壞掉的是已經在儲存庫外部流通的連結。然而成本不是在重新命名時支付:它支付在你不會重新命名這個事實,因為既然沒有辦法知道誰用舊名稱呼叫,遷移就被延後,而不再描述它所儲存內容的名稱就保留下來。
簽名累積每個可以對欄位提出的問題一個參數,而關於日期或數字的有用問題有好幾個;再乘以清單的數量,因為每個清單都從頭開始重複這個練習。效果體現在成長的方向:參數進來但不出去,因為移除 minCopies 需要證明沒有人呼叫它,而這個證明無法針對沒有在任何地方宣告的合約產生。該方法最終成為曾經呼叫過它的每個畫面的總和,包括那些已經不存在的。
sortBy 以文字到達並直接進入 .sort(),因此你可以排序的欄位清單不是由端點決定:它是由 schema 決定。而對欄位排序是一種讀取它的方式——用 sortBy=acquisitionPrice 與幾頁資料,你可以重建整個目錄中採購價格的相對順序,而回應從未回傳任何單一價格。二階效果是控制所在之處:該表面是透過編輯schema而變寬,而不是控制器,因此明天加入供應商利潤的人是在用一個沒有碰觸任何人會查看的檔案的 diff 來擴大 API 暴露的範圍。
這三種成本共享一個根源:客戶端可能要求的東西沒有以資料形式存在於任何地方,而是散落在方法簽名、幾個 if 的主體以及每個畫面組裝其 URL 的方式中。
Criteria 模式幾乎總是以相同的論點被引入:它避免了儲存庫方法的爆炸。在 CodelyTV 的表述中(這是西班牙語世界中該模式的參考),如果你必須依多個欄位過濾,「我們最終可能會有一個儲存庫,每個要過濾的欄位一個方法,加上可能存在的任何排列組合」——而 criteria 在尊重開放/封閉原則的同時解決了它。
它所描述的問題是真的,推理也是正確的。值得衡量的是它的大小。一個真實的儲存庫不會累積排列組合,它累積的是實際需要的那些方法:findByTitle、findByAuthorAndAvailable,以及不多不少的其他。成長不是組合式的,而是等於畫面的數量,而介面中四或五個相似的方法讀起來尷尬但修正起來便宜——它們正是上面清單中四個局部且可逆的點之一。
這個論點也沒有觸及某件事。方法爆炸完全在後端內部被解決:一個由 use case 手動組裝的 criteria,使用 new BookCriteria({ filters: [...] }),就已經移除了它,而為了做到這點,你不需要 DTO、不需要驗證、不需要公開欄位清單,也不需要在 URL 中傳遞運算子。一個只以此為理由的實作只會停在後端邊緣之前,而那正是本文問題開始的地方。另一個流傳的論點——這樣你就可以更換資料庫——在這裡更薄弱,因為 criteria 的轉換器正是遷移中便宜的部分;TypeORM 章節會展示它,但它是作為可驗證的後果而不是動機。
注意:可攜性論點確實有一個被認真捍衛的地方,那就是 Repository 模式,而我在那裡詳細衡量了它:NestJS 中的 Repository 模式——一個碰巧住在資料庫中的集合。如果你對那個討論感興趣,它全都在那裡;對接下來的内容來說,知道它不是 criteria 所支付的就足夠了。
取代它的問題是整個合約有哪些可用:不是為了避免介面中重複的方法,而是讓客戶端知道它可以要求什麼,而伺服器知道它接受什麼。在那裡,生態系提供的比通常被承認的還多。
nestjs-paginate 是開箱即用走得最遠的選擇,而且值得毫無保留地這麼說。目錄清單,在 15.0.1 版完整呈現:
// src/book/book.controller.ts
@Get()
async getAll(@Paginate() query: PaginateQuery): Promise<Paginated<Book>> {
return paginate(query, this.repository, {
relations: ["author"],
sortableColumns: ["title", "publishedAt", "copies"],
searchableColumns: ["title", "author.name"],
filterableColumns: {
available: [FilterOperator.EQ],
copies: [FilterOperator.GTE, FilterOperator.LTE],
"author.name": [FilterOperator.ILIKE],
},
defaultSortBy: [["publishedAt", "DESC"]],
defaultLimit: 20,
maxLimit: 100,
});
}
Enter fullscreen mode
Exit fullscreen mode
GET /books?filter.available=$eq:true&filter.copies=$gte:3&sortBy=publishedAt:DESC&page=2&limit=20
Enter fullscreen mode
Exit fullscreen mode
sortableColumns 是必填欄位,不是可選的,而 filterableColumns 宣告每個欄位接受哪些運算子,出自一個包含十一個運算子的目錄——$eq、$gte、$in、$btw、$ilike、$null、$contains 及其同伴——加上 $not 後綴與 $all / $any / $none 量化器。maxLimit 設定頁面上限,searchableColumns 提供全文搜尋,有游標分頁,而且 filterExpressionMaxComplexity 限制過濾器表達式可擁有的節點數量,以免有人用巢狀 filter= 把伺服器搞垮。也要注意 "author.name" 能運作:名稱可以跨越關聯,因此即使是本文用作嚴苛測試的案例也被涵蓋了。成本 2 消失了——一個參數——成本 3 也消失了:沒有宣告的東西不會進來。
它沒有解決的是詞彙,而其他一切都由此而來。接受的名稱是 Column<T> 型別,這是實體的屬性路徑,因此 URL 中的 publishedAt 在類別中也是 publishedAt,成本 1 依然未被觸及。而 paginate() 接受 TypeORM 的 Repository<T> 或 SelectQueryBuilder<T> 並回傳 TypeORM 實體:合約很優秀,但它與 ORM 密不可分,因此對使用 Mongoose 或 Prisma 的人來說它不存在,而且 use case 無法在不匯入 TypeORM 的情況下表達它。
GraphQL 透過另一條路徑解決整個問題——客戶端宣告它想要什麼,而 schema 就是合約:
query {
books(where: { available: true }, orderBy: { publishedAt: DESC }, first: 20) {
title
author {
name
}
}
}
Enter fullscreen mode
Exit fullscreen mode
三種成本一次消失。代價不是函式庫,而是傳輸:HTTP 快取、授權、速率限制與可觀測性全都移到其他地方,這讓它成為一個架構決策,而不是加到清單上的一層。
將整個 req.query 傳給 find() 在零行程式碼中解決了端點的成長:
@Get()
async getAll(@Query() query: FilterQuery<BookDocument>) {
return this.model.find(query);
}
Enter fullscreen mode
Exit fullscreen mode
而它把另外兩種成本加劇到極致,因為公開詞彙變成引擎的全部:?acquisitionPrice[$gt]=0 會依一個沒有人決定要暴露的欄位過濾,而且端點的表面不再有任何人能說出的限制。
Specification 與 Query Object,這些經典模式,解決了領域內部條件的組合——以草圖形式,repository.match(new Available().and(new PublishedAfter(2020)))。這是一個真實的問題,也是前一節討論的問題,但兩者都沒有提到 HTTP 傳輸或驗證到達的內容,而在這裡這佔了一半的工作。
| 成本 1 · URL 與 schema 耦合 | 成本 2 · 端點成長 | 成本 3 · 意外表面 | |
|---|---|---|---|
nestjs-paginate |
未處理:公開名稱就是實體屬性 | 已解決:單一參數 |
已解決:sortableColumns 是必填 |
| GraphQL | 已解決:schema 就是合約 | 已解決 | 已解決 |
ORM 的 where 傳入 find()
|
加劇它 | 已解決,沒有限制 | 加劇它:表面是整個引擎 |
| Specification / Query Object | 未處理 | 已解決 在後端內部 | 未處理 |
注意:表格沒有衡量人機工程或生產環境中第一個端點的時間,而且
nestjs-paginate在兩者都大幅領先。它也沒有衡量在任何其他之前決定的條件:底下坐的是哪個持久化引擎。於 2026 年 8 月針對nestjs-paginate15.0.1 與 TypeORM 1.1.0 檢查;這些 API 會在主要版本中改變。
差距就在那裡。對任何不在 TypeORM 上的人來說,這些都不可用,而對在它上面的人來說,合約最終是以 ORM 的詞彙表達。缺少的是一個既不命名欄位也不命名函式庫的清單描述。
Fowler 用一行定義了 Query Object——「一個代表資料庫查詢的物件」——並將其發展為一個直譯器:一個能夠將自己轉成 SQL 的物件結構。兩種表述都朝向引擎。本文的表述則是反過來看:
Criteria 不是在 URL 中旅行的查詢:它是清單的描述——什麼被過濾、如何排序、哪一頁——用領域的詞彙撰寫,並被翻譯兩次,在每個邊界各一次。
這句話決定了三個部分,而這三個部分佔據了文章剩下的部分。
每個實體一個公開欄位 enum。 合約的詞彙以資料形式存在於一個檔案中,而不是幾個 if 的殘餘。這是你決定客戶端可以命名什麼的地方,而這個決定不再取決於 schema 碰巧包含的東西:這是對成本 1 與 3 的直接回答。
一個沒有依賴的領域 criteria。 描述清單的物件不匯入 NestJS、不匯入驅動程式、也不匯入 ORM。Use case 建構它並交給儲存庫,而不知道背後是什麼,這允許同一個清單可以從 Mongo、從 Postgres 或從測試中的記憶體替身提供。
兩個彼此一無所知的翻譯。 第一個將 HTTP 請求轉成 criteria 並活在應用層;第二個將 criteria 轉成引擎的查詢並活在基礎設施中。兩者都不知道對方存在,而這就是接下來兩節要測試的特性:更換引擎只碰第二個,而改變客戶端可以要求的東西只碰第一個。
這是整個檔案,不是摘錄:
// src/shared/domain/criteria/criteria.ts
type Props = {
filters?: CriteriaFilter[];
order?: CriteriaOrder | null;
page?: number | null;
pageSize?: number | null;
search?: string | null;
};
export abstract class Criteria<T extends string> {
private _filters: CriteriaFilter[];
private _order: CriteriaOrder | null;
private _page: number | null;
private _pageSize: number | null;
private _search: string | null;
constructor({ filters, order, page, pageSize, search }: Props = {}) {
this._filters = filters ?? [];
this._order = order ?? null;
this._page = page ?? null;
this._pageSize = pageSize ?? null;
this._search = search ?? null;
}
get filters() {
return this._filters;
}
get order() {
return this._order;
}
get page() {
return this._page;
}
get pageSize() {
return this._pageSize;
}
get search() {
return this._search;
}
// Replaces the whole list: this is what the mapper does with the request's filters.
setFilters(v: CriteriaFilter[]) {
this._filters = v;
}
// Accumulates: what the server imposes is added and the request cannot drop it.
addFilters(v: CriteriaFilter[]) {
this._filters = [...this._filters, ...v];
}
find(field: T): CriteriaFilter[] {
return this._filters.filter((f) => f.field === field);
}
}
Enter fullscreen mode
Exit fullscreen mode
這個檔案重要的是它不包含的東西:沒有裝飾器、沒有 NestJS 匯入、沒有資料庫驅動程式匯入。這個特性是可檢查的——在專案依賴未安裝的情況下它可以編譯——而其他一切都依賴於它:同一個物件可以在 use case 中建構、旅行到 Mongo 儲存庫,也可以在測試期間旅行到記憶體替身。
三個細節比看起來更有份量。T extends string 參數是將每個 criteria 綁定到其欄位清單的東西,因此如果名稱不在實體的 enum 中,criteria.find("subtitle") 就不會編譯。find 回傳清單而不是單一過濾器,因為一個欄位可以攜帶兩個——publishedAt 在一個日期之後且在另一個日期之前是一個區間——而翻譯者需要同時擁有兩者。setFilters 與 addFilters 的區分存在是因為它們是兩種不同的情況:客戶端的過濾器取代清單,而伺服器強加的那些則累積,而且用不同的名稱時,差異在 use case 中是可見的,而不是必須被記住。
過濾器是一個欄位、一個運算子與一些值。基礎類別固定前兩個,並將第三個留給每種型別:
// src/shared/domain/criteria/criteria-filter.ts
export abstract class CriteriaFilter {
readonly field: string;
readonly operator: CriteriaFilterOperator;
constructor({ field, operator }: CriteriaFilterProps) {
this.field = field;
this.operator = operator;
}
abstract hasValues(): boolean;
}
Enter fullscreen mode
Exit fullscreen mode
// src/shared/domain/criteria/criteria-number-filter.ts
export class CriteriaNumberFilter extends CriteriaFilter {
readonly values: number[];
constructor(props: CriteriaFilterProps & { values: number[] }) {
super(props);
this.values = props.values;
}
numbers(): number[] {
return this.values;
}
// A filter with no values must not restrict the query.
hasValues(): boolean {
return this.numbers().length > 0;
}
}
Enter fullscreen mode
Exit fullscreen mode
有一種常見的替代方案,值得說明為什麼它比較差:儲存 values: unknown[] 並搭配一個判別欄位 type: "string" | "number" | "date" | "boolean"。使用那種形狀,轉換器會對 type 做 switch,而編譯器不會檢查它讀取的值是否符合所在的分支,因此每個 case 都需要 as number[] 斷言。使用每個型別一個類別,filter instanceof CriteriaNumberFilter 會縮小型別,而 filter.numbers() 已經回傳 number[]。差異在基礎設施轉換器中被收取,那是模式中一個長長的 switch,涵蓋運算子,也是最容易無聲出錯的地方。
這個檔案是客戶端可以命名的整個表面:
// src/book/domain/criteria/book-criteria-field.ts
export enum BookCriteriaField {
ID = "id",
TITLE = "title",
AUTHOR_NAME = "authorName",
PUBLISHED_AT = "publishedAt",
COPIES = "copies",
AVAILABLE = "available",
}
Enter fullscreen mode
Exit fullscreen mode
// src/book/domain/criteria/book-criteria.ts
export class BookCriteria extends Criteria<BookCriteriaField> {}
Enter fullscreen mode
Exit fullscreen mode
acquisitionPrice 不在裡面,而這個缺席就是對成本 3 的完整回答:API 的表面不再由 schema 決定。明天將供應商利潤加入文件的人不會擴大任何東西,因為從 URL 命名它將意味著編輯這個檔案,而這正是有人在審核時會尋找它的地方。
另一方面,authorName 在裡面,而它不是文件欄位。這個 enum 是清單的詞彙,而不是 schema 的詞彙——而這就是成本 1 被解決的地方,因為公開名稱不再與欄位名稱綁定,重新命名屬性變成內部變更。authorName 活在另一個集合中是轉換器的問題,而嚴苛測試會在後面處理它。
具體的 criteria 只有一行,因為它所有的型別都來自 enum:從這裡開始,任何在這六個名稱之外的 find 都不會編譯。
這是模式中唯一帶有裝飾器的檔案,而這種集中是故意的:它是所有你無法控制的東西通過的邊界。
// src/shared/application/dto/criteria-request.ts
export class CriteriaFilterRequest {
@IsString()
@IsNotEmpty()
field: string;
@IsEnum(CriteriaFilterOperator)
operator: CriteriaFilterOperator;
// qs returns a string when the query carries `value=x` once, and an array when it
// carries indexes (`value[0]=x`); normalised so it does not depend on how many
// values the client happened to send.
@IsDefined()
@Transform(({ value }) => (Array.isArray(value) ? value : [value]))
@IsArray()
@IsString({ each: true })
value: string[];
}
export class CriteriaRequest {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CriteriaFilterRequest)
filters?: CriteriaFilterRequest[];
@IsOptional()
@ValidateNested()
@Type(() => CriteriaOrderRequest)
order?: CriteriaOrderRequest;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
search?: string;
}
Enter fullscreen mode
Exit fullscreen mode
兩個在應用程式啟動時的設定決定這是否運作,而兩個都會無聲失敗:
// src/main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Express 5 parses the query with `simple`, which is querystring.parse and does not
// nest: `order[by]` would arrive as a key literally called "order[by]".
app.set("query parser", "extended");
// Without `transform`, the DTO's @Type() decorators are not applied and `page` is
// still a string.
app.useGlobalPipes(new ValidationPipe({ transform: true }));
Enter fullscreen mode
Exit fullscreen mode
第一個是版本 5 中的變更:在 Express 5.2.1 的 lib/application.js 中,預設設定是 this.set('query parser', 'simple'),而 extended 是插入 qs 的東西。沒有它,filters[0][field]=title 不會以巢狀物件到達,而驗證會因為不是客戶端的錯而拒絕整個請求。
這是兩個翻譯中的第一個。它將 DTO 轉成 criteria,並沿途做了三個決定:
// src/shared/application/criteria/criteria-request-mapper.ts
export type CriteriaFilterOption<T extends string> = {
field: T;
type: CriteriaFilterType;
};
export abstract class CriteriaRequestMapper<T extends string> {
abstract options(): CriteriaFilterOption<T>[];
execute({ criteria, request }: Props<T>): Criteria<T> {
if (request.search !== undefined) {
criteria.setSearch(this.mapSearch(request.search));
}
// Pagination is always set, whether the request carries it or not: a criteria
// with no pageSize translates into a query with no limit.
criteria.setPage(this.mapPage(request.page));
criteria.setPageSize(this.mapPageSize(request.pageSize));
if (request.order !== undefined) {
criteria.setOrder(
new CriteriaOrder({
orderBy: request.order.by,
orderType: request.order.type,
}),
);
}
if (request.filters !== undefined) {
const options = this.options();
const result: CriteriaFilter[] = [];
for (const filter of request.filters) {
const mapped = this.mapFilter(filter, options);
if (mapped !== null) {
result.push(mapped);
}
}
criteria.setFilters(result);
}
return criteria;
}
// The ceiling is what stops an absurd pageSize from ending up an unbounded find().
private mapPageSize(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value)) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(Math.max(Math.trunc(value), 1), MAX_PAGE_SIZE);
}
// What is not in options() is not filtered: there is no field to apply it to.
private mapFilter(
filter: CriteriaFilterRequest,
options: CriteriaFilterOption<T>[],
): CriteriaFilter | null {
const option = options.find((o) => o.field === filter.field);
if (option === undefined) {
return null;
}
const props = { field: option.field, operator: filter.operator };
switch (option.type) {
case CriteriaFilterType.STRING:
return new CriteriaStringFilter({ ...props, values: filter.value });
case CriteriaFilterType.NUMBER:
return new CriteriaNumberFilter({
...props,
values: this.mapNumbers(filter.value),
});
// ...dates and booleans, the same way
}
}
}
Enter fullscreen mode
Exit fullscreen mode
而實體的那一個宣告清單,這是每個清單唯一必須撰寫的部分:
// src/book/application/criteria/book-criteria-request-mapper.ts
export class BookCriteriaRequestMapper extends CriteriaRequestMapper<BookCriteriaField> {
options(): CriteriaFilterOption<BookCriteriaField>[] {
return [
{ field: BookCriteriaField.ID, type: CriteriaFilterType.STRING },
{ field: BookCriteriaField.TITLE, type: CriteriaFilterType.STRING },
{ field: BookCriteriaField.AUTHOR_NAME, type: CriteriaFilterType.STRING },
{ field: BookCriteriaField.PUBLISHED_AT, type: CriteriaFilterType.DATE },
{ field: BookCriteriaField.COPIES, type: CriteriaFilterType.NUMBER },
{ field: BookCriteriaField.AVAILABLE, type: CriteriaFilterType.BOOLEAN },
];
}
}
Enter fullscreen mode
Exit fullscreen mode
第一個決定是在 options() 中宣告的型別就是轉換文字的東西。查詢字串沒有型別,因此 copies 的 "3" 會在這裡變成數字 3,一次,而不是在每個引擎轉換器中各自做一次。第二個是頁面上限:總是設定 page 與 pageSize,以 MAX_PAGE_SIZE 為上限,就是阻止客戶端把清單變成集合傾印的東西。
第三個值得帶著它的成本陳述。對未宣告欄位的過濾器會被無聲丟棄,它不會回傳 400,這表示客戶端的一個打字錯誤會產生未過濾的清單而不是一個可見的錯誤——這更難除錯。選擇這種方式的原因是幾個月前儲存的 URL 在某個欄位停止可過濾時,仍然會回傳某個合理的東西,而不是壞掉。nestjs-paginate 採取相反的決定並提供 throwOnInvalidFilter;兩者都有道理,而沒有選擇是沒有道理的。
注意:編譯器不會檢查
options()是否涵蓋整個 enum。將其宣告為Record<BookCriteriaField, CriteriaFilterType>而不是清單會強制這點,但代價是失去表格形狀。目前的樣子,一個沒有型別的 enum 欄位是一個只有在實際過濾時才會注意到的疏忽。
這裡也是模式的成本,與好處在同一個地方:每個清單你必須撰寫四個檔案——enum、單行 criteria、帶有 options() 的對映器,以及稍後出現的基礎設施對映——而之前只有五個 @Query()。你換來的是這四個可以在一分鐘內讀完,並且說出關於端點接受什麼的全部真相。
領域儲存庫暴露單一的列出方法:
// src/book/domain/repository/book.repository.ts
export interface BookRepository {
pagination(criteria