@wordrhyme/auto-crud-server 0.2.0
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/LICENSE +21 -0
- package/README.md +712 -0
- package/dist/index.cjs +529 -0
- package/dist/index.d.cts +172 -0
- package/dist/index.d.ts +172 -0
- package/dist/index.js +498 -0
- package/package.json +67 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as _trpc_server2 from "@trpc/server";
|
|
3
|
+
import superjson from "superjson";
|
|
4
|
+
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
5
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
6
|
+
|
|
7
|
+
//#region src/trpc.d.ts
|
|
8
|
+
/**
|
|
9
|
+
* tRPC Context 类型
|
|
10
|
+
* 消费者需要提供包含 db 的 context
|
|
11
|
+
*/
|
|
12
|
+
interface Context {
|
|
13
|
+
db: PostgresJsDatabase<Record<string, never>>;
|
|
14
|
+
}
|
|
15
|
+
declare const t: _trpc_server2.TRPCRootObject<Context, object, {
|
|
16
|
+
transformer: typeof superjson;
|
|
17
|
+
}, {
|
|
18
|
+
ctx: Context;
|
|
19
|
+
meta: object;
|
|
20
|
+
errorShape: _trpc_server2.TRPCDefaultErrorShape;
|
|
21
|
+
transformer: true;
|
|
22
|
+
}>;
|
|
23
|
+
declare const router: _trpc_server2.TRPCRouterBuilder<{
|
|
24
|
+
ctx: Context;
|
|
25
|
+
meta: object;
|
|
26
|
+
errorShape: _trpc_server2.TRPCDefaultErrorShape;
|
|
27
|
+
transformer: true;
|
|
28
|
+
}>;
|
|
29
|
+
declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, object, object, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, false>;
|
|
30
|
+
type AnyProcedure = typeof t.procedure;
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/routers/_factory.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* 授权回调类型
|
|
35
|
+
* 返回 true 允许操作,返回 false 或抛出错误拒绝操作
|
|
36
|
+
*/
|
|
37
|
+
type AuthorizeCallback = (ctx: {
|
|
38
|
+
operation: "list" | "get" | "create" | "update" | "delete" | "deleteMany" | "updateMany";
|
|
39
|
+
input?: unknown;
|
|
40
|
+
}) => boolean | Promise<boolean>;
|
|
41
|
+
/**
|
|
42
|
+
* CRUD Router 配置
|
|
43
|
+
*/
|
|
44
|
+
interface CrudRouterConfig {
|
|
45
|
+
table: PgTable;
|
|
46
|
+
selectSchema: z.ZodType;
|
|
47
|
+
insertSchema: z.ZodType;
|
|
48
|
+
updateSchema: z.ZodType;
|
|
49
|
+
idField?: string;
|
|
50
|
+
/**
|
|
51
|
+
* 可过滤的列白名单
|
|
52
|
+
* 未指定时默认允许所有列(不安全,仅用于开发)
|
|
53
|
+
*/
|
|
54
|
+
filterableColumns?: string[];
|
|
55
|
+
/**
|
|
56
|
+
* 可排序的列白名单
|
|
57
|
+
* 未指定时默认允许所有列(不安全,仅用于开发)
|
|
58
|
+
*/
|
|
59
|
+
sortableColumns?: string[];
|
|
60
|
+
/**
|
|
61
|
+
* 批量操作的最大数量限制
|
|
62
|
+
* 默认: 100
|
|
63
|
+
*/
|
|
64
|
+
maxBatchSize?: number;
|
|
65
|
+
/**
|
|
66
|
+
* 自定义 procedure(用于注入授权中间件)
|
|
67
|
+
* 默认使用 publicProcedure
|
|
68
|
+
*/
|
|
69
|
+
procedure?: AnyProcedure;
|
|
70
|
+
/**
|
|
71
|
+
* 授权回调(简单场景下的快捷授权方式)
|
|
72
|
+
* 如果需要更复杂的授权逻辑,建议使用 procedure 注入中间件
|
|
73
|
+
*/
|
|
74
|
+
authorize?: AuthorizeCallback;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 创建通用 CRUD Router
|
|
78
|
+
*/
|
|
79
|
+
declare function createCrudRouter(config: CrudRouterConfig): _trpc_server2.TRPCBuiltRouter<{
|
|
80
|
+
ctx: Context;
|
|
81
|
+
meta: object;
|
|
82
|
+
errorShape: _trpc_server2.TRPCDefaultErrorShape;
|
|
83
|
+
transformer: true;
|
|
84
|
+
}, _trpc_server2.TRPCDecorateCreateRouterOptions<{
|
|
85
|
+
list: _trpc_server2.TRPCQueryProcedure<{
|
|
86
|
+
input: {
|
|
87
|
+
page?: number | undefined;
|
|
88
|
+
perPage?: number | undefined;
|
|
89
|
+
sort?: {
|
|
90
|
+
id: string;
|
|
91
|
+
desc: boolean;
|
|
92
|
+
}[] | undefined;
|
|
93
|
+
filters?: {
|
|
94
|
+
id: string;
|
|
95
|
+
value: string | string[];
|
|
96
|
+
variant: "number" | "boolean" | "date" | "text" | "range" | "dateRange" | "select" | "multiSelect";
|
|
97
|
+
operator: "iLike" | "notILike" | "eq" | "ne" | "isEmpty" | "isNotEmpty" | "lt" | "lte" | "gt" | "gte" | "isBetween" | "isRelativeToToday" | "inArray" | "notInArray";
|
|
98
|
+
filterId: string;
|
|
99
|
+
}[] | undefined;
|
|
100
|
+
joinOperator?: "and" | "or" | undefined;
|
|
101
|
+
};
|
|
102
|
+
output: {
|
|
103
|
+
data: {
|
|
104
|
+
[x: string]: unknown;
|
|
105
|
+
}[];
|
|
106
|
+
total: number;
|
|
107
|
+
page: number;
|
|
108
|
+
perPage: number;
|
|
109
|
+
pageCount: number;
|
|
110
|
+
};
|
|
111
|
+
meta: object;
|
|
112
|
+
}>;
|
|
113
|
+
getById: _trpc_server2.TRPCQueryProcedure<{
|
|
114
|
+
input: string;
|
|
115
|
+
output: {
|
|
116
|
+
[x: string]: unknown;
|
|
117
|
+
} | null;
|
|
118
|
+
meta: object;
|
|
119
|
+
}>;
|
|
120
|
+
create: _trpc_server2.TRPCMutationProcedure<{
|
|
121
|
+
input: unknown;
|
|
122
|
+
output: {
|
|
123
|
+
[x: string]: unknown;
|
|
124
|
+
} | undefined;
|
|
125
|
+
meta: object;
|
|
126
|
+
}>;
|
|
127
|
+
update: _trpc_server2.TRPCMutationProcedure<{
|
|
128
|
+
input: {
|
|
129
|
+
id: string;
|
|
130
|
+
data: unknown;
|
|
131
|
+
};
|
|
132
|
+
output: {
|
|
133
|
+
[x: string]: unknown;
|
|
134
|
+
} | undefined;
|
|
135
|
+
meta: object;
|
|
136
|
+
}>;
|
|
137
|
+
delete: _trpc_server2.TRPCMutationProcedure<{
|
|
138
|
+
input: string;
|
|
139
|
+
output: {
|
|
140
|
+
[x: string]: unknown;
|
|
141
|
+
} | undefined;
|
|
142
|
+
meta: object;
|
|
143
|
+
}>;
|
|
144
|
+
deleteMany: _trpc_server2.TRPCMutationProcedure<{
|
|
145
|
+
input: string[];
|
|
146
|
+
output: {
|
|
147
|
+
deleted: number;
|
|
148
|
+
};
|
|
149
|
+
meta: object;
|
|
150
|
+
}>;
|
|
151
|
+
updateMany: _trpc_server2.TRPCMutationProcedure<{
|
|
152
|
+
input: {
|
|
153
|
+
ids: string[];
|
|
154
|
+
data: unknown;
|
|
155
|
+
};
|
|
156
|
+
output: {
|
|
157
|
+
updated: number;
|
|
158
|
+
};
|
|
159
|
+
meta: object;
|
|
160
|
+
}>;
|
|
161
|
+
}>>;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/routers/index.d.ts
|
|
164
|
+
declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
|
|
165
|
+
ctx: Context;
|
|
166
|
+
meta: object;
|
|
167
|
+
errorShape: _trpc_server2.TRPCDefaultErrorShape;
|
|
168
|
+
transformer: true;
|
|
169
|
+
}, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
|
|
170
|
+
type AppRouter = typeof appRouter;
|
|
171
|
+
//#endregion
|
|
172
|
+
export { type AnyProcedure, type AppRouter, type AuthorizeCallback, type CrudRouterConfig, appRouter, createCrudRouter, publicProcedure, router };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { and, asc, desc, eq, gt, gte, ilike, inArray, isNull, lt, lte, ne, not, notIlike, notInArray, or, sql } from "drizzle-orm";
|
|
3
|
+
import { initTRPC } from "@trpc/server";
|
|
4
|
+
import superjson from "superjson";
|
|
5
|
+
import { addDays, endOfDay, startOfDay } from "date-fns";
|
|
6
|
+
|
|
7
|
+
//#region src/trpc.ts
|
|
8
|
+
const t = initTRPC.context().create({ transformer: superjson });
|
|
9
|
+
const router = t.router;
|
|
10
|
+
const publicProcedure = t.procedure;
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/lib/filter-columns.ts
|
|
14
|
+
/**
|
|
15
|
+
* 检查列是否为空
|
|
16
|
+
*/
|
|
17
|
+
function isEmpty(column) {
|
|
18
|
+
return or(isNull(column), eq(column, ""));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 安全解析日期时间戳
|
|
22
|
+
* @returns Date 对象,如果解析失败返回 null
|
|
23
|
+
*/
|
|
24
|
+
function safeParseDate(value) {
|
|
25
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
26
|
+
const timestamp = typeof value === "string" ? Number(value) : value;
|
|
27
|
+
if (Number.isNaN(timestamp)) return null;
|
|
28
|
+
const date = new Date(timestamp);
|
|
29
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
30
|
+
return date;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 安全解析数字
|
|
34
|
+
* @returns number,如果解析失败返回 null
|
|
35
|
+
*/
|
|
36
|
+
function safeParseNumber(value) {
|
|
37
|
+
if (value === void 0 || value === null || value.trim() === "") return null;
|
|
38
|
+
const num = Number(value);
|
|
39
|
+
return Number.isNaN(num) ? null : num;
|
|
40
|
+
}
|
|
41
|
+
function filterColumns({ table, filters, joinOperator }) {
|
|
42
|
+
const joinFn = joinOperator === "and" ? and : or;
|
|
43
|
+
const validConditions = filters.map((filter) => {
|
|
44
|
+
const column = getColumn(table, filter.id);
|
|
45
|
+
switch (filter.operator) {
|
|
46
|
+
case "iLike": return filter.variant === "text" && typeof filter.value === "string" ? ilike(column, `%${filter.value}%`) : void 0;
|
|
47
|
+
case "notILike": return filter.variant === "text" && typeof filter.value === "string" ? notIlike(column, `%${filter.value}%`) : void 0;
|
|
48
|
+
case "eq":
|
|
49
|
+
if (column.dataType === "boolean" && typeof filter.value === "string") return eq(column, filter.value === "true");
|
|
50
|
+
if (filter.variant === "dateRange" && Array.isArray(filter.value) && filter.value.length === 2) {
|
|
51
|
+
const startDate = safeParseDate(filter.value[0]);
|
|
52
|
+
const endDate = safeParseDate(filter.value[1]);
|
|
53
|
+
if (!startDate && !endDate) return void 0;
|
|
54
|
+
if (startDate) startDate.setHours(0, 0, 0, 0);
|
|
55
|
+
if (endDate) endDate.setHours(23, 59, 59, 999);
|
|
56
|
+
return and(startDate ? gte(column, startDate) : void 0, endDate ? lte(column, endDate) : void 0);
|
|
57
|
+
}
|
|
58
|
+
if (filter.variant === "date" && typeof filter.value === "string") {
|
|
59
|
+
const date = safeParseDate(filter.value);
|
|
60
|
+
if (!date) return void 0;
|
|
61
|
+
date.setHours(0, 0, 0, 0);
|
|
62
|
+
const end = new Date(date);
|
|
63
|
+
end.setHours(23, 59, 59, 999);
|
|
64
|
+
return and(gte(column, date), lte(column, end));
|
|
65
|
+
}
|
|
66
|
+
return eq(column, filter.value);
|
|
67
|
+
case "ne":
|
|
68
|
+
if (column.dataType === "boolean" && typeof filter.value === "string") return ne(column, filter.value === "true");
|
|
69
|
+
if (filter.variant === "date" || filter.variant === "dateRange") {
|
|
70
|
+
const date = safeParseDate(filter.value);
|
|
71
|
+
if (!date) return void 0;
|
|
72
|
+
date.setHours(0, 0, 0, 0);
|
|
73
|
+
const end = new Date(date);
|
|
74
|
+
end.setHours(23, 59, 59, 999);
|
|
75
|
+
return or(lt(column, date), gt(column, end));
|
|
76
|
+
}
|
|
77
|
+
return ne(column, filter.value);
|
|
78
|
+
case "inArray":
|
|
79
|
+
if (Array.isArray(filter.value)) return inArray(column, filter.value);
|
|
80
|
+
return;
|
|
81
|
+
case "notInArray":
|
|
82
|
+
if (Array.isArray(filter.value)) return notInArray(column, filter.value);
|
|
83
|
+
return;
|
|
84
|
+
case "lt":
|
|
85
|
+
if (filter.variant === "number" || filter.variant === "range") {
|
|
86
|
+
const num = safeParseNumber(filter.value);
|
|
87
|
+
return num !== null ? lt(column, num) : void 0;
|
|
88
|
+
}
|
|
89
|
+
if (filter.variant === "date" && typeof filter.value === "string") {
|
|
90
|
+
const date = safeParseDate(filter.value);
|
|
91
|
+
if (!date) return void 0;
|
|
92
|
+
date.setHours(23, 59, 59, 999);
|
|
93
|
+
return lt(column, date);
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
case "lte":
|
|
97
|
+
if (filter.variant === "number" || filter.variant === "range") {
|
|
98
|
+
const num = safeParseNumber(filter.value);
|
|
99
|
+
return num !== null ? lte(column, num) : void 0;
|
|
100
|
+
}
|
|
101
|
+
if (filter.variant === "date" && typeof filter.value === "string") {
|
|
102
|
+
const date = safeParseDate(filter.value);
|
|
103
|
+
if (!date) return void 0;
|
|
104
|
+
date.setHours(23, 59, 59, 999);
|
|
105
|
+
return lte(column, date);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
case "gt":
|
|
109
|
+
if (filter.variant === "number" || filter.variant === "range") {
|
|
110
|
+
const num = safeParseNumber(filter.value);
|
|
111
|
+
return num !== null ? gt(column, num) : void 0;
|
|
112
|
+
}
|
|
113
|
+
if (filter.variant === "date" && typeof filter.value === "string") {
|
|
114
|
+
const date = safeParseDate(filter.value);
|
|
115
|
+
if (!date) return void 0;
|
|
116
|
+
date.setHours(0, 0, 0, 0);
|
|
117
|
+
return gt(column, date);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
case "gte":
|
|
121
|
+
if (filter.variant === "number" || filter.variant === "range") {
|
|
122
|
+
const num = safeParseNumber(filter.value);
|
|
123
|
+
return num !== null ? gte(column, num) : void 0;
|
|
124
|
+
}
|
|
125
|
+
if (filter.variant === "date" && typeof filter.value === "string") {
|
|
126
|
+
const date = safeParseDate(filter.value);
|
|
127
|
+
if (!date) return void 0;
|
|
128
|
+
date.setHours(0, 0, 0, 0);
|
|
129
|
+
return gte(column, date);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
case "isBetween":
|
|
133
|
+
if ((filter.variant === "date" || filter.variant === "dateRange") && Array.isArray(filter.value) && filter.value.length === 2) {
|
|
134
|
+
const startDate = safeParseDate(filter.value[0]);
|
|
135
|
+
const endDate = safeParseDate(filter.value[1]);
|
|
136
|
+
if (!startDate && !endDate) return void 0;
|
|
137
|
+
if (startDate) startDate.setHours(0, 0, 0, 0);
|
|
138
|
+
if (endDate) endDate.setHours(23, 59, 59, 999);
|
|
139
|
+
return and(startDate ? gte(column, startDate) : void 0, endDate ? lte(column, endDate) : void 0);
|
|
140
|
+
}
|
|
141
|
+
if ((filter.variant === "number" || filter.variant === "range") && Array.isArray(filter.value) && filter.value.length === 2) {
|
|
142
|
+
const firstValue = safeParseNumber(filter.value[0]);
|
|
143
|
+
const secondValue = safeParseNumber(filter.value[1]);
|
|
144
|
+
if (firstValue === null && secondValue === null) return;
|
|
145
|
+
if (firstValue !== null && secondValue === null) return eq(column, firstValue);
|
|
146
|
+
if (firstValue === null && secondValue !== null) return eq(column, secondValue);
|
|
147
|
+
return and(firstValue !== null ? gte(column, firstValue) : void 0, secondValue !== null ? lte(column, secondValue) : void 0);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
case "isRelativeToToday":
|
|
151
|
+
if ((filter.variant === "date" || filter.variant === "dateRange") && typeof filter.value === "string") {
|
|
152
|
+
const today = /* @__PURE__ */ new Date();
|
|
153
|
+
const [amount, unit] = filter.value.split(" ") ?? [];
|
|
154
|
+
let startDate;
|
|
155
|
+
let endDate;
|
|
156
|
+
if (!amount || !unit) return void 0;
|
|
157
|
+
switch (unit) {
|
|
158
|
+
case "days":
|
|
159
|
+
startDate = startOfDay(addDays(today, Number.parseInt(amount, 10)));
|
|
160
|
+
endDate = endOfDay(startDate);
|
|
161
|
+
break;
|
|
162
|
+
case "weeks":
|
|
163
|
+
startDate = startOfDay(addDays(today, Number.parseInt(amount, 10) * 7));
|
|
164
|
+
endDate = endOfDay(addDays(startDate, 6));
|
|
165
|
+
break;
|
|
166
|
+
case "months":
|
|
167
|
+
startDate = startOfDay(addDays(today, Number.parseInt(amount, 10) * 30));
|
|
168
|
+
endDate = endOfDay(addDays(startDate, 29));
|
|
169
|
+
break;
|
|
170
|
+
default: return;
|
|
171
|
+
}
|
|
172
|
+
return and(gte(column, startDate), lte(column, endDate));
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
case "isEmpty": return isEmpty(column);
|
|
176
|
+
case "isNotEmpty": return not(isEmpty(column));
|
|
177
|
+
default: throw new Error(`Unsupported operator: ${filter.operator}`);
|
|
178
|
+
}
|
|
179
|
+
}).filter((condition) => condition !== void 0);
|
|
180
|
+
return validConditions.length > 0 ? joinFn(...validConditions) : void 0;
|
|
181
|
+
}
|
|
182
|
+
function getColumn(table, columnKey) {
|
|
183
|
+
return table[columnKey];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/config/data-table.ts
|
|
188
|
+
const dataTableConfig = {
|
|
189
|
+
textOperators: [
|
|
190
|
+
{
|
|
191
|
+
label: "Contains",
|
|
192
|
+
value: "iLike"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
label: "Does not contain",
|
|
196
|
+
value: "notILike"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
label: "Is",
|
|
200
|
+
value: "eq"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
label: "Is not",
|
|
204
|
+
value: "ne"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
label: "Is empty",
|
|
208
|
+
value: "isEmpty"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
label: "Is not empty",
|
|
212
|
+
value: "isNotEmpty"
|
|
213
|
+
}
|
|
214
|
+
],
|
|
215
|
+
numericOperators: [
|
|
216
|
+
{
|
|
217
|
+
label: "Is",
|
|
218
|
+
value: "eq"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
label: "Is not",
|
|
222
|
+
value: "ne"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
label: "Is less than",
|
|
226
|
+
value: "lt"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
label: "Is less than or equal to",
|
|
230
|
+
value: "lte"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
label: "Is greater than",
|
|
234
|
+
value: "gt"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
label: "Is greater than or equal to",
|
|
238
|
+
value: "gte"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
label: "Is between",
|
|
242
|
+
value: "isBetween"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
label: "Is empty",
|
|
246
|
+
value: "isEmpty"
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
label: "Is not empty",
|
|
250
|
+
value: "isNotEmpty"
|
|
251
|
+
}
|
|
252
|
+
],
|
|
253
|
+
dateOperators: [
|
|
254
|
+
{
|
|
255
|
+
label: "Is",
|
|
256
|
+
value: "eq"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
label: "Is not",
|
|
260
|
+
value: "ne"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
label: "Is before",
|
|
264
|
+
value: "lt"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
label: "Is after",
|
|
268
|
+
value: "gt"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
label: "Is on or before",
|
|
272
|
+
value: "lte"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
label: "Is on or after",
|
|
276
|
+
value: "gte"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
label: "Is between",
|
|
280
|
+
value: "isBetween"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
label: "Is relative to today",
|
|
284
|
+
value: "isRelativeToToday"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
label: "Is empty",
|
|
288
|
+
value: "isEmpty"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
label: "Is not empty",
|
|
292
|
+
value: "isNotEmpty"
|
|
293
|
+
}
|
|
294
|
+
],
|
|
295
|
+
selectOperators: [
|
|
296
|
+
{
|
|
297
|
+
label: "Is",
|
|
298
|
+
value: "eq"
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
label: "Is not",
|
|
302
|
+
value: "ne"
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
label: "Is empty",
|
|
306
|
+
value: "isEmpty"
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
label: "Is not empty",
|
|
310
|
+
value: "isNotEmpty"
|
|
311
|
+
}
|
|
312
|
+
],
|
|
313
|
+
multiSelectOperators: [
|
|
314
|
+
{
|
|
315
|
+
label: "Has any of",
|
|
316
|
+
value: "inArray"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
label: "Has none of",
|
|
320
|
+
value: "notInArray"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
label: "Is empty",
|
|
324
|
+
value: "isEmpty"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
label: "Is not empty",
|
|
328
|
+
value: "isNotEmpty"
|
|
329
|
+
}
|
|
330
|
+
],
|
|
331
|
+
booleanOperators: [{
|
|
332
|
+
label: "Is",
|
|
333
|
+
value: "eq"
|
|
334
|
+
}, {
|
|
335
|
+
label: "Is not",
|
|
336
|
+
value: "ne"
|
|
337
|
+
}],
|
|
338
|
+
sortOrders: [{
|
|
339
|
+
label: "Asc",
|
|
340
|
+
value: "asc"
|
|
341
|
+
}, {
|
|
342
|
+
label: "Desc",
|
|
343
|
+
value: "desc"
|
|
344
|
+
}],
|
|
345
|
+
filterVariants: [
|
|
346
|
+
"text",
|
|
347
|
+
"number",
|
|
348
|
+
"range",
|
|
349
|
+
"date",
|
|
350
|
+
"dateRange",
|
|
351
|
+
"boolean",
|
|
352
|
+
"select",
|
|
353
|
+
"multiSelect"
|
|
354
|
+
],
|
|
355
|
+
operators: [
|
|
356
|
+
"iLike",
|
|
357
|
+
"notILike",
|
|
358
|
+
"eq",
|
|
359
|
+
"ne",
|
|
360
|
+
"inArray",
|
|
361
|
+
"notInArray",
|
|
362
|
+
"isEmpty",
|
|
363
|
+
"isNotEmpty",
|
|
364
|
+
"lt",
|
|
365
|
+
"lte",
|
|
366
|
+
"gt",
|
|
367
|
+
"gte",
|
|
368
|
+
"isBetween",
|
|
369
|
+
"isRelativeToToday"
|
|
370
|
+
],
|
|
371
|
+
joinOperators: ["and", "or"]
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/routers/_factory.ts
|
|
376
|
+
/**
|
|
377
|
+
* 过滤器项 Schema
|
|
378
|
+
*/
|
|
379
|
+
const filterItemSchema = z.object({
|
|
380
|
+
id: z.string(),
|
|
381
|
+
value: z.union([z.string(), z.array(z.string())]),
|
|
382
|
+
variant: z.enum(dataTableConfig.filterVariants),
|
|
383
|
+
operator: z.enum(dataTableConfig.operators),
|
|
384
|
+
filterId: z.string()
|
|
385
|
+
});
|
|
386
|
+
/**
|
|
387
|
+
* 列表查询输入 Schema
|
|
388
|
+
*/
|
|
389
|
+
const listInputSchema = z.object({
|
|
390
|
+
page: z.number().min(1).default(1),
|
|
391
|
+
perPage: z.number().min(1).max(100).default(10),
|
|
392
|
+
sort: z.array(z.object({
|
|
393
|
+
id: z.string(),
|
|
394
|
+
desc: z.boolean()
|
|
395
|
+
})).optional(),
|
|
396
|
+
filters: z.array(filterItemSchema).optional(),
|
|
397
|
+
joinOperator: z.enum(["and", "or"]).default("and")
|
|
398
|
+
});
|
|
399
|
+
/**
|
|
400
|
+
* 验证列是否在白名单中
|
|
401
|
+
*/
|
|
402
|
+
function validateColumn(columnId, allowedColumns, columnType) {
|
|
403
|
+
if (!allowedColumns || allowedColumns.length === 0) return true;
|
|
404
|
+
return allowedColumns.includes(columnId);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* 创建通用 CRUD Router
|
|
408
|
+
*/
|
|
409
|
+
function createCrudRouter(config) {
|
|
410
|
+
const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, procedure: baseProcedure = publicProcedure, authorize } = config;
|
|
411
|
+
const getIdColumn = () => table[idField];
|
|
412
|
+
const withAuthorize = async (operation, input) => {
|
|
413
|
+
if (authorize) {
|
|
414
|
+
if (!await authorize({
|
|
415
|
+
operation,
|
|
416
|
+
input
|
|
417
|
+
})) throw new Error(`Unauthorized: ${operation} operation not allowed`);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
return router({
|
|
421
|
+
list: baseProcedure.input(listInputSchema).query(async ({ ctx, input }) => {
|
|
422
|
+
await withAuthorize("list", input);
|
|
423
|
+
const offset = (input.page - 1) * input.perPage;
|
|
424
|
+
const validatedFilters = input.filters?.filter((filter) => validateColumn(filter.id, filterableColumns, "filter"));
|
|
425
|
+
const where = validatedFilters?.length ? filterColumns({
|
|
426
|
+
table,
|
|
427
|
+
filters: validatedFilters,
|
|
428
|
+
joinOperator: input.joinOperator
|
|
429
|
+
}) : void 0;
|
|
430
|
+
let query = ctx.db.select().from(table).$dynamic();
|
|
431
|
+
if (where) query = query.where(where);
|
|
432
|
+
if (input.sort?.length) {
|
|
433
|
+
const sortField = input.sort[0];
|
|
434
|
+
if (sortField && validateColumn(sortField.id, sortableColumns, "sort")) {
|
|
435
|
+
const column = table[sortField.id];
|
|
436
|
+
if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const data = await query.limit(input.perPage).offset(offset);
|
|
440
|
+
let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
|
|
441
|
+
if (where) countQuery = countQuery.where(where);
|
|
442
|
+
const count = (await countQuery)[0]?.count ?? 0;
|
|
443
|
+
return {
|
|
444
|
+
data,
|
|
445
|
+
total: count,
|
|
446
|
+
page: input.page,
|
|
447
|
+
perPage: input.perPage,
|
|
448
|
+
pageCount: Math.ceil(count / input.perPage)
|
|
449
|
+
};
|
|
450
|
+
}),
|
|
451
|
+
getById: baseProcedure.input(z.string()).query(async ({ ctx, input }) => {
|
|
452
|
+
await withAuthorize("get", input);
|
|
453
|
+
const column = getIdColumn();
|
|
454
|
+
const [item] = await ctx.db.select().from(table).where(eq(column, input));
|
|
455
|
+
return item ?? null;
|
|
456
|
+
}),
|
|
457
|
+
create: baseProcedure.input(insertSchema).mutation(async ({ ctx, input }) => {
|
|
458
|
+
await withAuthorize("create", input);
|
|
459
|
+
const [created] = await ctx.db.insert(table).values(input).returning();
|
|
460
|
+
return created;
|
|
461
|
+
}),
|
|
462
|
+
update: baseProcedure.input(z.object({
|
|
463
|
+
id: z.string(),
|
|
464
|
+
data: updateSchema
|
|
465
|
+
})).mutation(async ({ ctx, input }) => {
|
|
466
|
+
await withAuthorize("update", input);
|
|
467
|
+
const column = getIdColumn();
|
|
468
|
+
const [updated] = await ctx.db.update(table).set(input.data).where(eq(column, input.id)).returning();
|
|
469
|
+
return updated;
|
|
470
|
+
}),
|
|
471
|
+
delete: baseProcedure.input(z.string()).mutation(async ({ ctx, input }) => {
|
|
472
|
+
await withAuthorize("delete", input);
|
|
473
|
+
const column = getIdColumn();
|
|
474
|
+
const [deleted] = await ctx.db.delete(table).where(eq(column, input)).returning();
|
|
475
|
+
return deleted;
|
|
476
|
+
}),
|
|
477
|
+
deleteMany: baseProcedure.input(z.array(z.string()).max(maxBatchSize, `Maximum ${maxBatchSize} items allowed`)).mutation(async ({ ctx, input }) => {
|
|
478
|
+
await withAuthorize("deleteMany", input);
|
|
479
|
+
const column = getIdColumn();
|
|
480
|
+
return { deleted: (await ctx.db.delete(table).where(inArray(column, input)).returning()).length };
|
|
481
|
+
}),
|
|
482
|
+
updateMany: baseProcedure.input(z.object({
|
|
483
|
+
ids: z.array(z.string()).max(maxBatchSize, `Maximum ${maxBatchSize} items allowed`),
|
|
484
|
+
data: updateSchema
|
|
485
|
+
})).mutation(async ({ ctx, input }) => {
|
|
486
|
+
await withAuthorize("updateMany", input);
|
|
487
|
+
const column = getIdColumn();
|
|
488
|
+
return { updated: (await ctx.db.update(table).set(input.data).where(inArray(column, input.ids)).returning()).length };
|
|
489
|
+
})
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/routers/index.ts
|
|
495
|
+
const appRouter = router({});
|
|
496
|
+
|
|
497
|
+
//#endregion
|
|
498
|
+
export { appRouter, createCrudRouter, publicProcedure, router };
|