@wordrhyme/auto-crud-server 0.8.0 → 0.10.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 +161 -95
- package/dist/index.cjs +204 -11
- package/dist/index.d.cts +178 -19
- package/dist/index.d.ts +177 -18
- package/dist/index.js +203 -11
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { and, asc, desc, eq, gt, gte, ilike, inArray, isNull, lt, lte, ne, not, notIlike, notInArray, or, sql } from "drizzle-orm";
|
|
3
|
+
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
3
4
|
import { TRPCError, initTRPC } from "@trpc/server";
|
|
4
5
|
import superjson from "superjson";
|
|
5
6
|
import { addDays, endOfDay, startOfDay } from "date-fns";
|
|
@@ -371,6 +372,26 @@ const dataTableConfig = {
|
|
|
371
372
|
joinOperators: ["and", "or"]
|
|
372
373
|
};
|
|
373
374
|
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/lib/schema-utils.ts
|
|
377
|
+
/**
|
|
378
|
+
* 检查是否为 ZodObject 类型
|
|
379
|
+
* 兼容 Zod v3 和 v4
|
|
380
|
+
*
|
|
381
|
+
* 实现策略:检查 schema 是否具有 ZodObject 特有的 .shape 属性
|
|
382
|
+
* 这种方式比检查内部 _def 属性更稳定,因为不依赖 Zod 的内部实现细节
|
|
383
|
+
*/
|
|
384
|
+
function isZodObject(schema) {
|
|
385
|
+
return schema !== null && typeof schema === "object" && "shape" in schema && typeof schema.shape === "object" && schema.shape !== null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 非空对象验证器
|
|
389
|
+
* 用于 updateSchema 的 refine,防止空更新
|
|
390
|
+
*/
|
|
391
|
+
function nonEmpty(value) {
|
|
392
|
+
return Object.keys(value).length > 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
374
395
|
//#endregion
|
|
375
396
|
//#region src/routers/_factory.ts
|
|
376
397
|
function resolveSoftDelete(option) {
|
|
@@ -406,6 +427,9 @@ function isProcedureMap(config) {
|
|
|
406
427
|
"deleteMany",
|
|
407
428
|
"updateMany",
|
|
408
429
|
"upsert",
|
|
430
|
+
"export",
|
|
431
|
+
"import",
|
|
432
|
+
"createMany",
|
|
409
433
|
"default"
|
|
410
434
|
];
|
|
411
435
|
return keys.some((k) => validKeys.includes(k));
|
|
@@ -457,20 +481,63 @@ const listInputSchema = z.object({
|
|
|
457
481
|
filters: z.array(filterItemSchema).optional(),
|
|
458
482
|
joinOperator: z.enum(["and", "or"]).default("and")
|
|
459
483
|
});
|
|
484
|
+
const exportInputSchema = z.object({
|
|
485
|
+
sort: z.array(z.object({
|
|
486
|
+
id: z.string(),
|
|
487
|
+
desc: z.boolean()
|
|
488
|
+
})).optional(),
|
|
489
|
+
filters: z.array(filterItemSchema).optional(),
|
|
490
|
+
joinOperator: z.enum(["and", "or"]).default("and"),
|
|
491
|
+
limit: z.number().min(1).optional()
|
|
492
|
+
});
|
|
493
|
+
const importInputSchema = z.object({
|
|
494
|
+
rows: z.array(z.unknown()).min(1).max(1e3),
|
|
495
|
+
onConflict: z.enum([
|
|
496
|
+
"skip",
|
|
497
|
+
"upsert",
|
|
498
|
+
"error"
|
|
499
|
+
]).default("skip")
|
|
500
|
+
});
|
|
501
|
+
const DEFAULT_OMIT_FIELDS = [
|
|
502
|
+
"id",
|
|
503
|
+
"createdAt",
|
|
504
|
+
"updatedAt"
|
|
505
|
+
];
|
|
506
|
+
/**
|
|
507
|
+
* 解析并派生 Schema
|
|
508
|
+
* - 优先使用显式传入的 Schema
|
|
509
|
+
* - 否则从 Drizzle table 自动派生
|
|
510
|
+
*/
|
|
511
|
+
function resolveSchemas(config) {
|
|
512
|
+
const { table, omitFields = DEFAULT_OMIT_FIELDS } = config;
|
|
513
|
+
let schema;
|
|
514
|
+
if (config.schema) {
|
|
515
|
+
if (!isZodObject(config.schema)) throw new Error("[createCrudRouter] schema must be a ZodObject (not ZodEffects or other types). If you're using .refine() or .transform(), please remove them from schema.");
|
|
516
|
+
schema = config.schema;
|
|
517
|
+
} else {
|
|
518
|
+
const rawSchema = createInsertSchema(table);
|
|
519
|
+
if (!isZodObject(rawSchema)) throw new Error("[createCrudRouter] drizzle-zod returned non-ZodObject schema. This may be due to table refinements. Please provide schema explicitly.");
|
|
520
|
+
const omitConfig = Object.fromEntries(omitFields.map((f) => [f, true]));
|
|
521
|
+
schema = rawSchema.omit(omitConfig);
|
|
522
|
+
}
|
|
523
|
+
let selectSchema;
|
|
524
|
+
if (config.selectSchema) selectSchema = config.selectSchema;
|
|
525
|
+
else if (config.schema) selectSchema = schema;
|
|
526
|
+
else selectSchema = createSelectSchema(table);
|
|
527
|
+
const updateSchema = config.updateSchema ?? schema.partial().refine(nonEmpty, { message: "Update payload cannot be empty. At least one field is required." });
|
|
528
|
+
return {
|
|
529
|
+
selectSchema,
|
|
530
|
+
schema,
|
|
531
|
+
updateSchema
|
|
532
|
+
};
|
|
533
|
+
}
|
|
460
534
|
function validateColumn(columnId, allowedColumns) {
|
|
461
535
|
if (!allowedColumns || allowedColumns.length === 0) return true;
|
|
462
536
|
return allowedColumns.includes(columnId);
|
|
463
537
|
}
|
|
464
|
-
/**
|
|
465
|
-
* 创建 CRUD Router
|
|
466
|
-
*
|
|
467
|
-
* @typeParam TContext - 上下文类型(包含 db)
|
|
468
|
-
* @typeParam TSelect - 查询返回类型(从 selectSchema 或 insertSchema 推断)
|
|
469
|
-
* @typeParam TInsert - 创建输入类型(从 insertSchema 推断)
|
|
470
|
-
* @typeParam TUpdate - 更新输入类型(从 updateSchema 推断)
|
|
471
|
-
*/
|
|
472
538
|
function createCrudRouter(config) {
|
|
473
|
-
const { table,
|
|
539
|
+
const { table, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, maxExportSize = 5e3, softDelete: softDeleteOption, middleware = {} } = config;
|
|
540
|
+
const { schema, updateSchema } = resolveSchemas(config);
|
|
474
541
|
const resolved = resolveConfig(config);
|
|
475
542
|
const softDelete = resolveSoftDelete(softDeleteOption);
|
|
476
543
|
const m = middleware;
|
|
@@ -568,7 +635,7 @@ function createCrudRouter(config) {
|
|
|
568
635
|
});
|
|
569
636
|
return doGet();
|
|
570
637
|
}),
|
|
571
|
-
create: resolved.procedureFactory("create").input(
|
|
638
|
+
create: resolved.procedureFactory("create").input(schema).mutation(async ({ ctx, input }) => {
|
|
572
639
|
await withGuard(ctx, "create");
|
|
573
640
|
const doCreate = async (inputData) => {
|
|
574
641
|
const injectData = resolved.getInject(ctx, "create");
|
|
@@ -675,7 +742,7 @@ function createCrudRouter(config) {
|
|
|
675
742
|
});
|
|
676
743
|
return doUpdateMany(input.data);
|
|
677
744
|
}),
|
|
678
|
-
upsert: resolved.procedureFactory("upsert").input(
|
|
745
|
+
upsert: resolved.procedureFactory("upsert").input(schema).mutation(async ({ ctx, input }) => {
|
|
679
746
|
await withGuard(ctx, "upsert");
|
|
680
747
|
const doUpsert = async (inputData) => {
|
|
681
748
|
const inputId = inputData[idField];
|
|
@@ -711,6 +778,131 @@ function createCrudRouter(config) {
|
|
|
711
778
|
next: async (modifiedInput) => doUpsert(modifiedInput ?? input)
|
|
712
779
|
});
|
|
713
780
|
return doUpsert(input);
|
|
781
|
+
}),
|
|
782
|
+
export: resolved.procedureFactory("export").input(exportInputSchema).query(async ({ ctx, input }) => {
|
|
783
|
+
await withGuard(ctx, "export");
|
|
784
|
+
const doExport = async (exportInput$1) => {
|
|
785
|
+
const effectiveLimit = Math.min(Math.max(exportInput$1.limit ?? maxExportSize, 1), maxExportSize);
|
|
786
|
+
const validatedFilters = exportInput$1.filters?.filter((filter) => validateColumn(filter.id, filterableColumns));
|
|
787
|
+
const where = buildWhere(ctx, "export", validatedFilters?.length ? filterColumns({
|
|
788
|
+
table,
|
|
789
|
+
filters: validatedFilters,
|
|
790
|
+
joinOperator: exportInput$1.joinOperator ?? "and"
|
|
791
|
+
}) : void 0);
|
|
792
|
+
let query = ctx.db.select().from(table).$dynamic();
|
|
793
|
+
if (where) query = query.where(where);
|
|
794
|
+
if (exportInput$1.sort?.length) {
|
|
795
|
+
const sortField = exportInput$1.sort[0];
|
|
796
|
+
if (sortField && validateColumn(sortField.id, sortableColumns)) {
|
|
797
|
+
const column = table[sortField.id];
|
|
798
|
+
if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const data = await query.limit(effectiveLimit);
|
|
802
|
+
let countQuery = ctx.db.select({ count: sql`count(*)::int` }).from(table).$dynamic();
|
|
803
|
+
if (where) countQuery = countQuery.where(where);
|
|
804
|
+
const total = (await countQuery)[0]?.count ?? 0;
|
|
805
|
+
return {
|
|
806
|
+
data,
|
|
807
|
+
total,
|
|
808
|
+
hasMore: total > data.length
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
const exportInput = input;
|
|
812
|
+
if (m.export) return m.export({
|
|
813
|
+
ctx,
|
|
814
|
+
input: exportInput,
|
|
815
|
+
next: async (modifiedInput) => doExport(modifiedInput ?? exportInput)
|
|
816
|
+
});
|
|
817
|
+
return doExport(exportInput);
|
|
818
|
+
}),
|
|
819
|
+
createMany: resolved.procedureFactory("createMany").input(z.array(schema).min(1).max(maxBatchSize)).mutation(async ({ ctx, input }) => {
|
|
820
|
+
await withGuard(ctx, "createMany");
|
|
821
|
+
const doCreateMany = async (items) => {
|
|
822
|
+
const injectData = resolved.getInject(ctx, "create");
|
|
823
|
+
const values = items.map((item) => ({
|
|
824
|
+
...item,
|
|
825
|
+
...injectData
|
|
826
|
+
}));
|
|
827
|
+
const created = await ctx.db.insert(table).values(values).onConflictDoNothing().returning();
|
|
828
|
+
return {
|
|
829
|
+
created,
|
|
830
|
+
count: created.length
|
|
831
|
+
};
|
|
832
|
+
};
|
|
833
|
+
if (m.createMany) return m.createMany({
|
|
834
|
+
ctx,
|
|
835
|
+
input,
|
|
836
|
+
next: async (modifiedInput) => doCreateMany(modifiedInput ?? input)
|
|
837
|
+
});
|
|
838
|
+
return doCreateMany(input);
|
|
839
|
+
}),
|
|
840
|
+
import: resolved.procedureFactory("import").input(importInputSchema).mutation(async ({ ctx, input }) => {
|
|
841
|
+
await withGuard(ctx, "import");
|
|
842
|
+
const doImport = async (importData$1) => {
|
|
843
|
+
const validRows = [];
|
|
844
|
+
const failed = [];
|
|
845
|
+
for (let i = 0; i < importData$1.rows.length; i++) {
|
|
846
|
+
const row = importData$1.rows[i];
|
|
847
|
+
const result = schema.safeParse(row);
|
|
848
|
+
if (result.success) validRows.push(result.data);
|
|
849
|
+
else {
|
|
850
|
+
const errors = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
851
|
+
failed.push({
|
|
852
|
+
row: i,
|
|
853
|
+
errors
|
|
854
|
+
});
|
|
855
|
+
if (importData$1.onConflict === "error") return {
|
|
856
|
+
success: 0,
|
|
857
|
+
updated: 0,
|
|
858
|
+
skipped: 0,
|
|
859
|
+
failed
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (validRows.length === 0) return {
|
|
864
|
+
success: 0,
|
|
865
|
+
updated: 0,
|
|
866
|
+
skipped: 0,
|
|
867
|
+
failed
|
|
868
|
+
};
|
|
869
|
+
const injectData = resolved.getInject(ctx, "create");
|
|
870
|
+
const values = validRows.map((row) => ({
|
|
871
|
+
...row,
|
|
872
|
+
...injectData
|
|
873
|
+
}));
|
|
874
|
+
let insertedCount = 0;
|
|
875
|
+
let updatedCount = 0;
|
|
876
|
+
if (importData$1.onConflict === "upsert") {
|
|
877
|
+
const existingCount = (await ctx.db.select({ id: getIdColumn() }).from(table).where(inArray(getIdColumn(), values.map((v) => v[idField]).filter(Boolean)))).length;
|
|
878
|
+
const updateFields = Object.keys(validRows[0]);
|
|
879
|
+
const setClause = { ...resolved.getInject(ctx, "update") };
|
|
880
|
+
for (const key of updateFields) setClause[key] = sql.raw(`excluded."${key}"`);
|
|
881
|
+
const results = await ctx.db.insert(table).values(values).onConflictDoUpdate({
|
|
882
|
+
target: getIdColumn(),
|
|
883
|
+
set: setClause
|
|
884
|
+
}).returning({ id: getIdColumn() });
|
|
885
|
+
updatedCount = Math.min(existingCount, results.length);
|
|
886
|
+
insertedCount = results.length - updatedCount;
|
|
887
|
+
} else {
|
|
888
|
+
insertedCount = (await ctx.db.insert(table).values(values).onConflictDoNothing().returning({ id: getIdColumn() })).length;
|
|
889
|
+
updatedCount = 0;
|
|
890
|
+
}
|
|
891
|
+
const skipped = validRows.length - insertedCount - updatedCount;
|
|
892
|
+
return {
|
|
893
|
+
success: insertedCount,
|
|
894
|
+
updated: updatedCount,
|
|
895
|
+
skipped,
|
|
896
|
+
failed
|
|
897
|
+
};
|
|
898
|
+
};
|
|
899
|
+
const importData = input;
|
|
900
|
+
if (m.import) return m.import({
|
|
901
|
+
ctx,
|
|
902
|
+
input: importData,
|
|
903
|
+
next: async (modifiedInput) => doImport(modifiedInput ?? importData)
|
|
904
|
+
});
|
|
905
|
+
return doImport(importData);
|
|
714
906
|
})
|
|
715
907
|
};
|
|
716
908
|
const crudRouter = router(procedures);
|
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.10.0",
|
|
5
5
|
"description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
|
|
6
6
|
"author": "wordrhyme",
|
|
7
7
|
"license": "MIT",
|
|
@@ -31,16 +31,16 @@
|
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@trpc/server": "^11.0.0",
|
|
34
|
-
"drizzle-orm": "^0.40.0",
|
|
35
|
-
"drizzle-zod": "^0.8.0",
|
|
34
|
+
"drizzle-orm": "^0.40.0 || ^1.0.0-beta",
|
|
35
|
+
"drizzle-zod": "^0.8.0 || ^1.0.0-beta",
|
|
36
36
|
"superjson": "^2.0.0",
|
|
37
37
|
"zod": "^3.0.0 || ^4.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@trpc/server": "^11.0.0",
|
|
41
41
|
"@types/node": "^22.19.3",
|
|
42
|
-
"drizzle-orm": "
|
|
43
|
-
"drizzle-zod": "
|
|
42
|
+
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
|
43
|
+
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
|
44
44
|
"eslint": "^9.39.2",
|
|
45
45
|
"superjson": "^2.2.6",
|
|
46
46
|
"tsdown": "^0.15.12",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"vitest": "^4.0.18",
|
|
49
49
|
"zod": "^4.1.13",
|
|
50
50
|
"@internal/eslint-config": "0.3.0",
|
|
51
|
-
"@internal/prettier-config": "0.0.1",
|
|
52
51
|
"@internal/tsconfig": "0.1.0",
|
|
52
|
+
"@internal/tsdown-config": "0.1.0",
|
|
53
53
|
"@internal/vitest-config": "0.1.0",
|
|
54
|
-
"@internal/
|
|
54
|
+
"@internal/prettier-config": "0.0.1"
|
|
55
55
|
},
|
|
56
56
|
"prettier": "@internal/prettier-config",
|
|
57
57
|
"scripts": {
|