@wordrhyme/auto-crud-server 0.2.0 → 0.3.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/README.md +94 -35
- package/dist/index.cjs +138 -52
- package/dist/index.d.cts +166 -129
- package/dist/index.d.ts +165 -127
- package/dist/index.js +138 -52
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ export { handler as GET, handler as POST };
|
|
|
136
136
|
|
|
137
137
|
## 📖 自动生成的路由
|
|
138
138
|
|
|
139
|
-
`createCrudRouter` 会自动生成以下
|
|
139
|
+
`createCrudRouter` 会自动生成以下 7 个路由:
|
|
140
140
|
|
|
141
141
|
### 1. `list` - 列表查询
|
|
142
142
|
|
|
@@ -276,6 +276,29 @@ void
|
|
|
276
276
|
await trpc.tasks.deleteMany({ ids: ["1", "2", "3"] });
|
|
277
277
|
```
|
|
278
278
|
|
|
279
|
+
### 7. `updateMany` - 批量更新
|
|
280
|
+
|
|
281
|
+
**输入**:
|
|
282
|
+
```typescript
|
|
283
|
+
{
|
|
284
|
+
ids: string[];
|
|
285
|
+
data: Partial<Omit<Task, "id" | "createdAt" | "updatedAt">>;
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**输出**:
|
|
290
|
+
```typescript
|
|
291
|
+
{ updated: number }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**示例**:
|
|
295
|
+
```typescript
|
|
296
|
+
await trpc.tasks.updateMany({
|
|
297
|
+
ids: ["1", "2", "3"],
|
|
298
|
+
data: { status: "done" },
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
279
302
|
---
|
|
280
303
|
|
|
281
304
|
## 🎯 高级过滤
|
|
@@ -392,13 +415,21 @@ interface CrudRouterConfig<TTable, TSelect, TInsert, TUpdate> {
|
|
|
392
415
|
#### 返回值
|
|
393
416
|
|
|
394
417
|
```typescript
|
|
418
|
+
// 返回增强的 tRPC router,带有 .procedures 属性
|
|
395
419
|
{
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
420
|
+
// 标准 tRPC router(可直接嵌套到 appRouter)
|
|
421
|
+
...routerMethods,
|
|
422
|
+
|
|
423
|
+
// 可 spread 的 procedures 对象(用于扩展自定义路由)
|
|
424
|
+
procedures: {
|
|
425
|
+
list: Procedure<ListInput, ListOutput>,
|
|
426
|
+
getById: Procedure<string, TSelect>,
|
|
427
|
+
create: Procedure<TInsert, TSelect>,
|
|
428
|
+
update: Procedure<{ id: string, data: TUpdate }, TSelect>,
|
|
429
|
+
delete: Procedure<string, TSelect>,
|
|
430
|
+
deleteMany: Procedure<string[], { deleted: number }>,
|
|
431
|
+
updateMany: Procedure<{ ids: string[], data: TUpdate }, { updated: number }>,
|
|
432
|
+
}
|
|
402
433
|
}
|
|
403
434
|
```
|
|
404
435
|
|
|
@@ -487,24 +518,29 @@ const result = await db
|
|
|
487
518
|
|
|
488
519
|
## 🎨 自定义扩展
|
|
489
520
|
|
|
490
|
-
###
|
|
521
|
+
### 添加自定义路由(推荐方式)
|
|
522
|
+
|
|
523
|
+
使用 `.procedures` 属性 spread 出 CRUD 路由,然后添加自定义路由:
|
|
491
524
|
|
|
492
525
|
```typescript
|
|
493
|
-
import { createCrudRouter } from "@wordrhyme/auto-crud-server";
|
|
494
|
-
import {
|
|
526
|
+
import { createCrudRouter, router } from "@wordrhyme/auto-crud-server";
|
|
527
|
+
import { protectedProcedure } from "../trpc";
|
|
528
|
+
import { z } from "zod";
|
|
495
529
|
|
|
496
|
-
|
|
530
|
+
// 创建基础 CRUD
|
|
531
|
+
const tasksCrud = createCrudRouter({
|
|
497
532
|
table: tasks,
|
|
498
533
|
selectSchema: selectTaskSchema,
|
|
499
534
|
insertSchema: insertTaskSchema,
|
|
500
535
|
updateSchema: updateTaskSchema,
|
|
501
536
|
});
|
|
502
537
|
|
|
503
|
-
|
|
504
|
-
|
|
538
|
+
// 使用 .procedures spread 并添加自定义路由
|
|
539
|
+
export const tasksRouter = router({
|
|
540
|
+
...tasksCrud.procedures,
|
|
505
541
|
|
|
506
|
-
//
|
|
507
|
-
archive:
|
|
542
|
+
// 自定义路由:归档
|
|
543
|
+
archive: protectedProcedure
|
|
508
544
|
.input(z.object({ id: z.string() }))
|
|
509
545
|
.mutation(async ({ input, ctx }) => {
|
|
510
546
|
return ctx.db
|
|
@@ -513,7 +549,8 @@ export const tasksRouter = {
|
|
|
513
549
|
.where(eq(tasks.id, input.id));
|
|
514
550
|
}),
|
|
515
551
|
|
|
516
|
-
|
|
552
|
+
// 自定义路由:取消归档
|
|
553
|
+
unarchive: protectedProcedure
|
|
517
554
|
.input(z.object({ id: z.string() }))
|
|
518
555
|
.mutation(async ({ input, ctx }) => {
|
|
519
556
|
return ctx.db
|
|
@@ -521,30 +558,52 @@ export const tasksRouter = {
|
|
|
521
558
|
.set({ archived: false })
|
|
522
559
|
.where(eq(tasks.id, input.id));
|
|
523
560
|
}),
|
|
524
|
-
};
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### 直接使用(不需要扩展时)
|
|
565
|
+
|
|
566
|
+
如果不需要添加自定义路由,可以直接使用返回的 router:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
const tasksCrud = createCrudRouter({
|
|
570
|
+
table: tasks,
|
|
571
|
+
selectSchema: selectTaskSchema,
|
|
572
|
+
insertSchema: insertTaskSchema,
|
|
573
|
+
updateSchema: updateTaskSchema,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// 直接嵌套到 appRouter
|
|
577
|
+
export const appRouter = router({
|
|
578
|
+
tasks: tasksCrud,
|
|
579
|
+
});
|
|
525
580
|
```
|
|
526
581
|
|
|
527
582
|
### 自定义过滤逻辑
|
|
528
583
|
|
|
529
584
|
```typescript
|
|
530
|
-
//
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
585
|
+
// 覆盖 list 路由,添加自定义过滤逻辑
|
|
586
|
+
const tasksCrud = createCrudRouter({ ... });
|
|
587
|
+
|
|
588
|
+
export const tasksRouter = router({
|
|
589
|
+
...tasksCrud.procedures,
|
|
590
|
+
|
|
591
|
+
// 覆盖 list,添加自定义逻辑
|
|
592
|
+
list: protectedProcedure
|
|
593
|
+
.input(listInputSchema)
|
|
594
|
+
.query(async ({ input, ctx }) => {
|
|
595
|
+
// 自定义过滤逻辑
|
|
596
|
+
const customFilters = input.filters?.map(filter => {
|
|
597
|
+
if (filter.id === "customField") {
|
|
598
|
+
return { ...filter, operator: "custom" };
|
|
599
|
+
}
|
|
600
|
+
return filter;
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// 调用原始 list(需要手动实现或使用 db 查询)
|
|
604
|
+
return ctx.db.select().from(tasks).where(...);
|
|
605
|
+
}),
|
|
606
|
+
});
|
|
548
607
|
```
|
|
549
608
|
|
|
550
609
|
---
|
package/dist/index.cjs
CHANGED
|
@@ -401,9 +401,58 @@ const dataTableConfig = {
|
|
|
401
401
|
|
|
402
402
|
//#endregion
|
|
403
403
|
//#region src/routers/_factory.ts
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
function resolveSoftDelete(option) {
|
|
405
|
+
if (!option) return null;
|
|
406
|
+
if (option === true) return {
|
|
407
|
+
column: "deletedAt",
|
|
408
|
+
getValue: () => /* @__PURE__ */ new Date()
|
|
409
|
+
};
|
|
410
|
+
if (typeof option === "string") return {
|
|
411
|
+
column: option,
|
|
412
|
+
getValue: () => /* @__PURE__ */ new Date()
|
|
413
|
+
};
|
|
414
|
+
const config = option;
|
|
415
|
+
return {
|
|
416
|
+
column: config.column,
|
|
417
|
+
getValue: config.value ?? (() => /* @__PURE__ */ new Date())
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function resolveConfig(config) {
|
|
421
|
+
const mode = config.mode || "declarative";
|
|
422
|
+
if (mode === "factory") {
|
|
423
|
+
const factoryConfig = config;
|
|
424
|
+
return {
|
|
425
|
+
procedureFactory: factoryConfig.procedureFactory,
|
|
426
|
+
guard: void 0,
|
|
427
|
+
getScope: () => void 0,
|
|
428
|
+
getInject: (ctx, op) => factoryConfig.inject?.(ctx, op) ?? {},
|
|
429
|
+
checkAuthorize: async () => true
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (mode === "procedures") {
|
|
433
|
+
const proceduresConfig = config;
|
|
434
|
+
const defaultProc = proceduresConfig.defaultProcedure ?? publicProcedure;
|
|
435
|
+
return {
|
|
436
|
+
procedureFactory: (op) => proceduresConfig.procedures[op] ?? defaultProc,
|
|
437
|
+
guard: void 0,
|
|
438
|
+
getScope: () => void 0,
|
|
439
|
+
getInject: (ctx, op) => proceduresConfig.inject?.(ctx, op) ?? {},
|
|
440
|
+
checkAuthorize: async () => true
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const declarativeConfig = config;
|
|
444
|
+
const baseProcedure = declarativeConfig.procedure ?? publicProcedure;
|
|
445
|
+
return {
|
|
446
|
+
procedureFactory: () => baseProcedure,
|
|
447
|
+
guard: declarativeConfig.guard,
|
|
448
|
+
getScope: (ctx, tbl, op) => declarativeConfig.scope?.(ctx, tbl, op),
|
|
449
|
+
getInject: (ctx, op) => declarativeConfig.inject?.(ctx, op) ?? {},
|
|
450
|
+
checkAuthorize: async (ctx, resource, op) => {
|
|
451
|
+
if (declarativeConfig.authorize) return declarativeConfig.authorize(ctx, resource, op);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
407
456
|
const filterItemSchema = zod.z.object({
|
|
408
457
|
id: zod.z.string(),
|
|
409
458
|
value: zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]),
|
|
@@ -411,9 +460,6 @@ const filterItemSchema = zod.z.object({
|
|
|
411
460
|
operator: zod.z.enum(dataTableConfig.operators),
|
|
412
461
|
filterId: zod.z.string()
|
|
413
462
|
});
|
|
414
|
-
/**
|
|
415
|
-
* 列表查询输入 Schema
|
|
416
|
-
*/
|
|
417
463
|
const listInputSchema = zod.z.object({
|
|
418
464
|
page: zod.z.number().min(1).default(1),
|
|
419
465
|
perPage: zod.z.number().min(1).max(100).default(10),
|
|
@@ -424,42 +470,53 @@ const listInputSchema = zod.z.object({
|
|
|
424
470
|
filters: zod.z.array(filterItemSchema).optional(),
|
|
425
471
|
joinOperator: zod.z.enum(["and", "or"]).default("and")
|
|
426
472
|
});
|
|
427
|
-
|
|
428
|
-
* 验证列是否在白名单中
|
|
429
|
-
*/
|
|
430
|
-
function validateColumn(columnId, allowedColumns, columnType) {
|
|
473
|
+
function validateColumn(columnId, allowedColumns) {
|
|
431
474
|
if (!allowedColumns || allowedColumns.length === 0) return true;
|
|
432
475
|
return allowedColumns.includes(columnId);
|
|
433
476
|
}
|
|
434
|
-
/**
|
|
435
|
-
* 创建通用 CRUD Router
|
|
436
|
-
*/
|
|
437
477
|
function createCrudRouter(config) {
|
|
438
|
-
const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100,
|
|
478
|
+
const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption } = config;
|
|
479
|
+
const resolved = resolveConfig(config);
|
|
480
|
+
const softDelete = resolveSoftDelete(softDeleteOption);
|
|
439
481
|
const getIdColumn = () => table[idField];
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
482
|
+
const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
|
|
483
|
+
const buildWhere = (ctx, operation, additionalCondition) => {
|
|
484
|
+
const conditions = [];
|
|
485
|
+
const scopeCondition = resolved.getScope(ctx, table, operation);
|
|
486
|
+
if (scopeCondition) conditions.push(scopeCondition);
|
|
487
|
+
if (softDelete && (operation === "list" || operation === "get")) {
|
|
488
|
+
const col = getSoftDeleteColumn();
|
|
489
|
+
if (col) conditions.push((0, drizzle_orm.isNull)(col));
|
|
490
|
+
}
|
|
491
|
+
if (additionalCondition) conditions.push(additionalCondition);
|
|
492
|
+
if (conditions.length === 0) return void 0;
|
|
493
|
+
if (conditions.length === 1) return conditions[0];
|
|
494
|
+
return (0, drizzle_orm.and)(...conditions);
|
|
495
|
+
};
|
|
496
|
+
const withGuard = async (ctx, operation) => {
|
|
497
|
+
if (resolved.guard) {
|
|
498
|
+
if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
|
|
446
499
|
}
|
|
447
500
|
};
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
501
|
+
const withAuthorize = async (ctx, resource, operation) => {
|
|
502
|
+
if (!resource) return;
|
|
503
|
+
if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new Error(`Forbidden: Cannot ${operation} this resource`);
|
|
504
|
+
};
|
|
505
|
+
const procedures = {
|
|
506
|
+
list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
|
|
507
|
+
await withGuard(ctx, "list");
|
|
451
508
|
const offset = (input.page - 1) * input.perPage;
|
|
452
|
-
const validatedFilters = input.filters?.filter((filter) => validateColumn(filter.id, filterableColumns
|
|
453
|
-
const where = validatedFilters?.length ? filterColumns({
|
|
509
|
+
const validatedFilters = input.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
|
|
510
|
+
const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
|
|
454
511
|
table,
|
|
455
512
|
filters: validatedFilters,
|
|
456
513
|
joinOperator: input.joinOperator
|
|
457
|
-
}) : void 0;
|
|
514
|
+
}) : void 0);
|
|
458
515
|
let query = ctx.db.select().from(table).$dynamic();
|
|
459
516
|
if (where) query = query.where(where);
|
|
460
517
|
if (input.sort?.length) {
|
|
461
518
|
const sortField = input.sort[0];
|
|
462
|
-
if (sortField && validateColumn(sortField.id, sortableColumns
|
|
519
|
+
if (sortField && validateColumn(sortField.id, sortableColumns)) {
|
|
463
520
|
const column = table[sortField.id];
|
|
464
521
|
if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
|
|
465
522
|
}
|
|
@@ -476,46 +533,75 @@ function createCrudRouter(config) {
|
|
|
476
533
|
pageCount: Math.ceil(count / input.perPage)
|
|
477
534
|
};
|
|
478
535
|
}),
|
|
479
|
-
getById:
|
|
480
|
-
await
|
|
481
|
-
const
|
|
482
|
-
const [item] = await ctx.db.select().from(table).where(
|
|
536
|
+
getById: resolved.procedureFactory("get").input(zod.z.string()).query(async ({ ctx, input }) => {
|
|
537
|
+
await withGuard(ctx, "get");
|
|
538
|
+
const where = buildWhere(ctx, "get", (0, drizzle_orm.eq)(getIdColumn(), input));
|
|
539
|
+
const [item] = await ctx.db.select().from(table).where(where);
|
|
540
|
+
await withAuthorize(ctx, item, "get");
|
|
483
541
|
return item ?? null;
|
|
484
542
|
}),
|
|
485
|
-
create:
|
|
486
|
-
await
|
|
487
|
-
const
|
|
543
|
+
create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
|
|
544
|
+
await withGuard(ctx, "create");
|
|
545
|
+
const injectData = resolved.getInject(ctx, "create");
|
|
546
|
+
const data = {
|
|
547
|
+
...input,
|
|
548
|
+
...injectData
|
|
549
|
+
};
|
|
550
|
+
const [created] = await ctx.db.insert(table).values(data).returning();
|
|
488
551
|
return created;
|
|
489
552
|
}),
|
|
490
|
-
update:
|
|
553
|
+
update: resolved.procedureFactory("update").input(zod.z.object({
|
|
491
554
|
id: zod.z.string(),
|
|
492
555
|
data: updateSchema
|
|
493
556
|
})).mutation(async ({ ctx, input }) => {
|
|
494
|
-
await
|
|
495
|
-
const
|
|
496
|
-
const [
|
|
557
|
+
await withGuard(ctx, "update");
|
|
558
|
+
const where = buildWhere(ctx, "update", (0, drizzle_orm.eq)(getIdColumn(), input.id));
|
|
559
|
+
const [existing] = await ctx.db.select().from(table).where(where);
|
|
560
|
+
await withAuthorize(ctx, existing, "update");
|
|
561
|
+
if (!existing) throw new Error("Resource not found or access denied");
|
|
562
|
+
const injectData = resolved.getInject(ctx, "update");
|
|
563
|
+
const data = {
|
|
564
|
+
...input.data,
|
|
565
|
+
...injectData
|
|
566
|
+
};
|
|
567
|
+
const [updated] = await ctx.db.update(table).set(data).where(where).returning();
|
|
497
568
|
return updated;
|
|
498
569
|
}),
|
|
499
|
-
delete:
|
|
500
|
-
await
|
|
501
|
-
const
|
|
502
|
-
const [
|
|
570
|
+
delete: resolved.procedureFactory("delete").input(zod.z.string()).mutation(async ({ ctx, input }) => {
|
|
571
|
+
await withGuard(ctx, "delete");
|
|
572
|
+
const where = buildWhere(ctx, "delete", (0, drizzle_orm.eq)(getIdColumn(), input));
|
|
573
|
+
const [existing] = await ctx.db.select().from(table).where(where);
|
|
574
|
+
await withAuthorize(ctx, existing, "delete");
|
|
575
|
+
if (!existing) throw new Error("Resource not found or access denied");
|
|
576
|
+
if (softDelete) {
|
|
577
|
+
const [deleted$1] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
|
|
578
|
+
return deleted$1;
|
|
579
|
+
}
|
|
580
|
+
const [deleted] = await ctx.db.delete(table).where(where).returning();
|
|
503
581
|
return deleted;
|
|
504
582
|
}),
|
|
505
|
-
deleteMany:
|
|
506
|
-
await
|
|
507
|
-
const
|
|
508
|
-
return { deleted: (await ctx.db.
|
|
583
|
+
deleteMany: resolved.procedureFactory("deleteMany").input(zod.z.array(zod.z.string()).max(maxBatchSize)).mutation(async ({ ctx, input }) => {
|
|
584
|
+
await withGuard(ctx, "deleteMany");
|
|
585
|
+
const where = buildWhere(ctx, "deleteMany", (0, drizzle_orm.inArray)(getIdColumn(), input));
|
|
586
|
+
if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
|
|
587
|
+
return { deleted: (await ctx.db.delete(table).where(where).returning()).length };
|
|
509
588
|
}),
|
|
510
|
-
updateMany:
|
|
511
|
-
ids: zod.z.array(zod.z.string()).max(maxBatchSize
|
|
589
|
+
updateMany: resolved.procedureFactory("updateMany").input(zod.z.object({
|
|
590
|
+
ids: zod.z.array(zod.z.string()).max(maxBatchSize),
|
|
512
591
|
data: updateSchema
|
|
513
592
|
})).mutation(async ({ ctx, input }) => {
|
|
514
|
-
await
|
|
515
|
-
const
|
|
516
|
-
|
|
593
|
+
await withGuard(ctx, "updateMany");
|
|
594
|
+
const where = buildWhere(ctx, "updateMany", (0, drizzle_orm.inArray)(getIdColumn(), input.ids));
|
|
595
|
+
const injectData = resolved.getInject(ctx, "update");
|
|
596
|
+
const data = {
|
|
597
|
+
...input.data,
|
|
598
|
+
...injectData
|
|
599
|
+
};
|
|
600
|
+
return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
|
|
517
601
|
})
|
|
518
|
-
}
|
|
602
|
+
};
|
|
603
|
+
const crudRouter = router(procedures);
|
|
604
|
+
return Object.assign(crudRouter, { procedures });
|
|
519
605
|
}
|
|
520
606
|
|
|
521
607
|
//#endregion
|