reltype 0.1.1

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.
Files changed (136) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +952 -0
  4. package/dist/configs/env.d.ts +14 -0
  5. package/dist/configs/env.d.ts.map +1 -0
  6. package/dist/configs/env.js +46 -0
  7. package/dist/configs/index.d.ts +2 -0
  8. package/dist/configs/index.d.ts.map +1 -0
  9. package/dist/configs/index.js +17 -0
  10. package/dist/features/connection/index.d.ts +3 -0
  11. package/dist/features/connection/index.d.ts.map +1 -0
  12. package/dist/features/connection/index.js +18 -0
  13. package/dist/features/connection/pool.d.ts +42 -0
  14. package/dist/features/connection/pool.d.ts.map +1 -0
  15. package/dist/features/connection/pool.js +131 -0
  16. package/dist/features/connection/tx.d.ts +10 -0
  17. package/dist/features/connection/tx.d.ts.map +1 -0
  18. package/dist/features/connection/tx.js +37 -0
  19. package/dist/features/query/builder.d.ts +253 -0
  20. package/dist/features/query/builder.d.ts.map +1 -0
  21. package/dist/features/query/builder.js +613 -0
  22. package/dist/features/query/bulkInsert.d.ts +10 -0
  23. package/dist/features/query/bulkInsert.d.ts.map +1 -0
  24. package/dist/features/query/bulkInsert.js +30 -0
  25. package/dist/features/query/delete.d.ts +8 -0
  26. package/dist/features/query/delete.d.ts.map +1 -0
  27. package/dist/features/query/delete.js +15 -0
  28. package/dist/features/query/index.d.ts +10 -0
  29. package/dist/features/query/index.d.ts.map +1 -0
  30. package/dist/features/query/index.js +25 -0
  31. package/dist/features/query/insert.d.ts +7 -0
  32. package/dist/features/query/insert.d.ts.map +1 -0
  33. package/dist/features/query/insert.js +18 -0
  34. package/dist/features/query/interfaces/Advanced.d.ts +177 -0
  35. package/dist/features/query/interfaces/Advanced.d.ts.map +1 -0
  36. package/dist/features/query/interfaces/Advanced.js +2 -0
  37. package/dist/features/query/interfaces/Order.d.ts +6 -0
  38. package/dist/features/query/interfaces/Order.d.ts.map +1 -0
  39. package/dist/features/query/interfaces/Order.js +2 -0
  40. package/dist/features/query/interfaces/Query.d.ts +6 -0
  41. package/dist/features/query/interfaces/Query.d.ts.map +1 -0
  42. package/dist/features/query/interfaces/Query.js +2 -0
  43. package/dist/features/query/interfaces/Where.d.ts +3 -0
  44. package/dist/features/query/interfaces/Where.d.ts.map +1 -0
  45. package/dist/features/query/interfaces/Where.js +2 -0
  46. package/dist/features/query/interfaces/index.d.ts +5 -0
  47. package/dist/features/query/interfaces/index.d.ts.map +1 -0
  48. package/dist/features/query/interfaces/index.js +20 -0
  49. package/dist/features/query/select.d.ts +15 -0
  50. package/dist/features/query/select.d.ts.map +1 -0
  51. package/dist/features/query/select.js +33 -0
  52. package/dist/features/query/update.d.ts +8 -0
  53. package/dist/features/query/update.d.ts.map +1 -0
  54. package/dist/features/query/update.js +28 -0
  55. package/dist/features/query/upsert.d.ts +11 -0
  56. package/dist/features/query/upsert.d.ts.map +1 -0
  57. package/dist/features/query/upsert.js +31 -0
  58. package/dist/features/query/where.d.ts +11 -0
  59. package/dist/features/query/where.d.ts.map +1 -0
  60. package/dist/features/query/where.js +30 -0
  61. package/dist/features/repository/base.d.ts +107 -0
  62. package/dist/features/repository/base.d.ts.map +1 -0
  63. package/dist/features/repository/base.js +243 -0
  64. package/dist/features/repository/create.d.ts +13 -0
  65. package/dist/features/repository/create.d.ts.map +1 -0
  66. package/dist/features/repository/create.js +16 -0
  67. package/dist/features/repository/index.d.ts +4 -0
  68. package/dist/features/repository/index.d.ts.map +1 -0
  69. package/dist/features/repository/index.js +19 -0
  70. package/dist/features/repository/interfaces/Find.d.ts +9 -0
  71. package/dist/features/repository/interfaces/Find.d.ts.map +1 -0
  72. package/dist/features/repository/interfaces/Find.js +2 -0
  73. package/dist/features/repository/interfaces/Repo.d.ts +29 -0
  74. package/dist/features/repository/interfaces/Repo.d.ts.map +1 -0
  75. package/dist/features/repository/interfaces/Repo.js +2 -0
  76. package/dist/features/repository/interfaces/index.d.ts +3 -0
  77. package/dist/features/repository/interfaces/index.d.ts.map +1 -0
  78. package/dist/features/repository/interfaces/index.js +18 -0
  79. package/dist/features/schema/column.d.ts +62 -0
  80. package/dist/features/schema/column.d.ts.map +1 -0
  81. package/dist/features/schema/column.js +74 -0
  82. package/dist/features/schema/index.d.ts +4 -0
  83. package/dist/features/schema/index.d.ts.map +1 -0
  84. package/dist/features/schema/index.js +19 -0
  85. package/dist/features/schema/interfaces/Column.d.ts +19 -0
  86. package/dist/features/schema/interfaces/Column.d.ts.map +1 -0
  87. package/dist/features/schema/interfaces/Column.js +2 -0
  88. package/dist/features/schema/interfaces/Infer.d.ts +20 -0
  89. package/dist/features/schema/interfaces/Infer.d.ts.map +1 -0
  90. package/dist/features/schema/interfaces/Infer.js +2 -0
  91. package/dist/features/schema/interfaces/Table.d.ts +7 -0
  92. package/dist/features/schema/interfaces/Table.d.ts.map +1 -0
  93. package/dist/features/schema/interfaces/Table.js +2 -0
  94. package/dist/features/schema/interfaces/index.d.ts +4 -0
  95. package/dist/features/schema/interfaces/index.d.ts.map +1 -0
  96. package/dist/features/schema/interfaces/index.js +19 -0
  97. package/dist/features/schema/table.d.ts +14 -0
  98. package/dist/features/schema/table.d.ts.map +1 -0
  99. package/dist/features/schema/table.js +17 -0
  100. package/dist/features/transform/case.d.ts +19 -0
  101. package/dist/features/transform/case.d.ts.map +1 -0
  102. package/dist/features/transform/case.js +32 -0
  103. package/dist/features/transform/index.d.ts +3 -0
  104. package/dist/features/transform/index.d.ts.map +1 -0
  105. package/dist/features/transform/index.js +18 -0
  106. package/dist/features/transform/mapper.d.ts +9 -0
  107. package/dist/features/transform/mapper.d.ts.map +1 -0
  108. package/dist/features/transform/mapper.js +17 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +63 -0
  112. package/dist/interfaces/DatabaseConfig.d.ts +25 -0
  113. package/dist/interfaces/DatabaseConfig.d.ts.map +1 -0
  114. package/dist/interfaces/DatabaseConfig.js +2 -0
  115. package/dist/interfaces/EnvSource.d.ts +4 -0
  116. package/dist/interfaces/EnvSource.d.ts.map +1 -0
  117. package/dist/interfaces/EnvSource.js +2 -0
  118. package/dist/interfaces/PostgresDriverOptions.d.ts +20 -0
  119. package/dist/interfaces/PostgresDriverOptions.d.ts.map +1 -0
  120. package/dist/interfaces/PostgresDriverOptions.js +2 -0
  121. package/dist/interfaces/index.d.ts +3 -0
  122. package/dist/interfaces/index.d.ts.map +1 -0
  123. package/dist/interfaces/index.js +18 -0
  124. package/dist/utils/dbError.d.ts +61 -0
  125. package/dist/utils/dbError.d.ts.map +1 -0
  126. package/dist/utils/dbError.js +122 -0
  127. package/dist/utils/index.d.ts +4 -0
  128. package/dist/utils/index.d.ts.map +1 -0
  129. package/dist/utils/index.js +19 -0
  130. package/dist/utils/logger.d.ts +63 -0
  131. package/dist/utils/logger.d.ts.map +1 -0
  132. package/dist/utils/logger.js +138 -0
  133. package/dist/utils/reader.d.ts +49 -0
  134. package/dist/utils/reader.d.ts.map +1 -0
  135. package/dist/utils/reader.js +159 -0
  136. package/package.json +73 -0
