@wordrhyme/auto-crud-server 1.0.5 → 1.0.7

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
@@ -489,6 +489,8 @@ interface CrudRouterConfig<TTable, TSelect, TInsert, TUpdate> {
489
489
 
490
490
  // ========== 其他配置 ==========
491
491
  idField?: string; // ID 字段名,默认 "id"
492
+ filterableColumns?: CrudColumnRef<TTable>[]; // 可过滤字段白名单
493
+ sortableColumns?: CrudColumnRef<TTable>[]; // 可排序字段白名单
492
494
  omitFields?: string[]; // 自动派生时排除的字段
493
495
  // 默认 ["id", "createdAt", "updatedAt"]
494
496
  }
@@ -1030,6 +1032,55 @@ export const tasksRouter = createCrudRouter({
1030
1032
  });
1031
1033
  ```
1032
1034
 
1035
+ 普通字段不需要特殊配置,前端仍然传字段 id:
1036
+
1037
+ ```typescript
1038
+ filters: [{ id: 'title', value: 'bug', operator: 'iLike', variant: 'text', filterId: 'title' }],
1039
+ sort: [{ id: 'createdAt', desc: true }],
1040
+ ```
1041
+
1042
+ jsonb/i18n 字段可以在对应字段配置里声明读取哪个 JSON field。服务端会显式生成
1043
+ text expression,不会自动把所有 jsonb 字段都 cast 成 text:
1044
+
1045
+ ```typescript
1046
+ export const productsRouter = createCrudRouter({
1047
+ table: shopProducts,
1048
+ idField: 'spuId',
1049
+ filterableColumns: [
1050
+ { id: 'name', jsonField: ['zh-CN', 'en-US', 'en', 'zh'] },
1051
+ 'status',
1052
+ 'categoryId',
1053
+ ],
1054
+ sortableColumns: [
1055
+ { id: 'name', jsonField: ['zh-CN', 'en-US', 'en', 'zh'] },
1056
+ 'createdAt',
1057
+ ],
1058
+ });
1059
+ ```
1060
+
1061
+ 上面的 `name` 过滤会生成类似 SQL:
1062
+
1063
+ ```sql
1064
+ case jsonb_typeof(name)
1065
+ when 'object' then coalesce(name ->> 'zh-CN', name ->> 'en-US', name ->> 'en', name ->> 'zh', '')
1066
+ when 'string' then name #>> '{}'
1067
+ else ''
1068
+ end ilike '%关键词%'
1069
+ ```
1070
+
1071
+ 复杂业务仍可用 `expression` 显式接管查询目标:
1072
+
1073
+ ```typescript
1074
+ import { sql } from 'drizzle-orm';
1075
+
1076
+ createCrudRouter({
1077
+ table: products,
1078
+ filterableColumns: [
1079
+ { id: 'displayName', expression: ({ table }) => sql<string>`lower(${table.name})` },
1080
+ ],
1081
+ });
1082
+ ```
1083
+
1033
1084
  ---
1034
1085
 
1035
1086
  ## 📦 导出的类型
@@ -1043,6 +1094,9 @@ export {
1043
1094
  createCrudRouter,
1044
1095
  } from './routers/_factory';
1045
1096
  export type {
1097
+ CrudColumnConfig,
1098
+ CrudColumnExpression,
1099
+ CrudColumnRef,
1046
1100
  CrudRouterConfig,
1047
1101
  CrudMiddleware,
1048
1102
  GetInput,
package/dist/index.cjs CHANGED
@@ -44,8 +44,15 @@ const publicProcedure = t.procedure;
44
44
  /**
45
45
  * 检查列是否为空
46
46
  */
47
+ function toSqlTarget(column) {
48
+ return drizzle_orm.sql`${column}`;
49
+ }
50
+ function isBooleanColumn(column) {
51
+ return "dataType" in column && column.dataType === "boolean";
52
+ }
47
53
  function isEmpty(column) {
48
- return (0, drizzle_orm.or)((0, drizzle_orm.isNull)(column), (0, drizzle_orm.eq)(column, ""));
54
+ const expr = toSqlTarget(column);
55
+ return (0, drizzle_orm.or)((0, drizzle_orm.isNull)(expr), (0, drizzle_orm.eq)(expr, ""));
49
56
  }
50
57
  /**
51
58
  * 安全解析日期时间戳
@@ -68,22 +75,24 @@ function safeParseNumber(value) {
68
75
  const num = Number(value);
69
76
  return Number.isNaN(num) ? null : num;
70
77
  }
71
- function filterColumns({ table, filters, joinOperator }) {
78
+ function filterColumns({ table, filters, joinOperator, resolveColumn }) {
72
79
  const joinFn = joinOperator === "and" ? drizzle_orm.and : drizzle_orm.or;
73
80
  const validConditions = filters.map((filter) => {
74
- const column = getColumn(table, filter.id);
81
+ const column = resolveColumn ? resolveColumn(filter.id) : getColumn(table, filter.id);
82
+ if (!column) return void 0;
83
+ const expr = toSqlTarget(column);
75
84
  switch (filter.operator) {
76
- case "iLike": return filter.variant === "text" && typeof filter.value === "string" ? (0, drizzle_orm.ilike)(column, `%${filter.value}%`) : void 0;
77
- case "notILike": return filter.variant === "text" && typeof filter.value === "string" ? (0, drizzle_orm.notIlike)(column, `%${filter.value}%`) : void 0;
85
+ case "iLike": return filter.variant === "text" && typeof filter.value === "string" ? (0, drizzle_orm.ilike)(expr, `%${filter.value}%`) : void 0;
86
+ case "notILike": return filter.variant === "text" && typeof filter.value === "string" ? (0, drizzle_orm.notIlike)(expr, `%${filter.value}%`) : void 0;
78
87
  case "eq":
79
- if (column.dataType === "boolean" && typeof filter.value === "string") return (0, drizzle_orm.eq)(column, filter.value === "true");
88
+ if (isBooleanColumn(column) && typeof filter.value === "string") return (0, drizzle_orm.eq)(expr, filter.value === "true");
80
89
  if (filter.variant === "dateRange" && Array.isArray(filter.value) && filter.value.length === 2) {
81
90
  const startDate = safeParseDate(filter.value[0]);
82
91
  const endDate = safeParseDate(filter.value[1]);
83
92
  if (!startDate && !endDate) return void 0;
84
93
  if (startDate) startDate.setHours(0, 0, 0, 0);
85
94
  if (endDate) endDate.setHours(23, 59, 59, 999);
86
- return (0, drizzle_orm.and)(startDate ? (0, drizzle_orm.gte)(column, startDate) : void 0, endDate ? (0, drizzle_orm.lte)(column, endDate) : void 0);
95
+ return (0, drizzle_orm.and)(startDate ? (0, drizzle_orm.gte)(expr, startDate) : void 0, endDate ? (0, drizzle_orm.lte)(expr, endDate) : void 0);
87
96
  }
88
97
  if (filter.variant === "date" && typeof filter.value === "string") {
89
98
  const date = safeParseDate(filter.value);
@@ -91,72 +100,72 @@ function filterColumns({ table, filters, joinOperator }) {
91
100
  date.setHours(0, 0, 0, 0);
92
101
  const end = new Date(date);
93
102
  end.setHours(23, 59, 59, 999);
94
- return (0, drizzle_orm.and)((0, drizzle_orm.gte)(column, date), (0, drizzle_orm.lte)(column, end));
103
+ return (0, drizzle_orm.and)((0, drizzle_orm.gte)(expr, date), (0, drizzle_orm.lte)(expr, end));
95
104
  }
96
- return (0, drizzle_orm.eq)(column, filter.value);
105
+ return (0, drizzle_orm.eq)(expr, filter.value);
97
106
  case "ne":
98
- if (column.dataType === "boolean" && typeof filter.value === "string") return (0, drizzle_orm.ne)(column, filter.value === "true");
107
+ if (isBooleanColumn(column) && typeof filter.value === "string") return (0, drizzle_orm.ne)(expr, filter.value === "true");
99
108
  if (filter.variant === "date" || filter.variant === "dateRange") {
100
109
  const date = safeParseDate(filter.value);
101
110
  if (!date) return void 0;
102
111
  date.setHours(0, 0, 0, 0);
103
112
  const end = new Date(date);
104
113
  end.setHours(23, 59, 59, 999);
105
- return (0, drizzle_orm.or)((0, drizzle_orm.lt)(column, date), (0, drizzle_orm.gt)(column, end));
114
+ return (0, drizzle_orm.or)((0, drizzle_orm.lt)(expr, date), (0, drizzle_orm.gt)(expr, end));
106
115
  }
107
- return (0, drizzle_orm.ne)(column, filter.value);
116
+ return (0, drizzle_orm.ne)(expr, filter.value);
108
117
  case "inArray":
109
- if (Array.isArray(filter.value)) return (0, drizzle_orm.inArray)(column, filter.value);
118
+ if (Array.isArray(filter.value)) return (0, drizzle_orm.inArray)(expr, filter.value);
110
119
  return;
111
120
  case "notInArray":
112
- if (Array.isArray(filter.value)) return (0, drizzle_orm.notInArray)(column, filter.value);
121
+ if (Array.isArray(filter.value)) return (0, drizzle_orm.notInArray)(expr, filter.value);
113
122
  return;
114
123
  case "lt":
115
124
  if (filter.variant === "number" || filter.variant === "range") {
116
125
  const num = safeParseNumber(filter.value);
117
- return num !== null ? (0, drizzle_orm.lt)(column, num) : void 0;
126
+ return num !== null ? (0, drizzle_orm.lt)(expr, num) : void 0;
118
127
  }
119
128
  if (filter.variant === "date" && typeof filter.value === "string") {
120
129
  const date = safeParseDate(filter.value);
121
130
  if (!date) return void 0;
122
131
  date.setHours(23, 59, 59, 999);
123
- return (0, drizzle_orm.lt)(column, date);
132
+ return (0, drizzle_orm.lt)(expr, date);
124
133
  }
125
134
  return;
126
135
  case "lte":
127
136
  if (filter.variant === "number" || filter.variant === "range") {
128
137
  const num = safeParseNumber(filter.value);
129
- return num !== null ? (0, drizzle_orm.lte)(column, num) : void 0;
138
+ return num !== null ? (0, drizzle_orm.lte)(expr, num) : void 0;
130
139
  }
131
140
  if (filter.variant === "date" && typeof filter.value === "string") {
132
141
  const date = safeParseDate(filter.value);
133
142
  if (!date) return void 0;
134
143
  date.setHours(23, 59, 59, 999);
135
- return (0, drizzle_orm.lte)(column, date);
144
+ return (0, drizzle_orm.lte)(expr, date);
136
145
  }
137
146
  return;
138
147
  case "gt":
139
148
  if (filter.variant === "number" || filter.variant === "range") {
140
149
  const num = safeParseNumber(filter.value);
141
- return num !== null ? (0, drizzle_orm.gt)(column, num) : void 0;
150
+ return num !== null ? (0, drizzle_orm.gt)(expr, num) : void 0;
142
151
  }
143
152
  if (filter.variant === "date" && typeof filter.value === "string") {
144
153
  const date = safeParseDate(filter.value);
145
154
  if (!date) return void 0;
146
155
  date.setHours(0, 0, 0, 0);
147
- return (0, drizzle_orm.gt)(column, date);
156
+ return (0, drizzle_orm.gt)(expr, date);
148
157
  }
149
158
  return;
150
159
  case "gte":
151
160
  if (filter.variant === "number" || filter.variant === "range") {
152
161
  const num = safeParseNumber(filter.value);
153
- return num !== null ? (0, drizzle_orm.gte)(column, num) : void 0;
162
+ return num !== null ? (0, drizzle_orm.gte)(expr, num) : void 0;
154
163
  }
155
164
  if (filter.variant === "date" && typeof filter.value === "string") {
156
165
  const date = safeParseDate(filter.value);
157
166
  if (!date) return void 0;
158
167
  date.setHours(0, 0, 0, 0);
159
- return (0, drizzle_orm.gte)(column, date);
168
+ return (0, drizzle_orm.gte)(expr, date);
160
169
  }
161
170
  return;
162
171
  case "isBetween":
@@ -166,15 +175,15 @@ function filterColumns({ table, filters, joinOperator }) {
166
175
  if (!startDate && !endDate) return void 0;
167
176
  if (startDate) startDate.setHours(0, 0, 0, 0);
168
177
  if (endDate) endDate.setHours(23, 59, 59, 999);
169
- return (0, drizzle_orm.and)(startDate ? (0, drizzle_orm.gte)(column, startDate) : void 0, endDate ? (0, drizzle_orm.lte)(column, endDate) : void 0);
178
+ return (0, drizzle_orm.and)(startDate ? (0, drizzle_orm.gte)(expr, startDate) : void 0, endDate ? (0, drizzle_orm.lte)(expr, endDate) : void 0);
170
179
  }
171
180
  if ((filter.variant === "number" || filter.variant === "range") && Array.isArray(filter.value) && filter.value.length === 2) {
172
181
  const firstValue = safeParseNumber(filter.value[0]);
173
182
  const secondValue = safeParseNumber(filter.value[1]);
174
183
  if (firstValue === null && secondValue === null) return;
175
- if (firstValue !== null && secondValue === null) return (0, drizzle_orm.eq)(column, firstValue);
176
- if (firstValue === null && secondValue !== null) return (0, drizzle_orm.eq)(column, secondValue);
177
- return (0, drizzle_orm.and)(firstValue !== null ? (0, drizzle_orm.gte)(column, firstValue) : void 0, secondValue !== null ? (0, drizzle_orm.lte)(column, secondValue) : void 0);
184
+ if (firstValue !== null && secondValue === null) return (0, drizzle_orm.eq)(expr, firstValue);
185
+ if (firstValue === null && secondValue !== null) return (0, drizzle_orm.eq)(expr, secondValue);
186
+ return (0, drizzle_orm.and)(firstValue !== null ? (0, drizzle_orm.gte)(expr, firstValue) : void 0, secondValue !== null ? (0, drizzle_orm.lte)(expr, secondValue) : void 0);
178
187
  }
179
188
  return;
180
189
  case "isRelativeToToday":
@@ -199,11 +208,11 @@ function filterColumns({ table, filters, joinOperator }) {
199
208
  break;
200
209
  default: return;
201
210
  }
202
- return (0, drizzle_orm.and)((0, drizzle_orm.gte)(column, startDate), (0, drizzle_orm.lte)(column, endDate));
211
+ return (0, drizzle_orm.and)((0, drizzle_orm.gte)(expr, startDate), (0, drizzle_orm.lte)(expr, endDate));
203
212
  }
204
213
  return;
205
- case "isEmpty": return isEmpty(column);
206
- case "isNotEmpty": return (0, drizzle_orm.not)(isEmpty(column));
214
+ case "isEmpty": return isEmpty(expr);
215
+ case "isNotEmpty": return (0, drizzle_orm.not)(isEmpty(expr));
207
216
  default: throw new Error(`Unsupported operator: ${filter.operator}`);
208
217
  }
209
218
  }).filter((condition) => condition !== void 0);
@@ -562,9 +571,43 @@ function resolveSchemas(config) {
562
571
  updateSchema
563
572
  };
564
573
  }
574
+ function getColumnRefId(columnRef) {
575
+ return typeof columnRef === "string" ? columnRef : columnRef.id;
576
+ }
577
+ function findColumnRef(columnId, allowedColumns) {
578
+ return allowedColumns?.find((columnRef) => getColumnRefId(columnRef) === columnId);
579
+ }
565
580
  function validateColumn(columnId, allowedColumns) {
566
581
  if (!allowedColumns || allowedColumns.length === 0) return true;
567
- return allowedColumns.includes(columnId);
582
+ return findColumnRef(columnId, allowedColumns) !== void 0;
583
+ }
584
+ function getTableColumn(table, columnId) {
585
+ return table[columnId];
586
+ }
587
+ function normalizeJsonFields(jsonField) {
588
+ return (Array.isArray(jsonField) ? jsonField : [jsonField]).filter((field) => field.length > 0);
589
+ }
590
+ function buildJsonFieldTextExpression(column, jsonField) {
591
+ const fields = normalizeJsonFields(jsonField);
592
+ if (fields.length === 0) throw new Error("[createCrudRouter] jsonField must include at least one json key.");
593
+ const objectFields = fields.map((field) => drizzle_orm.sql`${column} ->> ${field}`);
594
+ return drizzle_orm.sql`case jsonb_typeof(${column})
595
+ when 'object' then coalesce(${drizzle_orm.sql.join(objectFields, drizzle_orm.sql`, `)}, '')
596
+ when 'string' then ${column} #>> '{}'
597
+ else ''
598
+ end`;
599
+ }
600
+ function resolveColumnTarget({ table, columnId, allowedColumns }) {
601
+ const columnRef = findColumnRef(columnId, allowedColumns);
602
+ if ((allowedColumns?.length ?? 0) > 0 && columnRef === void 0) return;
603
+ if (columnRef && typeof columnRef !== "string") {
604
+ if (columnRef.expression) return columnRef.expression({ table });
605
+ const column = getTableColumn(table, columnRef.id);
606
+ if (!column) return void 0;
607
+ if (columnRef.jsonField) return buildJsonFieldTextExpression(column, columnRef.jsonField);
608
+ return column;
609
+ }
610
+ return getTableColumn(table, columnId);
568
611
  }
569
612
  /**
570
613
  * 创建 CRUD Router
@@ -667,14 +710,23 @@ function createCrudRouter(config) {
667
710
  const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
668
711
  table,
669
712
  filters: validatedFilters,
670
- joinOperator: listInput$1.joinOperator
713
+ joinOperator: listInput$1.joinOperator,
714
+ resolveColumn: (columnId) => resolveColumnTarget({
715
+ table,
716
+ columnId: String(columnId),
717
+ allowedColumns: filterableColumns
718
+ })
671
719
  }) : void 0);
672
720
  let query = ctx.db.select().from(table).$dynamic();
673
721
  if (where) query = query.where(where);
674
722
  if (listInput$1.sort?.length) {
675
723
  const sortField = listInput$1.sort[0];
676
724
  if (sortField && validateColumn(sortField.id, sortableColumns)) {
677
- const column = table[sortField.id];
725
+ const column = resolveColumnTarget({
726
+ table,
727
+ columnId: sortField.id,
728
+ allowedColumns: sortableColumns
729
+ });
678
730
  if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
679
731
  }
680
732
  }
@@ -868,14 +920,23 @@ function createCrudRouter(config) {
868
920
  const where = buildWhere(ctx, "export", validatedFilters?.length ? filterColumns({
869
921
  table,
870
922
  filters: validatedFilters,
871
- joinOperator: exportInput$1.joinOperator ?? "and"
923
+ joinOperator: exportInput$1.joinOperator ?? "and",
924
+ resolveColumn: (columnId) => resolveColumnTarget({
925
+ table,
926
+ columnId: String(columnId),
927
+ allowedColumns: filterableColumns
928
+ })
872
929
  }) : void 0);
873
930
  let query = ctx.db.select().from(table).$dynamic();
874
931
  if (where) query = query.where(where);
875
932
  if (exportInput$1.sort?.length) {
876
933
  const sortField = exportInput$1.sort[0];
877
934
  if (sortField && validateColumn(sortField.id, sortableColumns)) {
878
- const column = table[sortField.id];
935
+ const column = resolveColumnTarget({
936
+ table,
937
+ columnId: sortField.id,
938
+ allowedColumns: sortableColumns
939
+ });
879
940
  if (column) query = query.orderBy(sortField.desc ? (0, drizzle_orm.desc)(column) : (0, drizzle_orm.asc)(column));
880
941
  }
881
942
  }
package/dist/index.d.cts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
- import * as _trpc_server2 from "@trpc/server";
3
+ import * as _trpc_server0 from "@trpc/server";
4
4
  import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
5
- import { SQL } from "drizzle-orm";
5
+ import { AnyColumn, SQL } from "drizzle-orm";
6
6
 
7
7
  //#region src/trpc.d.ts
8
8
  /**
@@ -12,13 +12,13 @@ import { SQL } from "drizzle-orm";
12
12
  interface Context {
13
13
  db: PostgresJsDatabase<Record<string, never>>;
14
14
  }
15
- declare const router: _trpc_server2.TRPCRouterBuilder<{
15
+ declare const router: _trpc_server0.TRPCRouterBuilder<{
16
16
  ctx: Context;
17
17
  meta: object;
18
- errorShape: _trpc_server2.TRPCDefaultErrorShape;
18
+ errorShape: _trpc_server0.TRPCDefaultErrorShape;
19
19
  transformer: true;
20
20
  }>;
21
- declare const publicProcedure: _trpc_server2.TRPCProcedureBuilder<Context, object, object, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, _trpc_server2.TRPCUnsetMarker, false>;
21
+ declare const publicProcedure: _trpc_server0.TRPCProcedureBuilder<Context, object, object, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, _trpc_server0.TRPCUnsetMarker, false>;
22
22
  //#endregion
23
23
  //#region src/types/config.d.ts
24
24
  type CrudOperation = 'list' | 'get' | 'create' | 'update' | 'delete' | 'deleteMany' | 'updateMany' | 'upsert' | 'export' | 'import' | 'createMany';
@@ -29,6 +29,31 @@ type WriteOperation = 'create' | 'update';
29
29
  */
30
30
  type TableColumnKeys<T extends PgTable> = keyof T['_']['columns'];
31
31
  type AnyProcedure = any;
32
+ /**
33
+ * 过滤/排序字段的自定义 SQL expression。
34
+ *
35
+ * 普通字段无需配置 expression,直接使用字段名即可;jsonb、i18n、派生字段等
36
+ * 需要自定义查询目标时,可通过 expression 显式返回 Drizzle SQL 片段或列对象。
37
+ */
38
+ type CrudColumnExpression<TTable extends PgTable = PgTable> = (params: {
39
+ table: TTable;
40
+ }) => SQL | AnyColumn;
41
+ /**
42
+ * 过滤/排序字段配置。
43
+ *
44
+ * - string: 普通表字段,继续使用 table[id]
45
+ * - object.jsonField: 从 jsonb 字段中提取一个或多个 key 作为文本查询目标
46
+ * - object.expression: 高级场景自定义 SQL expression
47
+ */
48
+ interface CrudColumnConfig<TTable extends PgTable = PgTable> {
49
+ /** 前端传入的逻辑字段名,同时默认对应 table 上的列名 */
50
+ id: string;
51
+ /** jsonb 对象内部要读取的字段;数组按顺序 coalesce 作为 fallback */
52
+ jsonField?: string | string[];
53
+ /** 高级场景自定义查询目标,优先级高于 jsonField */
54
+ expression?: CrudColumnExpression<TTable>;
55
+ }
56
+ type CrudColumnRef<TTable extends PgTable = PgTable> = string | CrudColumnConfig<TTable>;
32
57
  interface SoftDeleteConfig {
33
58
  /** 软删除标记列名 */
34
59
  column: string;
@@ -496,10 +521,25 @@ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown,
496
521
  omitFields?: TableColumnKeys<TTable>[];
497
522
  /** ID 字段名,默认 "id" */
498
523
  idField?: string;
499
- /** 可过滤的列白名单 */
500
- filterableColumns?: string[];
501
- /** 可排序的列白名单 */
502
- sortableColumns?: string[];
524
+ /**
525
+ * 可过滤的列白名单。
526
+ *
527
+ * 普通字段使用字符串;jsonb/i18n 字段可用对象配置 jsonField,
528
+ * 由服务端显式生成 text expression,前端仍然传 id。
529
+ *
530
+ * @example
531
+ * filterableColumns: [
532
+ * 'status',
533
+ * { id: 'name', jsonField: ['zh-CN', 'en-US', 'en', 'zh'] },
534
+ * ]
535
+ */
536
+ filterableColumns?: CrudColumnRef<TTable>[];
537
+ /**
538
+ * 可排序的列白名单。
539
+ *
540
+ * 与 filterableColumns 相同,普通字段使用字符串,jsonb/i18n 字段可配置 jsonField。
541
+ */
542
+ sortableColumns?: CrudColumnRef<TTable>[];
503
543
  /** 批量操作的最大数量限制,默认 100 */
504
544
  maxBatchSize?: number;
505
545
  /**
@@ -771,12 +811,12 @@ type CrudHookEventMap<PluginId extends string, ResourceName extends string, TCre
771
811
  } } & { [K in `${PluginId}.${ResourceName}.createMany`]: TCreate[] };
772
812
  //#endregion
773
813
  //#region src/routers/index.d.ts
774
- declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
814
+ declare const appRouter: _trpc_server0.TRPCBuiltRouter<{
775
815
  ctx: Context;
776
816
  meta: object;
777
- errorShape: _trpc_server2.TRPCDefaultErrorShape;
817
+ errorShape: _trpc_server0.TRPCDefaultErrorShape;
778
818
  transformer: true;
779
- }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
819
+ }, _trpc_server0.TRPCDecorateCreateRouterOptions<{}>>;
780
820
  type AppRouter = typeof appRouter;
781
821
  //#endregion
782
- export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
822
+ export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { SQL } from "drizzle-orm";
2
+ import { AnyColumn, SQL } from "drizzle-orm";
3
3
  import * as _trpc_server2 from "@trpc/server";
4
4
  import superjson from "superjson";
5
5
  import { PgTable } from "drizzle-orm/pg-core";
@@ -30,6 +30,31 @@ type WriteOperation = 'create' | 'update';
30
30
  */
31
31
  type TableColumnKeys<T extends PgTable> = keyof T['_']['columns'];
32
32
  type AnyProcedure = any;
33
+ /**
34
+ * 过滤/排序字段的自定义 SQL expression。
35
+ *
36
+ * 普通字段无需配置 expression,直接使用字段名即可;jsonb、i18n、派生字段等
37
+ * 需要自定义查询目标时,可通过 expression 显式返回 Drizzle SQL 片段或列对象。
38
+ */
39
+ type CrudColumnExpression<TTable extends PgTable = PgTable> = (params: {
40
+ table: TTable;
41
+ }) => SQL | AnyColumn;
42
+ /**
43
+ * 过滤/排序字段配置。
44
+ *
45
+ * - string: 普通表字段,继续使用 table[id]
46
+ * - object.jsonField: 从 jsonb 字段中提取一个或多个 key 作为文本查询目标
47
+ * - object.expression: 高级场景自定义 SQL expression
48
+ */
49
+ interface CrudColumnConfig<TTable extends PgTable = PgTable> {
50
+ /** 前端传入的逻辑字段名,同时默认对应 table 上的列名 */
51
+ id: string;
52
+ /** jsonb 对象内部要读取的字段;数组按顺序 coalesce 作为 fallback */
53
+ jsonField?: string | string[];
54
+ /** 高级场景自定义查询目标,优先级高于 jsonField */
55
+ expression?: CrudColumnExpression<TTable>;
56
+ }
57
+ type CrudColumnRef<TTable extends PgTable = PgTable> = string | CrudColumnConfig<TTable>;
33
58
  interface SoftDeleteConfig {
34
59
  /** 软删除标记列名 */
35
60
  column: string;
@@ -497,10 +522,25 @@ interface CrudRouterConfig<TTable extends PgTable = PgTable, TContext = unknown,
497
522
  omitFields?: TableColumnKeys<TTable>[];
498
523
  /** ID 字段名,默认 "id" */
499
524
  idField?: string;
500
- /** 可过滤的列白名单 */
501
- filterableColumns?: string[];
502
- /** 可排序的列白名单 */
503
- sortableColumns?: string[];
525
+ /**
526
+ * 可过滤的列白名单。
527
+ *
528
+ * 普通字段使用字符串;jsonb/i18n 字段可用对象配置 jsonField,
529
+ * 由服务端显式生成 text expression,前端仍然传 id。
530
+ *
531
+ * @example
532
+ * filterableColumns: [
533
+ * 'status',
534
+ * { id: 'name', jsonField: ['zh-CN', 'en-US', 'en', 'zh'] },
535
+ * ]
536
+ */
537
+ filterableColumns?: CrudColumnRef<TTable>[];
538
+ /**
539
+ * 可排序的列白名单。
540
+ *
541
+ * 与 filterableColumns 相同,普通字段使用字符串,jsonb/i18n 字段可配置 jsonField。
542
+ */
543
+ sortableColumns?: CrudColumnRef<TTable>[];
504
544
  /** 批量操作的最大数量限制,默认 100 */
505
545
  maxBatchSize?: number;
506
546
  /**
@@ -780,4 +820,4 @@ declare const appRouter: _trpc_server2.TRPCBuiltRouter<{
780
820
  }, _trpc_server2.TRPCDecorateCreateRouterOptions<{}>>;
781
821
  type AppRouter = typeof appRouter;
782
822
  //#endregion
783
- export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
823
+ export { type AnyProcedure, type AppRouter, type CreateManyMiddlewareParams, type CreateMiddlewareParams, type CrudColumnConfig, type CrudColumnExpression, type CrudColumnRef, type CrudHookEventMap, type CrudMiddleware, type CrudOperation, type CrudProcedures, type CrudRouterConfig, type DeleteManyMiddlewareParams, type DeleteMiddlewareParams, type ExportInput, type ExportMiddlewareParams, type ExportResult, type GetInput, type GetMiddlewareParams, type ImportFailedRow, type ImportInput, type ImportMiddlewareParams, type ImportResult, type ListInput, type ListMiddlewareParams, type ListResult, type ProcedureConfig, type ProcedureFactory, type ProcedureMap, type SoftDeleteConfig, type SoftDeleteOption, type UpdateManyMiddlewareParams, type UpdateMiddlewareParams, type UpsertMiddlewareParams, type WriteOperation, afterMiddleware, appRouter, baseExportInputSchema, baseGetInputSchema, baseListInputSchema, beforeMiddleware, composeMiddleware, createCrudRouter, publicProcedure, router };
package/dist/index.js CHANGED
@@ -15,8 +15,15 @@ const publicProcedure = t.procedure;
15
15
  /**
16
16
  * 检查列是否为空
17
17
  */
18
+ function toSqlTarget(column) {
19
+ return sql`${column}`;
20
+ }
21
+ function isBooleanColumn(column) {
22
+ return "dataType" in column && column.dataType === "boolean";
23
+ }
18
24
  function isEmpty(column) {
19
- return or(isNull(column), eq(column, ""));
25
+ const expr = toSqlTarget(column);
26
+ return or(isNull(expr), eq(expr, ""));
20
27
  }
21
28
  /**
22
29
  * 安全解析日期时间戳
@@ -39,22 +46,24 @@ function safeParseNumber(value) {
39
46
  const num = Number(value);
40
47
  return Number.isNaN(num) ? null : num;
41
48
  }
42
- function filterColumns({ table, filters, joinOperator }) {
49
+ function filterColumns({ table, filters, joinOperator, resolveColumn }) {
43
50
  const joinFn = joinOperator === "and" ? and : or;
44
51
  const validConditions = filters.map((filter) => {
45
- const column = getColumn(table, filter.id);
52
+ const column = resolveColumn ? resolveColumn(filter.id) : getColumn(table, filter.id);
53
+ if (!column) return void 0;
54
+ const expr = toSqlTarget(column);
46
55
  switch (filter.operator) {
47
- case "iLike": return filter.variant === "text" && typeof filter.value === "string" ? ilike(column, `%${filter.value}%`) : void 0;
48
- case "notILike": return filter.variant === "text" && typeof filter.value === "string" ? notIlike(column, `%${filter.value}%`) : void 0;
56
+ case "iLike": return filter.variant === "text" && typeof filter.value === "string" ? ilike(expr, `%${filter.value}%`) : void 0;
57
+ case "notILike": return filter.variant === "text" && typeof filter.value === "string" ? notIlike(expr, `%${filter.value}%`) : void 0;
49
58
  case "eq":
50
- if (column.dataType === "boolean" && typeof filter.value === "string") return eq(column, filter.value === "true");
59
+ if (isBooleanColumn(column) && typeof filter.value === "string") return eq(expr, filter.value === "true");
51
60
  if (filter.variant === "dateRange" && Array.isArray(filter.value) && filter.value.length === 2) {
52
61
  const startDate = safeParseDate(filter.value[0]);
53
62
  const endDate = safeParseDate(filter.value[1]);
54
63
  if (!startDate && !endDate) return void 0;
55
64
  if (startDate) startDate.setHours(0, 0, 0, 0);
56
65
  if (endDate) endDate.setHours(23, 59, 59, 999);
57
- return and(startDate ? gte(column, startDate) : void 0, endDate ? lte(column, endDate) : void 0);
66
+ return and(startDate ? gte(expr, startDate) : void 0, endDate ? lte(expr, endDate) : void 0);
58
67
  }
59
68
  if (filter.variant === "date" && typeof filter.value === "string") {
60
69
  const date = safeParseDate(filter.value);
@@ -62,72 +71,72 @@ function filterColumns({ table, filters, joinOperator }) {
62
71
  date.setHours(0, 0, 0, 0);
63
72
  const end = new Date(date);
64
73
  end.setHours(23, 59, 59, 999);
65
- return and(gte(column, date), lte(column, end));
74
+ return and(gte(expr, date), lte(expr, end));
66
75
  }
67
- return eq(column, filter.value);
76
+ return eq(expr, filter.value);
68
77
  case "ne":
69
- if (column.dataType === "boolean" && typeof filter.value === "string") return ne(column, filter.value === "true");
78
+ if (isBooleanColumn(column) && typeof filter.value === "string") return ne(expr, filter.value === "true");
70
79
  if (filter.variant === "date" || filter.variant === "dateRange") {
71
80
  const date = safeParseDate(filter.value);
72
81
  if (!date) return void 0;
73
82
  date.setHours(0, 0, 0, 0);
74
83
  const end = new Date(date);
75
84
  end.setHours(23, 59, 59, 999);
76
- return or(lt(column, date), gt(column, end));
85
+ return or(lt(expr, date), gt(expr, end));
77
86
  }
78
- return ne(column, filter.value);
87
+ return ne(expr, filter.value);
79
88
  case "inArray":
80
- if (Array.isArray(filter.value)) return inArray(column, filter.value);
89
+ if (Array.isArray(filter.value)) return inArray(expr, filter.value);
81
90
  return;
82
91
  case "notInArray":
83
- if (Array.isArray(filter.value)) return notInArray(column, filter.value);
92
+ if (Array.isArray(filter.value)) return notInArray(expr, filter.value);
84
93
  return;
85
94
  case "lt":
86
95
  if (filter.variant === "number" || filter.variant === "range") {
87
96
  const num = safeParseNumber(filter.value);
88
- return num !== null ? lt(column, num) : void 0;
97
+ return num !== null ? lt(expr, num) : void 0;
89
98
  }
90
99
  if (filter.variant === "date" && typeof filter.value === "string") {
91
100
  const date = safeParseDate(filter.value);
92
101
  if (!date) return void 0;
93
102
  date.setHours(23, 59, 59, 999);
94
- return lt(column, date);
103
+ return lt(expr, date);
95
104
  }
96
105
  return;
97
106
  case "lte":
98
107
  if (filter.variant === "number" || filter.variant === "range") {
99
108
  const num = safeParseNumber(filter.value);
100
- return num !== null ? lte(column, num) : void 0;
109
+ return num !== null ? lte(expr, num) : void 0;
101
110
  }
102
111
  if (filter.variant === "date" && typeof filter.value === "string") {
103
112
  const date = safeParseDate(filter.value);
104
113
  if (!date) return void 0;
105
114
  date.setHours(23, 59, 59, 999);
106
- return lte(column, date);
115
+ return lte(expr, date);
107
116
  }
108
117
  return;
109
118
  case "gt":
110
119
  if (filter.variant === "number" || filter.variant === "range") {
111
120
  const num = safeParseNumber(filter.value);
112
- return num !== null ? gt(column, num) : void 0;
121
+ return num !== null ? gt(expr, num) : void 0;
113
122
  }
114
123
  if (filter.variant === "date" && typeof filter.value === "string") {
115
124
  const date = safeParseDate(filter.value);
116
125
  if (!date) return void 0;
117
126
  date.setHours(0, 0, 0, 0);
118
- return gt(column, date);
127
+ return gt(expr, date);
119
128
  }
120
129
  return;
121
130
  case "gte":
122
131
  if (filter.variant === "number" || filter.variant === "range") {
123
132
  const num = safeParseNumber(filter.value);
124
- return num !== null ? gte(column, num) : void 0;
133
+ return num !== null ? gte(expr, num) : void 0;
125
134
  }
126
135
  if (filter.variant === "date" && typeof filter.value === "string") {
127
136
  const date = safeParseDate(filter.value);
128
137
  if (!date) return void 0;
129
138
  date.setHours(0, 0, 0, 0);
130
- return gte(column, date);
139
+ return gte(expr, date);
131
140
  }
132
141
  return;
133
142
  case "isBetween":
@@ -137,15 +146,15 @@ function filterColumns({ table, filters, joinOperator }) {
137
146
  if (!startDate && !endDate) return void 0;
138
147
  if (startDate) startDate.setHours(0, 0, 0, 0);
139
148
  if (endDate) endDate.setHours(23, 59, 59, 999);
140
- return and(startDate ? gte(column, startDate) : void 0, endDate ? lte(column, endDate) : void 0);
149
+ return and(startDate ? gte(expr, startDate) : void 0, endDate ? lte(expr, endDate) : void 0);
141
150
  }
142
151
  if ((filter.variant === "number" || filter.variant === "range") && Array.isArray(filter.value) && filter.value.length === 2) {
143
152
  const firstValue = safeParseNumber(filter.value[0]);
144
153
  const secondValue = safeParseNumber(filter.value[1]);
145
154
  if (firstValue === null && secondValue === null) return;
146
- if (firstValue !== null && secondValue === null) return eq(column, firstValue);
147
- if (firstValue === null && secondValue !== null) return eq(column, secondValue);
148
- return and(firstValue !== null ? gte(column, firstValue) : void 0, secondValue !== null ? lte(column, secondValue) : void 0);
155
+ if (firstValue !== null && secondValue === null) return eq(expr, firstValue);
156
+ if (firstValue === null && secondValue !== null) return eq(expr, secondValue);
157
+ return and(firstValue !== null ? gte(expr, firstValue) : void 0, secondValue !== null ? lte(expr, secondValue) : void 0);
149
158
  }
150
159
  return;
151
160
  case "isRelativeToToday":
@@ -170,11 +179,11 @@ function filterColumns({ table, filters, joinOperator }) {
170
179
  break;
171
180
  default: return;
172
181
  }
173
- return and(gte(column, startDate), lte(column, endDate));
182
+ return and(gte(expr, startDate), lte(expr, endDate));
174
183
  }
175
184
  return;
176
- case "isEmpty": return isEmpty(column);
177
- case "isNotEmpty": return not(isEmpty(column));
185
+ case "isEmpty": return isEmpty(expr);
186
+ case "isNotEmpty": return not(isEmpty(expr));
178
187
  default: throw new Error(`Unsupported operator: ${filter.operator}`);
179
188
  }
180
189
  }).filter((condition) => condition !== void 0);
@@ -533,9 +542,43 @@ function resolveSchemas(config) {
533
542
  updateSchema
534
543
  };
535
544
  }
545
+ function getColumnRefId(columnRef) {
546
+ return typeof columnRef === "string" ? columnRef : columnRef.id;
547
+ }
548
+ function findColumnRef(columnId, allowedColumns) {
549
+ return allowedColumns?.find((columnRef) => getColumnRefId(columnRef) === columnId);
550
+ }
536
551
  function validateColumn(columnId, allowedColumns) {
537
552
  if (!allowedColumns || allowedColumns.length === 0) return true;
538
- return allowedColumns.includes(columnId);
553
+ return findColumnRef(columnId, allowedColumns) !== void 0;
554
+ }
555
+ function getTableColumn(table, columnId) {
556
+ return table[columnId];
557
+ }
558
+ function normalizeJsonFields(jsonField) {
559
+ return (Array.isArray(jsonField) ? jsonField : [jsonField]).filter((field) => field.length > 0);
560
+ }
561
+ function buildJsonFieldTextExpression(column, jsonField) {
562
+ const fields = normalizeJsonFields(jsonField);
563
+ if (fields.length === 0) throw new Error("[createCrudRouter] jsonField must include at least one json key.");
564
+ const objectFields = fields.map((field) => sql`${column} ->> ${field}`);
565
+ return sql`case jsonb_typeof(${column})
566
+ when 'object' then coalesce(${sql.join(objectFields, sql`, `)}, '')
567
+ when 'string' then ${column} #>> '{}'
568
+ else ''
569
+ end`;
570
+ }
571
+ function resolveColumnTarget({ table, columnId, allowedColumns }) {
572
+ const columnRef = findColumnRef(columnId, allowedColumns);
573
+ if ((allowedColumns?.length ?? 0) > 0 && columnRef === void 0) return;
574
+ if (columnRef && typeof columnRef !== "string") {
575
+ if (columnRef.expression) return columnRef.expression({ table });
576
+ const column = getTableColumn(table, columnRef.id);
577
+ if (!column) return void 0;
578
+ if (columnRef.jsonField) return buildJsonFieldTextExpression(column, columnRef.jsonField);
579
+ return column;
580
+ }
581
+ return getTableColumn(table, columnId);
539
582
  }
540
583
  /**
541
584
  * 创建 CRUD Router
@@ -638,14 +681,23 @@ function createCrudRouter(config) {
638
681
  const where = buildWhere(ctx, "list", validatedFilters?.length ? filterColumns({
639
682
  table,
640
683
  filters: validatedFilters,
641
- joinOperator: listInput$1.joinOperator
684
+ joinOperator: listInput$1.joinOperator,
685
+ resolveColumn: (columnId) => resolveColumnTarget({
686
+ table,
687
+ columnId: String(columnId),
688
+ allowedColumns: filterableColumns
689
+ })
642
690
  }) : void 0);
643
691
  let query = ctx.db.select().from(table).$dynamic();
644
692
  if (where) query = query.where(where);
645
693
  if (listInput$1.sort?.length) {
646
694
  const sortField = listInput$1.sort[0];
647
695
  if (sortField && validateColumn(sortField.id, sortableColumns)) {
648
- const column = table[sortField.id];
696
+ const column = resolveColumnTarget({
697
+ table,
698
+ columnId: sortField.id,
699
+ allowedColumns: sortableColumns
700
+ });
649
701
  if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
650
702
  }
651
703
  }
@@ -839,14 +891,23 @@ function createCrudRouter(config) {
839
891
  const where = buildWhere(ctx, "export", validatedFilters?.length ? filterColumns({
840
892
  table,
841
893
  filters: validatedFilters,
842
- joinOperator: exportInput$1.joinOperator ?? "and"
894
+ joinOperator: exportInput$1.joinOperator ?? "and",
895
+ resolveColumn: (columnId) => resolveColumnTarget({
896
+ table,
897
+ columnId: String(columnId),
898
+ allowedColumns: filterableColumns
899
+ })
843
900
  }) : void 0);
844
901
  let query = ctx.db.select().from(table).$dynamic();
845
902
  if (where) query = query.where(where);
846
903
  if (exportInput$1.sort?.length) {
847
904
  const sortField = exportInput$1.sort[0];
848
905
  if (sortField && validateColumn(sortField.id, sortableColumns)) {
849
- const column = table[sortField.id];
906
+ const column = resolveColumnTarget({
907
+ table,
908
+ columnId: sortField.id,
909
+ allowedColumns: sortableColumns
910
+ });
850
911
  if (column) query = query.orderBy(sortField.desc ? desc(column) : asc(column));
851
912
  }
852
913
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wordrhyme/auto-crud-server",
3
3
  "type": "module",
4
- "version": "1.0.5",
4
+ "version": "1.0.7",
5
5
  "description": "tRPC server utilities for auto-crud - automatic CRUD routers for Drizzle ORM",
6
6
  "author": "wordrhyme",
7
7
  "license": "MIT",
@@ -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/tsdown-config": "0.1.0",
52
- "@internal/vitest-config": "0.1.0",
53
51
  "@internal/tsconfig": "0.1.0",
54
- "@internal/prettier-config": "0.0.1"
52
+ "@internal/prettier-config": "0.0.1",
53
+ "@internal/tsdown-config": "0.1.0",
54
+ "@internal/vitest-config": "0.1.0"
55
55
  },
56
56
  "prettier": "@internal/prettier-config",
57
57
  "scripts": {