@gencow/core 0.1.13 → 0.1.15
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/crud.d.ts +35 -3
- package/dist/crud.js +141 -27
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/crud-codegen-integration.test.ts +253 -0
- package/src/__tests__/crud.test.ts +421 -1
- package/src/__tests__/dist-exports.test.ts +170 -0
- package/src/crud.ts +153 -27
- package/src/index.ts +1 -1
package/src/crud.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* packages/core/src/crud.ts — Zero-Boilerplate CRUD API Auto-Generator (
|
|
2
|
+
* packages/core/src/crud.ts — Zero-Boilerplate CRUD API Auto-Generator (v3)
|
|
3
3
|
*
|
|
4
4
|
* 사용자 코드 5줄로 완전한 CRUD API를 자동 생성:
|
|
5
5
|
*
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
* 내부적으로 query()/mutation()을 호출하여 글로벌 레지스트리에 등록하므로,
|
|
20
20
|
* 프론트엔드에서 useQuery(api.tasks.list) / useMutation(api.tasks.create) 로 바로 사용 가능.
|
|
21
21
|
*
|
|
22
|
-
* 📄 Spec: docs/specs/spec-crud-v2-enhancements.md
|
|
22
|
+
* 📄 Spec: docs/specs/spec-crud-advanced-filters.md (v3), docs/specs/spec-crud-v2-enhancements.md (v2)
|
|
23
23
|
* 📦 참조: saas-js/drizzle-crud 팩토리 패턴
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { eq, desc, asc, ilike, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
|
|
26
|
+
import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName, getTableColumns, type SQL } from "drizzle-orm";
|
|
27
27
|
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
28
28
|
import { query, mutation } from "./reactive";
|
|
29
29
|
import { v } from "./v";
|
|
@@ -67,6 +67,128 @@ function detectIdType(column: AnyPgColumn) {
|
|
|
67
67
|
return v.number();
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
|
|
71
|
+
|
|
72
|
+
/** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
|
|
73
|
+
const MAX_FILTER_DEPTH = 5;
|
|
74
|
+
|
|
75
|
+
/** 지원 연산자 목록 */
|
|
76
|
+
const FILTER_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike'] as const;
|
|
77
|
+
type FilterOp = typeof FILTER_OPS[number];
|
|
78
|
+
|
|
79
|
+
function isValidFilterOp(op: string): op is FilterOp {
|
|
80
|
+
return (FILTER_OPS as readonly string[]).includes(op);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 단일 필터 연산자를 Drizzle SQL 조건으로 변환.
|
|
85
|
+
* 지원하지 않는 op이거나 값 타입이 맞지 않으면 undefined 반환 (묵살).
|
|
86
|
+
*/
|
|
87
|
+
export function applyFilterOp(col: AnyPgColumn, op: FilterOp, value: unknown): SQL | undefined {
|
|
88
|
+
switch (op) {
|
|
89
|
+
case 'eq': return eq(col, value);
|
|
90
|
+
case 'ne': return ne(col, value);
|
|
91
|
+
case 'gt': return gt(col, value as any);
|
|
92
|
+
case 'gte': return gte(col, value as any);
|
|
93
|
+
case 'lt': return lt(col, value as any);
|
|
94
|
+
case 'lte': return lte(col, value as any);
|
|
95
|
+
case 'in':
|
|
96
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
97
|
+
return inArray(col, value);
|
|
98
|
+
case 'nin':
|
|
99
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
100
|
+
return notInArray(col, value);
|
|
101
|
+
case 'like':
|
|
102
|
+
if (typeof value !== 'string') return undefined;
|
|
103
|
+
return like(col, value);
|
|
104
|
+
case 'ilike':
|
|
105
|
+
if (typeof value !== 'string') return undefined;
|
|
106
|
+
return ilike(col, value);
|
|
107
|
+
default: return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 재귀적 필터 노드 파싱 — OR/AND 논리 중첩 + 연산자 매핑.
|
|
113
|
+
*
|
|
114
|
+
* @param node - 필터 JSON 객체
|
|
115
|
+
* @param table - Drizzle PgTable (컬럼 매핑)
|
|
116
|
+
* @param allowedFields - 허용 필드 화이트리스트 (미지정 시 모든 테이블 컬럼 허용)
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* // 암묵적 eq (v2 하위호환)
|
|
121
|
+
* parseFilterNode({ status: 'active' }, table)
|
|
122
|
+
* → and(eq(table.status, 'active'))
|
|
123
|
+
*
|
|
124
|
+
* // 명시적 연산자
|
|
125
|
+
* parseFilterNode({ qty: { op: 'gte', value: 10 } }, table)
|
|
126
|
+
* → and(gte(table.qty, 10))
|
|
127
|
+
*
|
|
128
|
+
* // OR / AND 중첩
|
|
129
|
+
* parseFilterNode({ OR: [{ a: 1 }, { b: { op: 'gt', value: 5 } }] }, table)
|
|
130
|
+
* → or(eq(table.a, 1), gt(table.b, 5))
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function parseFilterNode(
|
|
134
|
+
node: Record<string, unknown>,
|
|
135
|
+
table: any,
|
|
136
|
+
allowedFields?: readonly string[],
|
|
137
|
+
depth: number = 0,
|
|
138
|
+
): SQL | undefined {
|
|
139
|
+
// DoS 방어: 재귀 깊이 제한 초과 시 조건 묵살
|
|
140
|
+
if (depth > MAX_FILTER_DEPTH) return undefined;
|
|
141
|
+
|
|
142
|
+
const conditions: SQL[] = [];
|
|
143
|
+
|
|
144
|
+
for (const [key, val] of Object.entries(node)) {
|
|
145
|
+
// ── OR 논리 그룹 ──
|
|
146
|
+
if (key === 'OR') {
|
|
147
|
+
if (!Array.isArray(val)) continue; // 잘못된 타입 → 묵살
|
|
148
|
+
const orConds = val
|
|
149
|
+
.filter((child): child is Record<string, unknown> =>
|
|
150
|
+
child != null && typeof child === 'object' && !Array.isArray(child))
|
|
151
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
152
|
+
.filter((c): c is SQL => c !== undefined);
|
|
153
|
+
if (orConds.length > 0) conditions.push(or(...orConds)!);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── AND 논리 그룹 ──
|
|
158
|
+
if (key === 'AND') {
|
|
159
|
+
if (!Array.isArray(val)) continue;
|
|
160
|
+
const andConds = val
|
|
161
|
+
.filter((child): child is Record<string, unknown> =>
|
|
162
|
+
child != null && typeof child === 'object' && !Array.isArray(child))
|
|
163
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
164
|
+
.filter((c): c is SQL => c !== undefined);
|
|
165
|
+
if (andConds.length > 0) conditions.push(and(...andConds)!);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── 컬럼 필터 ──
|
|
170
|
+
// allowedFields 화이트리스트 검증 (재귀적으로 OR/AND 내부에도 적용됨)
|
|
171
|
+
if (allowedFields && !allowedFields.includes(key)) continue;
|
|
172
|
+
|
|
173
|
+
const col = table[key] as AnyPgColumn | undefined;
|
|
174
|
+
if (!col) continue;
|
|
175
|
+
|
|
176
|
+
// 명시적 연산자: { op: 'gte', value: 10 }
|
|
177
|
+
if (val != null && typeof val === 'object' && !Array.isArray(val) && 'op' in val) {
|
|
178
|
+
const { op, value } = val as { op: string; value: unknown };
|
|
179
|
+
if (!isValidFilterOp(op)) continue; // 미지원 op → 묵살
|
|
180
|
+
const cond = applyFilterOp(col, op, value);
|
|
181
|
+
if (cond) conditions.push(cond);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 암묵적 eq (v2 하위호환): { status: 'active' } → eq(status, 'active')
|
|
186
|
+
conditions.push(eq(col, val));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
70
192
|
// ─── crud — Zero-Boilerplate API Factory ──────────
|
|
71
193
|
|
|
72
194
|
/**
|
|
@@ -123,25 +245,29 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
123
245
|
conditions.push(or(...searchConds)!);
|
|
124
246
|
}
|
|
125
247
|
|
|
126
|
-
// Dynamic filters (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
248
|
+
// Dynamic filters — v3 재귀적 파싱 엔진 (OR/AND 중첩, 연산자 매핑)
|
|
249
|
+
// allowedFilters 설정 시: 화이트리스트 필드만 허용 (재귀 검증)
|
|
250
|
+
// allowedFilters 미설정 시: 필터 전체 무시 (Secure by Default — v2 호환)
|
|
251
|
+
if (args?.filters && typeof args.filters === 'object' && options?.allowedFilters?.length) {
|
|
252
|
+
const allowedFields = options.allowedFilters as unknown as string[];
|
|
253
|
+
const filterCondition = parseFilterNode(
|
|
254
|
+
args.filters as Record<string, unknown>,
|
|
255
|
+
anyTable,
|
|
256
|
+
allowedFields,
|
|
257
|
+
);
|
|
258
|
+
if (filterCondition) conditions.push(filterCondition);
|
|
134
259
|
}
|
|
135
260
|
|
|
136
261
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
137
262
|
}
|
|
138
263
|
|
|
139
264
|
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
265
|
+
// 순차 실행: 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
266
|
+
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
267
|
+
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
140
268
|
async function fetchListWithTotal(db: any, whereClause?: SQL) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
db.select({ count: drizzleCount() }).from(anyTable).where(whereClause),
|
|
144
|
-
]);
|
|
269
|
+
const data = await db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol));
|
|
270
|
+
const countResult = await db.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
|
|
145
271
|
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
146
272
|
}
|
|
147
273
|
|
|
@@ -175,18 +301,18 @@ export function crud<T extends PgTable>(table: T, options?: CrudOptions<T>) {
|
|
|
175
301
|
orderByClause = desc(defaultOrderCol);
|
|
176
302
|
}
|
|
177
303
|
|
|
178
|
-
// SELECT + COUNT
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
304
|
+
// SELECT + COUNT 순차 실행 — 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
305
|
+
// Note: data←→count 사이 INSERT/DELETE 시 total 불일치 가능 (BaaS 단일사용자/저부하에서 무시 가능)
|
|
306
|
+
// TODO(P2): 대규모 시 db.transaction() 래핑 검토
|
|
307
|
+
const results = await ctx.db.select()
|
|
308
|
+
.from(anyTable)
|
|
309
|
+
.where(whereClause)
|
|
310
|
+
.orderBy(orderByClause)
|
|
311
|
+
.limit(limit)
|
|
312
|
+
.offset(offset);
|
|
313
|
+
const countResult = await ctx.db.select({ count: drizzleCount() })
|
|
314
|
+
.from(anyTable)
|
|
315
|
+
.where(whereClause);
|
|
190
316
|
|
|
191
317
|
return {
|
|
192
318
|
data: results,
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
|
22
22
|
// ─── RLS + CRUD Factory ───────────
|
|
23
23
|
export { ownerRls } from "./rls";
|
|
24
24
|
export { createRlsDb } from "./rls-db";
|
|
25
|
-
export { crud } from "./crud";
|
|
25
|
+
export { crud, parseFilterNode, applyFilterOp } from "./crud";
|
|
26
26
|
|
|
27
27
|
// Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
|
|
28
28
|
export { crud as gencowCrud } from "./crud";
|