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
@@ -0,0 +1,613 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.QueryBuilder = void 0;
4
+ const pool_1 = require("../connection/pool");
5
+ const mapper_1 = require("../transform/mapper");
6
+ const case_1 = require("../transform/case");
7
+ const logger_1 = require("../../utils/logger");
8
+ const dbError_1 = require("../../utils/dbError");
9
+ const logger = logger_1.Logger.fromEnv(process.env, { prefix: '[Query]' });
10
+ // ── 내부 유틸 ──────────────────────────────────────────────────────────────
11
+ function isWhereVal(val) {
12
+ return (typeof val === 'object' &&
13
+ val !== null &&
14
+ !Array.isArray(val) &&
15
+ 'operator' in val);
16
+ }
17
+ function parseWhere(where) {
18
+ const conds = [];
19
+ for (const [key, val] of Object.entries(where)) {
20
+ if (val === undefined)
21
+ continue;
22
+ const col = (0, case_1.toSnake)(key);
23
+ if (val === null) {
24
+ conds.push({ col, op: 'IS NULL' });
25
+ }
26
+ else if (isWhereVal(val)) {
27
+ conds.push({ col, op: val.operator, val: val.value });
28
+ }
29
+ else {
30
+ conds.push({ col, op: '=', val });
31
+ }
32
+ }
33
+ return conds;
34
+ }
35
+ function renderCond(c, params) {
36
+ switch (c.op) {
37
+ case 'IS NULL':
38
+ case 'IS NOT NULL':
39
+ return `${c.col} ${c.op}`;
40
+ case 'IN':
41
+ case 'NOT IN': {
42
+ const arr = Array.isArray(c.val) ? c.val : [c.val];
43
+ const phs = arr.map((v) => { params.push(v); return `$${params.length}`; });
44
+ return `${c.col} ${c.op} (${phs.join(', ')})`;
45
+ }
46
+ default:
47
+ params.push(c.val);
48
+ return `${c.col} ${c.op} $${params.length}`;
49
+ }
50
+ }
51
+ // ── QueryBuilder ────────────────────────────────────────────────────────────
52
+ /**
53
+ * 플루언트 쿼리 빌더.
54
+ *
55
+ * ### 기본 사용
56
+ * ```ts
57
+ * const users = await repo.select({ isActive: true })
58
+ * .or({ email: { operator: 'ILIKE', value: '%@gmail.com' } })
59
+ * .orderBy([{ column: 'createdAt', direction: 'DESC' }])
60
+ * .limit(20);
61
+ * ```
62
+ *
63
+ * ### 페이지네이션 (OFFSET 방식)
64
+ * ```ts
65
+ * const page = await repo.select().paginate({ page: 1, pageSize: 20 });
66
+ * ```
67
+ *
68
+ * ### 커서 기반 페이지네이션 (대용량 최적화)
69
+ * ```ts
70
+ * const p1 = await repo.select().cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
71
+ * const p2 = await repo.select().cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
72
+ * ```
73
+ *
74
+ * ### 스트리밍 (메모리 효율)
75
+ * ```ts
76
+ * for await (const user of repo.select().stream()) {
77
+ * await processRow(user);
78
+ * }
79
+ * ```
80
+ *
81
+ * ### 배치 처리
82
+ * ```ts
83
+ * await repo.select().forEach(async (batch) => {
84
+ * await processBatch(batch);
85
+ * }, { batchSize: 500 });
86
+ * ```
87
+ *
88
+ * ### 훅
89
+ * ```ts
90
+ * repo.select()
91
+ * .hooks({ beforeExec: ({ sql }) => console.log(sql) })
92
+ * .exec();
93
+ * ```
94
+ */
95
+ class QueryBuilder {
96
+ constructor(table, initWhere) {
97
+ this._andConds = [];
98
+ this._orConds = [];
99
+ this._orderByClauses = [];
100
+ this._groupByCols = [];
101
+ this._joins = [];
102
+ this._cols = '*';
103
+ this._table = table;
104
+ if (initWhere) {
105
+ this._andConds = parseWhere(initWhere);
106
+ }
107
+ }
108
+ // ── Chain methods ──────────────────────────────────────────────────────────
109
+ /** AND 조건 추가 */
110
+ where(conditions) {
111
+ this._andConds.push(...parseWhere(conditions));
112
+ return this;
113
+ }
114
+ /** OR 조건 추가 */
115
+ or(conditions) {
116
+ this._orConds.push(...parseWhere(conditions));
117
+ return this;
118
+ }
119
+ /**
120
+ * ORDER BY 설정
121
+ * @example .orderBy([{ column: 'createdAt', direction: 'DESC' }])
122
+ */
123
+ orderBy(clauses) {
124
+ this._orderByClauses = clauses.map(({ column, direction }) => ({
125
+ col: (0, case_1.toSnake)(String(column)),
126
+ dir: direction ?? 'ASC',
127
+ }));
128
+ return this;
129
+ }
130
+ limit(n) {
131
+ this._limitVal = n;
132
+ return this;
133
+ }
134
+ offset(n) {
135
+ this._offsetVal = n;
136
+ return this;
137
+ }
138
+ /**
139
+ * GROUP BY 설정
140
+ * @example .groupBy(['status', 'isActive'])
141
+ */
142
+ groupBy(columns) {
143
+ this._groupByCols = columns.map((c) => (0, case_1.toSnake)(String(c)));
144
+ return this;
145
+ }
146
+ /**
147
+ * JOIN 추가 (여러 번 호출 가능)
148
+ * @example
149
+ * .join({ table: 'orders', on: 'users.id = orders.user_id', type: 'LEFT' })
150
+ */
151
+ join(j) {
152
+ this._joins.push(j);
153
+ return this;
154
+ }
155
+ /**
156
+ * SELECT 할 컬럼 지정 (기본값: *)
157
+ * @example .columns(['id', 'email', 'firstName'])
158
+ */
159
+ columns(cols) {
160
+ this._cols = cols.map((c) => (0, case_1.toSnake)(String(c))).join(', ');
161
+ return this;
162
+ }
163
+ /**
164
+ * 쿼리 실행 라이프사이클 훅을 등록합니다.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * repo.select()
169
+ * .hooks({
170
+ * beforeExec: ({ sql }) => logger.debug('SQL:', sql),
171
+ * afterExec: ({ elapsed }) => metrics.record(elapsed),
172
+ * onError: ({ err }) => alerting.notify(err),
173
+ * })
174
+ * .exec();
175
+ * ```
176
+ */
177
+ hooks(h) {
178
+ this._execHooks = h;
179
+ return this;
180
+ }
181
+ // ── Terminal async methods ─────────────────────────────────────────────────
182
+ /**
183
+ * 쿼리를 실행하고 rows 배열을 반환합니다.
184
+ * `await builder` 와 동일합니다.
185
+ */
186
+ async exec() {
187
+ const { sql, params } = this.buildSelectSQL();
188
+ return this.runQuery(sql, params);
189
+ }
190
+ /**
191
+ * 첫 번째 row 하나를 반환합니다. 없으면 null입니다.
192
+ */
193
+ async one() {
194
+ const saved = this._limitVal;
195
+ this._limitVal = 1;
196
+ try {
197
+ const rows = await this.exec();
198
+ return rows[0] ?? null;
199
+ }
200
+ finally {
201
+ this._limitVal = saved;
202
+ }
203
+ }
204
+ /**
205
+ * 집계 함수를 실행합니다.
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * const result = await repo.select({ isActive: true })
210
+ * .calculate([
211
+ * { fn: 'COUNT', alias: 'count' },
212
+ * { fn: 'AVG', column: 'price', alias: 'avgPrice' },
213
+ * ]);
214
+ * // → { count: '5', avgPrice: '12000.00' }
215
+ * ```
216
+ */
217
+ async calculate(fns) {
218
+ const { whereSQL, params } = this.buildWhereParts();
219
+ const selects = fns
220
+ .map(({ fn, column, alias }) => `${fn}(${column ?? '*'}) AS ${alias}`)
221
+ .join(', ');
222
+ const sql = [
223
+ `SELECT ${selects}`,
224
+ `FROM ${this._table}`,
225
+ ...this._joins.map((j) => `${j.type ?? 'INNER'} JOIN ${j.table} ON ${j.on}`),
226
+ whereSQL,
227
+ this._groupByCols.length > 0 ? `GROUP BY ${this._groupByCols.join(', ')}` : '',
228
+ ]
229
+ .filter(Boolean)
230
+ .join(' ');
231
+ const rows = await this.runQuery(sql, params);
232
+ return rows[0] ?? {};
233
+ }
234
+ /**
235
+ * OFFSET 기반 페이지네이션.
236
+ * COUNT + DATA 쿼리를 병렬로 실행합니다.
237
+ *
238
+ * > 수백만 건 이상의 테이블에서는 `cursorPaginate()`를 사용하세요.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * const result = await repo.select()
243
+ * .paginate({ page: 1, pageSize: 20 });
244
+ * // → { data, count, page, pageSize, nextAction, previousAction }
245
+ * ```
246
+ */
247
+ async paginate(opts) {
248
+ const { page, pageSize } = opts;
249
+ const { whereSQL, params: whereParams } = this.buildWhereParts();
250
+ const joinSQL = this.buildJoinSQL();
251
+ const groupSQL = this.buildGroupBySQL();
252
+ const orderSQL = this.buildOrderBySQL();
253
+ const countSql = [
254
+ `SELECT COUNT(*) AS count FROM ${this._table}`,
255
+ joinSQL, whereSQL, groupSQL,
256
+ ].filter(Boolean).join(' ');
257
+ const dataParams = [...whereParams];
258
+ dataParams.push(pageSize);
259
+ const limitIdx = dataParams.length;
260
+ dataParams.push((page - 1) * pageSize);
261
+ const offsetIdx = dataParams.length;
262
+ const dataSql = [
263
+ `SELECT ${this._cols} FROM ${this._table}`,
264
+ joinSQL, whereSQL, groupSQL, orderSQL,
265
+ `LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
266
+ ].filter(Boolean).join(' ');
267
+ try {
268
+ return await (0, pool_1.withClient)(async (client) => {
269
+ if (this._execHooks?.beforeExec) {
270
+ await this._execHooks.beforeExec({ sql: dataSql, params: dataParams });
271
+ }
272
+ const start = Date.now();
273
+ logger.debug(`COUNT SQL: ${countSql}`, whereParams);
274
+ logger.debug(`DATA SQL: ${dataSql}`, dataParams);
275
+ const [countResult, dataResult] = await Promise.all([
276
+ client.query(countSql, whereParams),
277
+ client.query(dataSql, dataParams),
278
+ ]);
279
+ const total = parseInt(String(countResult.rows[0].count ?? '0'), 10);
280
+ const data = (0, mapper_1.mapRows)(dataResult.rows);
281
+ if (this._execHooks?.afterExec) {
282
+ await this._execHooks.afterExec({ rows: data, elapsed: Date.now() - start, sql: dataSql });
283
+ }
284
+ return {
285
+ data,
286
+ count: total,
287
+ page,
288
+ pageSize,
289
+ nextAction: page * pageSize < total,
290
+ previousAction: page > 1,
291
+ };
292
+ });
293
+ }
294
+ catch (err) {
295
+ const dbErr = dbError_1.DbError.from(err);
296
+ if (this._execHooks?.onError) {
297
+ await this._execHooks.onError({ err: dbErr, sql: dataSql, params: dataParams });
298
+ }
299
+ throw dbErr;
300
+ }
301
+ }
302
+ /**
303
+ * 커서(Keyset) 기반 페이지네이션.
304
+ *
305
+ * OFFSET 스캔 없이 `WHERE cursor_col > last_value` 방식으로 동작하므로
306
+ * 수천만 건 규모에서도 일정한 응답 속도를 보장합니다.
307
+ *
308
+ * - `cursorColumn`에는 반드시 인덱스가 존재해야 합니다.
309
+ * - 결과는 항상 `cursorColumn` 기준으로 정렬됩니다.
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * // 첫 페이지
314
+ * const p1 = await repo.select({ isActive: true })
315
+ * .cursorPaginate({ pageSize: 20, cursorColumn: 'id' });
316
+ *
317
+ * // 다음 페이지
318
+ * if (p1.hasNext) {
319
+ * const p2 = await repo.select({ isActive: true })
320
+ * .cursorPaginate({ pageSize: 20, cursorColumn: 'id', cursor: p1.nextCursor });
321
+ * }
322
+ * ```
323
+ */
324
+ async cursorPaginate(opts) {
325
+ const { pageSize, cursor, cursorColumn, direction = 'asc' } = opts;
326
+ const colSnake = (0, case_1.toSnake)(cursorColumn);
327
+ // 커서 값 디코딩
328
+ let cursorValue;
329
+ if (cursor) {
330
+ try {
331
+ cursorValue = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
332
+ }
333
+ catch {
334
+ throw new Error(`유효하지 않은 cursor 값입니다: ${cursor}`);
335
+ }
336
+ }
337
+ // 기존 조건에 커서 조건 추가 (원본 builder를 변경하지 않음)
338
+ const qb = this.clone();
339
+ if (cursorValue !== undefined) {
340
+ qb._andConds.push({
341
+ col: colSnake,
342
+ op: direction === 'asc' ? '>' : '<',
343
+ val: cursorValue,
344
+ });
345
+ }
346
+ // pageSize + 1개를 조회하여 다음 페이지 존재 여부 확인
347
+ qb._orderByClauses = [{ col: colSnake, dir: direction === 'asc' ? 'ASC' : 'DESC' }];
348
+ qb._limitVal = pageSize + 1;
349
+ const rows = await qb.exec();
350
+ const hasNext = rows.length > pageSize;
351
+ const data = hasNext ? rows.slice(0, pageSize) : rows;
352
+ // 다음 커서 인코딩 (마지막 row의 cursorColumn 값)
353
+ let nextCursor = null;
354
+ if (hasNext && data.length > 0) {
355
+ const lastRow = data[data.length - 1];
356
+ const cursorVal = lastRow[cursorColumn];
357
+ nextCursor = Buffer.from(JSON.stringify(cursorVal)).toString('base64url');
358
+ }
359
+ return { data, nextCursor, pageSize, hasNext };
360
+ }
361
+ /**
362
+ * 결과를 배치 단위로 처리합니다. 전체 데이터를 메모리에 올리지 않습니다.
363
+ *
364
+ * 대용량 테이블의 일괄 처리(ETL, 이메일 발송, 마이그레이션 등)에 적합합니다.
365
+ *
366
+ * @param fn - 배치 배열을 받아 처리하는 비동기 함수
367
+ * @param opts.batchSize - 한 번에 처리할 row 수 (기본값: 500)
368
+ *
369
+ * @example
370
+ * ```ts
371
+ * await repo.select({ isActive: true })
372
+ * .orderBy([{ column: 'id', direction: 'ASC' }])
373
+ * .forEach(async (batch) => {
374
+ * await sendEmailBatch(batch);
375
+ * }, { batchSize: 200 });
376
+ * ```
377
+ */
378
+ async forEach(fn, opts) {
379
+ const batchSize = opts?.batchSize ?? 500;
380
+ const maxRows = this._limitVal;
381
+ let offset = this._offsetVal ?? 0;
382
+ let processed = 0;
383
+ while (true) {
384
+ const remaining = maxRows !== undefined ? maxRows - processed : undefined;
385
+ if (remaining !== undefined && remaining <= 0)
386
+ break;
387
+ const limit = remaining !== undefined
388
+ ? Math.min(batchSize, remaining)
389
+ : batchSize;
390
+ const batch = await this.clone().limit(limit).offset(offset).exec();
391
+ if (batch.length === 0)
392
+ break;
393
+ await fn(batch);
394
+ processed += batch.length;
395
+ offset += batch.length;
396
+ if (batch.length < limit)
397
+ break;
398
+ }
399
+ }
400
+ /**
401
+ * AsyncGenerator로 row를 하나씩 yield합니다.
402
+ * 내부적으로 배치 단위로 DB를 조회하여 메모리 효율을 유지합니다.
403
+ *
404
+ * @param opts.batchSize - 내부 배치 크기 (기본값: 500)
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * for await (const user of repo.select({ isActive: true }).stream()) {
409
+ * await processRow(user);
410
+ * }
411
+ * ```
412
+ */
413
+ async *stream(opts) {
414
+ const batchSize = opts?.batchSize ?? 500;
415
+ const maxRows = this._limitVal;
416
+ let offset = this._offsetVal ?? 0;
417
+ let yielded = 0;
418
+ while (true) {
419
+ const remaining = maxRows !== undefined ? maxRows - yielded : undefined;
420
+ if (remaining !== undefined && remaining <= 0)
421
+ break;
422
+ const limit = remaining !== undefined
423
+ ? Math.min(batchSize, remaining)
424
+ : batchSize;
425
+ const batch = await this.clone().limit(limit).offset(offset).exec();
426
+ if (batch.length === 0)
427
+ break;
428
+ for (const row of batch) {
429
+ yield row;
430
+ yielded++;
431
+ }
432
+ offset += batch.length;
433
+ if (batch.length < limit)
434
+ break;
435
+ }
436
+ }
437
+ /**
438
+ * `for await...of` 직접 사용을 지원합니다. `.stream()` 과 동일합니다.
439
+ *
440
+ * @example
441
+ * ```ts
442
+ * for await (const user of repo.select()) {
443
+ * // 각 row를 순서대로 처리
444
+ * }
445
+ * ```
446
+ */
447
+ [Symbol.asyncIterator]() {
448
+ return this.stream();
449
+ }
450
+ /**
451
+ * EXPLAIN (ANALYZE) 결과를 반환합니다.
452
+ * 쿼리 플랜 분석 및 인덱스 사용 여부 확인에 사용합니다.
453
+ *
454
+ * @param analyze - true이면 실제 실행 후 통계를 포함합니다 (기본값: false)
455
+ *
456
+ * @example
457
+ * ```ts
458
+ * const plan = await repo.select({ isActive: true }).explain(true);
459
+ * console.log(plan);
460
+ * ```
461
+ */
462
+ async explain(analyze = false) {
463
+ const { sql, params } = this.buildSelectSQL();
464
+ const prefix = analyze ? 'EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)' : 'EXPLAIN';
465
+ const explainSql = `${prefix} ${sql}`;
466
+ const rows = await QueryBuilder.raw(explainSql, params);
467
+ return rows.map((r) => r['QUERY PLAN']).join('\n');
468
+ }
469
+ /**
470
+ * 현재 builder 상태에서 최종 SQL과 params를 반환합니다.
471
+ * 디버깅 또는 로깅 목적으로 사용합니다.
472
+ */
473
+ toSQL() {
474
+ return this.buildSelectSQL();
475
+ }
476
+ /**
477
+ * Raw SQL을 직접 실행합니다.
478
+ * DB 컬럼명(snake_case) → TypeScript(camelCase) 자동 변환이 적용됩니다.
479
+ *
480
+ * @example
481
+ * ```ts
482
+ * const rows = await QueryBuilder.raw<UserRow>(
483
+ * 'SELECT * FROM users WHERE first_name ILIKE $1',
484
+ * ['%john%'],
485
+ * );
486
+ * ```
487
+ */
488
+ static async raw(sql, params) {
489
+ try {
490
+ return await (0, pool_1.withClient)(async (client) => {
491
+ logger.debug(`RAW SQL: ${sql}`, params);
492
+ const result = await client.query(sql, params ?? []);
493
+ return (0, mapper_1.mapRows)(result.rows);
494
+ });
495
+ }
496
+ catch (err) {
497
+ throw dbError_1.DbError.from(err);
498
+ }
499
+ }
500
+ /**
501
+ * builder 상태를 독립적으로 복사합니다.
502
+ * stream/forEach 내부에서 LIMIT/OFFSET을 덮어쓸 때 원본을 보호하기 위해 사용합니다.
503
+ */
504
+ clone() {
505
+ const c = new QueryBuilder(this._table);
506
+ c._andConds = [...this._andConds];
507
+ c._orConds = [...this._orConds];
508
+ c._orderByClauses = [...this._orderByClauses];
509
+ c._limitVal = this._limitVal;
510
+ c._offsetVal = this._offsetVal;
511
+ c._groupByCols = [...this._groupByCols];
512
+ c._joins = [...this._joins];
513
+ c._cols = this._cols;
514
+ c._execHooks = this._execHooks;
515
+ return c;
516
+ }
517
+ // ── Thenable ──────────────────────────────────────────────────────────────
518
+ then(onfulfilled, onrejected) {
519
+ return this.exec().then(onfulfilled, onrejected);
520
+ }
521
+ catch(onrejected) {
522
+ return this.exec().catch(onrejected);
523
+ }
524
+ // ── Private builders ───────────────────────────────────────────────────────
525
+ buildWhereParts() {
526
+ const params = [];
527
+ const andParts = this._andConds.map((c) => renderCond(c, params));
528
+ const orParts = this._orConds.map((c) => renderCond(c, params));
529
+ const andSQL = andParts.join(' AND ');
530
+ const orSQL = orParts.join(' OR ');
531
+ let whereSQL = '';
532
+ if (andSQL && orSQL)
533
+ whereSQL = `WHERE (${andSQL}) OR (${orSQL})`;
534
+ else if (andSQL)
535
+ whereSQL = `WHERE ${andSQL}`;
536
+ else if (orSQL)
537
+ whereSQL = `WHERE ${orSQL}`;
538
+ return { whereSQL, params };
539
+ }
540
+ buildJoinSQL() {
541
+ return this._joins
542
+ .map((j) => `${j.type ?? 'INNER'} JOIN ${j.table} ON ${j.on}`)
543
+ .join(' ');
544
+ }
545
+ buildGroupBySQL() {
546
+ return this._groupByCols.length > 0
547
+ ? `GROUP BY ${this._groupByCols.join(', ')}`
548
+ : '';
549
+ }
550
+ buildOrderBySQL() {
551
+ return this._orderByClauses.length > 0
552
+ ? `ORDER BY ${this._orderByClauses.map((o) => `${o.col} ${o.dir}`).join(', ')}`
553
+ : '';
554
+ }
555
+ buildSelectSQL() {
556
+ const { whereSQL, params } = this.buildWhereParts();
557
+ const parts = [
558
+ `SELECT ${this._cols} FROM ${this._table}`,
559
+ this.buildJoinSQL(),
560
+ whereSQL,
561
+ this.buildGroupBySQL(),
562
+ this.buildOrderBySQL(),
563
+ ].filter(Boolean);
564
+ if (this._limitVal !== undefined) {
565
+ params.push(this._limitVal);
566
+ parts.push(`LIMIT $${params.length}`);
567
+ }
568
+ if (this._offsetVal !== undefined) {
569
+ params.push(this._offsetVal);
570
+ parts.push(`OFFSET $${params.length}`);
571
+ }
572
+ return { sql: parts.join(' '), params };
573
+ }
574
+ /**
575
+ * 공통 쿼리 실행 (훅 + 로깅 포함).
576
+ */
577
+ async runQuery(sql, params) {
578
+ if (this._execHooks?.beforeExec) {
579
+ await this._execHooks.beforeExec({ sql, params });
580
+ }
581
+ const start = Date.now();
582
+ logger.debug(`SQL: ${sql}`, params);
583
+ try {
584
+ const rows = await (0, pool_1.withClient)(async (client) => {
585
+ const result = await client.query(sql, params);
586
+ return (0, mapper_1.mapRows)(result.rows);
587
+ });
588
+ const elapsed = Date.now() - start;
589
+ logger.debug(`완료 (${elapsed}ms) rowCount=${rows.length}`);
590
+ if (this._execHooks?.afterExec) {
591
+ await this._execHooks.afterExec({
592
+ rows: rows,
593
+ elapsed,
594
+ sql,
595
+ });
596
+ }
597
+ return rows;
598
+ }
599
+ catch (err) {
600
+ const dbErr = dbError_1.DbError.from(err);
601
+ logger.error(`쿼리 실패 [${this._table}]`, {
602
+ ...dbErr.toLogContext(),
603
+ sql,
604
+ elapsed: `${Date.now() - start}ms`,
605
+ });
606
+ if (this._execHooks?.onError) {
607
+ await this._execHooks.onError({ err: dbErr, sql, params });
608
+ }
609
+ throw dbErr;
610
+ }
611
+ }
612
+ }
613
+ exports.QueryBuilder = QueryBuilder;
@@ -0,0 +1,10 @@
1
+ import { BuiltQuery } from './interfaces/Query';
2
+ /**
3
+ * 여러 row를 한 번의 INSERT 쿼리로 삽입합니다.
4
+ * 빈 배열이 전달되면 빈 BuiltQuery를 반환합니다.
5
+ *
6
+ * @param table - 테이블명 (snake_case)
7
+ * @param rows - 삽입할 데이터 배열 (camelCase key, 모든 row는 동일한 key 구조여야 합니다)
8
+ */
9
+ export declare function buildBulkInsert(table: string, rows: Record<string, unknown>[]): BuiltQuery;
10
+ //# sourceMappingURL=bulkInsert.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bulkInsert.d.ts","sourceRoot":"","sources":["../../../src/features/query/bulkInsert.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAC9B,UAAU,CAqBZ"}
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildBulkInsert = buildBulkInsert;
4
+ const case_1 = require("../transform/case");
5
+ /**
6
+ * 여러 row를 한 번의 INSERT 쿼리로 삽입합니다.
7
+ * 빈 배열이 전달되면 빈 BuiltQuery를 반환합니다.
8
+ *
9
+ * @param table - 테이블명 (snake_case)
10
+ * @param rows - 삽입할 데이터 배열 (camelCase key, 모든 row는 동일한 key 구조여야 합니다)
11
+ */
12
+ function buildBulkInsert(table, rows) {
13
+ if (rows.length === 0) {
14
+ return { sql: '', params: [] };
15
+ }
16
+ const keys = Object.keys(rows[0]).filter((k) => rows[0][k] !== undefined);
17
+ const cols = keys.map(case_1.toSnake).join(', ');
18
+ const params = [];
19
+ const valueSets = rows.map((row) => {
20
+ const placeholders = keys.map((k) => {
21
+ params.push(row[k]);
22
+ return `$${params.length}`;
23
+ });
24
+ return `(${placeholders.join(', ')})`;
25
+ });
26
+ return {
27
+ sql: `INSERT INTO ${table} (${cols}) VALUES ${valueSets.join(', ')} RETURNING *`,
28
+ params,
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ import { BuiltQuery } from './interfaces/Query';
2
+ import { WhereInput } from './interfaces/Where';
3
+ /**
4
+ * DELETE 쿼리를 생성합니다.
5
+ * camelCase key → snake_case 컬럼명으로 자동 변환됩니다.
6
+ */
7
+ export declare function buildDelete<T extends Record<string, unknown>>(table: string, where: WhereInput<T>): BuiltQuery;
8
+ //# sourceMappingURL=delete.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delete.d.ts","sourceRoot":"","sources":["../../../src/features/query/delete.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,GACnB,UAAU,CAOZ"}
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildDelete = buildDelete;
4
+ const where_1 = require("./where");
5
+ /**
6
+ * DELETE 쿼리를 생성합니다.
7
+ * camelCase key → snake_case 컬럼명으로 자동 변환됩니다.
8
+ */
9
+ function buildDelete(table, where) {
10
+ const { sql: whereSql, params } = (0, where_1.buildWhere)(where);
11
+ return {
12
+ sql: [`DELETE FROM ${table}`, whereSql].filter(Boolean).join(' '),
13
+ params,
14
+ };
15
+ }
@@ -0,0 +1,10 @@
1
+ export * from './select';
2
+ export * from './insert';
3
+ export * from './update';
4
+ export * from './delete';
5
+ export * from './upsert';
6
+ export * from './bulkInsert';
7
+ export * from './where';
8
+ export * from './interfaces';
9
+ export * from './builder';
10
+ //# sourceMappingURL=index.d.ts.map