@goplusvn/core 0.1.47 → 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.47",
4
+ "version": "0.1.49",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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) => {
@@ -24,3 +24,4 @@ export {
24
24
  createCrudItemHandlers,
25
25
  } from './crud-route-handlers'
26
26
  export type { CrudHandlerDeps } from './crud-route-handlers'
27
+ export { compileFilterTree, conditionForOperator, type FilterTreeLeaf, type FilterTreeNode } from "./lib/filter-tree";