@simplysm/orm-common 13.0.69 → 13.0.70

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 (204) hide show
  1. package/README.md +54 -1447
  2. package/dist/create-db-context.d.ts +10 -10
  3. package/dist/create-db-context.js +9 -9
  4. package/dist/create-db-context.js.map +1 -1
  5. package/dist/ddl/column-ddl.d.ts +4 -4
  6. package/dist/ddl/initialize.d.ts +17 -17
  7. package/dist/ddl/initialize.js +2 -2
  8. package/dist/ddl/initialize.js.map +1 -1
  9. package/dist/ddl/relation-ddl.d.ts +6 -6
  10. package/dist/ddl/schema-ddl.d.ts +4 -4
  11. package/dist/ddl/table-ddl.d.ts +24 -24
  12. package/dist/ddl/table-ddl.js +4 -4
  13. package/dist/ddl/table-ddl.js.map +1 -1
  14. package/dist/errors/db-transaction-error.d.ts +15 -15
  15. package/dist/errors/db-transaction-error.d.ts.map +1 -1
  16. package/dist/exec/executable.d.ts +23 -23
  17. package/dist/exec/executable.js +3 -3
  18. package/dist/exec/executable.js.map +1 -1
  19. package/dist/exec/queryable.d.ts +160 -160
  20. package/dist/exec/queryable.js +119 -119
  21. package/dist/exec/queryable.js.map +1 -1
  22. package/dist/exec/search-parser.d.ts +37 -37
  23. package/dist/exec/search-parser.d.ts.map +1 -1
  24. package/dist/expr/expr-unit.d.ts +4 -4
  25. package/dist/expr/expr.d.ts +257 -257
  26. package/dist/expr/expr.js +265 -265
  27. package/dist/expr/expr.js.map +1 -1
  28. package/dist/query-builder/base/expr-renderer-base.d.ts +9 -9
  29. package/dist/query-builder/base/expr-renderer-base.js +2 -2
  30. package/dist/query-builder/base/expr-renderer-base.js.map +1 -1
  31. package/dist/query-builder/base/query-builder-base.d.ts +26 -26
  32. package/dist/query-builder/base/query-builder-base.d.ts.map +1 -1
  33. package/dist/query-builder/base/query-builder-base.js +22 -22
  34. package/dist/query-builder/base/query-builder-base.js.map +1 -1
  35. package/dist/query-builder/mssql/mssql-expr-renderer.d.ts +4 -4
  36. package/dist/query-builder/mssql/mssql-expr-renderer.d.ts.map +1 -1
  37. package/dist/query-builder/mssql/mssql-expr-renderer.js +18 -18
  38. package/dist/query-builder/mssql/mssql-expr-renderer.js.map +1 -1
  39. package/dist/query-builder/mssql/mssql-query-builder.d.ts +2 -2
  40. package/dist/query-builder/mssql/mssql-query-builder.d.ts.map +1 -1
  41. package/dist/query-builder/mssql/mssql-query-builder.js +11 -11
  42. package/dist/query-builder/mssql/mssql-query-builder.js.map +1 -1
  43. package/dist/query-builder/mysql/mysql-expr-renderer.d.ts +4 -4
  44. package/dist/query-builder/mysql/mysql-expr-renderer.d.ts.map +1 -1
  45. package/dist/query-builder/mysql/mysql-expr-renderer.js +17 -17
  46. package/dist/query-builder/mysql/mysql-expr-renderer.js.map +1 -1
  47. package/dist/query-builder/mysql/mysql-query-builder.d.ts +8 -8
  48. package/dist/query-builder/mysql/mysql-query-builder.d.ts.map +1 -1
  49. package/dist/query-builder/mysql/mysql-query-builder.js +5 -5
  50. package/dist/query-builder/mysql/mysql-query-builder.js.map +1 -1
  51. package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts +4 -4
  52. package/dist/query-builder/postgresql/postgresql-expr-renderer.d.ts.map +1 -1
  53. package/dist/query-builder/postgresql/postgresql-expr-renderer.js +17 -17
  54. package/dist/query-builder/postgresql/postgresql-expr-renderer.js.map +1 -1
  55. package/dist/query-builder/postgresql/postgresql-query-builder.d.ts +5 -5
  56. package/dist/query-builder/postgresql/postgresql-query-builder.d.ts.map +1 -1
  57. package/dist/query-builder/postgresql/postgresql-query-builder.js +8 -8
  58. package/dist/query-builder/postgresql/postgresql-query-builder.js.map +1 -1
  59. package/dist/query-builder/query-builder.d.ts +1 -1
  60. package/dist/schema/factory/column-builder.d.ts +79 -79
  61. package/dist/schema/factory/column-builder.js +42 -42
  62. package/dist/schema/factory/index-builder.d.ts +39 -39
  63. package/dist/schema/factory/index-builder.js +26 -26
  64. package/dist/schema/factory/relation-builder.d.ts +99 -99
  65. package/dist/schema/factory/relation-builder.d.ts.map +1 -1
  66. package/dist/schema/factory/relation-builder.js +38 -38
  67. package/dist/schema/procedure-builder.d.ts +49 -49
  68. package/dist/schema/procedure-builder.d.ts.map +1 -1
  69. package/dist/schema/procedure-builder.js +33 -33
  70. package/dist/schema/table-builder.d.ts +59 -59
  71. package/dist/schema/table-builder.d.ts.map +1 -1
  72. package/dist/schema/table-builder.js +43 -43
  73. package/dist/schema/view-builder.d.ts +49 -49
  74. package/dist/schema/view-builder.d.ts.map +1 -1
  75. package/dist/schema/view-builder.js +32 -32
  76. package/dist/types/column.d.ts +22 -22
  77. package/dist/types/column.js +1 -1
  78. package/dist/types/column.js.map +1 -1
  79. package/dist/types/db.d.ts +40 -40
  80. package/dist/types/expr.d.ts +59 -59
  81. package/dist/types/expr.d.ts.map +1 -1
  82. package/dist/types/query-def.d.ts +44 -44
  83. package/dist/types/query-def.d.ts.map +1 -1
  84. package/dist/utils/result-parser.d.ts +11 -11
  85. package/dist/utils/result-parser.js +3 -3
  86. package/dist/utils/result-parser.js.map +1 -1
  87. package/package.json +5 -5
  88. package/src/create-db-context.ts +20 -20
  89. package/src/ddl/column-ddl.ts +4 -4
  90. package/src/ddl/initialize.ts +259 -259
  91. package/src/ddl/relation-ddl.ts +89 -89
  92. package/src/ddl/schema-ddl.ts +4 -4
  93. package/src/ddl/table-ddl.ts +189 -189
  94. package/src/errors/db-transaction-error.ts +13 -13
  95. package/src/exec/executable.ts +25 -25
  96. package/src/exec/queryable.ts +2033 -2033
  97. package/src/exec/search-parser.ts +57 -57
  98. package/src/expr/expr-unit.ts +4 -4
  99. package/src/expr/expr.ts +2140 -2140
  100. package/src/query-builder/base/expr-renderer-base.ts +237 -237
  101. package/src/query-builder/base/query-builder-base.ts +213 -213
  102. package/src/query-builder/mssql/mssql-expr-renderer.ts +607 -607
  103. package/src/query-builder/mssql/mssql-query-builder.ts +650 -650
  104. package/src/query-builder/mysql/mysql-expr-renderer.ts +613 -613
  105. package/src/query-builder/mysql/mysql-query-builder.ts +759 -759
  106. package/src/query-builder/postgresql/postgresql-expr-renderer.ts +611 -611
  107. package/src/query-builder/postgresql/postgresql-query-builder.ts +686 -686
  108. package/src/query-builder/query-builder.ts +19 -19
  109. package/src/schema/factory/column-builder.ts +423 -423
  110. package/src/schema/factory/index-builder.ts +164 -164
  111. package/src/schema/factory/relation-builder.ts +453 -453
  112. package/src/schema/procedure-builder.ts +232 -232
  113. package/src/schema/table-builder.ts +319 -319
  114. package/src/schema/view-builder.ts +221 -221
  115. package/src/types/column.ts +188 -188
  116. package/src/types/db.ts +208 -208
  117. package/src/types/expr.ts +697 -697
  118. package/src/types/query-def.ts +513 -513
  119. package/src/utils/result-parser.ts +458 -458
  120. package/tests/db-context/create-db-context.spec.ts +224 -0
  121. package/tests/db-context/define-db-context.spec.ts +68 -0
  122. package/tests/ddl/basic.expected.ts +341 -0
  123. package/tests/ddl/basic.spec.ts +714 -0
  124. package/tests/ddl/column-builder.expected.ts +310 -0
  125. package/tests/ddl/column-builder.spec.ts +637 -0
  126. package/tests/ddl/index-builder.expected.ts +38 -0
  127. package/tests/ddl/index-builder.spec.ts +202 -0
  128. package/tests/ddl/procedure-builder.expected.ts +52 -0
  129. package/tests/ddl/procedure-builder.spec.ts +234 -0
  130. package/tests/ddl/relation-builder.expected.ts +36 -0
  131. package/tests/ddl/relation-builder.spec.ts +372 -0
  132. package/tests/ddl/table-builder.expected.ts +113 -0
  133. package/tests/ddl/table-builder.spec.ts +433 -0
  134. package/tests/ddl/view-builder.expected.ts +38 -0
  135. package/tests/ddl/view-builder.spec.ts +176 -0
  136. package/tests/dml/delete.expected.ts +96 -0
  137. package/tests/dml/delete.spec.ts +160 -0
  138. package/tests/dml/insert.expected.ts +192 -0
  139. package/tests/dml/insert.spec.ts +288 -0
  140. package/tests/dml/update.expected.ts +176 -0
  141. package/tests/dml/update.spec.ts +318 -0
  142. package/tests/dml/upsert.expected.ts +215 -0
  143. package/tests/dml/upsert.spec.ts +242 -0
  144. package/tests/errors/queryable-errors.spec.ts +177 -0
  145. package/tests/escape.spec.ts +100 -0
  146. package/tests/examples/pivot.expected.ts +211 -0
  147. package/tests/examples/pivot.spec.ts +533 -0
  148. package/tests/examples/sampling.expected.ts +69 -0
  149. package/tests/examples/sampling.spec.ts +104 -0
  150. package/tests/examples/unpivot.expected.ts +120 -0
  151. package/tests/examples/unpivot.spec.ts +226 -0
  152. package/tests/exec/search-parser.spec.ts +283 -0
  153. package/tests/executable/basic.expected.ts +18 -0
  154. package/tests/executable/basic.spec.ts +54 -0
  155. package/tests/expr/comparison.expected.ts +282 -0
  156. package/tests/expr/comparison.spec.ts +400 -0
  157. package/tests/expr/conditional.expected.ts +134 -0
  158. package/tests/expr/conditional.spec.ts +276 -0
  159. package/tests/expr/date.expected.ts +332 -0
  160. package/tests/expr/date.spec.ts +526 -0
  161. package/tests/expr/math.expected.ts +62 -0
  162. package/tests/expr/math.spec.ts +106 -0
  163. package/tests/expr/string.expected.ts +218 -0
  164. package/tests/expr/string.spec.ts +356 -0
  165. package/tests/expr/utility.expected.ts +147 -0
  166. package/tests/expr/utility.spec.ts +182 -0
  167. package/tests/select/basic.expected.ts +322 -0
  168. package/tests/select/basic.spec.ts +502 -0
  169. package/tests/select/filter.expected.ts +357 -0
  170. package/tests/select/filter.spec.ts +1068 -0
  171. package/tests/select/group.expected.ts +169 -0
  172. package/tests/select/group.spec.ts +244 -0
  173. package/tests/select/join.expected.ts +582 -0
  174. package/tests/select/join.spec.ts +805 -0
  175. package/tests/select/order.expected.ts +150 -0
  176. package/tests/select/order.spec.ts +189 -0
  177. package/tests/select/recursive-cte.expected.ts +244 -0
  178. package/tests/select/recursive-cte.spec.ts +514 -0
  179. package/tests/select/result-meta.spec.ts +270 -0
  180. package/tests/select/subquery.expected.ts +363 -0
  181. package/tests/select/subquery.spec.ts +537 -0
  182. package/tests/select/view.expected.ts +155 -0
  183. package/tests/select/view.spec.ts +235 -0
  184. package/tests/select/window.expected.ts +345 -0
  185. package/tests/select/window.spec.ts +618 -0
  186. package/tests/setup/MockExecutor.ts +18 -0
  187. package/tests/setup/TestDbContext.ts +59 -0
  188. package/tests/setup/models/Company.ts +13 -0
  189. package/tests/setup/models/Employee.ts +10 -0
  190. package/tests/setup/models/MonthlySales.ts +11 -0
  191. package/tests/setup/models/Post.ts +16 -0
  192. package/tests/setup/models/Sales.ts +10 -0
  193. package/tests/setup/models/User.ts +19 -0
  194. package/tests/setup/procedure/GetAllUsers.ts +9 -0
  195. package/tests/setup/procedure/GetUserById.ts +12 -0
  196. package/tests/setup/test-utils.ts +72 -0
  197. package/tests/setup/views/ActiveUsers.ts +8 -0
  198. package/tests/setup/views/UserSummary.ts +11 -0
  199. package/tests/types/nullable-queryable-record.spec.ts +145 -0
  200. package/tests/utils/result-parser-perf.spec.ts +210 -0
  201. package/tests/utils/result-parser.spec.ts +701 -0
  202. package/docs/expressions.md +0 -172
  203. package/docs/queries.md +0 -444
  204. package/docs/schema.md +0 -245
