@wordrhyme/auto-crud-server 0.2.0 → 0.4.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 CHANGED
@@ -136,7 +136,7 @@ export { handler as GET, handler as POST };
136
136
 
137
137
  ## 📖 自动生成的路由
138
138
 
139
- `createCrudRouter` 会自动生成以下 6 个路由:
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
- list: Procedure<ListInput, ListOutput>,
397
- get: Procedure<{ id: string }, TSelect>,
398
- create: Procedure<TInsert, TSelect>,
399
- update: Procedure<{ id: string, data: TUpdate }, TSelect>,
400
- delete: Procedure<{ id: string }, void>,
401
- deleteMany: Procedure<{ ids: string[] }, void>,
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 { publicProcedure } from "../trpc";
526
+ import { createCrudRouter, router } from "@wordrhyme/auto-crud-server";
527
+ import { protectedProcedure } from "../trpc";
528
+ import { z } from "zod";
495
529
 
496
- const baseCrudRouter = createCrudRouter({
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
- export const tasksRouter = {
504
- ...baseCrudRouter,
538
+ // 使用 .procedures spread 并添加自定义路由
539
+ export const tasksRouter = router({
540
+ ...tasksCrud.procedures,
505
541
 
506
- // 添加自定义路由
507
- archive: publicProcedure
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
- unarchive: publicProcedure
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
- // createCrudRouter 之前预处理过滤条件
531
- const customList = publicProcedure
532
- .input(listInputSchema)
533
- .query(async ({ input, ctx }) => {
534
- // 自定义过滤逻辑
535
- const customFilters = input.filters?.map(filter => {
536
- if (filter.id === "customField") {
537
- // 自定义处理
538
- return { ...filter, operator: "custom" };
539
- }
540
- return filter;
541
- });
542
-
543
- return baseCrudRouter.list({
544
- ...input,
545
- filters: customFilters,
546
- }, ctx);
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
- * 过滤器项 Schema
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,98 +470,178 @@ 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, procedure: baseProcedure = publicProcedure, authorize } = config;
478
+ const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, hooks = {} } = config;
479
+ const resolved = resolveConfig(config);
480
+ const softDelete = resolveSoftDelete(softDeleteOption);
481
+ const h = hooks;
439
482
  const getIdColumn = () => table[idField];
440
- const withAuthorize = async (operation, input) => {
441
- if (authorize) {
442
- if (!await authorize({
443
- operation,
444
- input
445
- })) throw new Error(`Unauthorized: ${operation} operation not allowed`);
483
+ const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
484
+ const buildWhere = (ctx, operation, additionalCondition) => {
485
+ const conditions = [];
486
+ const scopeCondition = resolved.getScope(ctx, table, operation);
487
+ if (scopeCondition) conditions.push(scopeCondition);
488
+ if (softDelete && (operation === "list" || operation === "get")) {
489
+ const col = getSoftDeleteColumn();
490
+ if (col) conditions.push((0, drizzle_orm.isNull)(col));
491
+ }
492
+ if (additionalCondition) conditions.push(additionalCondition);
493
+ if (conditions.length === 0) return void 0;
494
+ if (conditions.length === 1) return conditions[0];
495
+ return (0, drizzle_orm.and)(...conditions);
496
+ };
497
+ const withGuard = async (ctx, operation) => {
498
+ if (resolved.guard) {
499
+ if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
446
500
  }
447
501
  };
448
- return router({
449
- list: baseProcedure.input(listInputSchema).query(async ({ ctx, input }) => {
450
- await withAuthorize("list", input);
451
- const offset = (input.page - 1) * input.perPage;
452
- const validatedFilters = input.filters?.filter((filter) => validateColumn(filter.id, filterableColumns, "filter"));
453
- const where = validatedFilters?.length ? filterColumns({
502
+ const withAuthorize = async (ctx, resource, operation) => {
503
+ if (!resource) return;
504
+ if (!await resolved.checkAuthorize(ctx, resource, operation)) throw new Error(`Forbidden: Cannot ${operation} this resource`);
505
+ };
506
+ const procedures = {
507
+ list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
508
+ await withGuard(ctx, "list");
509
+ let processedInput = input;
510
+ if (h.beforeList) {
511
+ const result = await h.beforeList(ctx, processedInput);
512
+ if (result) processedInput = result;
513
+ }
514
+ const offset = (processedInput.page - 1) * processedInput.perPage;
515
+ const validatedFilters = processedInput.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
516
+ const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
454
517
  table,
455
518
  filters: validatedFilters,
456
- joinOperator: input.joinOperator
457
- }) : void 0;
519
+ joinOperator: processedInput.joinOperator
520
+ }) : void 0);
458
521
  let query = ctx.db.select().from(table).$dynamic();
459
522
  if (where) query = query.where(where);
460
- if (input.sort?.length) {
461
- const sortField = input.sort[0];
462
- if (sortField && validateColumn(sortField.id, sortableColumns, "sort")) {
523
+ if (processedInput.sort?.length) {
524
+ const sortField = processedInput.sort[0];
525
+ if (sortField && validateColumn(sortField.id, sortableColumns)) {
463
526
  const column = table[sortField.id];
464
527
  if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
465
528
  }
466
529
  }
467
- const data = await query.limit(input.perPage).offset(offset);
530
+ const data = await query.limit(processedInput.perPage).offset(offset);
468
531
  let countQuery = ctx.db.select({ count: drizzle_orm.sql`count(*)::int` }).from(table).$dynamic();
469
532
  if (where) countQuery = countQuery.where(where);
470
533
  const count = (await countQuery)[0]?.count ?? 0;
471
- return {
534
+ let listResult = {
472
535
  data,
473
536
  total: count,
474
- page: input.page,
475
- perPage: input.perPage,
476
- pageCount: Math.ceil(count / input.perPage)
537
+ page: processedInput.page,
538
+ perPage: processedInput.perPage,
539
+ pageCount: Math.ceil(count / processedInput.perPage)
477
540
  };
541
+ if (h.afterList) {
542
+ const result = await h.afterList(ctx, listResult);
543
+ if (result) listResult = result;
544
+ }
545
+ return listResult;
478
546
  }),
479
- getById: baseProcedure.input(zod.z.string()).query(async ({ ctx, input }) => {
480
- await withAuthorize("get", input);
481
- const column = getIdColumn();
482
- const [item] = await ctx.db.select().from(table).where((0, drizzle_orm.eq)(column, input));
483
- return item ?? null;
547
+ getById: resolved.procedureFactory("get").input(zod.z.string()).query(async ({ ctx, input }) => {
548
+ await withGuard(ctx, "get");
549
+ if (h.beforeGet) await h.beforeGet(ctx, input);
550
+ const where = buildWhere(ctx, "get", (0, drizzle_orm.eq)(getIdColumn(), input));
551
+ const [item] = await ctx.db.select().from(table).where(where);
552
+ await withAuthorize(ctx, item, "get");
553
+ let result = item ?? null;
554
+ if (h.afterGet) {
555
+ const hookResult = await h.afterGet(ctx, result);
556
+ if (hookResult !== void 0) result = hookResult;
557
+ }
558
+ return result;
484
559
  }),
485
- create: baseProcedure.input(insertSchema).mutation(async ({ ctx, input }) => {
486
- await withAuthorize("create", input);
487
- const [created] = await ctx.db.insert(table).values(input).returning();
560
+ create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
561
+ await withGuard(ctx, "create");
562
+ let processedData = input;
563
+ if (h.beforeCreate) {
564
+ const result = await h.beforeCreate(ctx, processedData);
565
+ if (result) processedData = result;
566
+ }
567
+ const injectData = resolved.getInject(ctx, "create");
568
+ const data = {
569
+ ...processedData,
570
+ ...injectData
571
+ };
572
+ const [created] = await ctx.db.insert(table).values(data).returning();
573
+ if (h.afterCreate) await h.afterCreate(ctx, created);
488
574
  return created;
489
575
  }),
490
- update: baseProcedure.input(zod.z.object({
576
+ update: resolved.procedureFactory("update").input(zod.z.object({
491
577
  id: zod.z.string(),
492
578
  data: updateSchema
493
579
  })).mutation(async ({ ctx, input }) => {
494
- await withAuthorize("update", input);
495
- const column = getIdColumn();
496
- const [updated] = await ctx.db.update(table).set(input.data).where((0, drizzle_orm.eq)(column, input.id)).returning();
580
+ await withGuard(ctx, "update");
581
+ const where = buildWhere(ctx, "update", (0, drizzle_orm.eq)(getIdColumn(), input.id));
582
+ const [existing] = await ctx.db.select().from(table).where(where);
583
+ await withAuthorize(ctx, existing, "update");
584
+ if (!existing) throw new Error("Resource not found or access denied");
585
+ let processedData = input.data;
586
+ if (h.beforeUpdate) {
587
+ const result = await h.beforeUpdate(ctx, input.id, processedData, existing);
588
+ if (result) processedData = result;
589
+ }
590
+ const injectData = resolved.getInject(ctx, "update");
591
+ const data = {
592
+ ...processedData,
593
+ ...injectData
594
+ };
595
+ const [updated] = await ctx.db.update(table).set(data).where(where).returning();
596
+ if (h.afterUpdate) await h.afterUpdate(ctx, updated);
497
597
  return updated;
498
598
  }),
499
- delete: baseProcedure.input(zod.z.string()).mutation(async ({ ctx, input }) => {
500
- await withAuthorize("delete", input);
501
- const column = getIdColumn();
502
- const [deleted] = await ctx.db.delete(table).where((0, drizzle_orm.eq)(column, input)).returning();
599
+ delete: resolved.procedureFactory("delete").input(zod.z.string()).mutation(async ({ ctx, input }) => {
600
+ await withGuard(ctx, "delete");
601
+ const where = buildWhere(ctx, "delete", (0, drizzle_orm.eq)(getIdColumn(), input));
602
+ const [existing] = await ctx.db.select().from(table).where(where);
603
+ await withAuthorize(ctx, existing, "delete");
604
+ if (!existing) throw new Error("Resource not found or access denied");
605
+ if (h.beforeDelete) await h.beforeDelete(ctx, input, existing);
606
+ let deleted;
607
+ if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
608
+ else [deleted] = await ctx.db.delete(table).where(where).returning();
609
+ if (h.afterDelete) await h.afterDelete(ctx, deleted);
503
610
  return deleted;
504
611
  }),
505
- deleteMany: baseProcedure.input(zod.z.array(zod.z.string()).max(maxBatchSize, `Maximum ${maxBatchSize} items allowed`)).mutation(async ({ ctx, input }) => {
506
- await withAuthorize("deleteMany", input);
507
- const column = getIdColumn();
508
- return { deleted: (await ctx.db.delete(table).where((0, drizzle_orm.inArray)(column, input)).returning()).length };
612
+ deleteMany: resolved.procedureFactory("deleteMany").input(zod.z.array(zod.z.string()).max(maxBatchSize)).mutation(async ({ ctx, input }) => {
613
+ await withGuard(ctx, "deleteMany");
614
+ if (h.beforeDeleteMany) await h.beforeDeleteMany(ctx, input);
615
+ const where = buildWhere(ctx, "deleteMany", (0, drizzle_orm.inArray)(getIdColumn(), input));
616
+ let result;
617
+ if (softDelete) result = { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
618
+ else result = { deleted: (await ctx.db.delete(table).where(where).returning()).length };
619
+ if (h.afterDeleteMany) await h.afterDeleteMany(ctx, result);
620
+ return result;
509
621
  }),
510
- updateMany: baseProcedure.input(zod.z.object({
511
- ids: zod.z.array(zod.z.string()).max(maxBatchSize, `Maximum ${maxBatchSize} items allowed`),
622
+ updateMany: resolved.procedureFactory("updateMany").input(zod.z.object({
623
+ ids: zod.z.array(zod.z.string()).max(maxBatchSize),
512
624
  data: updateSchema
513
625
  })).mutation(async ({ ctx, input }) => {
514
- await withAuthorize("updateMany", input);
515
- const column = getIdColumn();
516
- return { updated: (await ctx.db.update(table).set(input.data).where((0, drizzle_orm.inArray)(column, input.ids)).returning()).length };
626
+ await withGuard(ctx, "updateMany");
627
+ let processedData = input.data;
628
+ if (h.beforeUpdateMany) {
629
+ const result$1 = await h.beforeUpdateMany(ctx, input.ids, processedData);
630
+ if (result$1) processedData = result$1;
631
+ }
632
+ const where = buildWhere(ctx, "updateMany", (0, drizzle_orm.inArray)(getIdColumn(), input.ids));
633
+ const injectData = resolved.getInject(ctx, "update");
634
+ const data = {
635
+ ...processedData,
636
+ ...injectData
637
+ };
638
+ const result = { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
639
+ if (h.afterUpdateMany) await h.afterUpdateMany(ctx, result);
640
+ return result;
517
641
  })
518
- });
642
+ };
643
+ const crudRouter = router(procedures);
644
+ return Object.assign(crudRouter, { procedures });
519
645
  }
520
646
 
521
647
  //#endregion