@goplusvn/core 0.1.48 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { compileFilterTree } from "../lib/filter-tree";
|
|
4
|
+
|
|
5
|
+
const allowAll = { isAllowed: () => true };
|
|
6
|
+
|
|
7
|
+
describe("compileFilterTree", () => {
|
|
8
|
+
it("leaf đơn → điều kiện Prisma", () => {
|
|
9
|
+
expect(
|
|
10
|
+
compileFilterTree(
|
|
11
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
12
|
+
allowAll,
|
|
13
|
+
),
|
|
14
|
+
).toEqual({ status: "paid" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("khoảng gte+lte CÙNG field không đè nhau (điểm yếu của filter phẳng)", () => {
|
|
18
|
+
expect(
|
|
19
|
+
compileFilterTree(
|
|
20
|
+
{
|
|
21
|
+
$and: [
|
|
22
|
+
{ field: "total", operator: "gte", value: 1000 },
|
|
23
|
+
{ field: "total", operator: "lte", value: 5000 },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
allowAll,
|
|
27
|
+
),
|
|
28
|
+
).toEqual({
|
|
29
|
+
AND: [{ total: { gte: 1000 } }, { total: { lte: 5000 } }],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("$or lồng trong $and + dotted relation path", () => {
|
|
34
|
+
expect(
|
|
35
|
+
compileFilterTree(
|
|
36
|
+
{
|
|
37
|
+
$and: [
|
|
38
|
+
{ field: "branchId", operator: "eq", value: "b1" },
|
|
39
|
+
{
|
|
40
|
+
$or: [
|
|
41
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
42
|
+
{ field: "customer.name", operator: "contains", value: "an" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
allowAll,
|
|
48
|
+
),
|
|
49
|
+
).toEqual({
|
|
50
|
+
AND: [
|
|
51
|
+
{ branchId: "b1" },
|
|
52
|
+
{
|
|
53
|
+
OR: [
|
|
54
|
+
{ status: "paid" },
|
|
55
|
+
{ customer: { name: { contains: "an", mode: "insensitive" } } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("field bị cấm → bỏ leaf + báo onDisallowed, phần còn lại giữ nguyên", () => {
|
|
63
|
+
const disallowed: string[] = [];
|
|
64
|
+
const where = compileFilterTree(
|
|
65
|
+
{
|
|
66
|
+
$and: [
|
|
67
|
+
{ field: "secretCost", operator: "gt", value: 0 },
|
|
68
|
+
{ field: "status", operator: "eq", value: "paid" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
isAllowed: (f) => f !== "secretCost",
|
|
73
|
+
onDisallowed: (f) => disallowed.push(f),
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
expect(where).toEqual({ status: "paid" });
|
|
77
|
+
expect(disallowed).toEqual(["secretCost"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("value rỗng → bỏ leaf; isNull/isNotNull không cần value", () => {
|
|
81
|
+
expect(
|
|
82
|
+
compileFilterTree({ field: "status", operator: "eq", value: "" }, allowAll),
|
|
83
|
+
).toBeNull();
|
|
84
|
+
expect(
|
|
85
|
+
compileFilterTree({ field: "deletedAt", operator: "isNull" }, allowAll),
|
|
86
|
+
).toEqual({ deletedAt: null });
|
|
87
|
+
expect(
|
|
88
|
+
compileFilterTree({ field: "email", operator: "isNotNull" }, allowAll),
|
|
89
|
+
).toEqual({ email: { not: null } });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("nhóm 1 phần tử được rút gọn, nhóm rỗng → null", () => {
|
|
93
|
+
expect(
|
|
94
|
+
compileFilterTree(
|
|
95
|
+
{ $or: [{ field: "status", operator: "eq", value: "paid" }] },
|
|
96
|
+
allowAll,
|
|
97
|
+
),
|
|
98
|
+
).toEqual({ status: "paid" });
|
|
99
|
+
expect(compileFilterTree({ $and: [] }, allowAll)).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("chặn cây quá sâu và quá nhiều điều kiện", () => {
|
|
103
|
+
let node: Record<string, unknown> = {
|
|
104
|
+
field: "a",
|
|
105
|
+
operator: "eq",
|
|
106
|
+
value: 1,
|
|
107
|
+
};
|
|
108
|
+
for (let i = 0; i < 6; i++) node = { $and: [node] };
|
|
109
|
+
expect(() => compileFilterTree(node, allowAll)).toThrow(/độ sâu/);
|
|
110
|
+
|
|
111
|
+
const leaves = Array.from({ length: 31 }, (_, i) => ({
|
|
112
|
+
field: `f${i}`,
|
|
113
|
+
operator: "eq",
|
|
114
|
+
value: i,
|
|
115
|
+
}));
|
|
116
|
+
expect(() => compileFilterTree({ $and: leaves }, allowAll)).toThrow(/điều kiện/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("cấu trúc sai → throw (route trả 4xx, không âm thầm bỏ lọc)", () => {
|
|
120
|
+
expect(() => compileFilterTree("x", allowAll)).toThrow();
|
|
121
|
+
expect(() => compileFilterTree({ $and: "x" }, allowAll)).toThrow();
|
|
122
|
+
expect(() => compileFilterTree({ operator: "eq" }, allowAll)).toThrow();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter tree DSL → Prisma where.
|
|
3
|
+
*
|
|
4
|
+
* Bổ khuyết cho filter phẳng `ActiveFilter[]` của list(): phẳng chỉ AND ngầm
|
|
5
|
+
* và hai điều kiện cùng field ĐÈ nhau (không diễn đạt được khoảng gte+lte hay
|
|
6
|
+
* nhóm OR). Tree cho phép:
|
|
7
|
+
*
|
|
8
|
+
* { $and: [ { field: "total", operator: "gte", value: 1e6 },
|
|
9
|
+
* { field: "total", operator: "lte", value: 5e6 },
|
|
10
|
+
* { $or: [ { field: "status", operator: "eq", value: "paid" },
|
|
11
|
+
* { field: "customer.name", operator: "contains", value: "an" } ] } ] }
|
|
12
|
+
*
|
|
13
|
+
* An toàn: mọi leaf đi qua CÙNG guard `isAllowed` với filter phẳng (field
|
|
14
|
+
* ngoài config bị bỏ + cảnh báo); chặn sâu (depth) và số leaf để không nhận
|
|
15
|
+
* cây tuỳ ý từ client.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface FilterTreeLeaf {
|
|
19
|
+
field: string;
|
|
20
|
+
operator: string;
|
|
21
|
+
value?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FilterTreeNode =
|
|
25
|
+
| { $and: FilterTreeNode[] }
|
|
26
|
+
| { $or: FilterTreeNode[] }
|
|
27
|
+
| FilterTreeLeaf;
|
|
28
|
+
|
|
29
|
+
const MAX_DEPTH = 4;
|
|
30
|
+
const MAX_LEAVES = 30;
|
|
31
|
+
|
|
32
|
+
/** Điều kiện Prisma cho một toán tử — dùng chung với filter phẳng. */
|
|
33
|
+
export function conditionForOperator(op: string, value: unknown): unknown {
|
|
34
|
+
switch (op) {
|
|
35
|
+
case "contains":
|
|
36
|
+
return { contains: value, mode: "insensitive" };
|
|
37
|
+
case "in":
|
|
38
|
+
return { in: value };
|
|
39
|
+
case "notIn":
|
|
40
|
+
return { notIn: value };
|
|
41
|
+
case "eq":
|
|
42
|
+
return value;
|
|
43
|
+
case "ne":
|
|
44
|
+
return { not: value };
|
|
45
|
+
case "gt":
|
|
46
|
+
return { gt: value };
|
|
47
|
+
case "gte":
|
|
48
|
+
return { gte: value };
|
|
49
|
+
case "lt":
|
|
50
|
+
return { lt: value };
|
|
51
|
+
case "lte":
|
|
52
|
+
return { lte: value };
|
|
53
|
+
case "startsWith":
|
|
54
|
+
return { startsWith: value, mode: "insensitive" };
|
|
55
|
+
case "endsWith":
|
|
56
|
+
return { endsWith: value, mode: "insensitive" };
|
|
57
|
+
case "isNull":
|
|
58
|
+
return null;
|
|
59
|
+
case "isNotNull":
|
|
60
|
+
return { not: null };
|
|
61
|
+
default:
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** isNull/isNotNull không cần value — mọi toán tử khác value rỗng là bỏ leaf. */
|
|
67
|
+
const VALUELESS_OPS = new Set(["isNull", "isNotNull"]);
|
|
68
|
+
|
|
69
|
+
export interface CompileFilterTreeOptions {
|
|
70
|
+
/** Cùng guard với filter phẳng: field/relation phải nằm trong entity config. */
|
|
71
|
+
isAllowed: (name: string) => boolean;
|
|
72
|
+
/** Gọi khi một leaf bị bỏ (field cấm) — để log cảnh báo. */
|
|
73
|
+
onDisallowed?: (field: string) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compile tree → Prisma where. Trả `null` khi cây rỗng/không còn leaf hợp lệ
|
|
78
|
+
* (caller bỏ qua). Throw khi cây sai cấu trúc hoặc vượt giới hạn — route trả
|
|
79
|
+
* 400 cho client sửa, không âm thầm nuốt.
|
|
80
|
+
*/
|
|
81
|
+
export function compileFilterTree(
|
|
82
|
+
node: unknown,
|
|
83
|
+
options: CompileFilterTreeOptions,
|
|
84
|
+
): Record<string, unknown> | null {
|
|
85
|
+
const budget = { leaves: 0 };
|
|
86
|
+
return compileNode(node, options, 0, budget);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compileNode(
|
|
90
|
+
node: unknown,
|
|
91
|
+
options: CompileFilterTreeOptions,
|
|
92
|
+
depth: number,
|
|
93
|
+
budget: { leaves: number },
|
|
94
|
+
): Record<string, unknown> | null {
|
|
95
|
+
if (node === null || node === undefined) return null;
|
|
96
|
+
if (typeof node !== "object" || Array.isArray(node)) {
|
|
97
|
+
throw new Error("filterTree: node phải là object");
|
|
98
|
+
}
|
|
99
|
+
if (depth > MAX_DEPTH) {
|
|
100
|
+
throw new Error(`filterTree: vượt độ sâu tối đa ${MAX_DEPTH}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const group = node as { $and?: unknown; $or?: unknown };
|
|
104
|
+
if (group.$and !== undefined || group.$or !== undefined) {
|
|
105
|
+
const isAnd = group.$and !== undefined;
|
|
106
|
+
const children = isAnd ? group.$and : group.$or;
|
|
107
|
+
if (!Array.isArray(children)) {
|
|
108
|
+
throw new Error(`filterTree: ${isAnd ? "$and" : "$or"} phải là mảng`);
|
|
109
|
+
}
|
|
110
|
+
const compiled = children
|
|
111
|
+
.map((child) => compileNode(child, options, depth + 1, budget))
|
|
112
|
+
.filter((c): c is Record<string, unknown> => c !== null);
|
|
113
|
+
if (compiled.length === 0) return null;
|
|
114
|
+
if (compiled.length === 1) return compiled[0];
|
|
115
|
+
return isAnd ? { AND: compiled } : { OR: compiled };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Leaf
|
|
119
|
+
const leaf = node as FilterTreeLeaf;
|
|
120
|
+
if (typeof leaf.field !== "string" || typeof leaf.operator !== "string") {
|
|
121
|
+
throw new Error("filterTree: leaf cần { field, operator }");
|
|
122
|
+
}
|
|
123
|
+
if (++budget.leaves > MAX_LEAVES) {
|
|
124
|
+
throw new Error(`filterTree: vượt ${MAX_LEAVES} điều kiện`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { field, operator, value } = leaf;
|
|
128
|
+
if (
|
|
129
|
+
!VALUELESS_OPS.has(operator) &&
|
|
130
|
+
(value === undefined ||
|
|
131
|
+
value === null ||
|
|
132
|
+
value === "" ||
|
|
133
|
+
(Array.isArray(value) && value.length === 0))
|
|
134
|
+
) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!options.isAllowed(field)) {
|
|
138
|
+
options.onDisallowed?.(field);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const condition = conditionForOperator(operator, value);
|
|
143
|
+
// Dotted path → lồng theo relation: "customer.name" → { customer: { name: cond } }
|
|
144
|
+
const parts = field.split(".");
|
|
145
|
+
let out: Record<string, unknown> = { [parts.pop()!]: condition };
|
|
146
|
+
while (parts.length) {
|
|
147
|
+
out = { [parts.pop()!]: out };
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
16
|
// });
|
|
17
17
|
|
|
18
|
+
import { compileFilterTree } from "./lib/filter-tree";
|
|
18
19
|
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
19
20
|
import { serializeDecimalFields } from "../utils/serialize";
|
|
20
21
|
|
|
@@ -260,6 +261,19 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
260
261
|
else target[key] = value;
|
|
261
262
|
}
|
|
262
263
|
}
|
|
264
|
+
// Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
|
|
265
|
+
// cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
|
|
266
|
+
// nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
|
|
267
|
+
if (params.filterTree) {
|
|
268
|
+
const compiled = compileFilterTree(params.filterTree, {
|
|
269
|
+
isAllowed,
|
|
270
|
+
onDisallowed: (field) =>
|
|
271
|
+
log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
|
|
272
|
+
});
|
|
273
|
+
if (compiled) {
|
|
274
|
+
where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
263
277
|
|
|
264
278
|
const orderBy: any = {};
|
|
265
279
|
const applySort = (field: string, direction: any) => {
|
package/src/crud/server.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -621,6 +621,8 @@ export interface CrudQueryParams {
|
|
|
621
621
|
search?: string;
|
|
622
622
|
sort?: SortingState;
|
|
623
623
|
filters?: ActiveFilter[];
|
|
624
|
+
/** Bộ lọc nâng cao dạng cây $and/$or — xem crud/lib/filter-tree. */
|
|
625
|
+
filterTree?: import("../crud/lib/filter-tree").FilterTreeNode;
|
|
624
626
|
}
|
|
625
627
|
|
|
626
628
|
export interface CrudResponse<T = unknown> {
|