package/README.md ADDED
@@ -0,0 +1,952 @@
1
+ # reltype
2
+
3
+ [![npm version](https://img.shields.io/npm/v/reltype.svg)](https://www.npmjs.com/package/reltype)
4
+ [![npm downloads](https://img.shields.io/npm/dm/reltype.svg)](https://www.npmjs.com/package/reltype)
5
+ [![license](https://img.shields.io/npm/l/reltype.svg)](https://github.com/psh-suhyun/reltype/blob/main/LICENSE)
6
+ [![node](https://img.shields.io/node/v/reltype.svg)](https://www.npmjs.com/package/reltype)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
8
+
9
+ **Type-first relational modeling for PostgreSQL in TypeScript.**
10
+
11
+ PostgreSQL 테이블을 TypeScript 코드로 정의하면, 모든 쿼리의 반환 타입이 자동으로 추론됩니다.
12
+
13
+ - **타입 안전** — 스키마 정의에서 INSERT / SELECT / UPDATE 타입을 자동 추론
14
+ - **camelCase ↔ snake_case** — DB 컬럼과 TypeScript 변수명을 자동 변환
15
+ - **플루언트 쿼리 빌더** — `WHERE`, `OR`, `JOIN`, `GROUP BY`, `LIMIT`, `paginate`, `calculate`, `stream` 체인 지원
16
+ - **대용량 최적화** — 커서 페이지네이션, 배치 처리, AsyncGenerator 스트리밍
17
+ - **에러 분류** — `DbError`로 PostgreSQL 에러를 13가지 종류로 자동 분류
18
+ - **훅 시스템** — 쿼리 전/후 라이프사이클 훅으로 모니터링·APM 연동
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ # reltype 설치
26
+ npm install reltype
27
+
28
+ # pg는 peerDependency — 직접 설치해야 합니다
29
+ npm install pg
30
+ npm install --save-dev @types/pg
31
+ ```
32
+
33
+ > `pg` 버전 8.0.0 이상이 필요합니다.
34
+
35
+ ---
36
+
37
+ ## Environment Variables
38
+
39
+ `.env` 파일을 프로젝트 루트에 생성합니다.
40
+
41
+ ```env
42
+ # ── 필수 (CONNECTION_STRING 또는 DB_NAME 중 하나는 반드시 설정) ──────────────
43
+
44
+ # 방법 1: Connection String (우선 적용)
45
+ DB_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/mydb
46
+
47
+ # 방법 2: 개별 설정
48
+ DB_HOST=127.0.0.1
49
+ DB_PORT=5432
50
+ DB_NAME=mydb
51
+ DB_USER=postgres
52
+ DB_PASSWORD=postgres
53
+
54
+ # ── 선택 ────────────────────────────────────────────────────────────────────
55
+
56
+ DB_SSL=false # SSL 활성화 여부
57
+ DB_MAX=10 # 최대 연결 풀 수
58
+ DB_IDLE_TIMEOUT=30000 # idle 연결 해제 대기시간 (ms)
59
+ DB_CONNECTION_TIMEOUT=2000 # 연결 타임아웃 (ms)
60
+ DB_ALLOW_EXIT_ON_IDLE=false # idle 상태에서 프로세스 종료 허용
61
+ DB_STATEMENT_TIMEOUT=0 # SQL 문 실행 타임아웃 (ms, 0=무제한)
62
+ DB_QUERY_TIMEOUT=0 # 쿼리 타임아웃 (ms, 0=무제한)
63
+ DB_APPLICATION_NAME=my-app # pg_stat_activity에 표시될 앱 이름
64
+ DB_KEEP_ALIVE=true # TCP keep-alive 활성화
65
+ DB_KEEP_ALIVE_INITIAL_DELAY=10000 # keep-alive 최초 지연 (ms)
66
+
67
+ # ── 로깅 ────────────────────────────────────────────────────────────────────
68
+
69
+ LOGGER=true # 로거 활성화 (true / false)
70
+ LOG_LEVEL=info # 로그 레벨 (debug / info / log / warn / error)
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Quick Start
76
+
77
+ ### 1. 테이블 스키마 정의
78
+
79
+ ```ts
80
+ import { defineTable, col } from 'reltype';
81
+
82
+ export const usersTable = defineTable('users', {
83
+ id: col.serial().primaryKey(), // SERIAL PRIMARY KEY (INSERT 시 optional)
84
+ firstName: col.varchar(255).notNull(), // VARCHAR(255) NOT NULL
85
+ lastName: col.varchar(255).nullable(), // VARCHAR(255) NULL (INSERT 시 optional)
86
+ email: col.text().notNull(), // TEXT NOT NULL
87
+ isActive: col.boolean().default(), // BOOLEAN DEFAULT ... (INSERT 시 optional)
88
+ createdAt: col.timestamptz().defaultNow(), // TIMESTAMPTZ DEFAULT NOW() (INSERT 시 optional)
89
+ });
90
+ ```
91
+
92
+ ### 2. 타입 자동 추론
93
+
94
+ ```ts
95
+ import { InferRow, InferInsert, InferUpdate } from 'reltype';
96
+
97
+ // SELECT 결과 타입
98
+ type User = InferRow<typeof usersTable>;
99
+ // {
100
+ // id: number;
101
+ // firstName: string;
102
+ // lastName: string | null;
103
+ // email: string;
104
+ // isActive: boolean;
105
+ // createdAt: Date;
106
+ // }
107
+
108
+ // INSERT 입력 타입 (optional 컬럼 자동 제외)
109
+ type CreateUser = InferInsert<typeof usersTable>;
110
+ // { firstName: string; email: string; lastName?: string | null; isActive?: boolean; createdAt?: Date }
111
+
112
+ // UPDATE 입력 타입 (PK 제외, 전체 optional)
113
+ type UpdateUser = InferUpdate<typeof usersTable>;
114
+ // { firstName?: string; lastName?: string | null; email?: string; isActive?: boolean; createdAt?: Date }
115
+ ```
116
+
117
+ ### 3. 앱 진입점에서 dotenv 로드
118
+
119
+ `reltype`은 `process.env`를 읽기만 합니다. `.env` 파일 로딩은 **애플리케이션 진입점**에서 직접 하세요.
120
+
121
+ ```ts
122
+ // 애플리케이션 진입점 (index.ts / server.ts / app.ts)
123
+ import 'dotenv/config'; // 반드시 다른 import 전에 위치
124
+
125
+ // 이후 reltype import
126
+ import { getDatabaseConfig, getPool } from 'reltype';
127
+ ```
128
+
129
+ ### 4. Repository 생성
130
+
131
+ ```ts
132
+ import { createRepo } from 'reltype';
133
+ import { usersTable } from './schema';
134
+
135
+ export const userRepo = createRepo(usersTable);
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Repository API
141
+
142
+ ### 전체 메서드 요약
143
+
144
+ | 메서드 | 반환 타입 | 설명 |
145
+ |---|---|---|
146
+ | `create(data)` | `Promise<T>` | 단건 INSERT |
147
+ | `update(id, data)` | `Promise<T \| null>` | PK 기준 UPDATE |
148
+ | `delete(id)` | `Promise<boolean>` | PK 기준 DELETE |
149
+ | `upsert(data, col?)` | `Promise<T>` | INSERT or UPDATE |
150
+ | `bulkCreate(rows)` | `Promise<T[]>` | 다건 INSERT |
151
+ | `select(where?)` | `QueryBuilder<T>` | 플루언트 빌더 시작점 |
152
+ | `selectOne(where)` | `Promise<T \| null>` | 단건 조회 |
153
+ | `raw(sql, params?)` | `Promise<R[]>` | Raw SQL 실행 |
154
+ | `findAll(opts?)` | `Promise<T[]>` | 정적 전체 조회 |
155
+ | `findById(id)` | `Promise<T \| null>` | PK 단건 조회 |
156
+ | `findOne(where)` | `Promise<T \| null>` | 조건 단건 조회 |
157
+ | `useHooks(h)` | `this` | 글로벌 훅 등록 |
158
+
159
+ ---
160
+
161
+ ## create
162
+
163
+ 단건 INSERT. 자동으로 생성되는 컬럼(serial, default, nullable)은 입력을 생략할 수 있습니다.
164
+
165
+ ```ts
166
+ const user = await userRepo.create({
167
+ firstName: 'John',
168
+ email: 'john@example.com',
169
+ // lastName, isActive, createdAt → optional (DB default 또는 nullable)
170
+ });
171
+ // → User
172
+ ```
173
+
174
+ ---
175
+
176
+ ## update
177
+
178
+ PK를 기준으로 지정한 컬럼만 UPDATE합니다. 존재하지 않으면 `null`을 반환합니다.
179
+
180
+ ```ts
181
+ // 부분 업데이트
182
+ const updated = await userRepo.update(1, {
183
+ firstName: 'Jane',
184
+ isActive: false,
185
+ });
186
+ // → User | null
187
+
188
+ if (!updated) {
189
+ throw new Error('사용자를 찾을 수 없습니다.');
190
+ }
191
+ ```
192
+
193
+ ---
194
+
195
+ ## delete
196
+
197
+ PK를 기준으로 삭제합니다. 삭제된 row가 있으면 `true`, 없으면 `false`를 반환합니다.
198
+
199
+ ```ts
200
+ const deleted = await userRepo.delete(1);
201
+ // → boolean
202
+
203
+ if (!deleted) {
204
+ throw new Error('사용자를 찾을 수 없습니다.');
205
+ }
206
+ ```
207
+
208
+ ---
209
+
210
+ ## upsert
211
+
212
+ 충돌 컬럼 기준으로 INSERT 또는 UPDATE합니다.
213
+
214
+ ```ts
215
+ // PK(id) 기준 (기본값)
216
+ const user = await userRepo.upsert({
217
+ id: 1,
218
+ firstName: 'John',
219
+ email: 'john@example.com',
220
+ });
221
+
222
+ // 다른 unique 컬럼 기준 (snake_case)
223
+ const user = await userRepo.upsert(
224
+ { firstName: 'John', email: 'john@example.com' },
225
+ 'email',
226
+ );
227
+ // → User
228
+ ```
229
+
230
+ ---
231
+
232
+ ## bulkCreate
233
+
234
+ 여러 row를 단일 `INSERT` 쿼리로 삽입합니다.
235
+
236
+ ```ts
237
+ const created = await userRepo.bulkCreate([
238
+ { firstName: 'Alice', email: 'alice@example.com' },
239
+ { firstName: 'Bob', email: 'bob@example.com' },
240
+ ]);
241
+ // → User[]
242
+ ```
243
+
244
+ ---
245
+
246
+ ## select — 플루언트 쿼리 빌더
247
+
248
+ `repo.select(where?)`는 `QueryBuilder`를 반환합니다.
249
+ 메서드를 체인으로 조합한 후 `await` 하거나 `.exec()` 로 실행합니다.
250
+
251
+ ### 기본 조회
252
+
253
+ ```ts
254
+ // 전체 조회 (await 직접 사용 가능)
255
+ const users = await userRepo.select();
256
+
257
+ // 초기 WHERE 조건
258
+ const users = await userRepo.select({ isActive: true });
259
+ ```
260
+
261
+ ### WHERE — AND 조건
262
+
263
+ ```ts
264
+ // 단순 등호
265
+ const users = await userRepo.select().where({ isActive: true });
266
+
267
+ // 비교 연산자
268
+ const users = await userRepo.select()
269
+ .where({ createdAt: { operator: '>=', value: new Date('2024-01-01') } });
270
+
271
+ // IN
272
+ const users = await userRepo.select()
273
+ .where({ id: { operator: 'IN', value: [1, 2, 3] } });
274
+
275
+ // IS NULL
276
+ const users = await userRepo.select()
277
+ .where({ deletedAt: { operator: 'IS NULL' } });
278
+
279
+ // LIKE / ILIKE (대소문자 무시)
280
+ const users = await userRepo.select()
281
+ .where({ email: { operator: 'ILIKE', value: '%@gmail.com' } });
282
+ ```
283
+
284
+ 지원하는 연산자: `=` `!=` `>` `<` `>=` `<=` `LIKE` `ILIKE` `IN` `NOT IN` `IS NULL` `IS NOT NULL`
285
+
286
+ ### OR — OR 조건
287
+
288
+ `.or()`를 여러 번 호출하면 각각 OR로 연결됩니다.
289
+ AND 조건이 있을 경우 `WHERE (AND 조건들) OR (OR 조건들)` 형태로 생성됩니다.
290
+
291
+ ```ts
292
+ // firstName ILIKE '%john%' OR email ILIKE '%john%'
293
+ const users = await userRepo.select({ isActive: true })
294
+ .or({ firstName: { operator: 'ILIKE', value: '%john%' } })
295
+ .or({ email: { operator: 'ILIKE', value: '%john%' } });
296
+ // → WHERE (is_active = true) OR (first_name ILIKE '%john%') OR (email ILIKE '%john%')
297
+ ```
298
+
299
+ ### ORDER BY
300
+
301
+ ```ts
302
+ const users = await userRepo.select()
303
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }]);
304
+
305
+ // 다중 정렬
306
+ const users = await userRepo.select()
307
+ .orderBy([
308
+ { column: 'isActive', direction: 'DESC' },
309
+ { column: 'createdAt', direction: 'ASC' },
310
+ ]);
311
+ ```
312
+
313
+ ### LIMIT / OFFSET
314
+
315
+ ```ts
316
+ const users = await userRepo.select()
317
+ .orderBy([{ column: 'id', direction: 'ASC' }])
318
+ .limit(20)
319
+ .offset(40);
320
+ // 3페이지 (0-indexed offset)
321
+ ```
322
+
323
+ ### GROUP BY
324
+
325
+ ```ts
326
+ const result = await userRepo.select()
327
+ .groupBy(['isActive'])
328
+ .calculate([
329
+ { fn: 'COUNT', alias: 'count' },
330
+ ]);
331
+ // → { count: '42' }
332
+ ```
333
+
334
+ ### JOIN
335
+
336
+ ```ts
337
+ // LEFT JOIN
338
+ const result = await userRepo.select({ isActive: true })
339
+ .join({ table: 'orders', on: 'users.id = orders.user_id', type: 'LEFT' })
340
+ .columns(['users.id', 'users.email'])
341
+ .groupBy(['users.id', 'users.email'])
342
+ .orderBy([{ column: 'id', direction: 'ASC' }])
343
+ .exec();
344
+ ```
345
+
346
+ JOIN 타입: `INNER` `LEFT` `RIGHT` `FULL`
347
+
348
+ ### 컬럼 지정 (columns)
349
+
350
+ ```ts
351
+ const users = await userRepo.select()
352
+ .columns(['id', 'email', 'firstName'])
353
+ .exec();
354
+ ```
355
+
356
+ ---
357
+
358
+ ## selectOne
359
+
360
+ `select(where).one()` 의 단축형입니다. 조건에 맞는 첫 번째 row를 반환합니다.
361
+
362
+ ```ts
363
+ const user = await userRepo.selectOne({ email: 'john@example.com' });
364
+ // → User | null
365
+
366
+ const user = await userRepo.selectOne({ id: 1 });
367
+ if (!user) throw new Error('not found');
368
+ ```
369
+
370
+ ---
371
+
372
+ ## calculate — 집계 함수
373
+
374
+ `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`를 실행합니다.
375
+
376
+ ```ts
377
+ // 전체 카운트
378
+ const result = await userRepo.select().calculate([{ fn: 'COUNT', alias: 'count' }]);
379
+ const total = parseInt(String(result.count), 10);
380
+
381
+ // 다중 집계
382
+ const stats = await userRepo.select({ isActive: true })
383
+ .calculate([
384
+ { fn: 'COUNT', alias: 'count' },
385
+ { fn: 'AVG', column: 'score', alias: 'avgScore' },
386
+ { fn: 'MAX', column: 'score', alias: 'maxScore' },
387
+ ]);
388
+ // → { count: '42', avgScore: '87.5', maxScore: '100' }
389
+ ```
390
+
391
+ ---
392
+
393
+ ## paginate — OFFSET 페이지네이션
394
+
395
+ COUNT 쿼리와 DATA 쿼리를 병렬로 실행합니다.
396
+
397
+ ```ts
398
+ const result = await userRepo.select({ isActive: true })
399
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
400
+ .paginate({ page: 1, pageSize: 20 });
401
+
402
+ // result 구조
403
+ // {
404
+ // data: User[], // 현재 페이지 데이터
405
+ // count: 150, // 전체 row 수 (필터 적용)
406
+ // page: 1,
407
+ // pageSize: 20,
408
+ // nextAction: true, // 다음 페이지 존재 여부
409
+ // previousAction: false, // 이전 페이지 존재 여부
410
+ // }
411
+ ```
412
+
413
+ > 수백만 건 이상의 테이블에서는 `cursorPaginate()` 를 사용하세요.
414
+
415
+ ---
416
+
417
+ ## cursorPaginate — 커서 기반 페이지네이션 (대용량)
418
+
419
+ OFFSET 스캔 없이 `WHERE id > last_id` 방식으로 동작합니다.
420
+ 인덱스가 있는 컬럼을 `cursorColumn`으로 지정하면 수천만 건에서도 일정한 속도를 유지합니다.
421
+
422
+ ```ts
423
+ // 첫 페이지
424
+ const p1 = await userRepo.select({ isActive: true })
425
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
426
+
427
+ // p1 = { data: [...], nextCursor: 'xxx', pageSize: 20, hasNext: true }
428
+
429
+ // 다음 페이지
430
+ if (p1.hasNext) {
431
+ const p2 = await userRepo.select({ isActive: true })
432
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
433
+ }
434
+
435
+ // 내림차순 커서 (createdAt DESC)
436
+ const result = await userRepo.select()
437
+ .cursorPaginate({ pageSize: 20, cursorColumn: 'createdAt', direction: 'desc' });
438
+ ```
439
+
440
+ | `paginate` | `cursorPaginate` |
441
+ |---|---|
442
+ | 전체 count 제공 | count 미제공 |
443
+ | page 번호로 이동 | 이전/다음만 가능 |
444
+ | 대용량에서 느려짐 | 항상 일정한 속도 |
445
+
446
+ ---
447
+
448
+ ## forEach — 배치 처리
449
+
450
+ 전체 데이터를 메모리에 올리지 않고 청크 단위로 처리합니다.
451
+ 대용량 ETL, 이메일 일괄 발송, 데이터 마이그레이션에 적합합니다.
452
+
453
+ ```ts
454
+ await userRepo.select({ isActive: true })
455
+ .orderBy([{ column: 'id', direction: 'ASC' }])
456
+ .forEach(async (batch) => {
457
+ // batch: User[] (기본 500개씩)
458
+ await sendEmailBatch(batch);
459
+ }, { batchSize: 200 });
460
+ ```
461
+
462
+ ---
463
+
464
+ ## stream — 스트리밍 (AsyncGenerator)
465
+
466
+ `for await...of` 루프로 row를 하나씩 순회합니다.
467
+ 내부적으로 배치 단위로 DB를 조회하여 메모리 효율을 유지합니다.
468
+
469
+ ```ts
470
+ // for await...of 직접 사용 (Symbol.asyncIterator 지원)
471
+ for await (const user of userRepo.select({ isActive: true })) {
472
+ await processRow(user);
473
+ }
474
+
475
+ // 배치 크기 지정
476
+ for await (const user of userRepo.select().stream({ batchSize: 1000 })) {
477
+ await processRow(user);
478
+ }
479
+ ```
480
+
481
+ ---
482
+
483
+ ## raw — Raw SQL 직접 실행
484
+
485
+ 복잡한 쿼리가 필요할 때 SQL을 직접 작성합니다.
486
+ 결과 컬럼명은 `snake_case → camelCase`로 자동 변환됩니다.
487
+
488
+ ```ts
489
+ // repo.raw() 사용
490
+ const users = await userRepo.raw<UserRow>(
491
+ 'SELECT * FROM users WHERE first_name ILIKE $1 ORDER BY created_at DESC',
492
+ ['%john%'],
493
+ );
494
+
495
+ // QueryBuilder.raw() — 레포지토리 없이 독립적으로 사용
496
+ import { QueryBuilder } from 'reltype';
497
+
498
+ const rows = await QueryBuilder.raw(
499
+ `SELECT u.id, u.email, COUNT(o.id) AS order_count
500
+ FROM users u
501
+ LEFT JOIN orders o ON u.id = o.user_id
502
+ WHERE u.is_active = $1
503
+ GROUP BY u.id, u.email`,
504
+ [true],
505
+ );
506
+ ```
507
+
508
+ ---
509
+
510
+ ## explain — 쿼리 플랜 분석
511
+
512
+ 인덱스 사용 여부 및 성능 병목을 확인합니다.
513
+
514
+ ```ts
515
+ // EXPLAIN
516
+ const plan = await userRepo.select({ isActive: true }).explain();
517
+ console.log(plan);
518
+
519
+ // EXPLAIN ANALYZE (실제 실행 통계 포함)
520
+ const plan = await userRepo.select({ isActive: true })
521
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
522
+ .explain(true);
523
+ ```
524
+
525
+ ---
526
+
527
+ ## toSQL — SQL 미리 확인 (디버깅)
528
+
529
+ 실제 실행 없이 생성될 SQL과 params를 반환합니다.
530
+
531
+ ```ts
532
+ const { sql, params } = userRepo.select({ isActive: true })
533
+ .orderBy([{ column: 'createdAt', direction: 'DESC' }])
534
+ .limit(20)
535
+ .toSQL();
536
+
537
+ console.log(sql);
538
+ // SELECT * FROM users WHERE is_active = $1 ORDER BY created_at DESC LIMIT $2
539
+ console.log(params);
540
+ // [ true, 20 ]
541
+ ```
542
+
543
+ ---
544
+
545
+ ## hooks — 쿼리 라이프사이클 훅
546
+
547
+ ### 쿼리별 훅
548
+
549
+ ```ts
550
+ const users = await userRepo.select({ isActive: true })
551
+ .hooks({
552
+ beforeExec: ({ sql, params }) => logger.debug('SQL 실행 예정:', sql),
553
+ afterExec: ({ rows, elapsed }) => metrics.record('db.query.duration', elapsed),
554
+ onError: ({ err, sql }) => alerting.send({ err, sql }),
555
+ })
556
+ .paginate({ page: 1, pageSize: 20 });
557
+ ```
558
+
559
+ ### 레포지토리 전역 훅
560
+
561
+ 레포지토리의 모든 `select()` 빌더에 자동 적용됩니다.
562
+
563
+ ```ts
564
+ userRepo.useHooks({
565
+ beforeExec: ({ sql }) => logger.debug('SQL:', sql),
566
+ afterExec: ({ elapsed }) => metrics.histogram('db.latency', elapsed),
567
+ onError: ({ err }) => logger.error('DB 오류', { kind: err.message }),
568
+ });
569
+
570
+ // 이후 모든 select()에 훅이 적용됨
571
+ const users = await userRepo.select({ isActive: true }).exec();
572
+ ```
573
+
574
+ ---
575
+
576
+ ## 정적 CRUD (findAll / findById / findOne)
577
+
578
+ 단순한 조회에는 정적 메서드를 사용할 수 있습니다.
579
+
580
+ ```ts
581
+ // 전체 조회
582
+ const users = await userRepo.findAll();
583
+
584
+ // 조건 + 정렬 + 페이지네이션
585
+ const users = await userRepo.findAll({
586
+ where: { isActive: true },
587
+ orderBy: [{ col: 'createdAt', dir: 'DESC' }],
588
+ limit: 10,
589
+ offset: 0,
590
+ });
591
+
592
+ // PK로 단건 조회
593
+ const user = await userRepo.findById(1); // User | null
594
+
595
+ // 조건으로 단건 조회 (단순 등호만 지원)
596
+ const user = await userRepo.findOne({ email: 'john@example.com' }); // User | null
597
+ ```
598
+
599
+ > 연산자(LIKE, IN, OR 등)가 필요한 경우 `repo.select()` 를 사용하세요.
600
+
601
+ ---
602
+
603
+ ## Column Types
604
+
605
+ | 메서드 | PostgreSQL 타입 | TypeScript 타입 |
606
+ |---|---|---|
607
+ | `col.serial()` | `SERIAL` | `number` |
608
+ | `col.integer()` | `INTEGER` | `number` |
609
+ | `col.bigint()` | `BIGINT` | `bigint` |
610
+ | `col.numeric()` | `NUMERIC` | `number` |
611
+ | `col.varchar(n?)` | `VARCHAR(n)` | `string` |
612
+ | `col.text()` | `TEXT` | `string` |
613
+ | `col.boolean()` | `BOOLEAN` | `boolean` |
614
+ | `col.timestamp()` | `TIMESTAMP` | `Date` |
615
+ | `col.timestamptz()` | `TIMESTAMPTZ` | `Date` |
616
+ | `col.date()` | `DATE` | `Date` |
617
+ | `col.uuid()` | `UUID` | `string` |
618
+ | `col.jsonb<T>()` | `JSONB` | `T` (기본 `unknown`) |
619
+
620
+ ### Column Modifiers
621
+
622
+ ```ts
623
+ col.text().notNull() // NOT NULL (기본 상태)
624
+ col.text().nullable() // NULL 허용, INSERT 시 optional
625
+ col.integer().primaryKey() // PRIMARY KEY, INSERT 시 optional
626
+ col.boolean().default() // DB DEFAULT, INSERT 시 optional
627
+ col.timestamptz().defaultNow() // DEFAULT NOW(), INSERT 시 optional
628
+ ```
629
+
630
+ ---
631
+
632
+ ## Transaction
633
+
634
+ ```ts
635
+ import { runInTx } from 'reltype';
636
+
637
+ const result = await runInTx(async (client) => {
638
+ await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
639
+ await userRepo.create({ firstName: 'Bob', email: 'bob@example.com' });
640
+ return 'done';
641
+ });
642
+ // 하나라도 실패 시 자동 ROLLBACK
643
+ ```
644
+
645
+ ---
646
+
647
+ ## Connection Pool
648
+
649
+ ```ts
650
+ import { getPool, withClient, closePool } from 'reltype';
651
+
652
+ // Pool 직접 접근
653
+ const pool = getPool();
654
+
655
+ // Client 빌려서 Raw 쿼리 실행
656
+ const rows = await withClient(async (client) => {
657
+ const result = await client.query('SELECT NOW()');
658
+ return result.rows;
659
+ });
660
+
661
+ // 애플리케이션 종료 시
662
+ await closePool();
663
+ ```
664
+
665
+ ---
666
+
667
+ ## Raw Query Builders
668
+
669
+ Repository 없이 직접 쿼리를 빌드할 수 있습니다.
670
+
671
+ ```ts
672
+ import { buildSelect, buildInsert, buildUpdate, buildDelete, buildUpsert, buildBulkInsert, withClient } from 'reltype';
673
+
674
+ // SELECT
675
+ const { sql, params } = buildSelect('users', {
676
+ where: { isActive: true },
677
+ orderBy: [{ col: 'createdAt', dir: 'DESC' }],
678
+ limit: 5,
679
+ });
680
+
681
+ // INSERT
682
+ const built = buildInsert('users', { firstName: 'John', email: 'john@example.com' });
683
+
684
+ // UPDATE
685
+ const built = buildUpdate('users', { firstName: 'Jane' }, { id: 1 });
686
+
687
+ // DELETE
688
+ const built = buildDelete('users', { id: 1 });
689
+
690
+ // UPSERT
691
+ const built = buildUpsert('users', { id: 1, firstName: 'John', email: 'john@example.com' }, 'id');
692
+
693
+ // BULK INSERT
694
+ const built = buildBulkInsert('users', [
695
+ { firstName: 'Alice', email: 'alice@example.com' },
696
+ { firstName: 'Bob', email: 'bob@example.com' },
697
+ ]);
698
+
699
+ // 실행
700
+ await withClient(async (client) => {
701
+ const result = await client.query(sql, params);
702
+ return result.rows;
703
+ });
704
+ ```
705
+
706
+ > 모든 쿼리 빌더는 camelCase key → snake_case 컬럼명으로 자동 변환합니다.
707
+
708
+ ---
709
+
710
+ ## Case Conversion Utilities
711
+
712
+ ```ts
713
+ import { toCamel, toSnake, keysToCamel, keysToSnake } from 'reltype';
714
+
715
+ toCamel('first_name') // 'firstName'
716
+ toSnake('firstName') // 'first_name'
717
+
718
+ keysToCamel({ first_name: 'John', created_at: new Date() })
719
+ // { firstName: 'John', createdAt: Date }
720
+
721
+ keysToSnake({ firstName: 'John', createdAt: new Date() })
722
+ // { first_name: 'John', created_at: Date }
723
+ ```
724
+
725
+ ---
726
+
727
+ ## Logger
728
+
729
+ ```ts
730
+ import { Logger } from 'reltype';
731
+
732
+ const logger = Logger.fromEnv(process.env as Record<string, string | undefined>, {
733
+ prefix: '[MyApp]',
734
+ level: 'info',
735
+ });
736
+
737
+ logger.debug('debug message');
738
+ logger.info('info message');
739
+ logger.warn('warn message');
740
+ logger.error('error message', new Error('oops'));
741
+ ```
742
+
743
+ 환경 변수 `LOGGER=true`, `LOG_LEVEL=debug` 으로 활성화합니다.
744
+
745
+ ---
746
+
747
+ ## Extending BaseRepo
748
+
749
+ 커스텀 메서드를 추가하려면 `BaseRepo`를 상속합니다.
750
+
751
+ ```ts
752
+ import { BaseRepo, InferRow } from 'reltype';
753
+ import { usersTable } from './schema';
754
+
755
+ class UserRepo extends BaseRepo<typeof usersTable> {
756
+ async findActiveUsers(): Promise<InferRow<typeof usersTable>[]> {
757
+ return this.findAll({ where: { isActive: true } });
758
+ }
759
+
760
+ async findByEmail(email: string): Promise<InferRow<typeof usersTable> | null> {
761
+ return this.findOne({ email });
762
+ }
763
+ }
764
+
765
+ export const userRepo = new UserRepo(usersTable);
766
+ ```
767
+
768
+ ---
769
+
770
+ ## Error Handling
771
+
772
+ ### DbError — PostgreSQL 에러 분류
773
+
774
+ 모든 DB 에러는 자동으로 `DbError`로 변환됩니다.
775
+ `DbError`는 내부 로그용 상세 정보와 사용자 노출용 메시지를 분리합니다.
776
+
777
+ ```ts
778
+ import { DbError } from 'reltype';
779
+
780
+ try {
781
+ await userRepo.create({ firstName: 'Alice', email: 'alice@example.com' });
782
+ } catch (err) {
783
+ if (err instanceof DbError) {
784
+ // 사용자에게 안전하게 노출
785
+ console.log(err.toUserPayload());
786
+ // { error: '이미 존재하는 값입니다.', kind: 'uniqueViolation', isRetryable: false }
787
+
788
+ // 내부 로깅용 상세 정보
789
+ console.log(err.toLogContext());
790
+ // { pgCode: '23505', kind: 'uniqueViolation', table: 'users', constraint: '...', ... }
791
+
792
+ // 재시도 가능 여부 확인
793
+ if (err.isRetryable) {
794
+ // 재시도 로직
795
+ }
796
+ }
797
+ }
798
+ ```
799
+
800
+ ### Express에서 사용하는 예시
801
+
802
+ ```ts
803
+ app.post('/users', async (req, res) => {
804
+ try {
805
+ const user = await userRepo.create(req.body);
806
+ res.status(201).json(user);
807
+ } catch (err) {
808
+ if (err instanceof DbError) {
809
+ const status = err.kind === 'uniqueViolation' ? 409
810
+ : err.kind === 'notNullViolation' ? 400
811
+ : err.isRetryable ? 503
812
+ : 500;
813
+ res.status(status).json(err.toUserPayload());
814
+ } else {
815
+ res.status(500).json({ error: '알 수 없는 오류가 발생했습니다.' });
816
+ }
817
+ }
818
+ });
819
+ ```
820
+
821
+ ### DbErrorKind 전체 목록
822
+
823
+ | kind | PostgreSQL SQLSTATE | 설명 | isRetryable |
824
+ |---|---|---|---|
825
+ | `uniqueViolation` | 23505 | UNIQUE 제약 위반 | false |
826
+ | `foreignKeyViolation` | 23503 | FK 제약 위반 | false |
827
+ | `notNullViolation` | 23502 | NOT NULL 위반 | false |
828
+ | `checkViolation` | 23514 | CHECK 제약 위반 | false |
829
+ | `deadlock` | 40P01 | 교착 상태 | **true** |
830
+ | `serializationFailure` | 40001 | 직렬화 실패 | **true** |
831
+ | `connectionFailed` | 08xxx | 연결 실패 | **true** |
832
+ | `tooManyConnections` | 53300 | 연결 수 초과 | **true** |
833
+ | `queryTimeout` | 57014 | 쿼리 타임아웃 | false |
834
+ | `undefinedTable` | 42P01 | 테이블 없음 | false |
835
+ | `undefinedColumn` | 42703 | 컬럼 없음 | false |
836
+ | `invalidInput` | 22xxx | 잘못된 입력 형식 | false |
837
+ | `unknown` | 기타 | 분류 불가 | false |
838
+
839
+ ---
840
+
841
+ ## Pool Monitoring
842
+
843
+ ```ts
844
+ import { getPoolStatus, checkPoolHealth } from 'reltype';
845
+
846
+ // 현재 Pool 상태 조회
847
+ const status = getPoolStatus();
848
+ console.log(status);
849
+ // {
850
+ // totalCount: 5, // 총 생성된 연결 수
851
+ // idleCount: 3, // 유휴 연결 수
852
+ // waitingCount: 0, // 연결 대기 중인 요청 수
853
+ // isHealthy: true // 정상 여부
854
+ // }
855
+
856
+ // DB 서버와의 연결 헬스체크 (SELECT 1)
857
+ const isAlive = await checkPoolHealth();
858
+ ```
859
+
860
+ ### Too Many Connections 방지
861
+
862
+ `.env`에서 Pool 크기와 타임아웃을 반드시 설정하세요.
863
+
864
+ ```env
865
+ DB_MAX=10 # 최대 연결 풀 크기 (기본값: 10)
866
+ DB_CONNECTION_TIMEOUT=3000 # 연결 획득 타임아웃 ms (미설정 시 무한 대기 경고)
867
+ DB_IDLE_TIMEOUT=30000 # 유휴 연결 해제 시간 ms
868
+ DB_STATEMENT_TIMEOUT=10000 # SQL 문 최대 실행 시간 ms
869
+ ```
870
+
871
+ > `DB_CONNECTION_TIMEOUT`이 설정되지 않으면 Pool 소진 시 요청이 무한 대기합니다.
872
+ > 반드시 설정하세요.
873
+
874
+ ---
875
+
876
+ ## Log System
877
+
878
+ ### 포맷 설정
879
+
880
+ ```env
881
+ LOGGER=true # 로거 활성화
882
+ LOG_LEVEL=debug # debug / info / log / warn / error
883
+ LOG_FORMAT=json # text(기본) / json(프로덕션 권장)
884
+ ```
885
+
886
+ ### text 포맷 (개발 환경)
887
+
888
+ ```
889
+ 2024-01-01T00:00:00.000Z [Pool] INFO Pool 생성 완료 { max: 10, ... }
890
+ 2024-01-01T00:00:00.000Z [Repo] DEBUG SQL: SELECT * FROM users WHERE id = $1 [ 1 ]
891
+ 2024-01-01T00:00:00.000Z [Repo] DEBUG 완료 (12ms) rowCount=1
892
+ ```
893
+
894
+ ### json 포맷 (프로덕션 / 로그 수집기)
895
+
896
+ ```json
897
+ {"ts":"2024-01-01T00:00:00.000Z","level":"INFO","prefix":"[Pool]","msg":"Pool 생성 완료","meta":[{"max":10}]}
898
+ {"ts":"2024-01-01T00:00:00.000Z","level":"ERROR","prefix":"[Repo]","msg":"쿼리 실패 [users]","meta":[{"pgCode":"23505","kind":"uniqueViolation","constraint":"users_email_key"}]}
899
+ ```
900
+
901
+ ### 로그 이벤트 목록
902
+
903
+ | 레벨 | prefix | 이벤트 |
904
+ |---|---|---|
905
+ | INFO | [Pool] | Pool 생성 완료 / Pool 종료 |
906
+ | WARN | [Pool] | connectionTimeoutMillis 미설정 경고 |
907
+ | WARN | [Pool] | 최대 연결 수 도달 |
908
+ | DEBUG | [Pool] | 새 연결 생성 / 연결 제거 |
909
+ | ERROR | [Pool] | 유휴 클라이언트 오류 / 클라이언트 획득 실패 |
910
+ | DEBUG | [Repo] | SQL 실행 + 소요시간 |
911
+ | ERROR | [Repo] | 쿼리 실패 (pgCode, kind, 소요시간 포함) |
912
+ | DEBUG | [Tx] | 트랜잭션 시작 / 커밋 |
913
+ | WARN | [Tx] | 트랜잭션 롤백 |
914
+ | ERROR | [Tx] | 롤백 실패 |
915
+
916
+ ---
917
+
918
+ ## Architecture
919
+
920
+ ```
921
+ src/
922
+ ├── index.ts ← 공개 API 진입점
923
+ ├── configs/env.ts ← DB 설정 파싱
924
+ ├── utils/
925
+ │ ├── logger.ts ← Logger 클래스
926
+ │ └── reader.ts ← env 파서, PostgresConfig
927
+ └── features/
928
+ ├── schema/ ← defineTable, col, InferRow/Insert/Update
929
+ ├── transform/ ← camelCase ↔ snake_case 변환
930
+ ├── connection/ ← Pool 관리, Transaction
931
+ ├── query/ ← SQL 쿼리 빌더 (select/insert/update/delete/upsert/bulkInsert)
932
+ └── repository/ ← BaseRepo, createRepo, IRepo
933
+ ```
934
+
935
+ ---
936
+
937
+ ## Contributing
938
+
939
+ 버그 리포트, 기능 제안, PR 모두 환영합니다.
940
+ → [Issues](https://github.com/psh-suhyun/reltype/issues) · [Pull Requests](https://github.com/psh-suhyun/reltype/pulls)
941
+
942
+ ---
943
+
944
+ ## Changelog
945
+
946
+ 전체 변경 이력은 [CHANGELOG.md](./CHANGELOG.md) 를 참고하세요.
947
+
948
+ ---
949
+
950
+ ## License
951
+
952
+ MIT