@@ -1,2033 +1,2033 @@
1
- import { TableBuilder } from "../schema/table-builder";
2
- import { ViewBuilder } from "../schema/view-builder";
3
-
4
- import type { DataRecord, ResultMeta } from "../types/db";
5
- import type {
6
- DeleteQueryDef,
7
- InsertIfNotExistsQueryDef,
8
- InsertIntoQueryDef,
9
- InsertQueryDef,
10
- QueryDefObjectName,
11
- SelectQueryDef,
12
- SelectQueryDefJoin,
13
- UpdateQueryDef,
14
- UpsertQueryDef,
15
- } from "../types/query-def";
16
- import type { DbContextBase } from "../types/db-context-def";
17
- import {
18
- type ColumnBuilderRecord,
19
- type DataToColumnBuilderRecord,
20
- } from "../schema/factory/column-builder";
21
- import type { ColumnPrimitive, ColumnPrimitiveStr } from "../types/column";
22
- import type { WhereExprUnit, ExprInput } from "../expr/expr-unit";
23
- import { ExprUnit } from "../expr/expr-unit";
24
- import type { Expr } from "../types/expr";
25
- import { ArgumentError, objClearUndefined } from "@simplysm/core-common";
26
- import {
27
- ForeignKeyBuilder,
28
- ForeignKeyTargetBuilder,
29
- RelationKeyBuilder,
30
- RelationKeyTargetBuilder,
31
- } from "../schema/factory/relation-builder";
32
- import { parseSearchQuery } from "./search-parser";
33
- import { expr } from "../expr/expr";
34
-
35
- /**
36
- * JOIN 쿼리 빌더
37
- *
38
- * join/joinSingle 메서드 내부에서 사용되며, 조인 대상 테이블을 지정하는 역할을 수행
39
- */
40
- class JoinQueryable {
41
- constructor(
42
- private readonly _db: DbContextBase,
43
- private readonly _joinAlias: string,
44
- ) {}
45
-
46
- /**
47
- * 조인할 테이블을 지정
48
- *
49
- * @param table - 조인 대상 테이블
50
- * @returns 조인된 Queryable
51
- */
52
- from<T extends TableBuilder<any, any>>(table: T): Queryable<T["$infer"], T> {
53
- return queryable(this._db, table, this._joinAlias)();
54
- }
55
-
56
- /**
57
- * 조인 결과의 컬럼을 직접 지정
58
- *
59
- * @param columns - 커스텀 컬럼 정의
60
- * @returns 커스텀 컬럼이 적용된 Queryable
61
- */
62
- select<R extends DataRecord>(columns: QueryableRecord<R>): Queryable<R, never> {
63
- return new Queryable({
64
- db: this._db,
65
- as: this._joinAlias,
66
- columns,
67
- isCustomColumns: true,
68
- });
69
- }
70
-
71
- /**
72
- * 여러 Queryable을 UNION으로 결합
73
- *
74
- * @param queries - UNION할 Queryable 배열 (최소 2)
75
- * @returns UNION Queryable
76
- * @throws 2 미만의 queryable이 전달된 경우
77
- */
78
- union<TData extends DataRecord>(...queries: Queryable<TData, any>[]): Queryable<TData, never> {
79
- if (queries.length < 2) {
80
- throw new ArgumentError("union 최소 2개의 queryable이 필요합니다.", {
81
- provided: queries.length,
82
- minimum: 2,
83
- });
84
- }
85
-
86
- const first = queries[0];
87
-
88
- return new Queryable({
89
- db: first.meta.db,
90
- from: queries, // Queryable[] 배열로 저장
91
- as: this._joinAlias,
92
- columns: transformColumnsAlias(first.meta.columns, this._joinAlias, ""),
93
- });
94
- }
95
- }
96
-
97
- /**
98
- * 재귀 CTE(Common Table Expression) 빌더
99
- *
100
- * recursive() 메서드 내부에서 사용되며, 재귀 쿼리의 본문을 정의하는 역할을 수행
101
- *
102
- * @template TBaseData - 기본 쿼리의 데이터 타입
103
- */
104
- class RecursiveQueryable<TBaseData extends DataRecord> {
105
- constructor(
106
- private readonly _baseQr: Queryable<TBaseData, any>,
107
- private readonly _cteName: string,
108
- ) {}
109
-
110
- /**
111
- * 재귀 쿼리의 대상 테이블을 지정
112
- *
113
- * @param table - 재귀할 대상 테이블
114
- * @returns self 속성이 추가된 Queryable (자기 참조용)
115
- */
116
- from<T extends TableBuilder<any, any>>(
117
- table: T,
118
- ): Queryable<T["$infer"] & { self?: TBaseData[] }, T> {
119
- const selfAlias = `${this._cteName}.self`;
120
-
121
- return queryable(this._baseQr.meta.db, table, this._cteName)().join(
122
- "self",
123
- () =>
124
- new Queryable<TBaseData, never>({
125
- db: this._baseQr.meta.db,
126
- from: this._cteName,
127
- as: selfAlias,
128
- columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
129
- isCustomColumns: false,
130
- }),
131
- ) as any;
132
- }
133
-
134
- /**
135
- * 재귀 쿼리의 컬럼을 직접 지정
136
- *
137
- * @param columns - 커스텀 컬럼 정의
138
- * @returns self 속성이 추가된 Queryable
139
- */
140
- select<R extends DataRecord>(
141
- columns: QueryableRecord<R>,
142
- ): Queryable<R & { self?: TBaseData[] }, never> {
143
- const selfAlias = `${this._cteName}.self`;
144
-
145
- return new Queryable<R, never>({
146
- db: this._baseQr.meta.db,
147
- as: this._cteName,
148
- columns,
149
- isCustomColumns: true,
150
- }).join(
151
- "self",
152
- () =>
153
- new Queryable<TBaseData, never>({
154
- db: this._baseQr.meta.db,
155
- from: this._cteName,
156
- as: selfAlias,
157
- columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
158
- isCustomColumns: false,
159
- }),
160
- );
161
- }
162
-
163
- /**
164
- * 여러 Queryable을 UNION으로 결합 (재귀 쿼리용)
165
- *
166
- * @param queries - UNION할 Queryable 배열 (최소 2)
167
- * @returns self 속성이 추가된 UNION Queryable
168
- * @throws 2 미만의 queryable이 전달된 경우
169
- */
170
- union<TData extends DataRecord>(
171
- ...queries: Queryable<TData, any>[]
172
- ): Queryable<TData & { self?: TBaseData[] }, never> {
173
- if (queries.length < 2) {
174
- throw new ArgumentError("union 최소 2개의 queryable이 필요합니다.", {
175
- provided: queries.length,
176
- minimum: 2,
177
- });
178
- }
179
-
180
- const first = queries[0];
181
-
182
- const selfAlias = `${this._cteName}.self`;
183
-
184
- return new Queryable<any, never>({
185
- db: first.meta.db,
186
- from: queries, // Queryable[] 배열로 저장
187
- as: this._cteName,
188
- columns: transformColumnsAlias(first.meta.columns, this._cteName, ""),
189
- }).join(
190
- "self",
191
- () =>
192
- new Queryable({
193
- db: this._baseQr.meta.db,
194
- from: this._cteName,
195
- as: selfAlias,
196
- columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
197
- isCustomColumns: false,
198
- }),
199
- ) as any;
200
- }
201
- }
202
-
203
- /**
204
- * 쿼리 빌더 클래스
205
- *
206
- * 테이블/뷰에 대한 SELECT, INSERT, UPDATE, DELETE 등의 쿼리를 체이닝 방식으로 구성
207
- *
208
- * @template TData - 쿼리 결과의 데이터 타입
209
- * @template TFrom - 원본 테이블 (CUD 작업에 필요)
210
- *
211
- * @example
212
- * ```typescript
213
- * // 기본 조회
214
- * const users = await db.user()
215
- * .where((u) => [expr.eq(u.isActive, true)])
216
- * .orderBy((u) => u.name)
217
- * .result();
218
- *
219
- * // JOIN 조회
220
- * const posts = await db.post()
221
- * .include((p) => p.user)
222
- * .result();
223
- *
224
- * // INSERT
225
- * await db.user().insert([{ name: "홍길동", email: "test@test.com" }]);
226
- * ```
227
- */
228
- export class Queryable<
229
- TData extends DataRecord,
230
- TFrom extends TableBuilder<any, any> | never, // CUD는 TableBuilder 지원하기 위함
231
- > {
232
- constructor(readonly meta: QueryableMeta<TData>) {}
233
-
234
- //#region ========== 옵션 - SELECT / DISTINCT / LOCK ==========
235
-
236
- /**
237
- * SELECT할 컬럼을 지정합니다.
238
- *
239
- * @param fn - 컬럼 매핑 함수. 원본 컬럼을 받아 컬럼 구조를 반환
240
- * @returns 새로운 컬럼 구조가 적용된 Queryable
241
- *
242
- * @example
243
- * ```typescript
244
- * db.user().select((u) => ({
245
- * userName: u.name,
246
- * userEmail: u.email,
247
- * }))
248
- * ```
249
- */
250
- select<R extends Record<string, any>>(
251
- fn: (columns: QueryableRecord<TData>) => R,
252
- ): Queryable<UnwrapQueryableRecord<R>, never> {
253
- if (Array.isArray(this.meta.from)) {
254
- const newFroms = this.meta.from.map((from) => from.select(fn));
255
- return new Queryable({
256
- ...this.meta,
257
- from: newFroms,
258
- columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
259
- }) as any;
260
- }
261
-
262
- const newColumns = fn(this.meta.columns);
263
-
264
- return new Queryable<any, never>({
265
- ...this.meta,
266
- columns: newColumns,
267
- isCustomColumns: true,
268
- }) as any;
269
- }
270
-
271
- /**
272
- * DISTINCT 옵션을 적용하여 중복 행을 제거
273
- *
274
- * @returns DISTINCT 적용된 Queryable
275
- *
276
- * @example
277
- * ```typescript
278
- * db.user()
279
- * .select((u) => ({ name: u.name }))
280
- * .distinct()
281
- * ```
282
- */
283
- distinct(): Queryable<TData, never> {
284
- if (Array.isArray(this.meta.from)) {
285
- const newFroms = this.meta.from.map((from) => from.distinct());
286
- return new Queryable({
287
- ...this.meta,
288
- from: newFroms,
289
- });
290
- }
291
-
292
- return new Queryable({
293
- ...this.meta,
294
- distinct: true,
295
- });
296
- }
297
-
298
- /**
299
- * 잠금(FOR UPDATE)을 적용
300
- *
301
- * 트랜잭션 내에서 선택된 행에 대한 배타적 잠금을 획득
302
- *
303
- * @returns 잠금이 적용된 Queryable
304
- *
305
- * @example
306
- * ```typescript
307
- * await db.connect(async () => {
308
- * const user = await db.user()
309
- * .where((u) => [expr.eq(u.id, 1)])
310
- * .lock()
311
- * .single();
312
- * });
313
- * ```
314
- */
315
- lock(): Queryable<TData, TFrom> {
316
- if (Array.isArray(this.meta.from)) {
317
- const newFroms = this.meta.from.map((from) => from.lock());
318
- return new Queryable({
319
- ...this.meta,
320
- from: newFroms,
321
- });
322
- }
323
-
324
- return new Queryable({
325
- ...this.meta,
326
- lock: true,
327
- });
328
- }
329
-
330
- //#endregion
331
-
332
- //#region ========== 제한 - TOP / LIMIT ==========
333
-
334
- /**
335
- * 상위 N개의 행만 조회 (ORDER BY 없이 사용 가능)
336
- *
337
- * @param count - 조회할
338
- * @returns TOP 적용된 Queryable
339
- *
340
- * @example
341
- * ```typescript
342
- * // 최신 사용자 10명
343
- * db.user()
344
- * .orderBy((u) => u.createdAt, "DESC")
345
- * .top(10)
346
- * ```
347
- */
348
- top(count: number): Queryable<TData, TFrom> {
349
- if (Array.isArray(this.meta.from)) {
350
- const newFroms = this.meta.from.map((from) => from.top(count));
351
- return new Queryable({
352
- ...this.meta,
353
- from: newFroms,
354
- });
355
- }
356
-
357
- return new Queryable({
358
- ...this.meta,
359
- top: count,
360
- });
361
- }
362
-
363
- /**
364
- * 페이지네이션을 위한 LIMIT/OFFSET 설정합니다.
365
- * 반드시 orderBy() 먼저 호출해야 합니다.
366
- *
367
- * @param skip - 건너뛸 (OFFSET)
368
- * @param take - 가져올 (LIMIT)
369
- * @returns 페이지네이션이 적용된 Queryable
370
- * @throws ORDER BY 절이 없으면 에러
371
- *
372
- * @example
373
- * ```typescript
374
- * db.user
375
- * .orderBy((u) => u.createdAt)
376
- * .limit(0, 20) // 20
377
- * ```
378
- */
379
- limit(skip: number, take: number): Queryable<TData, TFrom> {
380
- if (Array.isArray(this.meta.from)) {
381
- const newFroms = this.meta.from.map((from) => from.limit(skip, take));
382
- return new Queryable({
383
- ...this.meta,
384
- from: newFroms,
385
- });
386
- }
387
-
388
- if (!this.meta.orderBy) {
389
- throw new ArgumentError("limit() ORDER BY 절이 필요합니다.", {
390
- method: "limit",
391
- required: "orderBy",
392
- });
393
- }
394
-
395
- return new Queryable({
396
- ...this.meta,
397
- limit: [skip, take],
398
- });
399
- }
400
-
401
- //#endregion
402
-
403
- //#region ========== 정렬 - ORDER BY ==========
404
-
405
- /**
406
- * 정렬 조건을 추가합니다. 여러 호출하면 순서대로 적용됩니다.
407
- *
408
- * @param fn - 정렬 기준 컬럼을 반환하는 함수
409
- * @param orderBy - 정렬 방향 (ASC/DESC). 기본값: ASC
410
- * @returns 정렬 조건이 추가된 Queryable
411
- *
412
- * @example
413
- * ```typescript
414
- * db.user
415
- * .orderBy((u) => u.name) // 이름 ASC
416
- * .orderBy((u) => u.age, "DESC") // 나이 DESC
417
- * ```
418
- */
419
- orderBy(
420
- fn: (columns: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>,
421
- orderBy?: "ASC" | "DESC",
422
- ): Queryable<TData, TFrom> {
423
- if (Array.isArray(this.meta.from)) {
424
- const newFroms = this.meta.from.map((from) => from.orderBy(fn, orderBy));
425
- return new Queryable({
426
- ...this.meta,
427
- from: newFroms,
428
- });
429
- }
430
-
431
- const column = fn(this.meta.columns);
432
-
433
- return new Queryable({
434
- ...this.meta,
435
- orderBy: [...(this.meta.orderBy ?? []), [column, orderBy]],
436
- });
437
- }
438
-
439
- //#endregion
440
-
441
- //#region ========== 검색 - WHERE ==========
442
-
443
- /**
444
- * WHERE 조건을 추가합니다. 여러 번 호출하면 AND로 결합됩니다.
445
- *
446
- * @param predicate - 조건 배열을 반환하는 함수
447
- * @returns 조건이 추가된 Queryable
448
- *
449
- * @example
450
- * ```typescript
451
- * db.user
452
- * .where((u) => [expr.eq(u.isActive, true)])
453
- * .where((u) => [expr.gte(u.age, 18)])
454
- * ```
455
- */
456
- where(predicate: (columns: QueryableRecord<TData>) => WhereExprUnit[]): Queryable<TData, TFrom> {
457
- if (Array.isArray(this.meta.from)) {
458
- const newFroms = this.meta.from.map((from) => from.where(predicate));
459
- return new Queryable({
460
- ...this.meta,
461
- from: newFroms,
462
- });
463
- }
464
-
465
- const conditions = predicate(this.meta.columns);
466
-
467
- return new Queryable({
468
- ...this.meta,
469
- where: [...(this.meta.where ?? []), ...conditions],
470
- });
471
- }
472
-
473
- /**
474
- * 텍스트 검색을 수행
475
- *
476
- * 검색 문법은 {@link parseSearchQuery}를 참조
477
- * - 공백으로 구분된 단어는 OR 조건
478
- * - `+`로 시작하는 단어는 필수 포함 (AND 조건)
479
- * - `-`로 시작하는 단어는 제외 (NOT 조건)
480
- *
481
- * @param fn - 검색 대상 컬럼을 반환하는 함수
482
- * @param searchText - 검색 텍스트
483
- * @returns 검색 조건이 추가된 Queryable
484
- *
485
- * @example
486
- * ```typescript
487
- * db.user()
488
- * .search((u) => [u.name, u.email], "홍길동 -탈퇴")
489
- * ```
490
- */
491
- search(
492
- fn: (columns: QueryableRecord<TData>) => ExprUnit<string | undefined>[],
493
- searchText: string,
494
- ): Queryable<TData, TFrom> {
495
- if (Array.isArray(this.meta.from)) {
496
- const newFroms = this.meta.from.map((from) => from.search(fn, searchText));
497
- return new Queryable({
498
- ...this.meta,
499
- from: newFroms,
500
- });
501
- }
502
-
503
- if (searchText.trim() === "") {
504
- return this;
505
- }
506
-
507
- const columns = fn(this.meta.columns);
508
- const parsed = parseSearchQuery(searchText);
509
-
510
- const conditions: WhereExprUnit[] = [];
511
-
512
- // OR 조건:컬럼에서 pattern 하나라도 매치하면 OK
513
- if (parsed.or.length === 1) {
514
- const pattern = parsed.or[0];
515
- const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
516
- conditions.push(expr.or(columnMatches));
517
- } else if (parsed.or.length > 1) {
518
- const orConditions = parsed.or.map((pattern) => {
519
- const columnMatches = columns.map((col) =>
520
- expr.like(expr.lower(col), pattern.toLowerCase()),
521
- );
522
- return expr.or(columnMatches);
523
- });
524
- conditions.push(expr.or(orConditions));
525
- }
526
-
527
- // MUST 조건: 각 pattern이 어떤 컬럼에서든 매치해야 함 (AND)
528
- for (const pattern of parsed.must) {
529
- const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
530
- conditions.push(expr.or(columnMatches));
531
- }
532
-
533
- // NOT 조건: 모든 컬럼에서 매치하지 않아야 함 (AND NOT)
534
- for (const pattern of parsed.not) {
535
- const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
536
- conditions.push(expr.not(expr.or(columnMatches)));
537
- }
538
-
539
- if (conditions.length === 0) {
540
- return this;
541
- }
542
-
543
- return this.where(() => [expr.and(conditions)]);
544
- }
545
-
546
- //#endregion
547
-
548
- //#region ========== 그룹 - GROUP BY / HAVING ==========
549
-
550
- /**
551
- * GROUP BY 절을 추가
552
- *
553
- * @param fn - 그룹화 기준 컬럼을 반환하는 함수
554
- * @returns GROUP BY 적용된 Queryable
555
- *
556
- * @example
557
- * ```typescript
558
- * db.order()
559
- * .select((o) => ({
560
- * userId: o.userId,
561
- * totalAmount: expr.sum(o.amount),
562
- * }))
563
- * .groupBy((o) => [o.userId])
564
- * ```
565
- */
566
- groupBy(
567
- fn: (columns: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>[],
568
- ): Queryable<TData, never> {
569
- if (Array.isArray(this.meta.from)) {
570
- const newFroms = this.meta.from.map((from) => from.groupBy(fn));
571
- return new Queryable({
572
- ...this.meta,
573
- from: newFroms,
574
- });
575
- }
576
-
577
- const groupBy = fn(this.meta.columns);
578
-
579
- return new Queryable({ ...this.meta, groupBy });
580
- }
581
-
582
- /**
583
- * HAVING 절을 추가 (GROUP BY 후 필터링)
584
- *
585
- * @param predicate - 조건 배열을 반환하는 함수
586
- * @returns HAVING이 적용된 Queryable
587
- *
588
- * @example
589
- * ```typescript
590
- * db.order()
591
- * .select((o) => ({
592
- * userId: o.userId,
593
- * totalAmount: expr.sum(o.amount),
594
- * }))
595
- * .groupBy((o) => [o.userId])
596
- * .having((o) => [expr.gte(o.totalAmount, 10000)])
597
- * ```
598
- */
599
- having(predicate: (columns: QueryableRecord<TData>) => WhereExprUnit[]): Queryable<TData, never> {
600
- if (Array.isArray(this.meta.from)) {
601
- const newFroms = this.meta.from.map((from) => from.having(predicate));
602
- return new Queryable({
603
- ...this.meta,
604
- from: newFroms,
605
- });
606
- }
607
-
608
- const conditions = predicate(this.meta.columns);
609
-
610
- return new Queryable({
611
- ...this.meta,
612
- having: [...(this.meta.having ?? []), ...conditions],
613
- });
614
- }
615
-
616
- //#endregion
617
-
618
- //#region ========== 조인 - JOIN / JOIN SINGLE ==========
619
-
620
- /**
621
- * 1:N 관계의 LEFT OUTER JOIN을 수행 (배열로 결과 추가)
622
- *
623
- * @param as - 결과에 추가할 속성 이름
624
- * @param fwd - 조인 조건을 정의하는 콜백 함수
625
- * @returns 조인 결과가 배열로 추가된 Queryable
626
- *
627
- * @example
628
- * ```typescript
629
- * db.user()
630
- * .join("posts", (qr, u) =>
631
- * qr.from(Post)
632
- * .where((p) => [expr.eq(p.userId, u.id)])
633
- * )
634
- * // 결과: { id, name, posts: [{ id, title }, ...] }
635
- * ```
636
- */
637
- join<A extends string, R extends DataRecord>(
638
- as: A,
639
- fwd: (qr: JoinQueryable, cols: QueryableRecord<TData>) => Queryable<R, any>,
640
- ): Queryable<TData & { [K in A]?: R[] }, TFrom> {
641
- if (Array.isArray(this.meta.from)) {
642
- const newFroms = this.meta.from.map((from) => from.join(as, fwd));
643
- return new Queryable({
644
- ...this.meta,
645
- from: newFroms,
646
- columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
647
- });
648
- }
649
-
650
- // 1. join alias 생성
651
- const joinAlias = `${this.meta.as}.${as}`;
652
-
653
- // 2. target → Queryable 변환 (alias 전달)
654
- const joinQr = new JoinQueryable(this.meta.db, joinAlias);
655
-
656
- // 3. fwd 실행 (where 등 조건 추가된 Queryable 반환)
657
- const resultQr = fwd(joinQr, this.meta.columns);
658
-
659
- // 4. 새 columns에 join 결과 추가
660
- const joinColumns = transformColumnsAlias(resultQr.meta.columns, joinAlias);
661
-
662
- return new Queryable({
663
- ...this.meta,
664
- columns: {
665
- ...this.meta.columns,
666
- [as]: [joinColumns],
667
- } as QueryableRecord<any>,
668
- isCustomColumns: true,
669
- joins: [...(this.meta.joins ?? []), { queryable: resultQr, isSingle: false }],
670
- }) as any;
671
- }
672
-
673
- /**
674
- * N:1 또는 1:1 관계의 LEFT OUTER JOIN을 수행 (단일 객체로 결과 추가)
675
- *
676
- * @param as - 결과에 추가할 속성 이름
677
- * @param fwd - 조인 조건을 정의하는 콜백 함수
678
- * @returns 조인 결과가 단일 객체로 추가된 Queryable
679
- *
680
- * @example
681
- * ```typescript
682
- * db.post()
683
- * .joinSingle("user", (qr, p) =>
684
- * qr.from(User)
685
- * .where((u) => [expr.eq(u.id, p.userId)])
686
- * )
687
- * // 결과: { id, title, user: { id, name } | undefined }
688
- * ```
689
- */
690
- joinSingle<A extends string, R extends DataRecord>(
691
- as: A,
692
- fwd: (qr: JoinQueryable, cols: QueryableRecord<TData>) => Queryable<R, any>,
693
- ): Queryable<
694
- { [K in keyof TData as K extends A ? never : K]: TData[K] } & { [K in A]?: R },
695
- TFrom
696
- > {
697
- if (Array.isArray(this.meta.from)) {
698
- const newFroms = this.meta.from.map((from) => from.joinSingle(as, fwd));
699
- return new Queryable({
700
- ...this.meta,
701
- from: newFroms,
702
- columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
703
- });
704
- }
705
-
706
- // 1. join alias 생성
707
- const joinAlias = `${this.meta.as}.${as}`;
708
-
709
- // 2. target → Queryable 변환 (alias 전달)
710
- const joinQr = new JoinQueryable(this.meta.db, joinAlias);
711
-
712
- // 3. fwd 실행 (where 등 조건 추가된 Queryable 반환)
713
- const resultQr = fwd(joinQr, this.meta.columns);
714
-
715
- // 4. 새 columns에 join 결과 추가
716
- const joinColumns = transformColumnsAlias(resultQr.meta.columns, joinAlias);
717
-
718
- return new Queryable({
719
- ...this.meta,
720
- columns: {
721
- ...this.meta.columns,
722
- [as]: joinColumns,
723
- } as QueryableRecord<any>,
724
- isCustomColumns: true,
725
- joins: [...(this.meta.joins ?? []), { queryable: resultQr, isSingle: true }],
726
- }) as any;
727
- }
728
-
729
- //#endregion
730
-
731
- //#region ========== 조인 - INCLUDE ==========
732
-
733
- /**
734
- * 관계된 테이블을 자동으로 JOIN합니다.
735
- * TableBuilder에 정의된 FK/FKT 관계를 기반으로 동작합니다.
736
- *
737
- * @param fn - 포함할 관계를 선택하는 함수 (PathProxy를 통해 타입 체크됨)
738
- * @returns JOIN이 추가된 Queryable
739
- * @throws 관계가 정의되지 않은 경우 에러
740
- *
741
- * @example
742
- * ```typescript
743
- * // 단일 관계 포함
744
- * db.post.include((p) => p.user)
745
- *
746
- * // 중첩 관계 포함
747
- * db.post.include((p) => p.user.company)
748
- *
749
- * // 다중 관계 포함
750
- * db.user
751
- * .include((u) => u.company)
752
- * .include((u) => u.posts)
753
- * ```
754
- */
755
- include(fn: (item: PathProxy<TData>) => PathProxy<any>): Queryable<TData, TFrom> {
756
- if (Array.isArray(this.meta.from)) {
757
- const newFroms = this.meta.from.map((from) => from.include(fn));
758
- return new Queryable({
759
- ...this.meta,
760
- from: newFroms,
761
- columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
762
- });
763
- }
764
-
765
- const proxy = createPathProxy<TData>();
766
- const result = fn(proxy);
767
- const relationChain = result[PATH_SYMBOL].join(".");
768
-
769
- return this._include(relationChain);
770
- }
771
-
772
- private _include(relationChain: string): Queryable<TData, TFrom> {
773
- const relationNames = relationChain.split(".");
774
-
775
- let result: Queryable<any, any> = this;
776
- let currentTable = this.meta.from;
777
- const chainParts: string[] = [];
778
-
779
- for (const relationName of relationNames) {
780
- if (!(currentTable instanceof TableBuilder)) {
781
- throw new Error("include() TableBuilder 기반 queryable에서만 사용할 있습니다.");
782
- }
783
-
784
- const parentChain = chainParts.join(".");
785
- chainParts.push(relationName);
786
-
787
- // 이미 JOIN된 경우 중복 추가 방지
788
- const targetAlias = `${result.meta.as}.${chainParts.join(".")}`;
789
- const existingJoin = result.meta.joins?.find((j) => j.queryable.meta.as === targetAlias);
790
- if (existingJoin) {
791
- // 기존 JOIN의 테이블로 currentTable 업데이트 후 continue
792
- const existingFrom = existingJoin.queryable.meta.from;
793
- if (existingFrom instanceof TableBuilder) {
794
- currentTable = existingFrom;
795
- }
796
- continue;
797
- }
798
-
799
- const relationDef = currentTable.meta.relations?.[relationName];
800
- if (relationDef == null) {
801
- throw new Error(`관계 '${relationName}'을(를) 찾을 수 없습니다.`);
802
- }
803
-
804
- if (relationDef instanceof ForeignKeyBuilder || relationDef instanceof RelationKeyBuilder) {
805
- // FK/RelationKey (N:1): Post.user → User
806
- // 조건: Post.userId = User.id
807
- const targetTable = relationDef.meta.targetFn();
808
- const fkColKeys = relationDef.meta.columns;
809
- const targetPkColKeys = getMatchedPrimaryKeys(fkColKeys, targetTable);
810
-
811
- result = result.joinSingle(chainParts.join("."), (joinQr, parentCols) => {
812
- const qr = joinQr.from(targetTable);
813
-
814
- // FKT join은 배열로 저장되므로 배열인 경우 첫 번째 요소 사용
815
- const srcColsRaw = parentChain ? parentCols[parentChain] : parentCols;
816
- const srcCols = (
817
- Array.isArray(srcColsRaw) ? srcColsRaw[0] : srcColsRaw
818
- ) as QueryableRecord<any>;
819
- const conditions: WhereExprUnit[] = [];
820
-
821
- for (let i = 0; i < fkColKeys.length; i++) {
822
- const fkCol = srcCols[fkColKeys[i]];
823
- const pkCol = qr.meta.columns[targetPkColKeys[i]] as ExprUnit<ColumnPrimitive>;
824
-
825
- conditions.push(expr.eq(pkCol, fkCol));
826
- }
827
-
828
- return qr.where(() => conditions);
829
- });
830
-
831
- currentTable = targetTable;
832
- } else if (
833
- relationDef instanceof ForeignKeyTargetBuilder ||
834
- relationDef instanceof RelationKeyTargetBuilder
835
- ) {
836
- // FKT/RelationKeyTarget (1:N 또는 1:1): User.posts → Post[]
837
- // 조건: Post.userId = User.id
838
- const targetTable = relationDef.meta.targetTableFn();
839
- const fkRelName = relationDef.meta.relationName;
840
- const sourceFk = targetTable.meta.relations?.[fkRelName];
841
- if (!(sourceFk instanceof ForeignKeyBuilder) && !(sourceFk instanceof RelationKeyBuilder)) {
842
- throw new Error(
843
- `'${relationName}'이 참조하는 '${fkRelName}'이(가) ` +
844
- `${targetTable.meta.name} 테이블의 유효한 ForeignKey/RelationKey가 아닙니다.`,
845
- );
846
- }
847
- const sourceTable = targetTable;
848
- const isSingle: boolean = relationDef.meta.isSingle ?? false;
849
-
850
- const fkColKeys = sourceFk.meta.columns;
851
- const pkColKeys = getMatchedPrimaryKeys(fkColKeys, currentTable);
852
-
853
- const buildJoin = (joinQr: JoinQueryable, parentCols: QueryableRecord<DataRecord>) => {
854
- const qr = joinQr.from(sourceTable);
855
-
856
- // FKT join은 배열로 저장되므로 배열인 경우 첫 번째 요소 사용
857
- const srcColsRaw = parentChain ? parentCols[parentChain] : parentCols;
858
- const srcCols = (
859
- Array.isArray(srcColsRaw) ? srcColsRaw[0] : srcColsRaw
860
- ) as QueryableRecord<any>;
861
- const conditions: WhereExprUnit[] = [];
862
-
863
- for (let i = 0; i < fkColKeys.length; i++) {
864
- const pkCol = srcCols[pkColKeys[i]] as ExprUnit<ColumnPrimitive>;
865
- const fkCol = qr.meta.columns[fkColKeys[i]] as ExprUnit<ColumnPrimitive>;
866
-
867
- conditions.push(expr.eq(fkCol, pkCol));
868
- }
869
-
870
- return qr.where(() => conditions);
871
- };
872
-
873
- result = isSingle
874
- ? result.joinSingle(chainParts.join("."), buildJoin)
875
- : result.join(chainParts.join("."), buildJoin);
876
-
877
- currentTable = sourceTable;
878
- }
879
- }
880
-
881
- return result as Queryable<TData, TFrom>;
882
- }
883
-
884
- //#endregion
885
-
886
- //#region ========== 서브쿼리 - WRAP / UNION ==========
887
-
888
- /**
889
- * 현재 Queryable을 서브쿼리로 감싸기
890
- *
891
- * distinct() 또는 groupBy() 후 count() 사용 시 필요
892
- *
893
- * @returns 서브쿼리로 감싸진 Queryable
894
- *
895
- * @example
896
- * ```typescript
897
- * // DISTINCT 후 카운트
898
- * const count = await db.user()
899
- * .select((u) => ({ name: u.name }))
900
- * .distinct()
901
- * .wrap()
902
- * .count();
903
- * ```
904
- */
905
- wrap(): Queryable<TData, never> {
906
- // 현재 Queryable을 서브쿼리로 감싸기
907
- const wrapAlias = this.meta.db.getNextAlias();
908
- return new Queryable({
909
- db: this.meta.db,
910
- from: this,
911
- as: wrapAlias,
912
- columns: transformColumnsAlias<TData>(this.meta.columns, wrapAlias, ""),
913
- });
914
- }
915
-
916
- /**
917
- * 여러 Queryable을 UNION으로 결합 (중복 제거)
918
- *
919
- * @param queries - UNION할 Queryable 배열 (최소 2)
920
- * @returns UNION Queryable
921
- * @throws 2 미만의 queryable이 전달된 경우
922
- *
923
- * @example
924
- * ```typescript
925
- * const combined = Queryable.union(
926
- * db.user().where((u) => [expr.eq(u.type, "admin")]),
927
- * db.user().where((u) => [expr.eq(u.type, "manager")]),
928
- * );
929
- * ```
930
- */
931
- static union<TData extends DataRecord>(
932
- ...queries: Queryable<TData, any>[]
933
- ): Queryable<TData, never> {
934
- if (queries.length < 2) {
935
- throw new ArgumentError("union 최소 2개의 queryable이 필요합니다.", {
936
- provided: queries.length,
937
- minimum: 2,
938
- });
939
- }
940
-
941
- const first = queries[0];
942
- const unionAlias = first.meta.db.getNextAlias();
943
- return new Queryable({
944
- db: first.meta.db,
945
- from: queries, // Queryable[] 배열로 저장
946
- as: unionAlias,
947
- columns: transformColumnsAlias(first.meta.columns, unionAlias, ""),
948
- });
949
- }
950
-
951
- //#endregion
952
-
953
- //#region ========== 재귀 - WITH RECURSIVE ==========
954
-
955
- /**
956
- * 재귀 CTE(Common Table Expression)를 생성
957
- *
958
- * 계층 구조 데이터(조직도, 카테고리 트리 등)를 조회할 때 사용
959
- *
960
- * @param fwd - 재귀 부분을 정의하는 콜백 함수
961
- * @returns 재귀 CTE가 적용된 Queryable
962
- *
963
- * @example
964
- * ```typescript
965
- * // 조직도 계층 조회
966
- * db.employee()
967
- * .where((e) => [expr.null(e.managerId)]) // 루트 노드
968
- * .recursive((cte) =>
969
- * cte.from(Employee)
970
- * .where((e) => [expr.eq(e.managerId, e.self[0].id)])
971
- * )
972
- * ```
973
- */
974
- recursive(
975
- fwd: (qr: RecursiveQueryable<TData>) => Queryable<TData, any>,
976
- ): Queryable<TData, never> {
977
- if (Array.isArray(this.meta.from)) {
978
- const newFroms = this.meta.from.map((from) => from.recursive(fwd));
979
- return new Queryable({
980
- ...this.meta,
981
- from: newFroms,
982
- columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
983
- });
984
- }
985
- // 동적 CTE 이름 생성
986
- const cteName = this.meta.db.getNextAlias();
987
-
988
- // 2. target → Queryable 변환 (CTE 이름 전달)
989
- const cteQr = new RecursiveQueryable(this, cteName);
990
-
991
- // 3. fwd 실행 (where 등 조건 추가된 Queryable 반환)
992
- const resultQr = fwd(cteQr);
993
-
994
- return new Queryable({
995
- db: this.meta.db,
996
- as: this.meta.as,
997
- from: cteName,
998
- columns: transformColumnsAlias(this.meta.columns, this.meta.as, ""),
999
- with: {
1000
- name: cteName,
1001
- base: this as any, // 순환 참조 타입 추론 차단
1002
- recursive: resultQr,
1003
- },
1004
- });
1005
- }
1006
-
1007
- //#endregion
1008
-
1009
- //#region ========== [쿼리] 조회 - SELECT ==========
1010
-
1011
- /**
1012
- * SELECT 쿼리를 실행하고 결과 배열을 반환
1013
- *
1014
- * @returns 쿼리 결과 배열
1015
- *
1016
- * @example
1017
- * ```typescript
1018
- * const users = await db.user()
1019
- * .where((u) => [expr.eq(u.isActive, true)])
1020
- * .result();
1021
- * ```
1022
- */
1023
- async result(): Promise<TData[]> {
1024
- const results = await this.meta.db.executeDefs<TData>(
1025
- [this.getSelectQueryDef()],
1026
- [this.getResultMeta()],
1027
- );
1028
- return results[0];
1029
- }
1030
-
1031
- /**
1032
- * 단일 결과를 반환 (2개 이상이면 에러)
1033
- *
1034
- * @returns 단일 결과 또는 undefined
1035
- * @throws 2개 이상의 결과가 반환된 경우
1036
- *
1037
- * @example
1038
- * ```typescript
1039
- * const user = await db.user()
1040
- * .where((u) => [expr.eq(u.id, 1)])
1041
- * .single();
1042
- * ```
1043
- */
1044
- async single(): Promise<TData | undefined> {
1045
- const result = await this.top(2).result();
1046
- if (result.length > 1) {
1047
- throw new ArgumentError("단일 결과를 기대했지만 2개 이상의 결과가 반환되었습니다.", {
1048
- table: this._getSourceName(),
1049
- resultCount: result.length,
1050
- });
1051
- }
1052
- return result[0];
1053
- }
1054
-
1055
- /**
1056
- * 쿼리 소스 이름 반환 (에러 메시지용)
1057
- */
1058
- private _getSourceName(): string {
1059
- const from = this.meta.from;
1060
- if (from instanceof TableBuilder || from instanceof ViewBuilder) {
1061
- return from.meta.name;
1062
- }
1063
- if (typeof from === "string") {
1064
- return from;
1065
- }
1066
- return this.meta.as;
1067
- }
1068
-
1069
- /**
1070
- * 첫 번째 결과를 반환 (여러 개여도 첫 번째만)
1071
- *
1072
- * @returns 첫 번째 결과 또는 undefined
1073
- *
1074
- * @example
1075
- * ```typescript
1076
- * const latestUser = await db.user()
1077
- * .orderBy((u) => u.createdAt, "DESC")
1078
- * .first();
1079
- * ```
1080
- */
1081
- async first(): Promise<TData | undefined> {
1082
- const results = await this.top(1).result();
1083
- return results[0];
1084
- }
1085
-
1086
- /**
1087
- * 결과 수를 반환
1088
- *
1089
- * @param fwd - 카운트할 컬럼을 지정하는 함수 (선택)
1090
- * @returns
1091
- * @throws distinct() 또는 groupBy() 후 직접 호출 시 에러 (wrap() 필요)
1092
- *
1093
- * @example
1094
- * ```typescript
1095
- * const count = await db.user()
1096
- * .where((u) => [expr.eq(u.isActive, true)])
1097
- * .count();
1098
- * ```
1099
- */
1100
- async count(fwd?: (cols: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>): Promise<number> {
1101
- if (this.meta.distinct) {
1102
- throw new Error("distinct() 후에는 count() 사용할 없습니다. wrap() 먼저 사용하세요.");
1103
- }
1104
- if (this.meta.groupBy) {
1105
- throw new Error("groupBy() 후에는 count() 사용할 없습니다. wrap() 먼저 사용하세요.");
1106
- }
1107
-
1108
- const countQr = fwd
1109
- ? this.select((c) => ({ cnt: expr.count(fwd(c)) }))
1110
- : this.select(() => ({ cnt: expr.count() }));
1111
-
1112
- const result = await countQr.single();
1113
-
1114
- return result?.cnt ?? 0;
1115
- }
1116
-
1117
- /**
1118
- * 조건에 맞는 데이터 존재 여부를 확인
1119
- *
1120
- * @returns 존재하면 true, 없으면 false
1121
- *
1122
- * @example
1123
- * ```typescript
1124
- * const hasAdmin = await db.user()
1125
- * .where((u) => [expr.eq(u.role, "admin")])
1126
- * .exists();
1127
- * ```
1128
- */
1129
- async exists(): Promise<boolean> {
1130
- const count = await this.count();
1131
- return count > 0;
1132
- }
1133
-
1134
- getSelectQueryDef(): SelectQueryDef {
1135
- return objClearUndefined({
1136
- type: "select",
1137
- from: this._buildFromDef(),
1138
- as: this.meta.as,
1139
- select: this.meta.isCustomColumns ? this._buildSelectDef(this.meta.columns, "") : undefined,
1140
- distinct: this.meta.distinct,
1141
- top: this.meta.top,
1142
- lock: this.meta.lock,
1143
- where: this.meta.where?.map((w) => w.expr),
1144
- joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1145
- orderBy: this.meta.orderBy?.map((o) => (o[1] ? [o[0].expr, o[1]] : [o[0].expr])),
1146
- limit: this.meta.limit,
1147
- groupBy: this.meta.groupBy?.map((g) => g.expr),
1148
- having: this.meta.having?.map((w) => w.expr),
1149
- with: this.meta.with
1150
- ? {
1151
- name: this.meta.with.name,
1152
- base: this.meta.with.base.getSelectQueryDef(),
1153
- recursive: this.meta.with.recursive.getSelectQueryDef(),
1154
- }
1155
- : undefined,
1156
- });
1157
- }
1158
-
1159
- private _buildFromDef():
1160
- | QueryDefObjectName
1161
- | SelectQueryDef
1162
- | SelectQueryDef[]
1163
- | string
1164
- | undefined {
1165
- const from = this.meta.from;
1166
-
1167
- if (from instanceof TableBuilder || from instanceof ViewBuilder) {
1168
- return this.meta.db.getQueryDefObjectName(from);
1169
- } else if (from instanceof Queryable) {
1170
- return from.getSelectQueryDef();
1171
- } else if (Array.isArray(from)) {
1172
- return from.map((qr) => qr.getSelectQueryDef());
1173
- }
1174
-
1175
- return from;
1176
- }
1177
-
1178
- private _buildSelectDef(
1179
- columns: QueryableRecord<any> | QueryableWriteRecord<any>,
1180
- prefix: string,
1181
- ): Record<string, Expr> {
1182
- const result: Record<string, Expr> = {};
1183
-
1184
- for (const [key, val] of Object.entries(columns)) {
1185
- const fullKey = prefix ? `${prefix}.${key}` : key;
1186
-
1187
- if (val instanceof ExprUnit) {
1188
- result[fullKey] = val.expr;
1189
- } else if (Array.isArray(val)) {
1190
- if (val.length > 0) {
1191
- Object.assign(result, this._buildSelectDef(val[0], fullKey));
1192
- }
1193
- } else if (typeof val === "object" && val != null) {
1194
- Object.assign(result, this._buildSelectDef(val, fullKey));
1195
- } else {
1196
- // Plain value (string, number, boolean, etc.) — convert to Expr
1197
- result[fullKey] = expr.toExpr(val);
1198
- }
1199
- }
1200
-
1201
- return result;
1202
- }
1203
-
1204
- private _buildJoinDefs(joins: QueryableMetaJoin[]): SelectQueryDefJoin[] {
1205
- const result: SelectQueryDefJoin[] = [];
1206
-
1207
- for (const join of joins) {
1208
- const joinQr = join.queryable;
1209
- const selectDef = joinQr.getSelectQueryDef();
1210
-
1211
- const joinDef: SelectQueryDefJoin = {
1212
- ...selectDef,
1213
- as: joinQr.meta.as,
1214
- isSingle: join.isSingle,
1215
- };
1216
-
1217
- result.push(joinDef);
1218
- }
1219
-
1220
- return result;
1221
- }
1222
-
1223
- getResultMeta(outputColumns?: string[]): ResultMeta {
1224
- const columns: Record<string, ColumnPrimitiveStr> = {};
1225
- const joins: Record<string, { isSingle: boolean }> = {};
1226
-
1227
- const buildResultMeta = (cols: QueryableRecord<any>, prefix: string) => {
1228
- for (const [key, val] of Object.entries(cols)) {
1229
- const fullKey = prefix ? `${prefix}.${key}` : key;
1230
- if (outputColumns && !outputColumns.includes(fullKey)) continue;
1231
-
1232
- if (val instanceof ExprUnit) {
1233
- // primitive 컬럼
1234
- columns[fullKey] = val.dataType;
1235
- } else if (Array.isArray(val)) {
1236
- // 배열 (1:N 관계)
1237
- if (val.length > 0) {
1238
- joins[fullKey] = { isSingle: false };
1239
- buildResultMeta(val[0], fullKey);
1240
- }
1241
- } else if (typeof val === "object") {
1242
- // 단일 객체 (N:1, 1:1 관계)
1243
- joins[fullKey] = { isSingle: true };
1244
- buildResultMeta(val, fullKey);
1245
- }
1246
- }
1247
- };
1248
-
1249
- buildResultMeta(this.meta.columns, "");
1250
-
1251
- return { columns, joins };
1252
- }
1253
-
1254
- //#endregion
1255
-
1256
- //#region ========== [쿼리] 삽입 - INSERT ==========
1257
-
1258
- /**
1259
- * INSERT 쿼리를 실행
1260
- *
1261
- * MSSQL의 1000개 제한을 위해 자동으로 1000개씩 청크로 분할하여 실행
1262
- *
1263
- * @param records - 삽입할 레코드 배열
1264
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1265
- * @returns outputColumns 지정 시 삽입된 레코드 배열 반환
1266
- *
1267
- * @example
1268
- * ```typescript
1269
- * // 단순 삽입
1270
- * await db.user().insert([
1271
- * { name: "홍길동", email: "hong@test.com" },
1272
- * ]);
1273
- *
1274
- * // 삽입 후 ID 반환
1275
- * const [inserted] = await db.user().insert(
1276
- * [{ name: "홍길동" }],
1277
- * ["id"],
1278
- * );
1279
- * ```
1280
- */
1281
- async insert(records: TFrom["$inferInsert"][]): Promise<void>;
1282
- async insert<K extends keyof TFrom["$inferColumns"] & string>(
1283
- records: TFrom["$inferInsert"][],
1284
- outputColumns: K[],
1285
- ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1286
- async insert<K extends keyof TFrom["$inferColumns"] & string>(
1287
- records: TFrom["$inferInsert"][],
1288
- outputColumns?: K[],
1289
- ): Promise<Pick<TFrom["$inferColumns"], K>[] | void> {
1290
- if (records.length === 0) {
1291
- return outputColumns ? [] : undefined;
1292
- }
1293
-
1294
- // MSSQL 1000개 제한을 위해 청크 분할
1295
- const CHUNK_SIZE = 1000;
1296
- const allResults: Pick<TFrom["$inferColumns"], K>[] = [];
1297
-
1298
- for (let i = 0; i < records.length; i += CHUNK_SIZE) {
1299
- const chunk = records.slice(i, i + CHUNK_SIZE);
1300
-
1301
- const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1302
- [this.getInsertQueryDef(chunk, outputColumns)],
1303
- outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1304
- );
1305
-
1306
- if (outputColumns) {
1307
- allResults.push(...results[0]);
1308
- }
1309
- }
1310
-
1311
- if (outputColumns) {
1312
- return allResults;
1313
- }
1314
- }
1315
-
1316
- /**
1317
- * WHERE 조건에 맞는 데이터가 없으면 INSERT
1318
- *
1319
- * @param record - 삽입할 레코드
1320
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1321
- * @returns outputColumns 지정 시 삽입된 레코드 반환
1322
- *
1323
- * @example
1324
- * ```typescript
1325
- * await db.user()
1326
- * .where((u) => [expr.eq(u.email, "test@test.com")])
1327
- * .insertIfNotExists({ name: "테스트", email: "test@test.com" });
1328
- * ```
1329
- */
1330
- async insertIfNotExists(record: TFrom["$inferInsert"]): Promise<void>;
1331
- async insertIfNotExists<K extends keyof TFrom["$inferColumns"] & string>(
1332
- record: TFrom["$inferInsert"],
1333
- outputColumns: K[],
1334
- ): Promise<Pick<TFrom["$inferColumns"], K>>;
1335
- async insertIfNotExists<K extends keyof TFrom["$inferColumns"] & string>(
1336
- record: TFrom["$inferInsert"],
1337
- outputColumns?: K[],
1338
- ): Promise<Pick<TFrom["$inferColumns"], K> | void> {
1339
- const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1340
- [this.getInsertIfNotExistsQueryDef(record)],
1341
- outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1342
- );
1343
-
1344
- if (outputColumns) {
1345
- return results[0][0];
1346
- }
1347
- }
1348
-
1349
- /**
1350
- * INSERT INTO ... SELECT (현재 SELECT 결과를 다른 테이블에 INSERT)
1351
- *
1352
- * @param targetTable - 삽입 대상 테이블
1353
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1354
- * @returns outputColumns 지정 시 삽입된 레코드 배열 반환
1355
- *
1356
- * @example
1357
- * ```typescript
1358
- * await db.user()
1359
- * .select((u) => ({ name: u.name, createdAt: u.createdAt }))
1360
- * .where((u) => [expr.eq(u.isArchived, false)])
1361
- * .insertInto(ArchivedUser);
1362
- * ```
1363
- */
1364
- async insertInto<TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>>(
1365
- targetTable: TTable,
1366
- ): Promise<void>;
1367
- async insertInto<
1368
- TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>,
1369
- TOut extends keyof TTable["$inferColumns"] & string,
1370
- >(targetTable: TTable, outputColumns: TOut[]): Promise<Pick<TData, TOut>[]>;
1371
- async insertInto<
1372
- TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>,
1373
- TOut extends keyof TTable["$inferColumns"] & string,
1374
- >(targetTable: TTable, outputColumns?: TOut[]): Promise<Pick<TData, TOut>[] | void> {
1375
- const results = await this.meta.db.executeDefs<Pick<TData, TOut>>(
1376
- [this.getInsertIntoQueryDef(targetTable)],
1377
- outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1378
- );
1379
-
1380
- if (outputColumns) {
1381
- return results[0];
1382
- }
1383
- }
1384
-
1385
- getInsertQueryDef(
1386
- records: TFrom["$inferInsert"][],
1387
- outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1388
- ): InsertQueryDef {
1389
- const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1390
- const outputDef = this._getCudOutputDef();
1391
-
1392
- // AI 컬럼에 명시적 값이 있으면 overrideIdentity 설정
1393
- const overrideIdentity =
1394
- outputDef.aiColName != null &&
1395
- records.some((r) => (r as Record<string, unknown>)[outputDef.aiColName!] !== undefined);
1396
-
1397
- return objClearUndefined({
1398
- type: "insert",
1399
- table: this.meta.db.getQueryDefObjectName(from),
1400
- records,
1401
- overrideIdentity: overrideIdentity || undefined,
1402
- output: outputColumns
1403
- ? {
1404
- columns: outputColumns,
1405
- pkColNames: outputDef.pkColNames,
1406
- aiColName: outputDef.aiColName,
1407
- }
1408
- : undefined,
1409
- });
1410
- }
1411
-
1412
- getInsertIfNotExistsQueryDef(
1413
- record: TFrom["$inferInsert"],
1414
- outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1415
- ): InsertIfNotExistsQueryDef {
1416
- const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1417
- const outputDef = this._getCudOutputDef();
1418
-
1419
- const { select: _, ...existsSelectQuery } = this.getSelectQueryDef();
1420
-
1421
- return objClearUndefined({
1422
- type: "insertIfNotExists",
1423
- table: this.meta.db.getQueryDefObjectName(from),
1424
- record,
1425
- existsSelectQuery,
1426
- output: outputColumns
1427
- ? {
1428
- columns: outputColumns,
1429
- pkColNames: outputDef.pkColNames,
1430
- aiColName: outputDef.aiColName,
1431
- }
1432
- : undefined,
1433
- });
1434
- }
1435
-
1436
- getInsertIntoQueryDef<TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>>(
1437
- targetTable: TTable,
1438
- outputColumns?: (keyof TTable["$inferColumns"] & string)[],
1439
- ): InsertIntoQueryDef {
1440
- const outputDef = this._getCudOutputDef();
1441
-
1442
- return objClearUndefined({
1443
- type: "insertInto",
1444
- table: this.meta.db.getQueryDefObjectName(targetTable),
1445
- recordsSelectQuery: this.getSelectQueryDef(),
1446
- output: outputColumns
1447
- ? {
1448
- columns: outputColumns,
1449
- pkColNames: outputDef.pkColNames,
1450
- aiColName: outputDef.aiColName,
1451
- }
1452
- : undefined,
1453
- });
1454
- }
1455
-
1456
- //#endregion
1457
-
1458
- //#region ========== [쿼리] 수정 - UPDATE / DELETE ==========
1459
-
1460
- /**
1461
- * UPDATE 쿼리를 실행
1462
- *
1463
- * @param recordFwd - 업데이트할 컬럼과 값을 반환하는 함수
1464
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1465
- * @returns outputColumns 지정 시 업데이트된 레코드 배열 반환
1466
- *
1467
- * @example
1468
- * ```typescript
1469
- * // 단순 업데이트
1470
- * await db.user()
1471
- * .where((u) => [expr.eq(u.id, 1)])
1472
- * .update((u) => ({
1473
- * name: expr.val("string", "새이름"),
1474
- * updatedAt: expr.val("DateTime", DateTime.now()),
1475
- * }));
1476
- *
1477
- * // 기존 참조
1478
- * await db.product()
1479
- * .update((p) => ({
1480
- * price: expr.mul(p.price, expr.val("number", 1.1)),
1481
- * }));
1482
- * ```
1483
- */
1484
- async update(
1485
- recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1486
- ): Promise<void>;
1487
- async update<K extends keyof TFrom["$columns"] & string>(
1488
- recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1489
- outputColumns: K[],
1490
- ): Promise<Pick<TFrom["$columns"], K>[]>;
1491
- async update<K extends keyof TFrom["$columns"] & string>(
1492
- recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1493
- outputColumns?: K[],
1494
- ): Promise<Pick<TFrom["$columns"], K>[] | void> {
1495
- const results = await this.meta.db.executeDefs<Pick<TFrom["$columns"], K>>(
1496
- [this.getUpdateQueryDef(recordFwd, outputColumns)],
1497
- outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1498
- );
1499
-
1500
- if (outputColumns) {
1501
- return results[0];
1502
- }
1503
- }
1504
-
1505
- /**
1506
- * DELETE 쿼리를 실행
1507
- *
1508
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1509
- * @returns outputColumns 지정 시 삭제된 레코드 배열 반환
1510
- *
1511
- * @example
1512
- * ```typescript
1513
- * // 단순 삭제
1514
- * await db.user()
1515
- * .where((u) => [expr.eq(u.id, 1)])
1516
- * .delete();
1517
- *
1518
- * // 삭제된 데이터 반환
1519
- * const deleted = await db.user()
1520
- * .where((u) => [expr.eq(u.isExpired, true)])
1521
- * .delete(["id", "name"]);
1522
- * ```
1523
- */
1524
- async delete(): Promise<void>;
1525
- async delete<K extends keyof TFrom["$columns"] & string>(
1526
- outputColumns: K[],
1527
- ): Promise<Pick<TFrom["$columns"], K>[]>;
1528
- async delete<K extends keyof TFrom["$columns"] & string>(
1529
- outputColumns?: K[],
1530
- ): Promise<Pick<TFrom["$columns"], K>[] | void> {
1531
- const results = await this.meta.db.executeDefs<Pick<TFrom["$columns"], K>>(
1532
- [this.getDeleteQueryDef(outputColumns)],
1533
- outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1534
- );
1535
-
1536
- if (outputColumns) {
1537
- return results[0];
1538
- }
1539
- }
1540
-
1541
- getUpdateQueryDef(
1542
- recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1543
- outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1544
- ): UpdateQueryDef {
1545
- const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1546
- const outputDef = this._getCudOutputDef();
1547
-
1548
- return objClearUndefined({
1549
- type: "update",
1550
- table: this.meta.db.getQueryDefObjectName(from),
1551
- as: this.meta.as,
1552
- record: this._buildSelectDef(recordFwd(this.meta.columns), ""),
1553
- top: this.meta.top,
1554
- where: this.meta.where?.map((w) => w.expr),
1555
- joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1556
- limit: this.meta.limit,
1557
- output: outputColumns
1558
- ? {
1559
- columns: outputColumns,
1560
- pkColNames: outputDef.pkColNames,
1561
- aiColName: outputDef.aiColName,
1562
- }
1563
- : undefined,
1564
- });
1565
- }
1566
-
1567
- getDeleteQueryDef(outputColumns?: (keyof TFrom["$inferColumns"] & string)[]): DeleteQueryDef {
1568
- const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1569
- const outputDef = this._getCudOutputDef();
1570
-
1571
- return objClearUndefined({
1572
- type: "delete",
1573
- table: this.meta.db.getQueryDefObjectName(from),
1574
- as: this.meta.as,
1575
- top: this.meta.top,
1576
- where: this.meta.where?.map((w) => w.expr),
1577
- joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1578
- limit: this.meta.limit,
1579
- output: outputColumns
1580
- ? {
1581
- columns: outputColumns,
1582
- pkColNames: outputDef.pkColNames,
1583
- aiColName: outputDef.aiColName,
1584
- }
1585
- : undefined,
1586
- });
1587
- }
1588
-
1589
- //#endregion
1590
-
1591
- //#region ========== [쿼리] 수정 - UPSERT ==========
1592
-
1593
- /**
1594
- * UPSERT (UPDATE or INSERT) 쿼리를 실행
1595
- *
1596
- * WHERE 조건에 맞는 데이터가 있으면 UPDATE, 없으면 INSERT
1597
- *
1598
- * @param updateFwd - 업데이트할 컬럼과 값을 반환하는 함수
1599
- * @param insertFwd - 삽입할 레코드를 반환하는 함수 (선택, 미지정 시 updateFwd와 동일)
1600
- * @param outputColumns - 반환받을 컬럼 이름 배열 (선택)
1601
- * @returns outputColumns 지정 시 영향받은 레코드 배열 반환
1602
- *
1603
- * @example
1604
- * ```typescript
1605
- * // UPDATE/INSERT 동일 데이터
1606
- * await db.user()
1607
- * .where((u) => [expr.eq(u.email, "test@test.com")])
1608
- * .upsert(() => ({
1609
- * name: expr.val("string", "테스트"),
1610
- * email: expr.val("string", "test@test.com"),
1611
- * }));
1612
- *
1613
- * // UPDATE/INSERT 다른 데이터
1614
- * await db.user()
1615
- * .where((u) => [expr.eq(u.email, "test@test.com")])
1616
- * .upsert(
1617
- * () => ({ loginCount: expr.val("number", 1) }),
1618
- * (update) => ({ ...update, email: expr.val("string", "test@test.com") }),
1619
- * );
1620
- * ```
1621
- */
1622
- async upsert(
1623
- updateFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1624
- ): Promise<void>;
1625
- async upsert<K extends keyof TFrom["$inferColumns"] & string>(
1626
- insertFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1627
- outputColumns?: K[],
1628
- ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1629
- async upsert<U extends QueryableWriteRecord<TFrom["$inferUpdate"]>>(
1630
- updateFwd: (cols: QueryableRecord<TData>) => U,
1631
- insertFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1632
- ): Promise<void>;
1633
- async upsert<
1634
- U extends QueryableWriteRecord<TFrom["$inferUpdate"]>,
1635
- K extends keyof TFrom["$inferColumns"] & string,
1636
- >(
1637
- updateFwd: (cols: QueryableRecord<TData>) => U,
1638
- insertFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1639
- outputColumns?: K[],
1640
- ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1641
- async upsert<
1642
- U extends QueryableWriteRecord<TFrom["$inferUpdate"]>,
1643
- K extends keyof TFrom["$inferColumns"] & string,
1644
- >(
1645
- updateFwdOrInsertFwd:
1646
- | ((cols: QueryableRecord<TData>) => U)
1647
- | ((cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferInsert"]>),
1648
- insertFwdOrOutputColumns?:
1649
- | ((updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>)
1650
- | K[],
1651
- outputColumns?: K[],
1652
- ): Promise<Pick<TFrom["$inferColumns"], K>[] | void> {
1653
- const updateRecordFwd = updateFwdOrInsertFwd as (cols: QueryableRecord<TData>) => U;
1654
-
1655
- const insertRecordFwd = (
1656
- insertFwdOrOutputColumns instanceof Function ? insertFwdOrOutputColumns : updateFwdOrInsertFwd
1657
- ) as (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>;
1658
-
1659
- const realOutputColumns =
1660
- insertFwdOrOutputColumns instanceof Function ? outputColumns : insertFwdOrOutputColumns;
1661
-
1662
- const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1663
- [this.getUpsertQueryDef(updateRecordFwd, insertRecordFwd, realOutputColumns)],
1664
- [realOutputColumns ? this.getResultMeta(realOutputColumns) : undefined],
1665
- );
1666
-
1667
- if (realOutputColumns) {
1668
- return results[0];
1669
- }
1670
- }
1671
-
1672
- getUpsertQueryDef<U extends QueryableWriteRecord<TFrom["$inferUpdate"]>>(
1673
- updateRecordFwd: (cols: QueryableRecord<TData>) => U,
1674
- insertRecordFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1675
- outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1676
- ): UpsertQueryDef {
1677
- const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1678
- const outputDef = this._getCudOutputDef();
1679
-
1680
- const { select: _sel, ...existsSelectQuery } = this.getSelectQueryDef();
1681
-
1682
- // updateRecord 생성
1683
- const updateQrRecord = updateRecordFwd(this.meta.columns);
1684
- const updateRecord: Record<string, Expr> = {};
1685
- for (const [key, value] of Object.entries(updateQrRecord)) {
1686
- updateRecord[key] = expr.toExpr(value);
1687
- }
1688
-
1689
- // insertRecord 생성 (updateRecordRaw를 두 번째 인자로)
1690
- const insertRecordRaw = insertRecordFwd(updateQrRecord);
1691
- const insertRecord = Object.fromEntries(
1692
- Object.entries(insertRecordRaw).map(([key, value]) => [key, expr.toExpr(value)]),
1693
- );
1694
-
1695
- return objClearUndefined({
1696
- type: "upsert",
1697
- table: this.meta.db.getQueryDefObjectName(from),
1698
- existsSelectQuery,
1699
- updateRecord,
1700
- insertRecord,
1701
- output: outputColumns
1702
- ? {
1703
- columns: outputColumns,
1704
- pkColNames: outputDef.pkColNames,
1705
- aiColName: outputDef.aiColName,
1706
- }
1707
- : undefined,
1708
- });
1709
- }
1710
-
1711
- //#endregion
1712
-
1713
- //#region ========== DDL Helper ==========
1714
-
1715
- /**
1716
- * FK 제약조건 on/off (트랜잭션 내 사용 가능)
1717
- */
1718
- async switchFk(switch_: "on" | "off"): Promise<void> {
1719
- const from = this.meta.from;
1720
- if (!(from instanceof TableBuilder) && !(from instanceof ViewBuilder)) {
1721
- throw new Error(
1722
- "switchFk는 TableBuilder 또는 ViewBuilder 기반 queryable에서만 사용할 수 있습니다.",
1723
- );
1724
- }
1725
- await this.meta.db.switchFk(this.meta.db.getQueryDefObjectName(from), switch_);
1726
- }
1727
-
1728
- //#endregion
1729
-
1730
- //#region ========== CUD 공통 ==========
1731
-
1732
- private _getCudOutputDef(): {
1733
- pkColNames: string[];
1734
- aiColName?: string;
1735
- } {
1736
- const from = this.meta.from;
1737
-
1738
- if (from instanceof TableBuilder) {
1739
- if (from.meta.columns == null) {
1740
- throw new Error(`테이블 '${from.meta.name}' 컬럼 정의가 없습니다.`);
1741
- }
1742
-
1743
- let aiColName: string | undefined;
1744
- for (const [key, col] of Object.entries(from.meta.columns as ColumnBuilderRecord)) {
1745
- if (col.meta.autoIncrement) {
1746
- aiColName = key;
1747
- }
1748
- }
1749
-
1750
- return {
1751
- pkColNames: from.meta.primaryKey ?? [],
1752
- aiColName,
1753
- };
1754
- }
1755
-
1756
- throw new Error("CUD 작업은 TableBuilder 기반 queryable에서만 사용할 있습니다.");
1757
- }
1758
-
1759
- //#endregion
1760
- }
1761
-
1762
- //#region ========== Helper Functions ==========
1763
-
1764
- /**
1765
- * FK 컬럼 배열과 대상 테이블의 PK를 매칭하여 PK 컬럼명 배열을 반환
1766
- *
1767
- * @param fkCols - FK 컬럼명 배열
1768
- * @param targetTable - 참조 대상 테이블 빌더
1769
- * @returns 매칭된 PK 컬럼명 배열
1770
- * @throws FK/PK 컬럼 수 불일치 시
1771
- */
1772
- export function getMatchedPrimaryKeys(
1773
- fkCols: string[],
1774
- targetTable: TableBuilder<any, any>,
1775
- ): string[] {
1776
- const pk = targetTable.meta.primaryKey;
1777
- if (pk == null || fkCols.length !== pk.length) {
1778
- throw new Error(
1779
- `FK/PK 컬럼 개수가 일치하지 않습니다 (대상: ${targetTable.meta.name}, FK: ${fkCols.length}개, PK: ${pk?.length ?? 0}개)`,
1780
- );
1781
- }
1782
- return pk;
1783
- }
1784
-
1785
- /**
1786
- * 중첩 columns 구조를 새 alias로 변환하는 공용 헬퍼
1787
- *
1788
- * 서브쿼리/JOIN 시 기존 alias를 새 alias로 변환하면서,
1789
- * 중첩 키(posts.userId)는 평면화된 키로 유지한다.
1790
- *
1791
- * 예: posts[0].userId 컬럼의 경로가 ["T1.posts", "userId"]인 경우,
1792
- * 새 alias "T2"로 변환하면 ["T2", "posts.userId"]가 된다.
1793
- *
1794
- * @param columns - 변환할 컬럼 레코드
1795
- * @param alias - 새 테이블 alias (예: "T2")
1796
- * @param keyPrefix - 현재 중첩 경로 (재귀 호출용, 기본값 "")
1797
- * @returns 변환된 컬럼 레코드
1798
- */
1799
- function transformColumnsAlias<TRecord extends DataRecord>(
1800
- columns: QueryableRecord<TRecord>,
1801
- alias: string,
1802
- keyPrefix: string = "",
1803
- ): QueryableRecord<TRecord> {
1804
- const result: Record<string, unknown> = {};
1805
-
1806
- for (const [key, value] of Object.entries(columns as Record<string, unknown>)) {
1807
- const fullKey = keyPrefix ? `${keyPrefix}.${key}` : key;
1808
-
1809
- if (value instanceof ExprUnit) {
1810
- result[key] = expr.col(value.dataType, alias, fullKey);
1811
- } else if (Array.isArray(value)) {
1812
- if (value.length > 0) {
1813
- result[key] = [
1814
- transformColumnsAlias(value[0] as QueryableRecord<DataRecord>, alias, fullKey),
1815
- ];
1816
- }
1817
- } else if (typeof value === "object" && value != null) {
1818
- result[key] = transformColumnsAlias(value as QueryableRecord<DataRecord>, alias, fullKey);
1819
- } else {
1820
- result[key] = value;
1821
- }
1822
- }
1823
-
1824
- return result as QueryableRecord<TRecord>;
1825
- }
1826
-
1827
- //#endregion
1828
-
1829
- //#region ========== Types ==========
1830
-
1831
- interface QueryableMeta<TData extends DataRecord> {
1832
- db: DbContextBase;
1833
- from?:
1834
- | TableBuilder<any, any>
1835
- | ViewBuilder<any, any, any>
1836
- | Queryable<any, any>
1837
- | Queryable<TData, any>[]
1838
- | string;
1839
- as: string;
1840
- columns: QueryableRecord<TData>;
1841
- isCustomColumns?: boolean;
1842
- distinct?: boolean;
1843
- top?: number;
1844
- lock?: boolean;
1845
- where?: WhereExprUnit[];
1846
- joins?: QueryableMetaJoin[];
1847
- orderBy?: [ExprUnit<ColumnPrimitive>, ("ASC" | "DESC")?][];
1848
- limit?: [number, number];
1849
- groupBy?: ExprUnit<ColumnPrimitive>[];
1850
- having?: WhereExprUnit[];
1851
- with?: { name: string; base: Queryable<any, any>; recursive: Queryable<any, any> };
1852
- }
1853
-
1854
- interface QueryableMetaJoin {
1855
- queryable: Queryable<any, any>;
1856
- isSingle: boolean;
1857
- }
1858
-
1859
- export type QueryableRecord<TData extends DataRecord> = {
1860
- [K in keyof TData]: TData[K] extends ColumnPrimitive
1861
- ? ExprUnit<TData[K]>
1862
- : TData[K] extends (infer U)[]
1863
- ? U extends DataRecord
1864
- ? QueryableRecord<U>[]
1865
- : never
1866
- : TData[K] extends (infer U)[] | undefined
1867
- ? U extends DataRecord
1868
- ? NullableQueryableRecord<U>[] | undefined
1869
- : never
1870
- : TData[K] extends DataRecord
1871
- ? QueryableRecord<TData[K]>
1872
- : TData[K] extends DataRecord | undefined
1873
- ? NullableQueryableRecord<Exclude<TData[K], undefined>> | undefined
1874
- : never;
1875
- };
1876
-
1877
- export type QueryableWriteRecord<TData> = {
1878
- [K in keyof TData]: TData[K] extends ColumnPrimitive ? ExprInput<TData[K]> : never;
1879
- };
1880
-
1881
- export type NullableQueryableRecord<TData extends DataRecord> = {
1882
- // Primitive — always | undefined (LEFT JOIN NULL propagation)
1883
- [K in keyof TData]: TData[K] extends ColumnPrimitive
1884
- ? ExprUnit<TData[K] | undefined>
1885
- : TData[K] extends (infer U)[]
1886
- ? U extends DataRecord
1887
- ? NullableQueryableRecord<U>[]
1888
- : never
1889
- : TData[K] extends (infer U)[] | undefined
1890
- ? U extends DataRecord
1891
- ? NullableQueryableRecord<U>[] | undefined
1892
- : never
1893
- : TData[K] extends DataRecord
1894
- ? NullableQueryableRecord<TData[K]>
1895
- : TData[K] extends DataRecord | undefined
1896
- ? NullableQueryableRecord<Exclude<TData[K], undefined>> | undefined
1897
- : never;
1898
- };
1899
-
1900
- /**
1901
- * QueryableRecord에서 DataRecord로 역변환
1902
- *
1903
- * ExprUnit<T>를 T로, 중첩 객체/배열을 재귀적으로 풀어냄
1904
- */
1905
- export type UnwrapQueryableRecord<R> = {
1906
- [K in keyof R]: R[K] extends ExprUnit<infer T>
1907
- ? T
1908
- : NonNullable<R[K]> extends (infer U)[]
1909
- ? U extends Record<string, any>
1910
- ? UnwrapQueryableRecord<U>[] | Extract<R[K], undefined>
1911
- : never
1912
- : NonNullable<R[K]> extends Record<string, any>
1913
- ? UnwrapQueryableRecord<NonNullable<R[K]>> | Extract<R[K], undefined>
1914
- : never;
1915
- };
1916
-
1917
- //#region ========== PathProxy - include용 타입 안전 경로 빌더 ==========
1918
-
1919
- /**
1920
- * include()에서 관계 경로를 타입 안전하게 지정하기 위한 Proxy 타입
1921
- * ColumnPrimitive가 아닌 필드(FK, FKT 관계)만 접근 가능
1922
- *
1923
- * @example
1924
- * ```typescript
1925
- * // item.user.company 접근 시 내부적으로 ["user", "company"] 경로 수집
1926
- * db.post.include(item => item.user.company)
1927
- *
1928
- * // item.title은 string(ColumnPrimitive)이므로 컴파일 에러
1929
- * db.post.include(item => item.title) // ❌ 에러
1930
- * ```
1931
- */
1932
- /**
1933
- * 배열이면 요소 타입 추출
1934
- */
1935
- type UnwrapArray<TArray> = TArray extends (infer TElement)[] ? TElement : TArray;
1936
-
1937
- const PATH_SYMBOL = Symbol("path");
1938
-
1939
- /**
1940
- * include()용 타입 안전 경로 프록시
1941
- */
1942
- export type PathProxy<TObject> = {
1943
- [K in keyof TObject as TObject[K] extends ColumnPrimitive ? never : K]-?: PathProxy<
1944
- UnwrapArray<TObject[K]>
1945
- >;
1946
- } & { readonly [PATH_SYMBOL]: string[] };
1947
-
1948
- /**
1949
- * PathProxy 인스턴스 생성
1950
- * Proxy를 사용하여 프로퍼티 접근을 가로채고 경로를 수집
1951
- */
1952
- function createPathProxy<TObject>(path: string[] = []): PathProxy<TObject> {
1953
- return new Proxy({} as PathProxy<TObject>, {
1954
- get(_, prop: string | symbol) {
1955
- if (prop === PATH_SYMBOL) return path;
1956
- if (typeof prop === "symbol") return undefined;
1957
- return createPathProxy<unknown>([...path, prop]);
1958
- },
1959
- });
1960
- }
1961
-
1962
- //#endregion
1963
-
1964
- /**
1965
- * 테이블 또는 뷰에 대한 Queryable 팩토리 함수를 생성
1966
- *
1967
- * DbContext에서 테이블/뷰별 getter를 정의할 때 사용
1968
- *
1969
- * @param db - DbContext 인스턴스
1970
- * @param tableOrView - TableBuilder 또는 ViewBuilder 인스턴스
1971
- * @param as - alias 지정 (선택, 미지정 시 자동 생성)
1972
- * @returns Queryable을 반환하는 팩토리 함수
1973
- *
1974
- * @example
1975
- * ```typescript
1976
- * class AppDbContext extends DbContext {
1977
- * // 호출 시마다 새로운 alias 할당
1978
- * user = queryable(this, User);
1979
- *
1980
- * // 사용 예시
1981
- * async getActiveUsers() {
1982
- * return this.user()
1983
- * .where((u) => [expr.eq(u.isActive, true)])
1984
- * .result();
1985
- * }
1986
- * }
1987
- * ```
1988
- */
1989
- export function queryable<TBuilder extends TableBuilder<any, any> | ViewBuilder<any, any, any>>(
1990
- db: DbContextBase,
1991
- tableOrView: TBuilder,
1992
- as?: string,
1993
- ): () => Queryable<TBuilder["$infer"], TBuilder extends TableBuilder<any, any> ? TBuilder : never> {
1994
- return () => {
1995
- // as가 명시되지 않으면 db.getNextAlias() 사용 (카운터 증가)
1996
- // as가 명시되면 그대로 사용 (카운터 증가 안함)
1997
- const finalAs = as ?? db.getNextAlias();
1998
-
1999
- // TableBuilder + columns
2000
- if (tableOrView instanceof TableBuilder && tableOrView.meta.columns != null) {
2001
- const columnDefs = tableOrView.meta.columns as ColumnBuilderRecord;
2002
-
2003
- return new Queryable({
2004
- db,
2005
- from: tableOrView,
2006
- as: finalAs,
2007
- columns: Object.fromEntries(
2008
- Object.entries(columnDefs).map(([key, colDef]) => [
2009
- key,
2010
- expr.col(colDef.meta.type, finalAs, key),
2011
- ]),
2012
- ),
2013
- }) as any;
2014
- }
2015
-
2016
- // ViewBuilder + viewFn
2017
- if (tableOrView instanceof ViewBuilder && tableOrView.meta.viewFn != null) {
2018
- const baseQr = tableOrView.meta.viewFn(db);
2019
-
2020
- // TFrom을 ViewBuilder로 설정하여 반환
2021
- return new Queryable({
2022
- db,
2023
- from: tableOrView,
2024
- as: finalAs,
2025
- columns: transformColumnsAlias(baseQr.meta.columns, finalAs),
2026
- }) as any;
2027
- }
2028
-
2029
- throw new Error(`잘못된 테이블/뷰 메타데이터: ${tableOrView.meta.name}`);
2030
- };
2031
- }
2032
-
2033
- //#endregion
1
+ import { TableBuilder } from "../schema/table-builder";
2
+ import { ViewBuilder } from "../schema/view-builder";
3
+
4
+ import type { DataRecord, ResultMeta } from "../types/db";
5
+ import type {
6
+ DeleteQueryDef,
7
+ InsertIfNotExistsQueryDef,
8
+ InsertIntoQueryDef,
9
+ InsertQueryDef,
10
+ QueryDefObjectName,
11
+ SelectQueryDef,
12
+ SelectQueryDefJoin,
13
+ UpdateQueryDef,
14
+ UpsertQueryDef,
15
+ } from "../types/query-def";
16
+ import type { DbContextBase } from "../types/db-context-def";
17
+ import {
18
+ type ColumnBuilderRecord,
19
+ type DataToColumnBuilderRecord,
20
+ } from "../schema/factory/column-builder";
21
+ import type { ColumnPrimitive, ColumnPrimitiveStr } from "../types/column";
22
+ import type { WhereExprUnit, ExprInput } from "../expr/expr-unit";
23
+ import { ExprUnit } from "../expr/expr-unit";
24
+ import type { Expr } from "../types/expr";
25
+ import { ArgumentError, objClearUndefined } from "@simplysm/core-common";
26
+ import {
27
+ ForeignKeyBuilder,
28
+ ForeignKeyTargetBuilder,
29
+ RelationKeyBuilder,
30
+ RelationKeyTargetBuilder,
31
+ } from "../schema/factory/relation-builder";
32
+ import { parseSearchQuery } from "./search-parser";
33
+ import { expr } from "../expr/expr";
34
+
35
+ /**
36
+ * JOIN query builder
37
+ *
38
+ * Used internally by join/joinSingle methods to specify the table to join
39
+ */
40
+ class JoinQueryable {
41
+ constructor(
42
+ private readonly _db: DbContextBase,
43
+ private readonly _joinAlias: string,
44
+ ) {}
45
+
46
+ /**
47
+ * Specify the table to join
48
+ *
49
+ * @param table - Table to join
50
+ * @returns Joined Queryable
51
+ */
52
+ from<T extends TableBuilder<any, any>>(table: T): Queryable<T["$infer"], T> {
53
+ return queryable(this._db, table, this._joinAlias)();
54
+ }
55
+
56
+ /**
57
+ * Directly specify columns in join result
58
+ *
59
+ * @param columns - Custom column definition
60
+ * @returns Queryable with custom columns applied
61
+ */
62
+ select<R extends DataRecord>(columns: QueryableRecord<R>): Queryable<R, never> {
63
+ return new Queryable({
64
+ db: this._db,
65
+ as: this._joinAlias,
66
+ columns,
67
+ isCustomColumns: true,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Combine multiple Queryables with UNION
73
+ *
74
+ * @param queries - Array of Queryables to UNION (minimum 2)
75
+ * @returns UNION-ed Queryable
76
+ * @throws If less than 2 queryables are passed
77
+ */
78
+ union<TData extends DataRecord>(...queries: Queryable<TData, any>[]): Queryable<TData, never> {
79
+ if (queries.length < 2) {
80
+ throw new ArgumentError("union requires at least 2 queryables.", {
81
+ provided: queries.length,
82
+ minimum: 2,
83
+ });
84
+ }
85
+
86
+ const first = queries[0];
87
+
88
+ return new Queryable({
89
+ db: first.meta.db,
90
+ from: queries, // stored as Queryable[] array
91
+ as: this._joinAlias,
92
+ columns: transformColumnsAlias(first.meta.columns, this._joinAlias, ""),
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Recursive CTE (Common Table Expression) builder
99
+ *
100
+ * used internally in recursive() method, Defines the body of a recursive query
101
+ *
102
+ * @template TBaseData - Base query data type
103
+ */
104
+ class RecursiveQueryable<TBaseData extends DataRecord> {
105
+ constructor(
106
+ private readonly _baseQr: Queryable<TBaseData, any>,
107
+ private readonly _cteName: string,
108
+ ) {}
109
+
110
+ /**
111
+ * specify the target table for recursive query
112
+ *
113
+ * @param table - Target table to recurse
114
+ * @returns Queryable with self property added (for self-reference)
115
+ */
116
+ from<T extends TableBuilder<any, any>>(
117
+ table: T,
118
+ ): Queryable<T["$infer"] & { self?: TBaseData[] }, T> {
119
+ const selfAlias = `${this._cteName}.self`;
120
+
121
+ return queryable(this._baseQr.meta.db, table, this._cteName)().join(
122
+ "self",
123
+ () =>
124
+ new Queryable<TBaseData, never>({
125
+ db: this._baseQr.meta.db,
126
+ from: this._cteName,
127
+ as: selfAlias,
128
+ columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
129
+ isCustomColumns: false,
130
+ }),
131
+ ) as any;
132
+ }
133
+
134
+ /**
135
+ * Directly specify columns in recursive query
136
+ *
137
+ * @param columns - Custom column definition
138
+ * @returns Queryable with self property added
139
+ */
140
+ select<R extends DataRecord>(
141
+ columns: QueryableRecord<R>,
142
+ ): Queryable<R & { self?: TBaseData[] }, never> {
143
+ const selfAlias = `${this._cteName}.self`;
144
+
145
+ return new Queryable<R, never>({
146
+ db: this._baseQr.meta.db,
147
+ as: this._cteName,
148
+ columns,
149
+ isCustomColumns: true,
150
+ }).join(
151
+ "self",
152
+ () =>
153
+ new Queryable<TBaseData, never>({
154
+ db: this._baseQr.meta.db,
155
+ from: this._cteName,
156
+ as: selfAlias,
157
+ columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
158
+ isCustomColumns: false,
159
+ }),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Combine multiple Queryables with UNION (for recursive query)
165
+ *
166
+ * @param queries - Array of Queryables to UNION (minimum 2)
167
+ * @returns UNION Queryable with self property added
168
+ * @throws If less than 2 queryables are passed
169
+ */
170
+ union<TData extends DataRecord>(
171
+ ...queries: Queryable<TData, any>[]
172
+ ): Queryable<TData & { self?: TBaseData[] }, never> {
173
+ if (queries.length < 2) {
174
+ throw new ArgumentError("union requires at least 2 queryables.", {
175
+ provided: queries.length,
176
+ minimum: 2,
177
+ });
178
+ }
179
+
180
+ const first = queries[0];
181
+
182
+ const selfAlias = `${this._cteName}.self`;
183
+
184
+ return new Queryable<any, never>({
185
+ db: first.meta.db,
186
+ from: queries, // stored as Queryable[] array
187
+ as: this._cteName,
188
+ columns: transformColumnsAlias(first.meta.columns, this._cteName, ""),
189
+ }).join(
190
+ "self",
191
+ () =>
192
+ new Queryable({
193
+ db: this._baseQr.meta.db,
194
+ from: this._cteName,
195
+ as: selfAlias,
196
+ columns: transformColumnsAlias(this._baseQr.meta.columns, selfAlias, ""),
197
+ isCustomColumns: false,
198
+ }),
199
+ ) as any;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Query builder class
205
+ *
206
+ * Construct SELECT, INSERT, UPDATE, DELETE queries on tables/views in a chaining manner
207
+ *
208
+ * @template TData - Data type of query result
209
+ * @template TFrom - Source table (needed for CUD operations)
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * // Basic query
214
+ * const users = await db.user()
215
+ * .where((u) => [expr.eq(u.isActive, true)])
216
+ * .orderBy((u) => u.name)
217
+ * .result();
218
+ *
219
+ * // JOIN query
220
+ * const posts = await db.post()
221
+ * .include((p) => p.user)
222
+ * .result();
223
+ *
224
+ * // INSERT
225
+ * await db.user().insert([{ name: "Gildong Hong", email: "test@test.com" }]);
226
+ * ```
227
+ */
228
+ export class Queryable<
229
+ TData extends DataRecord,
230
+ TFrom extends TableBuilder<any, any> | never, // Only TableBuilder is supported for CUD operations
231
+ > {
232
+ constructor(readonly meta: QueryableMeta<TData>) {}
233
+
234
+ //#region ========== option - SELECT / DISTINCT / LOCK ==========
235
+
236
+ /**
237
+ * Specify columns to SELECT.
238
+ *
239
+ * @param fn - Column mapping function. Receives original columns and returns new column structure
240
+ * @returns Queryable with new column structure applied
241
+ *
242
+ * @example
243
+ * ```typescript
244
+ * db.user().select((u) => ({
245
+ * userName: u.name,
246
+ * userEmail: u.email,
247
+ * }))
248
+ * ```
249
+ */
250
+ select<R extends Record<string, any>>(
251
+ fn: (columns: QueryableRecord<TData>) => R,
252
+ ): Queryable<UnwrapQueryableRecord<R>, never> {
253
+ if (Array.isArray(this.meta.from)) {
254
+ const newFroms = this.meta.from.map((from) => from.select(fn));
255
+ return new Queryable({
256
+ ...this.meta,
257
+ from: newFroms,
258
+ columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
259
+ }) as any;
260
+ }
261
+
262
+ const newColumns = fn(this.meta.columns);
263
+
264
+ return new Queryable<any, never>({
265
+ ...this.meta,
266
+ columns: newColumns,
267
+ isCustomColumns: true,
268
+ }) as any;
269
+ }
270
+
271
+ /**
272
+ * Apply DISTINCT option to remove duplicate rows
273
+ *
274
+ * @returns Queryable with DISTINCT applied
275
+ *
276
+ * @example
277
+ * ```typescript
278
+ * db.user()
279
+ * .select((u) => ({ name: u.name }))
280
+ * .distinct()
281
+ * ```
282
+ */
283
+ distinct(): Queryable<TData, never> {
284
+ if (Array.isArray(this.meta.from)) {
285
+ const newFroms = this.meta.from.map((from) => from.distinct());
286
+ return new Queryable({
287
+ ...this.meta,
288
+ from: newFroms,
289
+ });
290
+ }
291
+
292
+ return new Queryable({
293
+ ...this.meta,
294
+ distinct: true,
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Apply row lock (FOR UPDATE)
300
+ *
301
+ * Acquire exclusive lock on selected rows within transaction
302
+ *
303
+ * @returns Queryable with lock applied
304
+ *
305
+ * @example
306
+ * ```typescript
307
+ * await db.connect(async () => {
308
+ * const user = await db.user()
309
+ * .where((u) => [expr.eq(u.id, 1)])
310
+ * .lock()
311
+ * .single();
312
+ * });
313
+ * ```
314
+ */
315
+ lock(): Queryable<TData, TFrom> {
316
+ if (Array.isArray(this.meta.from)) {
317
+ const newFroms = this.meta.from.map((from) => from.lock());
318
+ return new Queryable({
319
+ ...this.meta,
320
+ from: newFroms,
321
+ });
322
+ }
323
+
324
+ return new Queryable({
325
+ ...this.meta,
326
+ lock: true,
327
+ });
328
+ }
329
+
330
+ //#endregion
331
+
332
+ //#region ========== restrict - TOP / LIMIT ==========
333
+
334
+ /**
335
+ * Select only top N rows (can be used without ORDER BY)
336
+ *
337
+ * @param count - number of rows to select
338
+ * @returns Queryable with TOP applied
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * // Latest 10 users
343
+ * db.user()
344
+ * .orderBy((u) => u.createdAt, "DESC")
345
+ * .top(10)
346
+ * ```
347
+ */
348
+ top(count: number): Queryable<TData, TFrom> {
349
+ if (Array.isArray(this.meta.from)) {
350
+ const newFroms = this.meta.from.map((from) => from.top(count));
351
+ return new Queryable({
352
+ ...this.meta,
353
+ from: newFroms,
354
+ });
355
+ }
356
+
357
+ return new Queryable({
358
+ ...this.meta,
359
+ top: count,
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Set LIMIT/OFFSET for pagination.
365
+ * Must call orderBy() first.
366
+ *
367
+ * @param skip - number of rows to skip (OFFSET)
368
+ * @param take - number of rows to fetch (LIMIT)
369
+ * @returns Queryable with pagination applied
370
+ * @throws Error if no ORDER BY clause
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * db.user
375
+ * .orderBy((u) => u.createdAt)
376
+ * .limit(0, 20) // first 20
377
+ * ```
378
+ */
379
+ limit(skip: number, take: number): Queryable<TData, TFrom> {
380
+ if (Array.isArray(this.meta.from)) {
381
+ const newFroms = this.meta.from.map((from) => from.limit(skip, take));
382
+ return new Queryable({
383
+ ...this.meta,
384
+ from: newFroms,
385
+ });
386
+ }
387
+
388
+ if (!this.meta.orderBy) {
389
+ throw new ArgumentError("limit() requires ORDER BY clause.", {
390
+ method: "limit",
391
+ required: "orderBy",
392
+ });
393
+ }
394
+
395
+ return new Queryable({
396
+ ...this.meta,
397
+ limit: [skip, take],
398
+ });
399
+ }
400
+
401
+ //#endregion
402
+
403
+ //#region ========== sorting - ORDER BY ==========
404
+
405
+ /**
406
+ * Add sorting condition. Multiple calls apply in order.
407
+ *
408
+ * @param fn - function returning columns to sort by
409
+ * @param orderBy - Sort direction (ASC/DESC). Default: ASC
410
+ * @returns sorting 조건이 추가된 Queryable
411
+ *
412
+ * @example
413
+ * ```typescript
414
+ * db.user
415
+ * .orderBy((u) => u.name) // 이름 ASC
416
+ * .orderBy((u) => u.age, "DESC") // 나이 DESC
417
+ * ```
418
+ */
419
+ orderBy(
420
+ fn: (columns: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>,
421
+ orderBy?: "ASC" | "DESC",
422
+ ): Queryable<TData, TFrom> {
423
+ if (Array.isArray(this.meta.from)) {
424
+ const newFroms = this.meta.from.map((from) => from.orderBy(fn, orderBy));
425
+ return new Queryable({
426
+ ...this.meta,
427
+ from: newFroms,
428
+ });
429
+ }
430
+
431
+ const column = fn(this.meta.columns);
432
+
433
+ return new Queryable({
434
+ ...this.meta,
435
+ orderBy: [...(this.meta.orderBy ?? []), [column, orderBy]],
436
+ });
437
+ }
438
+
439
+ //#endregion
440
+
441
+ //#region ========== 검색 - WHERE ==========
442
+
443
+ /**
444
+ * WHERE condition을 추가합니다. 여러 번 호출하면 AND로 결합됩니다.
445
+ *
446
+ * @param predicate - Condition 배열을 반환하는 function
447
+ * @returns 조건이 추가된 Queryable
448
+ *
449
+ * @example
450
+ * ```typescript
451
+ * db.user
452
+ * .where((u) => [expr.eq(u.isActive, true)])
453
+ * .where((u) => [expr.gte(u.age, 18)])
454
+ * ```
455
+ */
456
+ where(predicate: (columns: QueryableRecord<TData>) => WhereExprUnit[]): Queryable<TData, TFrom> {
457
+ if (Array.isArray(this.meta.from)) {
458
+ const newFroms = this.meta.from.map((from) => from.where(predicate));
459
+ return new Queryable({
460
+ ...this.meta,
461
+ from: newFroms,
462
+ });
463
+ }
464
+
465
+ const conditions = predicate(this.meta.columns);
466
+
467
+ return new Queryable({
468
+ ...this.meta,
469
+ where: [...(this.meta.where ?? []), ...conditions],
470
+ });
471
+ }
472
+
473
+ /**
474
+ * 텍스트 검색을 수행
475
+ *
476
+ * 검색 문법은 {@link parseSearchQuery}를 참조
477
+ * - 공백으로 구분된 단어는 OR condition
478
+ * - `+`로 시작하는 단어는 required include (AND Condition)
479
+ * - `-`로 시작하는 단어는 exclude (NOT Condition)
480
+ *
481
+ * @param fn - 검색 대상 column을 반환하는 function
482
+ * @param searchText - 검색 텍스트
483
+ * @returns 검색 조건이 추가된 Queryable
484
+ *
485
+ * @example
486
+ * ```typescript
487
+ * db.user()
488
+ * .search((u) => [u.name, u.email], "Gildong Hong -탈퇴")
489
+ * ```
490
+ */
491
+ search(
492
+ fn: (columns: QueryableRecord<TData>) => ExprUnit<string | undefined>[],
493
+ searchText: string,
494
+ ): Queryable<TData, TFrom> {
495
+ if (Array.isArray(this.meta.from)) {
496
+ const newFroms = this.meta.from.map((from) => from.search(fn, searchText));
497
+ return new Queryable({
498
+ ...this.meta,
499
+ from: newFroms,
500
+ });
501
+ }
502
+
503
+ if (searchText.trim() === "") {
504
+ return this;
505
+ }
506
+
507
+ const columns = fn(this.meta.columns);
508
+ const parsed = parseSearchQuery(searchText);
509
+
510
+ const conditions: WhereExprUnit[] = [];
511
+
512
+ // OR condition:column에서 pattern 하나라도 매치하면 OK
513
+ if (parsed.or.length === 1) {
514
+ const pattern = parsed.or[0];
515
+ const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
516
+ conditions.push(expr.or(columnMatches));
517
+ } else if (parsed.or.length > 1) {
518
+ const orConditions = parsed.or.map((pattern) => {
519
+ const columnMatches = columns.map((col) =>
520
+ expr.like(expr.lower(col), pattern.toLowerCase()),
521
+ );
522
+ return expr.or(columnMatches);
523
+ });
524
+ conditions.push(expr.or(orConditions));
525
+ }
526
+
527
+ // MUST condition: 각 pattern이 어떤 column에서든 매치해야 함 (AND)
528
+ for (const pattern of parsed.must) {
529
+ const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
530
+ conditions.push(expr.or(columnMatches));
531
+ }
532
+
533
+ // NOT condition: 모든 column에서 매치하지 않아야 함 (AND NOT)
534
+ for (const pattern of parsed.not) {
535
+ const columnMatches = columns.map((col) => expr.like(expr.lower(col), pattern.toLowerCase()));
536
+ conditions.push(expr.not(expr.or(columnMatches)));
537
+ }
538
+
539
+ if (conditions.length === 0) {
540
+ return this;
541
+ }
542
+
543
+ return this.where(() => [expr.and(conditions)]);
544
+ }
545
+
546
+ //#endregion
547
+
548
+ //#region ========== 그룹 - GROUP BY / HAVING ==========
549
+
550
+ /**
551
+ * GROUP BY 절을 Add
552
+ *
553
+ * @param fn - Group화 기준 column을 반환하는 function
554
+ * @returns Queryable with GROUP BY applied
555
+ *
556
+ * @example
557
+ * ```typescript
558
+ * db.order()
559
+ * .select((o) => ({
560
+ * userId: o.userId,
561
+ * totalAmount: expr.sum(o.amount),
562
+ * }))
563
+ * .groupBy((o) => [o.userId])
564
+ * ```
565
+ */
566
+ groupBy(
567
+ fn: (columns: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>[],
568
+ ): Queryable<TData, never> {
569
+ if (Array.isArray(this.meta.from)) {
570
+ const newFroms = this.meta.from.map((from) => from.groupBy(fn));
571
+ return new Queryable({
572
+ ...this.meta,
573
+ from: newFroms,
574
+ });
575
+ }
576
+
577
+ const groupBy = fn(this.meta.columns);
578
+
579
+ return new Queryable({ ...this.meta, groupBy });
580
+ }
581
+
582
+ /**
583
+ * HAVING 절을 Add (GROUP BY 후 filtering)
584
+ *
585
+ * @param predicate - Condition 배열을 반환하는 function
586
+ * @returns HAVING이 apply된 Queryable
587
+ *
588
+ * @example
589
+ * ```typescript
590
+ * db.order()
591
+ * .select((o) => ({
592
+ * userId: o.userId,
593
+ * totalAmount: expr.sum(o.amount),
594
+ * }))
595
+ * .groupBy((o) => [o.userId])
596
+ * .having((o) => [expr.gte(o.totalAmount, 10000)])
597
+ * ```
598
+ */
599
+ having(predicate: (columns: QueryableRecord<TData>) => WhereExprUnit[]): Queryable<TData, never> {
600
+ if (Array.isArray(this.meta.from)) {
601
+ const newFroms = this.meta.from.map((from) => from.having(predicate));
602
+ return new Queryable({
603
+ ...this.meta,
604
+ from: newFroms,
605
+ });
606
+ }
607
+
608
+ const conditions = predicate(this.meta.columns);
609
+
610
+ return new Queryable({
611
+ ...this.meta,
612
+ having: [...(this.meta.having ?? []), ...conditions],
613
+ });
614
+ }
615
+
616
+ //#endregion
617
+
618
+ //#region ========== join - JOIN / JOIN SINGLE ==========
619
+
620
+ /**
621
+ * 1:N 관계의 LEFT OUTER JOIN을 수행 (배열로 result Add)
622
+ *
623
+ * @param as - Result에 추가할 property 이름
624
+ * @param fwd - Join 조건을 정의하는 콜백 function
625
+ * @returns join 결과가 배열로 추가된 Queryable
626
+ *
627
+ * @example
628
+ * ```typescript
629
+ * db.user()
630
+ * .join("posts", (qr, u) =>
631
+ * qr.from(Post)
632
+ * .where((p) => [expr.eq(p.userId, u.id)])
633
+ * )
634
+ * // Result: { id, name, posts: [{ id, title }, ...] }
635
+ * ```
636
+ */
637
+ join<A extends string, R extends DataRecord>(
638
+ as: A,
639
+ fwd: (qr: JoinQueryable, cols: QueryableRecord<TData>) => Queryable<R, any>,
640
+ ): Queryable<TData & { [K in A]?: R[] }, TFrom> {
641
+ if (Array.isArray(this.meta.from)) {
642
+ const newFroms = this.meta.from.map((from) => from.join(as, fwd));
643
+ return new Queryable({
644
+ ...this.meta,
645
+ from: newFroms,
646
+ columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
647
+ });
648
+ }
649
+
650
+ // 1. join alias Generate
651
+ const joinAlias = `${this.meta.as}.${as}`;
652
+
653
+ // 2. target → Queryable Transform (alias 전달)
654
+ const joinQr = new JoinQueryable(this.meta.db, joinAlias);
655
+
656
+ // 3. fwd 실행 (where 등 condition 추가된 Queryable return)
657
+ const resultQr = fwd(joinQr, this.meta.columns);
658
+
659
+ // 4. 새 columns에 join result Add
660
+ const joinColumns = transformColumnsAlias(resultQr.meta.columns, joinAlias);
661
+
662
+ return new Queryable({
663
+ ...this.meta,
664
+ columns: {
665
+ ...this.meta.columns,
666
+ [as]: [joinColumns],
667
+ } as QueryableRecord<any>,
668
+ isCustomColumns: true,
669
+ joins: [...(this.meta.joins ?? []), { queryable: resultQr, isSingle: false }],
670
+ }) as any;
671
+ }
672
+
673
+ /**
674
+ * N:1 또는 1:1 관계의 LEFT OUTER JOIN을 수행 (단일 객체로 result Add)
675
+ *
676
+ * @param as - Result에 추가할 property 이름
677
+ * @param fwd - Join 조건을 정의하는 콜백 function
678
+ * @returns join 결과가 단일 객체로 추가된 Queryable
679
+ *
680
+ * @example
681
+ * ```typescript
682
+ * db.post()
683
+ * .joinSingle("user", (qr, p) =>
684
+ * qr.from(User)
685
+ * .where((u) => [expr.eq(u.id, p.userId)])
686
+ * )
687
+ * // Result: { id, title, user: { id, name } | undefined }
688
+ * ```
689
+ */
690
+ joinSingle<A extends string, R extends DataRecord>(
691
+ as: A,
692
+ fwd: (qr: JoinQueryable, cols: QueryableRecord<TData>) => Queryable<R, any>,
693
+ ): Queryable<
694
+ { [K in keyof TData as K extends A ? never : K]: TData[K] } & { [K in A]?: R },
695
+ TFrom
696
+ > {
697
+ if (Array.isArray(this.meta.from)) {
698
+ const newFroms = this.meta.from.map((from) => from.joinSingle(as, fwd));
699
+ return new Queryable({
700
+ ...this.meta,
701
+ from: newFroms,
702
+ columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
703
+ });
704
+ }
705
+
706
+ // 1. join alias Generate
707
+ const joinAlias = `${this.meta.as}.${as}`;
708
+
709
+ // 2. target → Queryable Transform (alias 전달)
710
+ const joinQr = new JoinQueryable(this.meta.db, joinAlias);
711
+
712
+ // 3. fwd 실행 (where 등 condition 추가된 Queryable return)
713
+ const resultQr = fwd(joinQr, this.meta.columns);
714
+
715
+ // 4. 새 columns에 join result Add
716
+ const joinColumns = transformColumnsAlias(resultQr.meta.columns, joinAlias);
717
+
718
+ return new Queryable({
719
+ ...this.meta,
720
+ columns: {
721
+ ...this.meta.columns,
722
+ [as]: joinColumns,
723
+ } as QueryableRecord<any>,
724
+ isCustomColumns: true,
725
+ joins: [...(this.meta.joins ?? []), { queryable: resultQr, isSingle: true }],
726
+ }) as any;
727
+ }
728
+
729
+ //#endregion
730
+
731
+ //#region ========== join - INCLUDE ==========
732
+
733
+ /**
734
+ * 관계된 Table을 automatic으로 JOIN합니다.
735
+ * TableBuilder에 정의된 FK/FKT 관계를 기반으로 동작합니다.
736
+ *
737
+ * @param fn - 포함할 관계를 선택하는 function (PathProxy를 통해 type 체크됨)
738
+ * @returns JOIN이 추가된 Queryable
739
+ * @throws 관계가 정의되지 않은 경우 에러
740
+ *
741
+ * @example
742
+ * ```typescript
743
+ * // 단일 relationship include
744
+ * db.post.include((p) => p.user)
745
+ *
746
+ * // 중첩 relationship include
747
+ * db.post.include((p) => p.user.company)
748
+ *
749
+ * // 다중 relationship include
750
+ * db.user
751
+ * .include((u) => u.company)
752
+ * .include((u) => u.posts)
753
+ * ```
754
+ */
755
+ include(fn: (item: PathProxy<TData>) => PathProxy<any>): Queryable<TData, TFrom> {
756
+ if (Array.isArray(this.meta.from)) {
757
+ const newFroms = this.meta.from.map((from) => from.include(fn));
758
+ return new Queryable({
759
+ ...this.meta,
760
+ from: newFroms,
761
+ columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
762
+ });
763
+ }
764
+
765
+ const proxy = createPathProxy<TData>();
766
+ const result = fn(proxy);
767
+ const relationChain = result[PATH_SYMBOL].join(".");
768
+
769
+ return this._include(relationChain);
770
+ }
771
+
772
+ private _include(relationChain: string): Queryable<TData, TFrom> {
773
+ const relationNames = relationChain.split(".");
774
+
775
+ let result: Queryable<any, any> = this;
776
+ let currentTable = this.meta.from;
777
+ const chainParts: string[] = [];
778
+
779
+ for (const relationName of relationNames) {
780
+ if (!(currentTable instanceof TableBuilder)) {
781
+ throw new Error("include() can only be used on TableBuilder-based queryables.");
782
+ }
783
+
784
+ const parentChain = chainParts.join(".");
785
+ chainParts.push(relationName);
786
+
787
+ // 이미 JOIN된 경우 중복 Add 방지
788
+ const targetAlias = `${result.meta.as}.${chainParts.join(".")}`;
789
+ const existingJoin = result.meta.joins?.find((j) => j.queryable.meta.as === targetAlias);
790
+ if (existingJoin) {
791
+ // 기존 JOIN의 Table로 currentTable 업데이트 후 continue
792
+ const existingFrom = existingJoin.queryable.meta.from;
793
+ if (existingFrom instanceof TableBuilder) {
794
+ currentTable = existingFrom;
795
+ }
796
+ continue;
797
+ }
798
+
799
+ const relationDef = currentTable.meta.relations?.[relationName];
800
+ if (relationDef == null) {
801
+ throw new Error(`Relation '${relationName}' not found.`);
802
+ }
803
+
804
+ if (relationDef instanceof ForeignKeyBuilder || relationDef instanceof RelationKeyBuilder) {
805
+ // FK/RelationKey (N:1): Post.user → User
806
+ // condition: Post.userId = User.id
807
+ const targetTable = relationDef.meta.targetFn();
808
+ const fkColKeys = relationDef.meta.columns;
809
+ const targetPkColKeys = getMatchedPrimaryKeys(fkColKeys, targetTable);
810
+
811
+ result = result.joinSingle(chainParts.join("."), (joinQr, parentCols) => {
812
+ const qr = joinQr.from(targetTable);
813
+
814
+ // FKT join은 배열로 저장되므로 배열인 경우 첫 번째 요소 사용
815
+ const srcColsRaw = parentChain ? parentCols[parentChain] : parentCols;
816
+ const srcCols = (
817
+ Array.isArray(srcColsRaw) ? srcColsRaw[0] : srcColsRaw
818
+ ) as QueryableRecord<any>;
819
+ const conditions: WhereExprUnit[] = [];
820
+
821
+ for (let i = 0; i < fkColKeys.length; i++) {
822
+ const fkCol = srcCols[fkColKeys[i]];
823
+ const pkCol = qr.meta.columns[targetPkColKeys[i]] as ExprUnit<ColumnPrimitive>;
824
+
825
+ conditions.push(expr.eq(pkCol, fkCol));
826
+ }
827
+
828
+ return qr.where(() => conditions);
829
+ });
830
+
831
+ currentTable = targetTable;
832
+ } else if (
833
+ relationDef instanceof ForeignKeyTargetBuilder ||
834
+ relationDef instanceof RelationKeyTargetBuilder
835
+ ) {
836
+ // FKT/RelationKeyTarget (1:N 또는 1:1): User.posts → Post[]
837
+ // condition: Post.userId = User.id
838
+ const targetTable = relationDef.meta.targetTableFn();
839
+ const fkRelName = relationDef.meta.relationName;
840
+ const sourceFk = targetTable.meta.relations?.[fkRelName];
841
+ if (!(sourceFk instanceof ForeignKeyBuilder) && !(sourceFk instanceof RelationKeyBuilder)) {
842
+ throw new Error(
843
+ `'${relationName}'이 참조하는 '${fkRelName}'이(가) ` +
844
+ `${targetTable.meta.name} Table의 유효한 ForeignKey/RelationKey가 아닙니다.`,
845
+ );
846
+ }
847
+ const sourceTable = targetTable;
848
+ const isSingle: boolean = relationDef.meta.isSingle ?? false;
849
+
850
+ const fkColKeys = sourceFk.meta.columns;
851
+ const pkColKeys = getMatchedPrimaryKeys(fkColKeys, currentTable);
852
+
853
+ const buildJoin = (joinQr: JoinQueryable, parentCols: QueryableRecord<DataRecord>) => {
854
+ const qr = joinQr.from(sourceTable);
855
+
856
+ // FKT join은 배열로 저장되므로 배열인 경우 첫 번째 요소 사용
857
+ const srcColsRaw = parentChain ? parentCols[parentChain] : parentCols;
858
+ const srcCols = (
859
+ Array.isArray(srcColsRaw) ? srcColsRaw[0] : srcColsRaw
860
+ ) as QueryableRecord<any>;
861
+ const conditions: WhereExprUnit[] = [];
862
+
863
+ for (let i = 0; i < fkColKeys.length; i++) {
864
+ const pkCol = srcCols[pkColKeys[i]] as ExprUnit<ColumnPrimitive>;
865
+ const fkCol = qr.meta.columns[fkColKeys[i]] as ExprUnit<ColumnPrimitive>;
866
+
867
+ conditions.push(expr.eq(fkCol, pkCol));
868
+ }
869
+
870
+ return qr.where(() => conditions);
871
+ };
872
+
873
+ result = isSingle
874
+ ? result.joinSingle(chainParts.join("."), buildJoin)
875
+ : result.join(chainParts.join("."), buildJoin);
876
+
877
+ currentTable = sourceTable;
878
+ }
879
+ }
880
+
881
+ return result as Queryable<TData, TFrom>;
882
+ }
883
+
884
+ //#endregion
885
+
886
+ //#region ========== Subquery - WRAP / UNION ==========
887
+
888
+ /**
889
+ * 현재 Queryable을 Subquery로 감싸기
890
+ *
891
+ * distinct() 또는 groupBy() 후 count() 사용 시 필요
892
+ *
893
+ * @returns Subquery로 감싸진 Queryable
894
+ *
895
+ * @example
896
+ * ```typescript
897
+ * // DISTINCT 후 카운트
898
+ * const count = await db.user()
899
+ * .select((u) => ({ name: u.name }))
900
+ * .distinct()
901
+ * .wrap()
902
+ * .count();
903
+ * ```
904
+ */
905
+ wrap(): Queryable<TData, never> {
906
+ // 현재 Queryable을 Subquery로 감싸기
907
+ const wrapAlias = this.meta.db.getNextAlias();
908
+ return new Queryable({
909
+ db: this.meta.db,
910
+ from: this,
911
+ as: wrapAlias,
912
+ columns: transformColumnsAlias<TData>(this.meta.columns, wrapAlias, ""),
913
+ });
914
+ }
915
+
916
+ /**
917
+ * Combine multiple Queryables with UNION (remove duplicates)
918
+ *
919
+ * @param queries - Array of Queryables to UNION (minimum 2)
920
+ * @returns UNION-ed Queryable
921
+ * @throws If less than 2 queryables are passed
922
+ *
923
+ * @example
924
+ * ```typescript
925
+ * const combined = Queryable.union(
926
+ * db.user().where((u) => [expr.eq(u.type, "admin")]),
927
+ * db.user().where((u) => [expr.eq(u.type, "manager")]),
928
+ * );
929
+ * ```
930
+ */
931
+ static union<TData extends DataRecord>(
932
+ ...queries: Queryable<TData, any>[]
933
+ ): Queryable<TData, never> {
934
+ if (queries.length < 2) {
935
+ throw new ArgumentError("union requires at least 2 queryables.", {
936
+ provided: queries.length,
937
+ minimum: 2,
938
+ });
939
+ }
940
+
941
+ const first = queries[0];
942
+ const unionAlias = first.meta.db.getNextAlias();
943
+ return new Queryable({
944
+ db: first.meta.db,
945
+ from: queries, // stored as Queryable[] array
946
+ as: unionAlias,
947
+ columns: transformColumnsAlias(first.meta.columns, unionAlias, ""),
948
+ });
949
+ }
950
+
951
+ //#endregion
952
+
953
+ //#region ========== recursive - WITH RECURSIVE ==========
954
+
955
+ /**
956
+ * recursive CTE(Common Table Expression)를 Generate
957
+ *
958
+ * 계층 structure data(조직도, 카테고리 트리 등)를 조회할 때 사용
959
+ *
960
+ * @param fwd - recursive part을 정의하는 콜백 function
961
+ * @returns recursive CTE가 apply된 Queryable
962
+ *
963
+ * @example
964
+ * ```typescript
965
+ * // 조직도 계층 조회
966
+ * db.employee()
967
+ * .where((e) => [expr.null(e.managerId)]) // 루트 노드
968
+ * .recursive((cte) =>
969
+ * cte.from(Employee)
970
+ * .where((e) => [expr.eq(e.managerId, e.self[0].id)])
971
+ * )
972
+ * ```
973
+ */
974
+ recursive(
975
+ fwd: (qr: RecursiveQueryable<TData>) => Queryable<TData, any>,
976
+ ): Queryable<TData, never> {
977
+ if (Array.isArray(this.meta.from)) {
978
+ const newFroms = this.meta.from.map((from) => from.recursive(fwd));
979
+ return new Queryable({
980
+ ...this.meta,
981
+ from: newFroms,
982
+ columns: transformColumnsAlias(newFroms[0].meta.columns, this.meta.as, ""),
983
+ });
984
+ }
985
+ // 동적 CTE 이름 Generate
986
+ const cteName = this.meta.db.getNextAlias();
987
+
988
+ // 2. target → Queryable Transform (CTE 이름 전달)
989
+ const cteQr = new RecursiveQueryable(this, cteName);
990
+
991
+ // 3. fwd 실행 (where 등 condition 추가된 Queryable return)
992
+ const resultQr = fwd(cteQr);
993
+
994
+ return new Queryable({
995
+ db: this.meta.db,
996
+ as: this.meta.as,
997
+ from: cteName,
998
+ columns: transformColumnsAlias(this.meta.columns, this.meta.as, ""),
999
+ with: {
1000
+ name: cteName,
1001
+ base: this as any, // circular 참조 Type inference 차단
1002
+ recursive: resultQr,
1003
+ },
1004
+ });
1005
+ }
1006
+
1007
+ //#endregion
1008
+
1009
+ //#region ========== [query] 조회 - SELECT ==========
1010
+
1011
+ /**
1012
+ * SELECT query를 실행하고 result 배열을 return
1013
+ *
1014
+ * @returns Query result array
1015
+ *
1016
+ * @example
1017
+ * ```typescript
1018
+ * const users = await db.user()
1019
+ * .where((u) => [expr.eq(u.isActive, true)])
1020
+ * .result();
1021
+ * ```
1022
+ */
1023
+ async result(): Promise<TData[]> {
1024
+ const results = await this.meta.db.executeDefs<TData>(
1025
+ [this.getSelectQueryDef()],
1026
+ [this.getResultMeta()],
1027
+ );
1028
+ return results[0];
1029
+ }
1030
+
1031
+ /**
1032
+ * 단일 결과를 return (2개 이상이면 Error)
1033
+ *
1034
+ * @returns 단일 result 또는 undefined
1035
+ * @throws 2개 이상의 결과가 반환된 경우
1036
+ *
1037
+ * @example
1038
+ * ```typescript
1039
+ * const user = await db.user()
1040
+ * .where((u) => [expr.eq(u.id, 1)])
1041
+ * .single();
1042
+ * ```
1043
+ */
1044
+ async single(): Promise<TData | undefined> {
1045
+ const result = await this.top(2).result();
1046
+ if (result.length > 1) {
1047
+ throw new ArgumentError("Expected single result but multiple results returned.", {
1048
+ table: this._getSourceName(),
1049
+ resultCount: result.length,
1050
+ });
1051
+ }
1052
+ return result[0];
1053
+ }
1054
+
1055
+ /**
1056
+ * query 소스 이름 return (error message용)
1057
+ */
1058
+ private _getSourceName(): string {
1059
+ const from = this.meta.from;
1060
+ if (from instanceof TableBuilder || from instanceof ViewBuilder) {
1061
+ return from.meta.name;
1062
+ }
1063
+ if (typeof from === "string") {
1064
+ return from;
1065
+ }
1066
+ return this.meta.as;
1067
+ }
1068
+
1069
+ /**
1070
+ * 첫 번째 결과를 return (여러 개여도 첫 번째만)
1071
+ *
1072
+ * @returns 첫 번째 result 또는 undefined
1073
+ *
1074
+ * @example
1075
+ * ```typescript
1076
+ * const latestUser = await db.user()
1077
+ * .orderBy((u) => u.createdAt, "DESC")
1078
+ * .first();
1079
+ * ```
1080
+ */
1081
+ async first(): Promise<TData | undefined> {
1082
+ const results = await this.top(1).result();
1083
+ return results[0];
1084
+ }
1085
+
1086
+ /**
1087
+ * result row 수를 return
1088
+ *
1089
+ * @param fwd - 카운트할 column을 지정하는 function (Select)
1090
+ * @returns row
1091
+ * @throws distinct() 또는 groupBy() 후 직접 호출 시 에러 (wrap() 필요)
1092
+ *
1093
+ * @example
1094
+ * ```typescript
1095
+ * const count = await db.user()
1096
+ * .where((u) => [expr.eq(u.isActive, true)])
1097
+ * .count();
1098
+ * ```
1099
+ */
1100
+ async count(fwd?: (cols: QueryableRecord<TData>) => ExprUnit<ColumnPrimitive>): Promise<number> {
1101
+ if (this.meta.distinct) {
1102
+ throw new Error("Cannot use count() after distinct(). Use wrap() first.");
1103
+ }
1104
+ if (this.meta.groupBy) {
1105
+ throw new Error("Cannot use count() after groupBy(). Use wrap() first.");
1106
+ }
1107
+
1108
+ const countQr = fwd
1109
+ ? this.select((c) => ({ cnt: expr.count(fwd(c)) }))
1110
+ : this.select(() => ({ cnt: expr.count() }));
1111
+
1112
+ const result = await countQr.single();
1113
+
1114
+ return result?.cnt ?? 0;
1115
+ }
1116
+
1117
+ /**
1118
+ * 조건에 맞는 data 존재 여부를 확인
1119
+ *
1120
+ * @returns 존재하면 true, 없으면 false
1121
+ *
1122
+ * @example
1123
+ * ```typescript
1124
+ * const hasAdmin = await db.user()
1125
+ * .where((u) => [expr.eq(u.role, "admin")])
1126
+ * .exists();
1127
+ * ```
1128
+ */
1129
+ async exists(): Promise<boolean> {
1130
+ const count = await this.count();
1131
+ return count > 0;
1132
+ }
1133
+
1134
+ getSelectQueryDef(): SelectQueryDef {
1135
+ return objClearUndefined({
1136
+ type: "select",
1137
+ from: this._buildFromDef(),
1138
+ as: this.meta.as,
1139
+ select: this.meta.isCustomColumns ? this._buildSelectDef(this.meta.columns, "") : undefined,
1140
+ distinct: this.meta.distinct,
1141
+ top: this.meta.top,
1142
+ lock: this.meta.lock,
1143
+ where: this.meta.where?.map((w) => w.expr),
1144
+ joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1145
+ orderBy: this.meta.orderBy?.map((o) => (o[1] ? [o[0].expr, o[1]] : [o[0].expr])),
1146
+ limit: this.meta.limit,
1147
+ groupBy: this.meta.groupBy?.map((g) => g.expr),
1148
+ having: this.meta.having?.map((w) => w.expr),
1149
+ with: this.meta.with
1150
+ ? {
1151
+ name: this.meta.with.name,
1152
+ base: this.meta.with.base.getSelectQueryDef(),
1153
+ recursive: this.meta.with.recursive.getSelectQueryDef(),
1154
+ }
1155
+ : undefined,
1156
+ });
1157
+ }
1158
+
1159
+ private _buildFromDef():
1160
+ | QueryDefObjectName
1161
+ | SelectQueryDef
1162
+ | SelectQueryDef[]
1163
+ | string
1164
+ | undefined {
1165
+ const from = this.meta.from;
1166
+
1167
+ if (from instanceof TableBuilder || from instanceof ViewBuilder) {
1168
+ return this.meta.db.getQueryDefObjectName(from);
1169
+ } else if (from instanceof Queryable) {
1170
+ return from.getSelectQueryDef();
1171
+ } else if (Array.isArray(from)) {
1172
+ return from.map((qr) => qr.getSelectQueryDef());
1173
+ }
1174
+
1175
+ return from;
1176
+ }
1177
+
1178
+ private _buildSelectDef(
1179
+ columns: QueryableRecord<any> | QueryableWriteRecord<any>,
1180
+ prefix: string,
1181
+ ): Record<string, Expr> {
1182
+ const result: Record<string, Expr> = {};
1183
+
1184
+ for (const [key, val] of Object.entries(columns)) {
1185
+ const fullKey = prefix ? `${prefix}.${key}` : key;
1186
+
1187
+ if (val instanceof ExprUnit) {
1188
+ result[fullKey] = val.expr;
1189
+ } else if (Array.isArray(val)) {
1190
+ if (val.length > 0) {
1191
+ Object.assign(result, this._buildSelectDef(val[0], fullKey));
1192
+ }
1193
+ } else if (typeof val === "object" && val != null) {
1194
+ Object.assign(result, this._buildSelectDef(val, fullKey));
1195
+ } else {
1196
+ // Plain value (string, number, boolean, etc.) — convert to Expr
1197
+ result[fullKey] = expr.toExpr(val);
1198
+ }
1199
+ }
1200
+
1201
+ return result;
1202
+ }
1203
+
1204
+ private _buildJoinDefs(joins: QueryableMetaJoin[]): SelectQueryDefJoin[] {
1205
+ const result: SelectQueryDefJoin[] = [];
1206
+
1207
+ for (const join of joins) {
1208
+ const joinQr = join.queryable;
1209
+ const selectDef = joinQr.getSelectQueryDef();
1210
+
1211
+ const joinDef: SelectQueryDefJoin = {
1212
+ ...selectDef,
1213
+ as: joinQr.meta.as,
1214
+ isSingle: join.isSingle,
1215
+ };
1216
+
1217
+ result.push(joinDef);
1218
+ }
1219
+
1220
+ return result;
1221
+ }
1222
+
1223
+ getResultMeta(outputColumns?: string[]): ResultMeta {
1224
+ const columns: Record<string, ColumnPrimitiveStr> = {};
1225
+ const joins: Record<string, { isSingle: boolean }> = {};
1226
+
1227
+ const buildResultMeta = (cols: QueryableRecord<any>, prefix: string) => {
1228
+ for (const [key, val] of Object.entries(cols)) {
1229
+ const fullKey = prefix ? `${prefix}.${key}` : key;
1230
+ if (outputColumns && !outputColumns.includes(fullKey)) continue;
1231
+
1232
+ if (val instanceof ExprUnit) {
1233
+ // primitive column
1234
+ columns[fullKey] = val.dataType;
1235
+ } else if (Array.isArray(val)) {
1236
+ // array (1:N relationship)
1237
+ if (val.length > 0) {
1238
+ joins[fullKey] = { isSingle: false };
1239
+ buildResultMeta(val[0], fullKey);
1240
+ }
1241
+ } else if (typeof val === "object") {
1242
+ // 단일 object (N:1, 1:1 relationship)
1243
+ joins[fullKey] = { isSingle: true };
1244
+ buildResultMeta(val, fullKey);
1245
+ }
1246
+ }
1247
+ };
1248
+
1249
+ buildResultMeta(this.meta.columns, "");
1250
+
1251
+ return { columns, joins };
1252
+ }
1253
+
1254
+ //#endregion
1255
+
1256
+ //#region ========== [query] 삽입 - INSERT ==========
1257
+
1258
+ /**
1259
+ * INSERT query를 실행
1260
+ *
1261
+ * MSSQL의 1000개 제한을 위해 automatic으로 1000개씩 청크로 분할하여 실행
1262
+ *
1263
+ * @param records - Insert할 레코드 array
1264
+ * @param outputColumns - column name array to receive (Select)
1265
+ * @returns outputColumns 지정 시 삽입된 레코드 array return
1266
+ *
1267
+ * @example
1268
+ * ```typescript
1269
+ * // 단순 삽입
1270
+ * await db.user().insert([
1271
+ * { name: "Gildong Hong", email: "hong@test.com" },
1272
+ * ]);
1273
+ *
1274
+ * // 삽입 후 ID return
1275
+ * const [inserted] = await db.user().insert(
1276
+ * [{ name: "Gildong Hong" }],
1277
+ * ["id"],
1278
+ * );
1279
+ * ```
1280
+ */
1281
+ async insert(records: TFrom["$inferInsert"][]): Promise<void>;
1282
+ async insert<K extends keyof TFrom["$inferColumns"] & string>(
1283
+ records: TFrom["$inferInsert"][],
1284
+ outputColumns: K[],
1285
+ ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1286
+ async insert<K extends keyof TFrom["$inferColumns"] & string>(
1287
+ records: TFrom["$inferInsert"][],
1288
+ outputColumns?: K[],
1289
+ ): Promise<Pick<TFrom["$inferColumns"], K>[] | void> {
1290
+ if (records.length === 0) {
1291
+ return outputColumns ? [] : undefined;
1292
+ }
1293
+
1294
+ // MSSQL 1000개 제한을 위해 청크 split
1295
+ const CHUNK_SIZE = 1000;
1296
+ const allResults: Pick<TFrom["$inferColumns"], K>[] = [];
1297
+
1298
+ for (let i = 0; i < records.length; i += CHUNK_SIZE) {
1299
+ const chunk = records.slice(i, i + CHUNK_SIZE);
1300
+
1301
+ const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1302
+ [this.getInsertQueryDef(chunk, outputColumns)],
1303
+ outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1304
+ );
1305
+
1306
+ if (outputColumns) {
1307
+ allResults.push(...results[0]);
1308
+ }
1309
+ }
1310
+
1311
+ if (outputColumns) {
1312
+ return allResults;
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * WHERE condition에 맞는 data가 없으면 INSERT
1318
+ *
1319
+ * @param record - Insert할 레코드
1320
+ * @param outputColumns - column name array to receive (Select)
1321
+ * @returns outputColumns 지정 시 삽입된 레코드 return
1322
+ *
1323
+ * @example
1324
+ * ```typescript
1325
+ * await db.user()
1326
+ * .where((u) => [expr.eq(u.email, "test@test.com")])
1327
+ * .insertIfNotExists({ name: "testing", email: "test@test.com" });
1328
+ * ```
1329
+ */
1330
+ async insertIfNotExists(record: TFrom["$inferInsert"]): Promise<void>;
1331
+ async insertIfNotExists<K extends keyof TFrom["$inferColumns"] & string>(
1332
+ record: TFrom["$inferInsert"],
1333
+ outputColumns: K[],
1334
+ ): Promise<Pick<TFrom["$inferColumns"], K>>;
1335
+ async insertIfNotExists<K extends keyof TFrom["$inferColumns"] & string>(
1336
+ record: TFrom["$inferInsert"],
1337
+ outputColumns?: K[],
1338
+ ): Promise<Pick<TFrom["$inferColumns"], K> | void> {
1339
+ const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1340
+ [this.getInsertIfNotExistsQueryDef(record)],
1341
+ outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1342
+ );
1343
+
1344
+ if (outputColumns) {
1345
+ return results[0][0];
1346
+ }
1347
+ }
1348
+
1349
+ /**
1350
+ * INSERT INTO ... SELECT (현재 SELECT 결과를 다른 Table에 INSERT)
1351
+ *
1352
+ * @param targetTable - Insert 대상 Table
1353
+ * @param outputColumns - column name array to receive (Select)
1354
+ * @returns outputColumns 지정 시 삽입된 레코드 array return
1355
+ *
1356
+ * @example
1357
+ * ```typescript
1358
+ * await db.user()
1359
+ * .select((u) => ({ name: u.name, createdAt: u.createdAt }))
1360
+ * .where((u) => [expr.eq(u.isArchived, false)])
1361
+ * .insertInto(ArchivedUser);
1362
+ * ```
1363
+ */
1364
+ async insertInto<TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>>(
1365
+ targetTable: TTable,
1366
+ ): Promise<void>;
1367
+ async insertInto<
1368
+ TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>,
1369
+ TOut extends keyof TTable["$inferColumns"] & string,
1370
+ >(targetTable: TTable, outputColumns: TOut[]): Promise<Pick<TData, TOut>[]>;
1371
+ async insertInto<
1372
+ TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>,
1373
+ TOut extends keyof TTable["$inferColumns"] & string,
1374
+ >(targetTable: TTable, outputColumns?: TOut[]): Promise<Pick<TData, TOut>[] | void> {
1375
+ const results = await this.meta.db.executeDefs<Pick<TData, TOut>>(
1376
+ [this.getInsertIntoQueryDef(targetTable)],
1377
+ outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1378
+ );
1379
+
1380
+ if (outputColumns) {
1381
+ return results[0];
1382
+ }
1383
+ }
1384
+
1385
+ getInsertQueryDef(
1386
+ records: TFrom["$inferInsert"][],
1387
+ outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1388
+ ): InsertQueryDef {
1389
+ const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1390
+ const outputDef = this._getCudOutputDef();
1391
+
1392
+ // AI column에 explicit 값이 있으면 overrideIdentity 설정
1393
+ const overrideIdentity =
1394
+ outputDef.aiColName != null &&
1395
+ records.some((r) => (r as Record<string, unknown>)[outputDef.aiColName!] !== undefined);
1396
+
1397
+ return objClearUndefined({
1398
+ type: "insert",
1399
+ table: this.meta.db.getQueryDefObjectName(from),
1400
+ records,
1401
+ overrideIdentity: overrideIdentity || undefined,
1402
+ output: outputColumns
1403
+ ? {
1404
+ columns: outputColumns,
1405
+ pkColNames: outputDef.pkColNames,
1406
+ aiColName: outputDef.aiColName,
1407
+ }
1408
+ : undefined,
1409
+ });
1410
+ }
1411
+
1412
+ getInsertIfNotExistsQueryDef(
1413
+ record: TFrom["$inferInsert"],
1414
+ outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1415
+ ): InsertIfNotExistsQueryDef {
1416
+ const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1417
+ const outputDef = this._getCudOutputDef();
1418
+
1419
+ const { select: _, ...existsSelectQuery } = this.getSelectQueryDef();
1420
+
1421
+ return objClearUndefined({
1422
+ type: "insertIfNotExists",
1423
+ table: this.meta.db.getQueryDefObjectName(from),
1424
+ record,
1425
+ existsSelectQuery,
1426
+ output: outputColumns
1427
+ ? {
1428
+ columns: outputColumns,
1429
+ pkColNames: outputDef.pkColNames,
1430
+ aiColName: outputDef.aiColName,
1431
+ }
1432
+ : undefined,
1433
+ });
1434
+ }
1435
+
1436
+ getInsertIntoQueryDef<TTable extends TableBuilder<DataToColumnBuilderRecord<TData>, any>>(
1437
+ targetTable: TTable,
1438
+ outputColumns?: (keyof TTable["$inferColumns"] & string)[],
1439
+ ): InsertIntoQueryDef {
1440
+ const outputDef = this._getCudOutputDef();
1441
+
1442
+ return objClearUndefined({
1443
+ type: "insertInto",
1444
+ table: this.meta.db.getQueryDefObjectName(targetTable),
1445
+ recordsSelectQuery: this.getSelectQueryDef(),
1446
+ output: outputColumns
1447
+ ? {
1448
+ columns: outputColumns,
1449
+ pkColNames: outputDef.pkColNames,
1450
+ aiColName: outputDef.aiColName,
1451
+ }
1452
+ : undefined,
1453
+ });
1454
+ }
1455
+
1456
+ //#endregion
1457
+
1458
+ //#region ========== [query] Modify - UPDATE / DELETE ==========
1459
+
1460
+ /**
1461
+ * UPDATE query를 실행
1462
+ *
1463
+ * @param recordFwd - Update할 column과 값을 반환하는 function
1464
+ * @param outputColumns - column name array to receive (Select)
1465
+ * @returns outputColumns 지정 시 업데이트된 레코드 array return
1466
+ *
1467
+ * @example
1468
+ * ```typescript
1469
+ * // 단순 업데이트
1470
+ * await db.user()
1471
+ * .where((u) => [expr.eq(u.id, 1)])
1472
+ * .update((u) => ({
1473
+ * name: expr.val("string", "New Name"),
1474
+ * updatedAt: expr.val("DateTime", DateTime.now()),
1475
+ * }));
1476
+ *
1477
+ * // 기존 value 참조
1478
+ * await db.product()
1479
+ * .update((p) => ({
1480
+ * price: expr.mul(p.price, expr.val("number", 1.1)),
1481
+ * }));
1482
+ * ```
1483
+ */
1484
+ async update(
1485
+ recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1486
+ ): Promise<void>;
1487
+ async update<K extends keyof TFrom["$columns"] & string>(
1488
+ recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1489
+ outputColumns: K[],
1490
+ ): Promise<Pick<TFrom["$columns"], K>[]>;
1491
+ async update<K extends keyof TFrom["$columns"] & string>(
1492
+ recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1493
+ outputColumns?: K[],
1494
+ ): Promise<Pick<TFrom["$columns"], K>[] | void> {
1495
+ const results = await this.meta.db.executeDefs<Pick<TFrom["$columns"], K>>(
1496
+ [this.getUpdateQueryDef(recordFwd, outputColumns)],
1497
+ outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1498
+ );
1499
+
1500
+ if (outputColumns) {
1501
+ return results[0];
1502
+ }
1503
+ }
1504
+
1505
+ /**
1506
+ * DELETE query를 실행
1507
+ *
1508
+ * @param outputColumns - column name array to receive (Select)
1509
+ * @returns outputColumns 지정 시 삭제된 레코드 array return
1510
+ *
1511
+ * @example
1512
+ * ```typescript
1513
+ * // 단순 Delete
1514
+ * await db.user()
1515
+ * .where((u) => [expr.eq(u.id, 1)])
1516
+ * .delete();
1517
+ *
1518
+ * // 삭제된 data return
1519
+ * const deleted = await db.user()
1520
+ * .where((u) => [expr.eq(u.isExpired, true)])
1521
+ * .delete(["id", "name"]);
1522
+ * ```
1523
+ */
1524
+ async delete(): Promise<void>;
1525
+ async delete<K extends keyof TFrom["$columns"] & string>(
1526
+ outputColumns: K[],
1527
+ ): Promise<Pick<TFrom["$columns"], K>[]>;
1528
+ async delete<K extends keyof TFrom["$columns"] & string>(
1529
+ outputColumns?: K[],
1530
+ ): Promise<Pick<TFrom["$columns"], K>[] | void> {
1531
+ const results = await this.meta.db.executeDefs<Pick<TFrom["$columns"], K>>(
1532
+ [this.getDeleteQueryDef(outputColumns)],
1533
+ outputColumns ? [this.getResultMeta(outputColumns)] : undefined,
1534
+ );
1535
+
1536
+ if (outputColumns) {
1537
+ return results[0];
1538
+ }
1539
+ }
1540
+
1541
+ getUpdateQueryDef(
1542
+ recordFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1543
+ outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1544
+ ): UpdateQueryDef {
1545
+ const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1546
+ const outputDef = this._getCudOutputDef();
1547
+
1548
+ return objClearUndefined({
1549
+ type: "update",
1550
+ table: this.meta.db.getQueryDefObjectName(from),
1551
+ as: this.meta.as,
1552
+ record: this._buildSelectDef(recordFwd(this.meta.columns), ""),
1553
+ top: this.meta.top,
1554
+ where: this.meta.where?.map((w) => w.expr),
1555
+ joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1556
+ limit: this.meta.limit,
1557
+ output: outputColumns
1558
+ ? {
1559
+ columns: outputColumns,
1560
+ pkColNames: outputDef.pkColNames,
1561
+ aiColName: outputDef.aiColName,
1562
+ }
1563
+ : undefined,
1564
+ });
1565
+ }
1566
+
1567
+ getDeleteQueryDef(outputColumns?: (keyof TFrom["$inferColumns"] & string)[]): DeleteQueryDef {
1568
+ const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1569
+ const outputDef = this._getCudOutputDef();
1570
+
1571
+ return objClearUndefined({
1572
+ type: "delete",
1573
+ table: this.meta.db.getQueryDefObjectName(from),
1574
+ as: this.meta.as,
1575
+ top: this.meta.top,
1576
+ where: this.meta.where?.map((w) => w.expr),
1577
+ joins: this.meta.joins ? this._buildJoinDefs(this.meta.joins) : undefined,
1578
+ limit: this.meta.limit,
1579
+ output: outputColumns
1580
+ ? {
1581
+ columns: outputColumns,
1582
+ pkColNames: outputDef.pkColNames,
1583
+ aiColName: outputDef.aiColName,
1584
+ }
1585
+ : undefined,
1586
+ });
1587
+ }
1588
+
1589
+ //#endregion
1590
+
1591
+ //#region ========== [query] Modify - UPSERT ==========
1592
+
1593
+ /**
1594
+ * UPSERT (UPDATE or INSERT) query를 실행
1595
+ *
1596
+ * WHERE condition에 맞는 data가 있으면 UPDATE, 없으면 INSERT
1597
+ *
1598
+ * @param updateFwd - Update할 column과 값을 반환하는 function
1599
+ * @param insertFwd - Insert할 레코드를 반환하는 function (selection, 미지정 시 updateFwd와 동일)
1600
+ * @param outputColumns - column name array to receive (Select)
1601
+ * @returns outputColumns 지정 시 영향받은 레코드 array return
1602
+ *
1603
+ * @example
1604
+ * ```typescript
1605
+ * // UPDATE/INSERT 동일 data
1606
+ * await db.user()
1607
+ * .where((u) => [expr.eq(u.email, "test@test.com")])
1608
+ * .upsert(() => ({
1609
+ * name: expr.val("string", "testing"),
1610
+ * email: expr.val("string", "test@test.com"),
1611
+ * }));
1612
+ *
1613
+ * // UPDATE/INSERT 다른 data
1614
+ * await db.user()
1615
+ * .where((u) => [expr.eq(u.email, "test@test.com")])
1616
+ * .upsert(
1617
+ * () => ({ loginCount: expr.val("number", 1) }),
1618
+ * (update) => ({ ...update, email: expr.val("string", "test@test.com") }),
1619
+ * );
1620
+ * ```
1621
+ */
1622
+ async upsert(
1623
+ updateFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferUpdate"]>,
1624
+ ): Promise<void>;
1625
+ async upsert<K extends keyof TFrom["$inferColumns"] & string>(
1626
+ insertFwd: (cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1627
+ outputColumns?: K[],
1628
+ ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1629
+ async upsert<U extends QueryableWriteRecord<TFrom["$inferUpdate"]>>(
1630
+ updateFwd: (cols: QueryableRecord<TData>) => U,
1631
+ insertFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1632
+ ): Promise<void>;
1633
+ async upsert<
1634
+ U extends QueryableWriteRecord<TFrom["$inferUpdate"]>,
1635
+ K extends keyof TFrom["$inferColumns"] & string,
1636
+ >(
1637
+ updateFwd: (cols: QueryableRecord<TData>) => U,
1638
+ insertFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1639
+ outputColumns?: K[],
1640
+ ): Promise<Pick<TFrom["$inferColumns"], K>[]>;
1641
+ async upsert<
1642
+ U extends QueryableWriteRecord<TFrom["$inferUpdate"]>,
1643
+ K extends keyof TFrom["$inferColumns"] & string,
1644
+ >(
1645
+ updateFwdOrInsertFwd:
1646
+ | ((cols: QueryableRecord<TData>) => U)
1647
+ | ((cols: QueryableRecord<TData>) => QueryableWriteRecord<TFrom["$inferInsert"]>),
1648
+ insertFwdOrOutputColumns?:
1649
+ | ((updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>)
1650
+ | K[],
1651
+ outputColumns?: K[],
1652
+ ): Promise<Pick<TFrom["$inferColumns"], K>[] | void> {
1653
+ const updateRecordFwd = updateFwdOrInsertFwd as (cols: QueryableRecord<TData>) => U;
1654
+
1655
+ const insertRecordFwd = (
1656
+ insertFwdOrOutputColumns instanceof Function ? insertFwdOrOutputColumns : updateFwdOrInsertFwd
1657
+ ) as (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>;
1658
+
1659
+ const realOutputColumns =
1660
+ insertFwdOrOutputColumns instanceof Function ? outputColumns : insertFwdOrOutputColumns;
1661
+
1662
+ const results = await this.meta.db.executeDefs<Pick<TFrom["$inferColumns"], K>>(
1663
+ [this.getUpsertQueryDef(updateRecordFwd, insertRecordFwd, realOutputColumns)],
1664
+ [realOutputColumns ? this.getResultMeta(realOutputColumns) : undefined],
1665
+ );
1666
+
1667
+ if (realOutputColumns) {
1668
+ return results[0];
1669
+ }
1670
+ }
1671
+
1672
+ getUpsertQueryDef<U extends QueryableWriteRecord<TFrom["$inferUpdate"]>>(
1673
+ updateRecordFwd: (cols: QueryableRecord<TData>) => U,
1674
+ insertRecordFwd: (updateRecord: U) => QueryableWriteRecord<TFrom["$inferInsert"]>,
1675
+ outputColumns?: (keyof TFrom["$inferColumns"] & string)[],
1676
+ ): UpsertQueryDef {
1677
+ const from = this.meta.from as TableBuilder<any, any> | ViewBuilder<any, any, any>;
1678
+ const outputDef = this._getCudOutputDef();
1679
+
1680
+ const { select: _sel, ...existsSelectQuery } = this.getSelectQueryDef();
1681
+
1682
+ // updateRecord Generate
1683
+ const updateQrRecord = updateRecordFwd(this.meta.columns);
1684
+ const updateRecord: Record<string, Expr> = {};
1685
+ for (const [key, value] of Object.entries(updateQrRecord)) {
1686
+ updateRecord[key] = expr.toExpr(value);
1687
+ }
1688
+
1689
+ // insertRecord Generate (updateRecordRaw를 두 번째 인자로)
1690
+ const insertRecordRaw = insertRecordFwd(updateQrRecord);
1691
+ const insertRecord = Object.fromEntries(
1692
+ Object.entries(insertRecordRaw).map(([key, value]) => [key, expr.toExpr(value)]),
1693
+ );
1694
+
1695
+ return objClearUndefined({
1696
+ type: "upsert",
1697
+ table: this.meta.db.getQueryDefObjectName(from),
1698
+ existsSelectQuery,
1699
+ updateRecord,
1700
+ insertRecord,
1701
+ output: outputColumns
1702
+ ? {
1703
+ columns: outputColumns,
1704
+ pkColNames: outputDef.pkColNames,
1705
+ aiColName: outputDef.aiColName,
1706
+ }
1707
+ : undefined,
1708
+ });
1709
+ }
1710
+
1711
+ //#endregion
1712
+
1713
+ //#region ========== DDL Helper ==========
1714
+
1715
+ /**
1716
+ * FK constraint on/off (transaction 내 사용 가능)
1717
+ */
1718
+ async switchFk(switch_: "on" | "off"): Promise<void> {
1719
+ const from = this.meta.from;
1720
+ if (!(from instanceof TableBuilder) && !(from instanceof ViewBuilder)) {
1721
+ throw new Error(
1722
+ "switchFk는 TableBuilder 또는 ViewBuilder 기반 queryable에서만 사용할 수 있습니다.",
1723
+ );
1724
+ }
1725
+ await this.meta.db.switchFk(this.meta.db.getQueryDefObjectName(from), switch_);
1726
+ }
1727
+
1728
+ //#endregion
1729
+
1730
+ //#region ========== CUD 공통 ==========
1731
+
1732
+ private _getCudOutputDef(): {
1733
+ pkColNames: string[];
1734
+ aiColName?: string;
1735
+ } {
1736
+ const from = this.meta.from;
1737
+
1738
+ if (from instanceof TableBuilder) {
1739
+ if (from.meta.columns == null) {
1740
+ throw new Error(`Table '${from.meta.name}' has no Column definition.`);
1741
+ }
1742
+
1743
+ let aiColName: string | undefined;
1744
+ for (const [key, col] of Object.entries(from.meta.columns as ColumnBuilderRecord)) {
1745
+ if (col.meta.autoIncrement) {
1746
+ aiColName = key;
1747
+ }
1748
+ }
1749
+
1750
+ return {
1751
+ pkColNames: from.meta.primaryKey ?? [],
1752
+ aiColName,
1753
+ };
1754
+ }
1755
+
1756
+ throw new Error("CUD operations can only be used on TableBuilder-based queryables.");
1757
+ }
1758
+
1759
+ //#endregion
1760
+ }
1761
+
1762
+ //#region ========== Helper Functions ==========
1763
+
1764
+ /**
1765
+ * FK column 배열과 대상 Table의 PK를 매칭하여 PK column명 배열을 return
1766
+ *
1767
+ * @param fkCols - FK column명 array
1768
+ * @param targetTable - 참조 대상 Table builder
1769
+ * @returns 매칭된 PK column명 array
1770
+ * @throws FK/PK column 수 불일치 시
1771
+ */
1772
+ export function getMatchedPrimaryKeys(
1773
+ fkCols: string[],
1774
+ targetTable: TableBuilder<any, any>,
1775
+ ): string[] {
1776
+ const pk = targetTable.meta.primaryKey;
1777
+ if (pk == null || fkCols.length !== pk.length) {
1778
+ throw new Error(
1779
+ `FK/PK column 개수가 일치하지 않습니다 (대상: ${targetTable.meta.name}, FK: ${fkCols.length}개, PK: ${pk?.length ?? 0}개)`,
1780
+ );
1781
+ }
1782
+ return pk;
1783
+ }
1784
+
1785
+ /**
1786
+ * 중첩 columns structure를 새 alias로 Transform하는 공용 헬퍼
1787
+ *
1788
+ * Subquery/JOIN 시 기존 alias를 새 alias로 Transform하면서,
1789
+ * 중첩 키(posts.userId)는 평면화된 키로 유지한다.
1790
+ *
1791
+ * 예: posts[0].userId column의 경로가 ["T1.posts", "userId"]인 경우,
1792
+ * 새 alias "T2"로 Transform하면 ["T2", "posts.userId"]가 된다.
1793
+ *
1794
+ * @param columns - Transform할 column 레코드
1795
+ * @param alias - 새 Table alias (예: "T2")
1796
+ * @param keyPrefix - 현재 중첩 경로 (recursive 호출용, Default value "")
1797
+ * @returns Transform된 column 레코드
1798
+ */
1799
+ function transformColumnsAlias<TRecord extends DataRecord>(
1800
+ columns: QueryableRecord<TRecord>,
1801
+ alias: string,
1802
+ keyPrefix: string = "",
1803
+ ): QueryableRecord<TRecord> {
1804
+ const result: Record<string, unknown> = {};
1805
+
1806
+ for (const [key, value] of Object.entries(columns as Record<string, unknown>)) {
1807
+ const fullKey = keyPrefix ? `${keyPrefix}.${key}` : key;
1808
+
1809
+ if (value instanceof ExprUnit) {
1810
+ result[key] = expr.col(value.dataType, alias, fullKey);
1811
+ } else if (Array.isArray(value)) {
1812
+ if (value.length > 0) {
1813
+ result[key] = [
1814
+ transformColumnsAlias(value[0] as QueryableRecord<DataRecord>, alias, fullKey),
1815
+ ];
1816
+ }
1817
+ } else if (typeof value === "object" && value != null) {
1818
+ result[key] = transformColumnsAlias(value as QueryableRecord<DataRecord>, alias, fullKey);
1819
+ } else {
1820
+ result[key] = value;
1821
+ }
1822
+ }
1823
+
1824
+ return result as QueryableRecord<TRecord>;
1825
+ }
1826
+
1827
+ //#endregion
1828
+
1829
+ //#region ========== Types ==========
1830
+
1831
+ interface QueryableMeta<TData extends DataRecord> {
1832
+ db: DbContextBase;
1833
+ from?:
1834
+ | TableBuilder<any, any>
1835
+ | ViewBuilder<any, any, any>
1836
+ | Queryable<any, any>
1837
+ | Queryable<TData, any>[]
1838
+ | string;
1839
+ as: string;
1840
+ columns: QueryableRecord<TData>;
1841
+ isCustomColumns?: boolean;
1842
+ distinct?: boolean;
1843
+ top?: number;
1844
+ lock?: boolean;
1845
+ where?: WhereExprUnit[];
1846
+ joins?: QueryableMetaJoin[];
1847
+ orderBy?: [ExprUnit<ColumnPrimitive>, ("ASC" | "DESC")?][];
1848
+ limit?: [number, number];
1849
+ groupBy?: ExprUnit<ColumnPrimitive>[];
1850
+ having?: WhereExprUnit[];
1851
+ with?: { name: string; base: Queryable<any, any>; recursive: Queryable<any, any> };
1852
+ }
1853
+
1854
+ interface QueryableMetaJoin {
1855
+ queryable: Queryable<any, any>;
1856
+ isSingle: boolean;
1857
+ }
1858
+
1859
+ export type QueryableRecord<TData extends DataRecord> = {
1860
+ [K in keyof TData]: TData[K] extends ColumnPrimitive
1861
+ ? ExprUnit<TData[K]>
1862
+ : TData[K] extends (infer U)[]
1863
+ ? U extends DataRecord
1864
+ ? QueryableRecord<U>[]
1865
+ : never
1866
+ : TData[K] extends (infer U)[] | undefined
1867
+ ? U extends DataRecord
1868
+ ? NullableQueryableRecord<U>[] | undefined
1869
+ : never
1870
+ : TData[K] extends DataRecord
1871
+ ? QueryableRecord<TData[K]>
1872
+ : TData[K] extends DataRecord | undefined
1873
+ ? NullableQueryableRecord<Exclude<TData[K], undefined>> | undefined
1874
+ : never;
1875
+ };
1876
+
1877
+ export type QueryableWriteRecord<TData> = {
1878
+ [K in keyof TData]: TData[K] extends ColumnPrimitive ? ExprInput<TData[K]> : never;
1879
+ };
1880
+
1881
+ export type NullableQueryableRecord<TData extends DataRecord> = {
1882
+ // Primitive — always | undefined (LEFT JOIN NULL propagation)
1883
+ [K in keyof TData]: TData[K] extends ColumnPrimitive
1884
+ ? ExprUnit<TData[K] | undefined>
1885
+ : TData[K] extends (infer U)[]
1886
+ ? U extends DataRecord
1887
+ ? NullableQueryableRecord<U>[]
1888
+ : never
1889
+ : TData[K] extends (infer U)[] | undefined
1890
+ ? U extends DataRecord
1891
+ ? NullableQueryableRecord<U>[] | undefined
1892
+ : never
1893
+ : TData[K] extends DataRecord
1894
+ ? NullableQueryableRecord<TData[K]>
1895
+ : TData[K] extends DataRecord | undefined
1896
+ ? NullableQueryableRecord<Exclude<TData[K], undefined>> | undefined
1897
+ : never;
1898
+ };
1899
+
1900
+ /**
1901
+ * QueryableRecord에서 DataRecord로 역transform
1902
+ *
1903
+ * ExprUnit<T>를 T로, 중첩 object/배열을 재귀적으로 풀어냄
1904
+ */
1905
+ export type UnwrapQueryableRecord<R> = {
1906
+ [K in keyof R]: R[K] extends ExprUnit<infer T>
1907
+ ? T
1908
+ : NonNullable<R[K]> extends (infer U)[]
1909
+ ? U extends Record<string, any>
1910
+ ? UnwrapQueryableRecord<U>[] | Extract<R[K], undefined>
1911
+ : never
1912
+ : NonNullable<R[K]> extends Record<string, any>
1913
+ ? UnwrapQueryableRecord<NonNullable<R[K]>> | Extract<R[K], undefined>
1914
+ : never;
1915
+ };
1916
+
1917
+ //#region ========== PathProxy - include용 type 안전 경로 builder ==========
1918
+
1919
+ /**
1920
+ * include()에서 relationship 경로를 type 안전하게 지정하기 위한 Proxy type
1921
+ * ColumnPrimitive가 아닌 필드(FK, FKT relationship)만 access 가능
1922
+ *
1923
+ * @example
1924
+ * ```typescript
1925
+ * // item.user.company access 시 내부적으로 ["user", "company"] 경로 수집
1926
+ * db.post.include(item => item.user.company)
1927
+ *
1928
+ * // item.title은 string(ColumnPrimitive)이므로 컴파일 에러
1929
+ * db.post.include(item => item.title) // ❌ 에러
1930
+ * ```
1931
+ */
1932
+ /**
1933
+ * 배열이면 요소 type 추출
1934
+ */
1935
+ type UnwrapArray<TArray> = TArray extends (infer TElement)[] ? TElement : TArray;
1936
+
1937
+ const PATH_SYMBOL = Symbol("path");
1938
+
1939
+ /**
1940
+ * include()용 type 안전 경로 프록시
1941
+ */
1942
+ export type PathProxy<TObject> = {
1943
+ [K in keyof TObject as TObject[K] extends ColumnPrimitive ? never : K]-?: PathProxy<
1944
+ UnwrapArray<TObject[K]>
1945
+ >;
1946
+ } & { readonly [PATH_SYMBOL]: string[] };
1947
+
1948
+ /**
1949
+ * PathProxy instance Generate
1950
+ * Proxy를 사용하여 프로퍼티 접근을 가로채고 경로를 수집
1951
+ */
1952
+ function createPathProxy<TObject>(path: string[] = []): PathProxy<TObject> {
1953
+ return new Proxy({} as PathProxy<TObject>, {
1954
+ get(_, prop: string | symbol) {
1955
+ if (prop === PATH_SYMBOL) return path;
1956
+ if (typeof prop === "symbol") return undefined;
1957
+ return createPathProxy<unknown>([...path, prop]);
1958
+ },
1959
+ });
1960
+ }
1961
+
1962
+ //#endregion
1963
+
1964
+ /**
1965
+ * Table 또는 View에 대한 Queryable factory 함수를 Generate
1966
+ *
1967
+ * DbContext에서 Table/View별 getter를 정의할 때 사용
1968
+ *
1969
+ * @param db - DbContext instance
1970
+ * @param tableOrView - TableBuilder 또는 ViewBuilder instance
1971
+ * @param as - alias 지정 (selection, 미지정 시 automatic Create)
1972
+ * @returns Queryable을 반환하는 factory function
1973
+ *
1974
+ * @example
1975
+ * ```typescript
1976
+ * class AppDbContext extends DbContext {
1977
+ * // 호출 시마다 새로운 alias 할당
1978
+ * user = queryable(this, User);
1979
+ *
1980
+ * // 사용 예시
1981
+ * async getActiveUsers() {
1982
+ * return this.user()
1983
+ * .where((u) => [expr.eq(u.isActive, true)])
1984
+ * .result();
1985
+ * }
1986
+ * }
1987
+ * ```
1988
+ */
1989
+ export function queryable<TBuilder extends TableBuilder<any, any> | ViewBuilder<any, any, any>>(
1990
+ db: DbContextBase,
1991
+ tableOrView: TBuilder,
1992
+ as?: string,
1993
+ ): () => Queryable<TBuilder["$infer"], TBuilder extends TableBuilder<any, any> ? TBuilder : never> {
1994
+ return () => {
1995
+ // as가 명시되지 않으면 db.getNextAlias() 사용 (카운터 증가)
1996
+ // as가 명시되면 그대로 사용 (카운터 증가 안함)
1997
+ const finalAs = as ?? db.getNextAlias();
1998
+
1999
+ // TableBuilder + columns
2000
+ if (tableOrView instanceof TableBuilder && tableOrView.meta.columns != null) {
2001
+ const columnDefs = tableOrView.meta.columns as ColumnBuilderRecord;
2002
+
2003
+ return new Queryable({
2004
+ db,
2005
+ from: tableOrView,
2006
+ as: finalAs,
2007
+ columns: Object.fromEntries(
2008
+ Object.entries(columnDefs).map(([key, colDef]) => [
2009
+ key,
2010
+ expr.col(colDef.meta.type, finalAs, key),
2011
+ ]),
2012
+ ),
2013
+ }) as any;
2014
+ }
2015
+
2016
+ // ViewBuilder + viewFn
2017
+ if (tableOrView instanceof ViewBuilder && tableOrView.meta.viewFn != null) {
2018
+ const baseQr = tableOrView.meta.viewFn(db);
2019
+
2020
+ // TFrom을 ViewBuilder로 설정하여 return
2021
+ return new Queryable({
2022
+ db,
2023
+ from: tableOrView,
2024
+ as: finalAs,
2025
+ columns: transformColumnsAlias(baseQr.meta.columns, finalAs),
2026
+ }) as any;
2027
+ }
2028
+
2029
+ throw new Error(`Invalid Table/View Metadata: ${tableOrView.meta.name}`);
2030
+ };
2031
+ }
2032
+
2033
+ //#endregion