@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/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, insertSchema, updateSchema, idField = "id", filterableColumns, sortableColumns, maxBatchSize = 100, softDelete: softDeleteOption, middleware = {} } = config;
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(insertSchema).mutation(async ({ ctx, 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(insertSchema).mutation(async ({ ctx, 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.8.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": "^0.45.1",
43
- "drizzle-zod": "^0.8.3",
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/tsdown-config": "0.1.0"
54
+ "@internal/prettier-config": "0.0.1"
55
55
  },
56
56
  "prettier": "@internal/prettier-config",
57
57
  "scripts": {