@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/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,23 +195,24 @@ 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
|
}
|
|
96
209
|
// ── 내부 헬퍼: list+count 데이터 가져오기 (realtime push용 재사용) ──
|
|
210
|
+
// 순차 실행: 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
211
|
+
// ⚠️ limit/offset 없이 전체 SELECT — 대량 데이터 시 성능 저하 주의
|
|
212
|
+
// TODO(P2): realtime emit 시 invalidation 메시지만 전송하고 클라이언트가 re-fetch하는 패턴 검토
|
|
97
213
|
async function fetchListWithTotal(db, whereClause) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
db.select({ count: drizzleCount() }).from(anyTable).where(whereClause),
|
|
101
|
-
]);
|
|
214
|
+
const data = await db.select().from(anyTable).where(whereClause).orderBy(desc(defaultOrderCol));
|
|
215
|
+
const countResult = await db.select({ count: drizzleCount() }).from(anyTable).where(whereClause);
|
|
102
216
|
return { data, total: Number(countResult[0]?.count ?? 0) };
|
|
103
217
|
}
|
|
104
218
|
// ── list ──────────────────────────────────────
|
|
@@ -128,18 +242,18 @@ export function crud(table, options) {
|
|
|
128
242
|
else {
|
|
129
243
|
orderByClause = desc(defaultOrderCol);
|
|
130
244
|
}
|
|
131
|
-
// SELECT + COUNT
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
245
|
+
// SELECT + COUNT 순차 실행 — 소규모 커넥션 풀 환경에서 동시 점유 방지
|
|
246
|
+
// Note: data←→count 사이 INSERT/DELETE 시 total 불일치 가능 (BaaS 단일사용자/저부하에서 무시 가능)
|
|
247
|
+
// TODO(P2): 대규모 시 db.transaction() 래핑 검토
|
|
248
|
+
const results = await ctx.db.select()
|
|
249
|
+
.from(anyTable)
|
|
250
|
+
.where(whereClause)
|
|
251
|
+
.orderBy(orderByClause)
|
|
252
|
+
.limit(limit)
|
|
253
|
+
.offset(offset);
|
|
254
|
+
const countResult = await ctx.db.select({ count: drizzleCount() })
|
|
255
|
+
.from(anyTable)
|
|
256
|
+
.where(whereClause);
|
|
143
257
|
return {
|
|
144
258
|
data: results,
|
|
145
259
|
total: Number(countResult[0]?.count ?? 0),
|
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/package.json
CHANGED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/core/src/__tests__/crud-codegen-integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* crud() → 레지스트리 등록 → codegen 인식 통합 테스트.
|
|
5
|
+
*
|
|
6
|
+
* 이 테스트의 존재 이유:
|
|
7
|
+
* 2026-04-02 사고: crud()가 query/mutation을 레지스트리에 자동 등록하지만,
|
|
8
|
+
* codegen(gencow-extract.ts)이 getRegisteredQueries()/getRegisteredMutations()를
|
|
9
|
+
* 통해 이를 인식하는 전체 파이프라인이 검증되지 않았음.
|
|
10
|
+
* 결과: 사용자 모듈이 crud()를 사용하면 api.ts에 CRUD 엔드포인트가 누락됨.
|
|
11
|
+
*
|
|
12
|
+
* 검증 항목:
|
|
13
|
+
* 1. crud() 호출 후 getRegisteredQueries()에 {prefix}.list, {prefix}.get 포함
|
|
14
|
+
* 2. crud() 호출 후 getRegisteredMutations()에 {prefix}.create/update/remove 포함
|
|
15
|
+
* 3. 수동 query()/mutation()과 crud() 혼용 시 양쪽 모두 레지스트리에 존재
|
|
16
|
+
* 4. public/private 상태가 레지스트리에 정확히 반영
|
|
17
|
+
* 5. 여러 테이블의 crud()를 동시 호출해도 충돌 없음
|
|
18
|
+
*
|
|
19
|
+
* Run: bun test packages/core/src/__tests__/crud-codegen-integration.test.ts
|
|
20
|
+
*
|
|
21
|
+
* @see docs/analysis/analysis-test032-gencow-crud-api-mismatch.md
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, beforeAll } from "bun:test";
|
|
25
|
+
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
26
|
+
|
|
27
|
+
import { crud } from "../crud";
|
|
28
|
+
import {
|
|
29
|
+
query,
|
|
30
|
+
mutation,
|
|
31
|
+
getRegisteredQueries,
|
|
32
|
+
getRegisteredMutations,
|
|
33
|
+
getQueryDef,
|
|
34
|
+
} from "../reactive";
|
|
35
|
+
|
|
36
|
+
// ─── 테스트용 테이블 정의 ─────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const keywords = pgTable("cg_keywords", {
|
|
39
|
+
id: serial("id").primaryKey(),
|
|
40
|
+
keyword: text("keyword").notNull(),
|
|
41
|
+
userId: text("user_id"),
|
|
42
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const crawlLogs = pgTable("cg_crawl_logs", {
|
|
46
|
+
id: serial("id").primaryKey(),
|
|
47
|
+
status: text("status"),
|
|
48
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const digests = pgTable("cg_digests", {
|
|
52
|
+
id: serial("id").primaryKey(),
|
|
53
|
+
title: text("title"),
|
|
54
|
+
content: text("content"),
|
|
55
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const appSettings = pgTable("cg_app_settings", {
|
|
59
|
+
id: serial("id").primaryKey(),
|
|
60
|
+
key: text("key"),
|
|
61
|
+
value: text("value"),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
65
|
+
// 1. crud() → 레지스트리 등록 → codegen 인식 전체 파이프라인
|
|
66
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
describe("crud() → codegen 통합 — 다중 테이블", () => {
|
|
69
|
+
beforeAll(() => {
|
|
70
|
+
// test032 패턴 재현: 4개 모듈이 각각 crud() 호출
|
|
71
|
+
crud(keywords, { public: true });
|
|
72
|
+
crud(crawlLogs, { public: true });
|
|
73
|
+
crud(digests, { public: true });
|
|
74
|
+
crud(appSettings, { public: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("4개 테이블의 list/get query가 모두 레지스트리에 등록된다", () => {
|
|
78
|
+
const queries = getRegisteredQueries();
|
|
79
|
+
|
|
80
|
+
// 각 테이블의 list + get = 8개
|
|
81
|
+
expect(queries).toContain("cg_keywords.list");
|
|
82
|
+
expect(queries).toContain("cg_keywords.get");
|
|
83
|
+
expect(queries).toContain("cg_crawl_logs.list");
|
|
84
|
+
expect(queries).toContain("cg_crawl_logs.get");
|
|
85
|
+
expect(queries).toContain("cg_digests.list");
|
|
86
|
+
expect(queries).toContain("cg_digests.get");
|
|
87
|
+
expect(queries).toContain("cg_app_settings.list");
|
|
88
|
+
expect(queries).toContain("cg_app_settings.get");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("4개 테이블의 create/update/remove mutation이 모두 레지스트리에 등록된다", () => {
|
|
92
|
+
const mutations = getRegisteredMutations();
|
|
93
|
+
const names = mutations.map((m) => m.name);
|
|
94
|
+
|
|
95
|
+
// 각 테이블의 create + update + remove = 12개
|
|
96
|
+
expect(names).toContain("cg_keywords.create");
|
|
97
|
+
expect(names).toContain("cg_keywords.update");
|
|
98
|
+
expect(names).toContain("cg_keywords.remove");
|
|
99
|
+
expect(names).toContain("cg_crawl_logs.create");
|
|
100
|
+
expect(names).toContain("cg_crawl_logs.update");
|
|
101
|
+
expect(names).toContain("cg_crawl_logs.remove");
|
|
102
|
+
expect(names).toContain("cg_digests.create");
|
|
103
|
+
expect(names).toContain("cg_digests.update");
|
|
104
|
+
expect(names).toContain("cg_digests.remove");
|
|
105
|
+
expect(names).toContain("cg_app_settings.create");
|
|
106
|
+
expect(names).toContain("cg_app_settings.update");
|
|
107
|
+
expect(names).toContain("cg_app_settings.remove");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
112
|
+
// 2. crud() + 수동 query/mutation 혼용 — 양쪽 모두 codegen에 포함
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
114
|
+
|
|
115
|
+
describe("crud() + 수동 query/mutation 혼용", () => {
|
|
116
|
+
beforeAll(() => {
|
|
117
|
+
// crud로 자동 등록
|
|
118
|
+
const articlesTable = pgTable("cg_articles", {
|
|
119
|
+
id: serial("id").primaryKey(),
|
|
120
|
+
title: text("title"),
|
|
121
|
+
createdAt: timestamp("created_at").defaultNow(),
|
|
122
|
+
});
|
|
123
|
+
crud(articlesTable, { public: true });
|
|
124
|
+
|
|
125
|
+
// 수동으로 추가 등록 (test032의 digestsModule.generate 패턴)
|
|
126
|
+
query("cg_digests.latest", {
|
|
127
|
+
public: true,
|
|
128
|
+
handler: async (ctx) => {
|
|
129
|
+
return { id: 1, title: "latest" };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
mutation("cg_digests.generate", {
|
|
134
|
+
invalidates: ["cg_digests.list"],
|
|
135
|
+
public: true,
|
|
136
|
+
handler: async (ctx) => {
|
|
137
|
+
return { success: true };
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("crud 등록 + 수동 등록이 모두 getRegisteredQueries()에 포함된다", () => {
|
|
143
|
+
const queries = getRegisteredQueries();
|
|
144
|
+
|
|
145
|
+
// crud 자동 등록
|
|
146
|
+
expect(queries).toContain("cg_articles.list");
|
|
147
|
+
expect(queries).toContain("cg_articles.get");
|
|
148
|
+
|
|
149
|
+
// 수동 등록
|
|
150
|
+
expect(queries).toContain("cg_digests.latest");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("crud 등록 + 수동 등록이 모두 getRegisteredMutations()에 포함된다", () => {
|
|
154
|
+
const mutations = getRegisteredMutations();
|
|
155
|
+
const names = mutations.map((m) => m.name);
|
|
156
|
+
|
|
157
|
+
// crud 자동 등록
|
|
158
|
+
expect(names).toContain("cg_articles.create");
|
|
159
|
+
expect(names).toContain("cg_articles.update");
|
|
160
|
+
expect(names).toContain("cg_articles.remove");
|
|
161
|
+
|
|
162
|
+
// 수동 등록
|
|
163
|
+
expect(names).toContain("cg_digests.generate");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
168
|
+
// 3. public/private 상태 정확성 — codegen이 isPublic 기반으로 auth 분기
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
describe("crud() isPublic 상태 — codegen auth 분기 정확성", () => {
|
|
172
|
+
beforeAll(() => {
|
|
173
|
+
const publicTable = pgTable("cg_public_data", {
|
|
174
|
+
id: serial("id").primaryKey(),
|
|
175
|
+
name: text("name"),
|
|
176
|
+
});
|
|
177
|
+
const privateTable = pgTable("cg_private_data", {
|
|
178
|
+
id: serial("id").primaryKey(),
|
|
179
|
+
name: text("name"),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
crud(publicTable, { public: true });
|
|
183
|
+
crud(privateTable); // default: auth 필수
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("public: true 테이블의 모든 엔드포인트가 isPublic === true", () => {
|
|
187
|
+
const listDef = getQueryDef("cg_public_data.list");
|
|
188
|
+
const getDef = getQueryDef("cg_public_data.get");
|
|
189
|
+
|
|
190
|
+
expect(listDef!.isPublic).toBe(true);
|
|
191
|
+
expect(getDef!.isPublic).toBe(true);
|
|
192
|
+
|
|
193
|
+
const mutations = getRegisteredMutations();
|
|
194
|
+
const createDef = mutations.find((m) => m.name === "cg_public_data.create");
|
|
195
|
+
expect(createDef!.isPublic).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("기본(private) 테이블의 모든 엔드포인트가 isPublic === false", () => {
|
|
199
|
+
const listDef = getQueryDef("cg_private_data.list");
|
|
200
|
+
const getDef = getQueryDef("cg_private_data.get");
|
|
201
|
+
|
|
202
|
+
expect(listDef!.isPublic).toBe(false);
|
|
203
|
+
expect(getDef!.isPublic).toBe(false);
|
|
204
|
+
|
|
205
|
+
const mutations = getRegisteredMutations();
|
|
206
|
+
const createDef = mutations.find((m) => m.name === "cg_private_data.create");
|
|
207
|
+
expect(createDef!.isPublic).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
212
|
+
// 4. codegen 시뮬레이션 — getRegisteredQueries/Mutations로 api.ts 생성
|
|
213
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
214
|
+
|
|
215
|
+
describe("codegen 시뮬레이션 — api.ts 생성 가능 여부", () => {
|
|
216
|
+
it("getRegisteredQueries()로 모든 query key를 열거할 수 있다", () => {
|
|
217
|
+
const queries = getRegisteredQueries();
|
|
218
|
+
expect(queries.length).toBeGreaterThan(0);
|
|
219
|
+
|
|
220
|
+
// 모든 query key가 "namespace.action" 패턴이어야 함
|
|
221
|
+
for (const key of queries) {
|
|
222
|
+
expect(key).toMatch(/^[a-z_]+\.[a-z_]+$/i);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("getRegisteredMutations()로 모든 mutation을 열거할 수 있다", () => {
|
|
227
|
+
const mutations = getRegisteredMutations();
|
|
228
|
+
expect(mutations.length).toBeGreaterThan(0);
|
|
229
|
+
|
|
230
|
+
// 모든 mutation이 name + handler를 가져야 함
|
|
231
|
+
for (const mut of mutations) {
|
|
232
|
+
expect(mut.name).toBeTruthy();
|
|
233
|
+
expect(typeof mut.handler).toBe("function");
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("getQueryDef()로 개별 query의 argsSchema에 접근할 수 있다", () => {
|
|
238
|
+
const listDef = getQueryDef("cg_keywords.list");
|
|
239
|
+
expect(listDef).toBeDefined();
|
|
240
|
+
// list handler에는 argsSchema가 있음 (limit, offset, sort, filters)
|
|
241
|
+
expect(listDef!.handler).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("codegen 대상 query/mutation 총 개수가 올바르다", () => {
|
|
245
|
+
const queries = getRegisteredQueries();
|
|
246
|
+
const mutations = getRegisteredMutations();
|
|
247
|
+
|
|
248
|
+
// 최소 8개 query (4테이블 × list+get) + 수동 1개 + 이전 테스트들
|
|
249
|
+
expect(queries.length).toBeGreaterThanOrEqual(8);
|
|
250
|
+
// 최소 12개 mutation (4테이블 × create+update+remove) + 수동 1개
|
|
251
|
+
expect(mutations.length).toBeGreaterThanOrEqual(12);
|
|
252
|
+
});
|
|
253
|
+
});
|