@gencow/core 0.1.14 → 0.1.16
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 +124 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reactive.d.ts +17 -6
- package/dist/reactive.js +19 -3
- package/package.json +1 -1
- package/src/__tests__/crud.test.ts +421 -1
- package/src/crud.ts +136 -11
- package/src/index.ts +1 -1
- package/src/reactive.ts +29 -7
package/dist/crud.d.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,10 +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
|
-
import type
|
|
25
|
+
import { type SQL } from "drizzle-orm";
|
|
26
|
+
import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
26
27
|
type CrudOptions<T extends PgTable> = {
|
|
27
28
|
/** 검색 대상 필드 — list의 search 파라미터에 사용 */
|
|
28
29
|
searchFields?: (keyof T["_"]["columns"])[];
|
|
@@ -48,6 +49,37 @@ type CrudOptions<T extends PgTable> = {
|
|
|
48
49
|
/** 키 접두사 오버라이드 (기본: 테이블명) */
|
|
49
50
|
prefix?: string;
|
|
50
51
|
};
|
|
52
|
+
/** 지원 연산자 목록 */
|
|
53
|
+
declare const FILTER_OPS: readonly ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "like", "ilike"];
|
|
54
|
+
type FilterOp = typeof FILTER_OPS[number];
|
|
55
|
+
/**
|
|
56
|
+
* 단일 필터 연산자를 Drizzle SQL 조건으로 변환.
|
|
57
|
+
* 지원하지 않는 op이거나 값 타입이 맞지 않으면 undefined 반환 (묵살).
|
|
58
|
+
*/
|
|
59
|
+
export declare function applyFilterOp(col: AnyPgColumn, op: FilterOp, value: unknown): SQL | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* 재귀적 필터 노드 파싱 — OR/AND 논리 중첩 + 연산자 매핑.
|
|
62
|
+
*
|
|
63
|
+
* @param node - 필터 JSON 객체
|
|
64
|
+
* @param table - Drizzle PgTable (컬럼 매핑)
|
|
65
|
+
* @param allowedFields - 허용 필드 화이트리스트 (미지정 시 모든 테이블 컬럼 허용)
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* // 암묵적 eq (v2 하위호환)
|
|
70
|
+
* parseFilterNode({ status: 'active' }, table)
|
|
71
|
+
* → and(eq(table.status, 'active'))
|
|
72
|
+
*
|
|
73
|
+
* // 명시적 연산자
|
|
74
|
+
* parseFilterNode({ qty: { op: 'gte', value: 10 } }, table)
|
|
75
|
+
* → and(gte(table.qty, 10))
|
|
76
|
+
*
|
|
77
|
+
* // OR / AND 중첩
|
|
78
|
+
* parseFilterNode({ OR: [{ a: 1 }, { b: { op: 'gt', value: 5 } }] }, table)
|
|
79
|
+
* → or(eq(table.a, 1), gt(table.b, 5))
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseFilterNode(node: Record<string, unknown>, table: any, allowedFields?: readonly string[], depth?: number): SQL | undefined;
|
|
51
83
|
/**
|
|
52
84
|
* 테이블을 받아 query/mutation을 자동 등록하고,
|
|
53
85
|
* { list, get, create, update, remove } 를 반환한다.
|
package/dist/crud.js
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,10 +19,10 @@
|
|
|
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
|
-
import { eq, desc, asc, ilike, or, and, count as drizzleCount, getTableName } from "drizzle-orm";
|
|
25
|
+
import { eq, ne, gt, gte, lt, lte, desc, asc, like, ilike, inArray, notInArray, or, and, count as drizzleCount, getTableName } from "drizzle-orm";
|
|
26
26
|
import { query, mutation } from "./reactive";
|
|
27
27
|
import { v } from "./v";
|
|
28
28
|
// ─── Helpers ────────────────────────────────────────────
|
|
@@ -37,6 +37,119 @@ function detectIdType(column) {
|
|
|
37
37
|
return v.string();
|
|
38
38
|
return v.number();
|
|
39
39
|
}
|
|
40
|
+
// ─── v3 Filter Engine — 재귀적 동적 필터링 ──────────────
|
|
41
|
+
/** 재귀 필터 최대 깊이 — DoS 방어 (깊이 초과 시 조건 묵살) */
|
|
42
|
+
const MAX_FILTER_DEPTH = 5;
|
|
43
|
+
/** 지원 연산자 목록 */
|
|
44
|
+
const FILTER_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike'];
|
|
45
|
+
function isValidFilterOp(op) {
|
|
46
|
+
return FILTER_OPS.includes(op);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 단일 필터 연산자를 Drizzle SQL 조건으로 변환.
|
|
50
|
+
* 지원하지 않는 op이거나 값 타입이 맞지 않으면 undefined 반환 (묵살).
|
|
51
|
+
*/
|
|
52
|
+
export function applyFilterOp(col, op, value) {
|
|
53
|
+
switch (op) {
|
|
54
|
+
case 'eq': return eq(col, value);
|
|
55
|
+
case 'ne': return ne(col, value);
|
|
56
|
+
case 'gt': return gt(col, value);
|
|
57
|
+
case 'gte': return gte(col, value);
|
|
58
|
+
case 'lt': return lt(col, value);
|
|
59
|
+
case 'lte': return lte(col, value);
|
|
60
|
+
case 'in':
|
|
61
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
return inArray(col, value);
|
|
64
|
+
case 'nin':
|
|
65
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
66
|
+
return undefined;
|
|
67
|
+
return notInArray(col, value);
|
|
68
|
+
case 'like':
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
return undefined;
|
|
71
|
+
return like(col, value);
|
|
72
|
+
case 'ilike':
|
|
73
|
+
if (typeof value !== 'string')
|
|
74
|
+
return undefined;
|
|
75
|
+
return ilike(col, value);
|
|
76
|
+
default: return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 재귀적 필터 노드 파싱 — OR/AND 논리 중첩 + 연산자 매핑.
|
|
81
|
+
*
|
|
82
|
+
* @param node - 필터 JSON 객체
|
|
83
|
+
* @param table - Drizzle PgTable (컬럼 매핑)
|
|
84
|
+
* @param allowedFields - 허용 필드 화이트리스트 (미지정 시 모든 테이블 컬럼 허용)
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* // 암묵적 eq (v2 하위호환)
|
|
89
|
+
* parseFilterNode({ status: 'active' }, table)
|
|
90
|
+
* → and(eq(table.status, 'active'))
|
|
91
|
+
*
|
|
92
|
+
* // 명시적 연산자
|
|
93
|
+
* parseFilterNode({ qty: { op: 'gte', value: 10 } }, table)
|
|
94
|
+
* → and(gte(table.qty, 10))
|
|
95
|
+
*
|
|
96
|
+
* // OR / AND 중첩
|
|
97
|
+
* parseFilterNode({ OR: [{ a: 1 }, { b: { op: 'gt', value: 5 } }] }, table)
|
|
98
|
+
* → or(eq(table.a, 1), gt(table.b, 5))
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function parseFilterNode(node, table, allowedFields, depth = 0) {
|
|
102
|
+
// DoS 방어: 재귀 깊이 제한 초과 시 조건 묵살
|
|
103
|
+
if (depth > MAX_FILTER_DEPTH)
|
|
104
|
+
return undefined;
|
|
105
|
+
const conditions = [];
|
|
106
|
+
for (const [key, val] of Object.entries(node)) {
|
|
107
|
+
// ── OR 논리 그룹 ──
|
|
108
|
+
if (key === 'OR') {
|
|
109
|
+
if (!Array.isArray(val))
|
|
110
|
+
continue; // 잘못된 타입 → 묵살
|
|
111
|
+
const orConds = val
|
|
112
|
+
.filter((child) => child != null && typeof child === 'object' && !Array.isArray(child))
|
|
113
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
114
|
+
.filter((c) => c !== undefined);
|
|
115
|
+
if (orConds.length > 0)
|
|
116
|
+
conditions.push(or(...orConds));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// ── AND 논리 그룹 ──
|
|
120
|
+
if (key === 'AND') {
|
|
121
|
+
if (!Array.isArray(val))
|
|
122
|
+
continue;
|
|
123
|
+
const andConds = val
|
|
124
|
+
.filter((child) => child != null && typeof child === 'object' && !Array.isArray(child))
|
|
125
|
+
.map((child) => parseFilterNode(child, table, allowedFields, depth + 1))
|
|
126
|
+
.filter((c) => c !== undefined);
|
|
127
|
+
if (andConds.length > 0)
|
|
128
|
+
conditions.push(and(...andConds));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// ── 컬럼 필터 ──
|
|
132
|
+
// allowedFields 화이트리스트 검증 (재귀적으로 OR/AND 내부에도 적용됨)
|
|
133
|
+
if (allowedFields && !allowedFields.includes(key))
|
|
134
|
+
continue;
|
|
135
|
+
const col = table[key];
|
|
136
|
+
if (!col)
|
|
137
|
+
continue;
|
|
138
|
+
// 명시적 연산자: { op: 'gte', value: 10 }
|
|
139
|
+
if (val != null && typeof val === 'object' && !Array.isArray(val) && 'op' in val) {
|
|
140
|
+
const { op, value } = val;
|
|
141
|
+
if (!isValidFilterOp(op))
|
|
142
|
+
continue; // 미지원 op → 묵살
|
|
143
|
+
const cond = applyFilterOp(col, op, value);
|
|
144
|
+
if (cond)
|
|
145
|
+
conditions.push(cond);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// 암묵적 eq (v2 하위호환): { status: 'active' } → eq(status, 'active')
|
|
149
|
+
conditions.push(eq(col, val));
|
|
150
|
+
}
|
|
151
|
+
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
152
|
+
}
|
|
40
153
|
// ─── crud — Zero-Boilerplate API Factory ──────────
|
|
41
154
|
/**
|
|
42
155
|
* 테이블을 받아 query/mutation을 자동 등록하고,
|
|
@@ -82,14 +195,14 @@ export function crud(table, options) {
|
|
|
82
195
|
const searchConds = options.searchFields.map((f) => ilike(anyTable[f], `%${args.search}%`));
|
|
83
196
|
conditions.push(or(...searchConds));
|
|
84
197
|
}
|
|
85
|
-
// Dynamic filters (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
198
|
+
// Dynamic filters — v3 재귀적 파싱 엔진 (OR/AND 중첩, 연산자 매핑)
|
|
199
|
+
// allowedFilters 설정 시: 화이트리스트 필드만 허용 (재귀 검증)
|
|
200
|
+
// allowedFilters 미설정 시: 필터 전체 무시 (Secure by Default — v2 호환)
|
|
201
|
+
if (args?.filters && typeof args.filters === 'object' && options?.allowedFilters?.length) {
|
|
202
|
+
const allowedFields = options.allowedFilters;
|
|
203
|
+
const filterCondition = parseFilterNode(args.filters, anyTable, allowedFields);
|
|
204
|
+
if (filterCondition)
|
|
205
|
+
conditions.push(filterCondition);
|
|
93
206
|
}
|
|
94
207
|
return conditions.length > 0 ? and(...conditions) : undefined;
|
|
95
208
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,5 +19,5 @@ export { defineAuth } from "./auth-config";
|
|
|
19
19
|
export type { GencowAuthConfig, AuthEmailVerification } from "./auth-config";
|
|
20
20
|
export { ownerRls } from "./rls";
|
|
21
21
|
export { createRlsDb } from "./rls-db";
|
|
22
|
-
export { crud } from "./crud";
|
|
22
|
+
export { crud, parseFilterNode, applyFilterOp } from "./crud";
|
|
23
23
|
export { crud as gencowCrud } from "./crud";
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,6 @@ export { defineAuth } from "./auth-config";
|
|
|
13
13
|
// ─── RLS + CRUD Factory ───────────
|
|
14
14
|
export { ownerRls } from "./rls";
|
|
15
15
|
export { createRlsDb } from "./rls-db";
|
|
16
|
-
export { crud } from "./crud";
|
|
16
|
+
export { crud, parseFilterNode, applyFilterOp } from "./crud";
|
|
17
17
|
// Deprecated alias — 하위호환용, 향후 메이저 버전에서 제거 예정
|
|
18
18
|
export { crud as gencowCrud } from "./crud";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface AIResult {
|
|
|
48
48
|
model: string;
|
|
49
49
|
}
|
|
50
50
|
export interface AIContext {
|
|
51
|
-
/** AI 텍스트 생성
|
|
51
|
+
/** AI 텍스트 생성 */
|
|
52
52
|
chat: (opts: {
|
|
53
53
|
model?: string;
|
|
54
54
|
messages: AIMessage[];
|
|
@@ -61,9 +61,9 @@ export interface AIContext {
|
|
|
61
61
|
type: string;
|
|
62
62
|
};
|
|
63
63
|
}) => Promise<AIResult>;
|
|
64
|
-
/** 텍스트 임베딩 (단일)
|
|
64
|
+
/** 텍스트 임베딩 (단일) */
|
|
65
65
|
embed: (text: string) => Promise<number[]>;
|
|
66
|
-
/** 배치 임베딩
|
|
66
|
+
/** 배치 임베딩 */
|
|
67
67
|
embedMany: (texts: string[]) => Promise<number[][]>;
|
|
68
68
|
}
|
|
69
69
|
export interface GencowCtx {
|
|
@@ -81,7 +81,7 @@ export interface GencowCtx {
|
|
|
81
81
|
realtime: RealtimeCtx;
|
|
82
82
|
/** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
|
|
83
83
|
retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
|
|
84
|
-
/** AI 헬퍼
|
|
84
|
+
/** AI 헬퍼 */
|
|
85
85
|
ai?: AIContext;
|
|
86
86
|
}
|
|
87
87
|
type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
@@ -248,8 +248,18 @@ export declare function deregisterClient(ws: WSContext): void;
|
|
|
248
248
|
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
249
249
|
*
|
|
250
250
|
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
251
|
+
*
|
|
252
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
253
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
254
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
251
255
|
*/
|
|
252
|
-
export declare function buildRealtimeCtx(
|
|
256
|
+
export declare function buildRealtimeCtx(options?: {
|
|
257
|
+
httpCallback?: (event: {
|
|
258
|
+
type: "emit";
|
|
259
|
+
queryKey: string;
|
|
260
|
+
data: unknown;
|
|
261
|
+
}) => void;
|
|
262
|
+
}): RealtimeCtx;
|
|
253
263
|
/**
|
|
254
264
|
* mutation이 끝난 후 호출되는 legacy fallback.
|
|
255
265
|
* `ctx.realtime.emit()`을 사용하는 새 mutation에서는 빈 배열([])을 전달하면 됩니다.
|
|
@@ -258,8 +268,9 @@ export declare function buildRealtimeCtx(): RealtimeCtx;
|
|
|
258
268
|
* (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
|
|
259
269
|
*
|
|
260
270
|
* @deprecated ctx.realtime.emit() 사용 권장
|
|
271
|
+
* @param httpInvalidateCallback BaaS 모드: Platform WS Gateway에 HTTP로 invalidation 전달.
|
|
261
272
|
*/
|
|
262
|
-
export declare function invalidateQueries(queryKeys: string[], ctx: GencowCtx): Promise<void>;
|
|
273
|
+
export declare function invalidateQueries(queryKeys: string[], ctx: GencowCtx, httpInvalidateCallback?: (queryKeys: string[]) => void): Promise<void>;
|
|
263
274
|
export declare function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer): void;
|
|
264
275
|
export declare function getQueryHandler(key: string): QueryHandler | undefined;
|
|
265
276
|
export declare function getQueryDef(key: string): QueryDef | undefined;
|
package/dist/reactive.js
CHANGED
|
@@ -181,8 +181,12 @@ export function deregisterClient(ws) {
|
|
|
181
181
|
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
182
182
|
*
|
|
183
183
|
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
184
|
+
*
|
|
185
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
186
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
187
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
184
188
|
*/
|
|
185
|
-
export function buildRealtimeCtx() {
|
|
189
|
+
export function buildRealtimeCtx(options) {
|
|
186
190
|
const pendingEmits = new Map();
|
|
187
191
|
return {
|
|
188
192
|
emit(queryKey, data) {
|
|
@@ -192,6 +196,12 @@ export function buildRealtimeCtx() {
|
|
|
192
196
|
clearTimeout(existing.timer);
|
|
193
197
|
const timer = setTimeout(() => {
|
|
194
198
|
pendingEmits.delete(queryKey);
|
|
199
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
200
|
+
if (options?.httpCallback) {
|
|
201
|
+
options.httpCallback({ type: "emit", queryKey, data });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// 로컬 dev: WS 직접 push (기존 동작)
|
|
195
205
|
const clients = subscribers.get(queryKey);
|
|
196
206
|
if (!clients || clients.size === 0)
|
|
197
207
|
return;
|
|
@@ -221,11 +231,17 @@ export function buildRealtimeCtx() {
|
|
|
221
231
|
* (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
|
|
222
232
|
*
|
|
223
233
|
* @deprecated ctx.realtime.emit() 사용 권장
|
|
234
|
+
* @param httpInvalidateCallback BaaS 모드: Platform WS Gateway에 HTTP로 invalidation 전달.
|
|
224
235
|
*/
|
|
225
|
-
export async function invalidateQueries(queryKeys, ctx) {
|
|
236
|
+
export async function invalidateQueries(queryKeys, ctx, httpInvalidateCallback) {
|
|
226
237
|
if (queryKeys.length === 0)
|
|
227
238
|
return; // emit() 방식에서는 no-op
|
|
228
|
-
//
|
|
239
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
240
|
+
if (httpInvalidateCallback) {
|
|
241
|
+
httpInvalidateCallback(queryKeys);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// 로컬 dev: WS 직접 broadcast (기존 동작)
|
|
229
245
|
const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
|
|
230
246
|
for (const ws of connectedClients) {
|
|
231
247
|
try {
|
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
|
26
26
|
|
|
27
27
|
// ─── crud import ───────────────────────────────────────────────────────
|
|
28
28
|
|
|
29
|
-
import { crud } from "../crud";
|
|
29
|
+
import { crud, parseFilterNode, applyFilterOp } from "../crud";
|
|
30
30
|
|
|
31
31
|
// ─── 테스트 테이블 정의 ────────────────────────────────────────────────────
|
|
32
32
|
|
|
@@ -525,3 +525,423 @@ describe("crud() — allowedFilters", () => {
|
|
|
525
525
|
expect(result2).toHaveProperty("data");
|
|
526
526
|
});
|
|
527
527
|
});
|
|
528
|
+
|
|
529
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
530
|
+
// 11. v3 Filter Engine — parseFilterNode 직접 테스트 (spec TC1~5)
|
|
531
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
532
|
+
|
|
533
|
+
describe("v3 Filter Engine — parseFilterNode", () => {
|
|
534
|
+
// TC1: 하위 호환성 (Implicit eq)
|
|
535
|
+
it("TC1: 단순 key-value는 암묵적 eq로 파싱된다 (하위호환)", () => {
|
|
536
|
+
const table = pgTable("tc1_test", {
|
|
537
|
+
id: serial("id").primaryKey(),
|
|
538
|
+
status: text("status"),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const result = parseFilterNode({ status: "active" }, table);
|
|
542
|
+
expect(result).toBeDefined();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("TC1: 여러 key-value 동시 사용 시 모두 AND 결합된다", () => {
|
|
546
|
+
const table = pgTable("tc1b_test", {
|
|
547
|
+
id: serial("id").primaryKey(),
|
|
548
|
+
status: text("status"),
|
|
549
|
+
category: text("category"),
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const result = parseFilterNode({ status: "active", category: "tech" }, table);
|
|
553
|
+
expect(result).toBeDefined();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// TC2: 다중 논리망 (Nested OR & AND)
|
|
557
|
+
it("TC2: AND + 중첩 OR 필터가 SQL 조건으로 빌드된다", () => {
|
|
558
|
+
const table = pgTable("tc2_test", {
|
|
559
|
+
id: serial("id").primaryKey(),
|
|
560
|
+
category: text("category"),
|
|
561
|
+
qty: serial("qty"),
|
|
562
|
+
flag: text("flag"),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const result = parseFilterNode({
|
|
566
|
+
AND: [
|
|
567
|
+
{ category: "A" },
|
|
568
|
+
{ OR: [
|
|
569
|
+
{ qty: { op: "gte", value: 10 } },
|
|
570
|
+
{ flag: true },
|
|
571
|
+
] },
|
|
572
|
+
],
|
|
573
|
+
}, table);
|
|
574
|
+
|
|
575
|
+
expect(result).toBeDefined();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("TC2: 단순 OR 배열도 파싱된다", () => {
|
|
579
|
+
const table = pgTable("tc2b_test", {
|
|
580
|
+
id: serial("id").primaryKey(),
|
|
581
|
+
title: text("title"),
|
|
582
|
+
description: text("description"),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const result = parseFilterNode({
|
|
586
|
+
OR: [
|
|
587
|
+
{ title: { op: "ilike", value: "%AI%" } },
|
|
588
|
+
{ description: { op: "ilike", value: "%데이터%" } },
|
|
589
|
+
],
|
|
590
|
+
}, table);
|
|
591
|
+
|
|
592
|
+
expect(result).toBeDefined();
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// TC3: 보안 방어 — allowedFilters 위반
|
|
596
|
+
it("TC3: allowedFilters에 없는 필드는 완전 묵살 (직접 접근)", () => {
|
|
597
|
+
const table = pgTable("tc3_test", {
|
|
598
|
+
id: serial("id").primaryKey(),
|
|
599
|
+
status: text("status"),
|
|
600
|
+
title: text("title"),
|
|
601
|
+
passwordHash: text("password_hash"),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// password_hash 직접 접근 → 묵살
|
|
605
|
+
const r1 = parseFilterNode(
|
|
606
|
+
{ passwordHash: { op: "eq", value: "xxx" } },
|
|
607
|
+
table,
|
|
608
|
+
["status", "title"],
|
|
609
|
+
);
|
|
610
|
+
expect(r1).toBeUndefined(); // 모든 조건 제거됨
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("TC3: OR 틈새 우회 시도 시 미허용 필드만 묵살, 허용 필드는 유지", () => {
|
|
614
|
+
const table = pgTable("tc3b_test", {
|
|
615
|
+
id: serial("id").primaryKey(),
|
|
616
|
+
status: text("status"),
|
|
617
|
+
isAdmin: text("is_admin"),
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const r = parseFilterNode(
|
|
621
|
+
{ OR: [{ status: "public" }, { isAdmin: true }] },
|
|
622
|
+
table,
|
|
623
|
+
["status"],
|
|
624
|
+
);
|
|
625
|
+
// status는 허용 → OR 조건 살아있음
|
|
626
|
+
expect(r).toBeDefined();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("TC3: 모든 필드가 미허용이면 undefined 반환", () => {
|
|
630
|
+
const table = pgTable("tc3c_test", {
|
|
631
|
+
id: serial("id").primaryKey(),
|
|
632
|
+
secret: text("secret"),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const r = parseFilterNode(
|
|
636
|
+
{ OR: [{ secret: "hi" }] },
|
|
637
|
+
table,
|
|
638
|
+
["status"],
|
|
639
|
+
);
|
|
640
|
+
expect(r).toBeUndefined();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// TC4: 악의적 페이로드 구조 방어
|
|
644
|
+
it("TC4: OR에 비배열 전달 시 무시, 크래시 없음", () => {
|
|
645
|
+
const table = pgTable("tc4_test", {
|
|
646
|
+
id: serial("id").primaryKey(),
|
|
647
|
+
age: serial("age"),
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
expect(parseFilterNode({ OR: "not-an-array" as any }, table)).toBeUndefined();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("TC4: 미지원 op 키워드(DROP TABLE 등) 묵살, 크래시 없음", () => {
|
|
654
|
+
const table = pgTable("tc4b_test", {
|
|
655
|
+
id: serial("id").primaryKey(),
|
|
656
|
+
age: serial("age"),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
expect(parseFilterNode({ age: { op: "DROP TABLE", value: 1 } }, table)).toBeUndefined();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("TC4: AND에 null/string/number 등 비객체 원소 묵살", () => {
|
|
663
|
+
const table = pgTable("tc4c_test", {
|
|
664
|
+
id: serial("id").primaryKey(),
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
expect(parseFilterNode({ AND: [null, "string", 42] as any }, table)).toBeUndefined();
|
|
668
|
+
expect(parseFilterNode({ AND: null as any }, table)).toBeUndefined();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("TC4: 존재하지 않는 컬럼명 필터는 묵살", () => {
|
|
672
|
+
const table = pgTable("tc4d_test", {
|
|
673
|
+
id: serial("id").primaryKey(),
|
|
674
|
+
name: text("name"),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const r = parseFilterNode({ nonExistentCol: "value" }, table);
|
|
678
|
+
expect(r).toBeUndefined();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// TC5: IN / NIN 연산자 배열값 처리
|
|
682
|
+
it("TC5: in 연산자 — 정상 배열은 SQL 생성", () => {
|
|
683
|
+
const table = pgTable("tc5_test", {
|
|
684
|
+
id: serial("id").primaryKey(),
|
|
685
|
+
status: text("status"),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const r = parseFilterNode({ id: { op: "in", value: [1, 2, 3] } }, table);
|
|
689
|
+
expect(r).toBeDefined();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("TC5: in 연산자 — 비배열은 묵살", () => {
|
|
693
|
+
const table = pgTable("tc5b_test", {
|
|
694
|
+
id: serial("id").primaryKey(),
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const r = parseFilterNode({ id: { op: "in", value: "1" } }, table);
|
|
698
|
+
expect(r).toBeUndefined();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("TC5: in 연산자 — 빈 배열은 묵살", () => {
|
|
702
|
+
const table = pgTable("tc5c_test", {
|
|
703
|
+
id: serial("id").primaryKey(),
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
const r = parseFilterNode({ id: { op: "in", value: [] } }, table);
|
|
707
|
+
expect(r).toBeUndefined();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("TC5: nin 연산자 — 정상 배열은 SQL 생성", () => {
|
|
711
|
+
const table = pgTable("tc5d_test", {
|
|
712
|
+
id: serial("id").primaryKey(),
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const r = parseFilterNode({ id: { op: "nin", value: [10, 20] } }, table);
|
|
716
|
+
expect(r).toBeDefined();
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
721
|
+
// 12. v3 Filter Engine — applyFilterOp 직접 테스트
|
|
722
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
723
|
+
|
|
724
|
+
describe("v3 Filter Engine — applyFilterOp", () => {
|
|
725
|
+
const table = pgTable("op_test", {
|
|
726
|
+
id: serial("id").primaryKey(),
|
|
727
|
+
name: text("name"),
|
|
728
|
+
age: serial("age"),
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("eq 연산자가 SQL 조건을 반환한다", () => {
|
|
732
|
+
expect(applyFilterOp(table.name, "eq", "test")).toBeDefined();
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("ne 연산자가 SQL 조건을 반환한다", () => {
|
|
736
|
+
expect(applyFilterOp(table.name, "ne", "test")).toBeDefined();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("gt/gte/lt/lte 비교 연산자가 SQL 조건을 반환한다", () => {
|
|
740
|
+
expect(applyFilterOp(table.age, "gt", 10)).toBeDefined();
|
|
741
|
+
expect(applyFilterOp(table.age, "gte", 18)).toBeDefined();
|
|
742
|
+
expect(applyFilterOp(table.age, "lt", 100)).toBeDefined();
|
|
743
|
+
expect(applyFilterOp(table.age, "lte", 65)).toBeDefined();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("like/ilike 패턴 연산자가 SQL 조건을 반환한다", () => {
|
|
747
|
+
expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
|
|
748
|
+
expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("in 연산자 — 정상 배열은 SQL 반환", () => {
|
|
752
|
+
expect(applyFilterOp(table.id, "in", [1, 2, 3])).toBeDefined();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("nin 연산자 — 빈 배열은 undefined 반환", () => {
|
|
756
|
+
expect(applyFilterOp(table.id, "nin", [])).toBeUndefined();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("미지원 연산자는 undefined 반환", () => {
|
|
760
|
+
expect(applyFilterOp(table.id, "INVALID" as any, 1)).toBeUndefined();
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
765
|
+
// 13. v3 crud() handler 통합 테스트 — Advanced Filters
|
|
766
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
767
|
+
|
|
768
|
+
describe("v3 crud() — advanced filters through handler", () => {
|
|
769
|
+
it("allowedFilters 미설정 시 필터가 무시된다 (Secure by Default)", async () => {
|
|
770
|
+
const openTable = pgTable("v3_open_filter", {
|
|
771
|
+
id: serial("id").primaryKey(),
|
|
772
|
+
status: text("status"),
|
|
773
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// allowedFilters 미설정 → 필터 전체 무시 (v2 호환, Secure by Default)
|
|
777
|
+
crud(openTable, { public: true });
|
|
778
|
+
|
|
779
|
+
const listDef = getQueryDef("v3_open_filter.list");
|
|
780
|
+
expect(listDef).toBeDefined();
|
|
781
|
+
|
|
782
|
+
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
783
|
+
const result = await listDef!.handler(mockCtx, {
|
|
784
|
+
filters: { status: { op: "eq", value: "active" } },
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// 필터가 무시되어도 에러 없이 정상 동작
|
|
788
|
+
expect(result).toHaveProperty("data");
|
|
789
|
+
expect(result).toHaveProperty("total");
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("allowedFilters 설정 시 v3 고급 필터가 동작한다", async () => {
|
|
793
|
+
const advTable = pgTable("v3_adv_filter", {
|
|
794
|
+
id: serial("id").primaryKey(),
|
|
795
|
+
status: text("status"),
|
|
796
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
crud(advTable, { public: true, allowedFilters: ["status"] });
|
|
800
|
+
|
|
801
|
+
const listDef = getQueryDef("v3_adv_filter.list");
|
|
802
|
+
const mockCtx = createListMockCtx([{ id: 1, status: "active" }]);
|
|
803
|
+
const result = await listDef!.handler(mockCtx, {
|
|
804
|
+
filters: { status: { op: "eq", value: "active" } },
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
expect(result).toHaveProperty("data");
|
|
808
|
+
expect(result).toHaveProperty("total");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("복합 OR/AND 필터가 handler에서 에러 없이 실행된다", async () => {
|
|
812
|
+
const complexTable = pgTable("v3_complex", {
|
|
813
|
+
id: serial("id").primaryKey(),
|
|
814
|
+
title: text("title"),
|
|
815
|
+
status: text("status"),
|
|
816
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
crud(complexTable, { public: true, allowedFilters: ["title", "status"] });
|
|
820
|
+
|
|
821
|
+
const listDef = getQueryDef("v3_complex.list");
|
|
822
|
+
const mockCtx = createListMockCtx([]);
|
|
823
|
+
|
|
824
|
+
const result = await listDef!.handler(mockCtx, {
|
|
825
|
+
filters: {
|
|
826
|
+
OR: [
|
|
827
|
+
{ title: { op: "ilike", value: "%AI%" } },
|
|
828
|
+
{ status: { op: "in", value: ["active", "archived"] } },
|
|
829
|
+
],
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(result).toHaveProperty("data");
|
|
834
|
+
expect(result.total).toBe(0);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it("악성 페이로드가 handler를 크래시시키지 않는다", async () => {
|
|
838
|
+
const safeTable = pgTable("v3_safe", {
|
|
839
|
+
id: serial("id").primaryKey(),
|
|
840
|
+
name: text("name"),
|
|
841
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
crud(safeTable, { public: true });
|
|
845
|
+
|
|
846
|
+
const listDef = getQueryDef("v3_safe.list");
|
|
847
|
+
const mockCtx = createListMockCtx([{ id: 1 }]);
|
|
848
|
+
|
|
849
|
+
// 다양한 악성 페이로드 — 모두 에러 없이 실행
|
|
850
|
+
const payloads = [
|
|
851
|
+
{ filters: { OR: "not-array" } },
|
|
852
|
+
{ filters: { AND: null } },
|
|
853
|
+
{ filters: { name: { op: "EVIL_OP", value: 1 } } },
|
|
854
|
+
{ filters: { OR: [null, undefined, 42, "bad"] } },
|
|
855
|
+
{ filters: {} },
|
|
856
|
+
];
|
|
857
|
+
|
|
858
|
+
for (const payload of payloads) {
|
|
859
|
+
const result = await listDef!.handler(mockCtx, payload);
|
|
860
|
+
expect(result).toHaveProperty("data");
|
|
861
|
+
expect(result).toHaveProperty("total");
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
867
|
+
// 14. v3 보안 강화 — 재귀 깊이 제한 + like/ilike 타입 검증
|
|
868
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
869
|
+
|
|
870
|
+
describe("v3 보안 강화 — 재귀 깊이 제한", () => {
|
|
871
|
+
it("MAX_FILTER_DEPTH(5) 초과 시 조건 묵살", () => {
|
|
872
|
+
const table = pgTable("depth_test", {
|
|
873
|
+
id: serial("id").primaryKey(),
|
|
874
|
+
name: text("name"),
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// 깊이 6단계 중첩 — MAX_FILTER_DEPTH=5 초과
|
|
878
|
+
const deepFilter = {
|
|
879
|
+
OR: [{ AND: [{ OR: [{ AND: [{ OR: [{ AND: [{ name: "deep" }] }] }] }] }] }],
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const result = parseFilterNode(deepFilter, table);
|
|
883
|
+
// 깊이 초과된 부분이 묵살되어 결과가 undefined이거나 부분 조건만 남음
|
|
884
|
+
// 중요한 것은 스택 오버플로 없이 정상 반환
|
|
885
|
+
expect(() => parseFilterNode(deepFilter, table)).not.toThrow();
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it("MAX_FILTER_DEPTH 이내의 중첩은 정상 동작", () => {
|
|
889
|
+
const table = pgTable("depth_ok_test", {
|
|
890
|
+
id: serial("id").primaryKey(),
|
|
891
|
+
name: text("name"),
|
|
892
|
+
status: text("status"),
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// 깊이 3단계 — 정상 범위
|
|
896
|
+
const normalFilter = {
|
|
897
|
+
OR: [
|
|
898
|
+
{ AND: [{ OR: [{ name: "test" }] }] },
|
|
899
|
+
{ status: "active" },
|
|
900
|
+
],
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const result = parseFilterNode(normalFilter, table);
|
|
904
|
+
expect(result).toBeDefined();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("극단적 깊이(100단계)에서도 크래시 없음", () => {
|
|
908
|
+
const table = pgTable("depth_extreme_test", {
|
|
909
|
+
id: serial("id").primaryKey(),
|
|
910
|
+
name: text("name"),
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// 100단계 중첩 생성
|
|
914
|
+
let filter: any = { name: "leaf" };
|
|
915
|
+
for (let i = 0; i < 100; i++) {
|
|
916
|
+
filter = i % 2 === 0 ? { OR: [filter] } : { AND: [filter] };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
expect(() => parseFilterNode(filter, table)).not.toThrow();
|
|
920
|
+
// 깊이 초과로 조건 묵살 → undefined
|
|
921
|
+
const result = parseFilterNode(filter, table);
|
|
922
|
+
expect(result).toBeUndefined();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
describe("v3 보안 강화 — like/ilike 타입 검증", () => {
|
|
927
|
+
const table = pgTable("like_type_test", {
|
|
928
|
+
id: serial("id").primaryKey(),
|
|
929
|
+
name: text("name"),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("like에 문자열이 아닌 값 전달 시 undefined 반환", () => {
|
|
933
|
+
expect(applyFilterOp(table.name, "like", 12345)).toBeUndefined();
|
|
934
|
+
expect(applyFilterOp(table.name, "like", null)).toBeUndefined();
|
|
935
|
+
expect(applyFilterOp(table.name, "like", { evil: true })).toBeUndefined();
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("ilike에 문자열이 아닌 값 전달 시 undefined 반환", () => {
|
|
939
|
+
expect(applyFilterOp(table.name, "ilike", 99)).toBeUndefined();
|
|
940
|
+
expect(applyFilterOp(table.name, "ilike", ["array"])).toBeUndefined();
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("like/ilike에 정상 문자열 전달 시 SQL 반환", () => {
|
|
944
|
+
expect(applyFilterOp(table.name, "like", "%test%")).toBeDefined();
|
|
945
|
+
expect(applyFilterOp(table.name, "ilike", "%TEST%")).toBeDefined();
|
|
946
|
+
});
|
|
947
|
+
});
|
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,14 +245,17 @@ 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;
|
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";
|
package/src/reactive.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface AIResult {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface AIContext {
|
|
57
|
-
/** AI 텍스트 생성
|
|
57
|
+
/** AI 텍스트 생성 */
|
|
58
58
|
chat: (opts: {
|
|
59
59
|
model?: string;
|
|
60
60
|
messages: AIMessage[];
|
|
@@ -65,9 +65,9 @@ export interface AIContext {
|
|
|
65
65
|
/** Response format — e.g. { type: "json_object" } for JSON mode */
|
|
66
66
|
responseFormat?: { type: string };
|
|
67
67
|
}) => Promise<AIResult>;
|
|
68
|
-
/** 텍스트 임베딩 (단일)
|
|
68
|
+
/** 텍스트 임베딩 (단일) */
|
|
69
69
|
embed: (text: string) => Promise<number[]>;
|
|
70
|
-
/** 배치 임베딩
|
|
70
|
+
/** 배치 임베딩 */
|
|
71
71
|
embedMany: (texts: string[]) => Promise<number[][]>;
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -86,7 +86,7 @@ export interface GencowCtx {
|
|
|
86
86
|
realtime: RealtimeCtx;
|
|
87
87
|
/** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
|
|
88
88
|
retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
|
|
89
|
-
/** AI 헬퍼
|
|
89
|
+
/** AI 헬퍼 */
|
|
90
90
|
ai?: AIContext;
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -392,8 +392,14 @@ export function deregisterClient(ws: WSContext) {
|
|
|
392
392
|
* 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
|
|
393
393
|
*
|
|
394
394
|
* ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
|
|
395
|
+
*
|
|
396
|
+
* @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
|
|
397
|
+
* 설정되면 WS 직접 push 대신 이 콜백을 호출.
|
|
398
|
+
* 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
|
|
395
399
|
*/
|
|
396
|
-
export function buildRealtimeCtx(
|
|
400
|
+
export function buildRealtimeCtx(options?: {
|
|
401
|
+
httpCallback?: (event: { type: "emit"; queryKey: string; data: unknown }) => void;
|
|
402
|
+
}): RealtimeCtx {
|
|
397
403
|
const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
|
|
398
404
|
|
|
399
405
|
return {
|
|
@@ -404,6 +410,14 @@ export function buildRealtimeCtx(): RealtimeCtx {
|
|
|
404
410
|
|
|
405
411
|
const timer = setTimeout(() => {
|
|
406
412
|
pendingEmits.delete(queryKey);
|
|
413
|
+
|
|
414
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
415
|
+
if (options?.httpCallback) {
|
|
416
|
+
options.httpCallback({ type: "emit", queryKey, data });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 로컬 dev: WS 직접 push (기존 동작)
|
|
407
421
|
const clients = subscribers.get(queryKey);
|
|
408
422
|
if (!clients || clients.size === 0) return;
|
|
409
423
|
|
|
@@ -430,14 +444,22 @@ export function buildRealtimeCtx(): RealtimeCtx {
|
|
|
430
444
|
* (서버에서 쿼리를 재실행하지 않으므로 DB 부하 없음)
|
|
431
445
|
*
|
|
432
446
|
* @deprecated ctx.realtime.emit() 사용 권장
|
|
447
|
+
* @param httpInvalidateCallback BaaS 모드: Platform WS Gateway에 HTTP로 invalidation 전달.
|
|
433
448
|
*/
|
|
434
449
|
export async function invalidateQueries(
|
|
435
450
|
queryKeys: string[],
|
|
436
|
-
ctx: GencowCtx
|
|
451
|
+
ctx: GencowCtx,
|
|
452
|
+
httpInvalidateCallback?: (queryKeys: string[]) => void,
|
|
437
453
|
): Promise<void> {
|
|
438
454
|
if (queryKeys.length === 0) return; // emit() 방식에서는 no-op
|
|
439
455
|
|
|
440
|
-
//
|
|
456
|
+
// BaaS 모드: Platform WS Gateway에 HTTP callback
|
|
457
|
+
if (httpInvalidateCallback) {
|
|
458
|
+
httpInvalidateCallback(queryKeys);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 로컬 dev: WS 직접 broadcast (기존 동작)
|
|
441
463
|
const invalidateMsg = JSON.stringify({ type: "invalidate", queries: queryKeys });
|
|
442
464
|
for (const ws of connectedClients) {
|
|
443
465
|
try { ws.send(invalidateMsg); } catch { connectedClients.delete(ws); }
|