@wordrhyme/auto-crud-server 0.4.0 → 0.6.1
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 +161 -2
- package/dist/index.cjs +232 -96
- package/dist/index.d.cts +215 -120
- package/dist/index.d.ts +208 -113
- package/dist/index.js +230 -97
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -446,11 +446,19 @@ function validateColumn(columnId, allowedColumns) {
|
|
|
446
446
|
if (!allowedColumns || allowedColumns.length === 0) return true;
|
|
447
447
|
return allowedColumns.includes(columnId);
|
|
448
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* 创建 CRUD Router
|
|
451
|
+
*
|
|
452
|
+
* @typeParam TContext - 上下文类型(包含 db)
|
|
453
|
+
* @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
|
|
454
|
+
* @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
|
|
455
|
+
* @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
|
|
456
|
+
*/
|
|
449
457
|
function createCrudRouter(config) {
|
|
450
|
-
const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption,
|
|
458
|
+
const { table, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
|
|
451
459
|
const resolved = resolveConfig(config);
|
|
452
460
|
const softDelete = resolveSoftDelete(softDeleteOption);
|
|
453
|
-
const
|
|
461
|
+
const m = middleware;
|
|
454
462
|
const getIdColumn = () => table[idField];
|
|
455
463
|
const getSoftDeleteColumn = () => softDelete ? table[softDelete.column] : null;
|
|
456
464
|
const buildWhere = (ctx, operation, additionalCondition) => {
|
|
@@ -468,6 +476,12 @@ function createCrudRouter(config) {
|
|
|
468
476
|
};
|
|
469
477
|
const withGuard = async (ctx, operation) => {
|
|
470
478
|
if (resolved.guard) {
|
|
479
|
+
if (operation === "upsert") {
|
|
480
|
+
const canCreate = await resolved.guard(ctx, "create");
|
|
481
|
+
const canUpdate = await resolved.guard(ctx, "update");
|
|
482
|
+
if (!canCreate || !canUpdate) throw new Error("Forbidden: upsert requires both create and update permissions");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
471
485
|
if (!await resolved.guard(ctx, operation)) throw new Error(`Forbidden: ${operation} not allowed`);
|
|
472
486
|
}
|
|
473
487
|
};
|
|
@@ -478,72 +492,75 @@ function createCrudRouter(config) {
|
|
|
478
492
|
const procedures = {
|
|
479
493
|
list: resolved.procedureFactory("list").input(listInputSchema).query(async ({ ctx, input }) => {
|
|
480
494
|
await withGuard(ctx, "list");
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
table
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (sortField && validateColumn(sortField.id, sortableColumns)) {
|
|
498
|
-
const column = table[sortField.id];
|
|
499
|
-
if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
|
|
495
|
+
const doList = async (listInput$1) => {
|
|
496
|
+
const offset = (listInput$1.page - 1) * listInput$1.perPage;
|
|
497
|
+
const validatedFilters = listInput$1.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
|
|
498
|
+
const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
|
|
499
|
+
table,
|
|
500
|
+
filters: validatedFilters,
|
|
501
|
+
joinOperator: listInput$1.joinOperator
|
|
502
|
+
}) : void 0);
|
|
503
|
+
let query = ctx.db.select().from(table).$dynamic();
|
|
504
|
+
if (where) query = query.where(where);
|
|
505
|
+
if (listInput$1.sort?.length) {
|
|
506
|
+
const sortField = listInput$1.sort[0];
|
|
507
|
+
if (sortField && validateColumn(sortField.id, sortableColumns)) {
|
|
508
|
+
const column = table[sortField.id];
|
|
509
|
+
if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
|
|
510
|
+
}
|
|
500
511
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
+
const data = await query.limit(listInput$1.perPage).offset(offset);
|
|
513
|
+
let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
|
|
514
|
+
if (where) countQuery = countQuery.where(where);
|
|
515
|
+
const count = (await countQuery)[0]?.count ?? 0;
|
|
516
|
+
return {
|
|
517
|
+
data,
|
|
518
|
+
total: count,
|
|
519
|
+
page: listInput$1.page,
|
|
520
|
+
perPage: listInput$1.perPage,
|
|
521
|
+
pageCount: Math.ceil(count / listInput$1.perPage)
|
|
522
|
+
};
|
|
512
523
|
};
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
524
|
+
const listInput = input;
|
|
525
|
+
if (m.list) return m.list({
|
|
526
|
+
ctx,
|
|
527
|
+
input: listInput,
|
|
528
|
+
next: async (modifiedInput) => doList(modifiedInput ?? listInput)
|
|
529
|
+
});
|
|
530
|
+
return doList(listInput);
|
|
518
531
|
}),
|
|
519
|
-
|
|
532
|
+
get: resolved.procedureFactory("get").input(z.string()).query(async ({ ctx, input }) => {
|
|
520
533
|
await withGuard(ctx, "get");
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
534
|
+
const doGet = async () => {
|
|
535
|
+
const where = buildWhere(ctx, "get", eq(getIdColumn(), input));
|
|
536
|
+
const [item] = await ctx.db.select().from(table).where(where);
|
|
537
|
+
await withAuthorize(ctx, item, "get");
|
|
538
|
+
return item ?? null;
|
|
539
|
+
};
|
|
540
|
+
if (m.get) return m.get({
|
|
541
|
+
ctx,
|
|
542
|
+
id: input,
|
|
543
|
+
next: doGet
|
|
544
|
+
});
|
|
545
|
+
return doGet();
|
|
531
546
|
}),
|
|
532
547
|
create: resolved.procedureFactory("create").input(insertSchema).mutation(async ({ ctx, input }) => {
|
|
533
548
|
await withGuard(ctx, "create");
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
...injectData
|
|
549
|
+
const doCreate = async (inputData) => {
|
|
550
|
+
const injectData = resolved.getInject(ctx, "create");
|
|
551
|
+
const data = {
|
|
552
|
+
...inputData,
|
|
553
|
+
...injectData
|
|
554
|
+
};
|
|
555
|
+
const [created] = await ctx.db.insert(table).values(data).returning();
|
|
556
|
+
return created;
|
|
543
557
|
};
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
558
|
+
if (m.create) return m.create({
|
|
559
|
+
ctx,
|
|
560
|
+
input,
|
|
561
|
+
next: async (modifiedInput) => doCreate(modifiedInput ?? input)
|
|
562
|
+
});
|
|
563
|
+
return doCreate(input);
|
|
547
564
|
}),
|
|
548
565
|
update: resolved.procedureFactory("update").input(z.object({
|
|
549
566
|
id: z.string(),
|
|
@@ -554,19 +571,23 @@ function createCrudRouter(config) {
|
|
|
554
571
|
const [existing] = await ctx.db.select().from(table).where(where);
|
|
555
572
|
await withAuthorize(ctx, existing, "update");
|
|
556
573
|
if (!existing) throw new Error("Resource not found or access denied");
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
...injectData
|
|
574
|
+
const doUpdate = async (updateData) => {
|
|
575
|
+
const injectData = resolved.getInject(ctx, "update");
|
|
576
|
+
const data = {
|
|
577
|
+
...updateData,
|
|
578
|
+
...injectData
|
|
579
|
+
};
|
|
580
|
+
const [updated] = await ctx.db.update(table).set(data).where(where).returning();
|
|
581
|
+
return updated;
|
|
566
582
|
};
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
583
|
+
if (m.update) return m.update({
|
|
584
|
+
ctx,
|
|
585
|
+
id: input.id,
|
|
586
|
+
data: input.data,
|
|
587
|
+
existing,
|
|
588
|
+
next: async (modifiedData) => doUpdate(modifiedData ?? input.data)
|
|
589
|
+
});
|
|
590
|
+
return doUpdate(input.data);
|
|
570
591
|
}),
|
|
571
592
|
delete: resolved.procedureFactory("delete").input(z.string()).mutation(async ({ ctx, input }) => {
|
|
572
593
|
await withGuard(ctx, "delete");
|
|
@@ -574,51 +595,163 @@ function createCrudRouter(config) {
|
|
|
574
595
|
const [existing] = await ctx.db.select().from(table).where(where);
|
|
575
596
|
await withAuthorize(ctx, existing, "delete");
|
|
576
597
|
if (!existing) throw new Error("Resource not found or access denied");
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
598
|
+
const doDelete = async () => {
|
|
599
|
+
let deleted;
|
|
600
|
+
if (softDelete) [deleted] = await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning();
|
|
601
|
+
else [deleted] = await ctx.db.delete(table).where(where).returning();
|
|
602
|
+
return deleted;
|
|
603
|
+
};
|
|
604
|
+
if (m.delete) return m.delete({
|
|
605
|
+
ctx,
|
|
606
|
+
id: input,
|
|
607
|
+
existing,
|
|
608
|
+
next: doDelete
|
|
609
|
+
});
|
|
610
|
+
return doDelete();
|
|
583
611
|
}),
|
|
584
612
|
deleteMany: resolved.procedureFactory("deleteMany").input(z.array(z.string()).max(maxBatchSize)).mutation(async ({ ctx, input }) => {
|
|
585
613
|
await withGuard(ctx, "deleteMany");
|
|
586
|
-
if (h.beforeDeleteMany) await h.beforeDeleteMany(ctx, input);
|
|
587
614
|
const where = buildWhere(ctx, "deleteMany", inArray(getIdColumn(), input));
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
return
|
|
615
|
+
const doDeleteMany = async () => {
|
|
616
|
+
if (softDelete) return { deleted: (await ctx.db.update(table).set({ [softDelete.column]: softDelete.getValue() }).where(where).returning()).length };
|
|
617
|
+
else return { deleted: (await ctx.db.delete(table).where(where).returning()).length };
|
|
618
|
+
};
|
|
619
|
+
if (m.deleteMany) return m.deleteMany({
|
|
620
|
+
ctx,
|
|
621
|
+
ids: input,
|
|
622
|
+
next: doDeleteMany
|
|
623
|
+
});
|
|
624
|
+
return doDeleteMany();
|
|
593
625
|
}),
|
|
594
626
|
updateMany: resolved.procedureFactory("updateMany").input(z.object({
|
|
595
627
|
ids: z.array(z.string()).max(maxBatchSize),
|
|
596
628
|
data: updateSchema
|
|
597
629
|
})).mutation(async ({ ctx, input }) => {
|
|
598
630
|
await withGuard(ctx, "updateMany");
|
|
599
|
-
let processedData = input.data;
|
|
600
|
-
if (h.beforeUpdateMany) {
|
|
601
|
-
const result$1 = await h.beforeUpdateMany(ctx, input.ids, processedData);
|
|
602
|
-
if (result$1) processedData = result$1;
|
|
603
|
-
}
|
|
604
631
|
const where = buildWhere(ctx, "updateMany", inArray(getIdColumn(), input.ids));
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
632
|
+
const doUpdateMany = async (updateData) => {
|
|
633
|
+
const injectData = resolved.getInject(ctx, "update");
|
|
634
|
+
const data = {
|
|
635
|
+
...updateData,
|
|
636
|
+
...injectData
|
|
637
|
+
};
|
|
638
|
+
return { updated: (await ctx.db.update(table).set(data).where(where).returning()).length };
|
|
639
|
+
};
|
|
640
|
+
if (m.updateMany) return m.updateMany({
|
|
641
|
+
ctx,
|
|
642
|
+
ids: input.ids,
|
|
643
|
+
data: input.data,
|
|
644
|
+
next: async (modifiedData) => doUpdateMany(modifiedData ?? input.data)
|
|
645
|
+
});
|
|
646
|
+
return doUpdateMany(input.data);
|
|
647
|
+
}),
|
|
648
|
+
upsert: resolved.procedureFactory("upsert").input(insertSchema).mutation(async ({ ctx, input }) => {
|
|
649
|
+
await withGuard(ctx, "upsert");
|
|
650
|
+
const doUpsert = async (inputData) => {
|
|
651
|
+
const injectData = resolved.getInject(ctx, "create");
|
|
652
|
+
const data = {
|
|
653
|
+
...inputData,
|
|
654
|
+
...injectData
|
|
655
|
+
};
|
|
656
|
+
const inputId = inputData[idField];
|
|
657
|
+
let isNew = true;
|
|
658
|
+
if (inputId) {
|
|
659
|
+
const where = buildWhere(ctx, "get", eq(getIdColumn(), inputId));
|
|
660
|
+
const [existing] = await ctx.db.select().from(table).where(where);
|
|
661
|
+
isNew = !existing;
|
|
662
|
+
}
|
|
663
|
+
const [result] = await ctx.db.insert(table).values(data).onConflictDoUpdate({
|
|
664
|
+
target: getIdColumn(),
|
|
665
|
+
set: data
|
|
666
|
+
}).returning();
|
|
667
|
+
return {
|
|
668
|
+
data: result,
|
|
669
|
+
isNew
|
|
670
|
+
};
|
|
609
671
|
};
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
672
|
+
if (m.upsert) return m.upsert({
|
|
673
|
+
ctx,
|
|
674
|
+
input,
|
|
675
|
+
next: async (modifiedInput) => doUpsert(modifiedInput ?? input)
|
|
676
|
+
});
|
|
677
|
+
return doUpsert(input);
|
|
613
678
|
})
|
|
614
679
|
};
|
|
615
680
|
const crudRouter = router(procedures);
|
|
616
681
|
return Object.assign(crudRouter, { procedures });
|
|
617
682
|
}
|
|
618
683
|
|
|
684
|
+
//#endregion
|
|
685
|
+
//#region src/lib/middleware-helpers.ts
|
|
686
|
+
/**
|
|
687
|
+
* Middleware 便捷工具函数
|
|
688
|
+
* 简化常见场景的 middleware 创建
|
|
689
|
+
*/
|
|
690
|
+
/**
|
|
691
|
+
* 创建 after-only 的 middleware(最常见场景)
|
|
692
|
+
* 在操作完成后执行副作用,不修改返回值
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* middleware: {
|
|
696
|
+
* create: afterMiddleware(async (ctx, result) => {
|
|
697
|
+
* await sendEmail(result);
|
|
698
|
+
* await logAudit(ctx.user, 'create', result);
|
|
699
|
+
* }),
|
|
700
|
+
* }
|
|
701
|
+
*/
|
|
702
|
+
function afterMiddleware(fn) {
|
|
703
|
+
return async (params) => {
|
|
704
|
+
const result = await params.next();
|
|
705
|
+
await fn(params.ctx, result);
|
|
706
|
+
return result;
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* 创建 before-only 的 middleware
|
|
711
|
+
* 在操作执行前修改输入数据
|
|
712
|
+
*
|
|
713
|
+
* @example
|
|
714
|
+
* middleware: {
|
|
715
|
+
* create: beforeMiddleware(async (ctx, input) => {
|
|
716
|
+
* return { ...input, slug: slugify(input.title), createdBy: ctx.user.id };
|
|
717
|
+
* }),
|
|
718
|
+
* }
|
|
719
|
+
*/
|
|
720
|
+
function beforeMiddleware(fn) {
|
|
721
|
+
return async (params) => {
|
|
722
|
+
const modifiedInput = await fn(params.ctx, params.input);
|
|
723
|
+
return params.next(modifiedInput);
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* 组合多个 middleware 为一个
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* middleware: {
|
|
731
|
+
* create: composeMiddleware(
|
|
732
|
+
* beforeMiddleware((ctx, input) => ({ ...input, slug: slugify(input.title) })),
|
|
733
|
+
* afterMiddleware((ctx, result) => sendEmail(result)),
|
|
734
|
+
* ),
|
|
735
|
+
* }
|
|
736
|
+
*/
|
|
737
|
+
function composeMiddleware(...middlewares) {
|
|
738
|
+
return async (params) => {
|
|
739
|
+
let index = 0;
|
|
740
|
+
const executeNext = async (...args) => {
|
|
741
|
+
if (index >= middlewares.length) return params.next(...args);
|
|
742
|
+
const currentMiddleware = middlewares[index++];
|
|
743
|
+
return currentMiddleware({
|
|
744
|
+
...params,
|
|
745
|
+
next: executeNext
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
return executeNext();
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
619
752
|
//#endregion
|
|
620
753
|
//#region src/routers/index.ts
|
|
621
754
|
const appRouter = router({});
|
|
622
755
|
|
|
623
756
|
//#endregion
|
|
624
|
-
export { appRouter, createCrudRouter, publicProcedure, router };
|
|
757
|
+
export { afterMiddleware, appRouter, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wordrhyme/auto-crud-server",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
|
|
6
6
|
"author": "wordrhyme",
|
|
7
7
|
"license": "MIT",
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"typescript": "^5.9.3",
|
|
48
48
|
"zod": "^4.1.13",
|
|
49
49
|
"@internal/eslint-config": "0.3.0",
|
|
50
|
-
"@internal/tsconfig": "0.1.0",
|
|
51
50
|
"@internal/prettier-config": "0.0.1",
|
|
52
|
-
"@internal/
|
|
53
|
-
"@internal/vitest-config": "0.1.0"
|
|
51
|
+
"@internal/tsconfig": "0.1.0",
|
|
52
|
+
"@internal/vitest-config": "0.1.0",
|
|
53
|
+
"@internal/tsdown-config": "0.1.0"
|
|
54
54
|
},
|
|
55
55
|
"prettier": "@internal/prettier-config",
|
|
56
56
|
"scripts": {
|