@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,613 +1,613 @@
1
- import { DateOnly, DateTime, Time, Uuid, bytesToHex } from "@simplysm/core-common";
2
- import type {
3
- ExprColumn,
4
- ExprValue,
5
- ExprRaw,
6
- ExprEq,
7
- ExprGt,
8
- ExprLt,
9
- ExprGte,
10
- ExprLte,
11
- ExprBetween,
12
- ExprIsNull,
13
- ExprLike,
14
- ExprRegexp,
15
- ExprIn,
16
- ExprInQuery,
17
- ExprExists,
18
- ExprNot,
19
- ExprAnd,
20
- ExprOr,
21
- ExprConcat,
22
- ExprLeft,
23
- ExprRight,
24
- ExprTrim,
25
- ExprPadStart,
26
- ExprReplace,
27
- ExprUpper,
28
- ExprLower,
29
- ExprLength,
30
- ExprByteLength,
31
- ExprSubstring,
32
- ExprIndexOf,
33
- ExprAbs,
34
- ExprRound,
35
- ExprCeil,
36
- ExprFloor,
37
- ExprYear,
38
- ExprMonth,
39
- ExprDay,
40
- ExprHour,
41
- ExprMinute,
42
- ExprSecond,
43
- ExprIsoWeek,
44
- ExprIsoWeekStartDate,
45
- ExprIsoYearMonth,
46
- ExprDateDiff,
47
- ExprDateAdd,
48
- ExprFormatDate,
49
- ExprIfNull,
50
- ExprNullIf,
51
- ExprIs,
52
- ExprSwitch,
53
- ExprIf,
54
- ExprCount,
55
- ExprSum,
56
- ExprAvg,
57
- ExprMax,
58
- ExprMin,
59
- ExprGreatest,
60
- ExprLeast,
61
- ExprRowNum,
62
- ExprCast,
63
- ExprWindow,
64
- ExprSubquery,
65
- DateSeparator,
66
- } from "../../types/expr";
67
- import type { DataType } from "../../types/column";
68
- import { ExprRendererBase } from "../base/expr-renderer-base";
69
-
70
- /**
71
- * MySQL Expr 렌더러
72
- */
73
- export class MysqlExprRenderer extends ExprRendererBase {
74
- //#region ========== 유틸리티 (public - QueryBuilder에서도 사용) ==========
75
-
76
- /** 식별자 감싸기 */
77
- wrap(name: string): string {
78
- return `\`${name.replace(/`/g, "``")}\``;
79
- }
80
-
81
- /** SQL 문자열 리터럴용 이스케이프 (따옴표 없이 반환) */
82
- escapeString(value: string): string {
83
- return value
84
- .replace(/\\/g, "\\\\") // 백슬래시 (최우선)
85
- .replace(/'/g, "''") // 따옴표
86
- .replace(/\0/g, "\\0") // NULL 바이트
87
- .replace(/\n/g, "\\n") // 줄바꿈
88
- .replace(/\r/g, "\\r") // 캐리지 리턴
89
- .replace(/\t/g, "\\t"); // 탭
90
- }
91
-
92
- /** 이스케이프 */
93
- escapeValue(value: unknown): string {
94
- if (value == null) {
95
- return "NULL";
96
- }
97
- if (typeof value === "string") {
98
- return `'${this.escapeString(value)}'`;
99
- }
100
- if (typeof value === "number") {
101
- return String(value);
102
- }
103
- if (typeof value === "boolean") {
104
- return value ? "TRUE" : "FALSE";
105
- }
106
- if (value instanceof DateTime) {
107
- return `'${value.toFormatString("yyyy-MM-dd HH:mm:ss")}'`;
108
- }
109
- if (value instanceof DateOnly) {
110
- return `'${value.toFormatString("yyyy-MM-dd")}'`;
111
- }
112
- if (value instanceof Time) {
113
- return `'${value.toFormatString("HH:mm:ss")}'`;
114
- }
115
- if (value instanceof Uuid) {
116
- return `0x${bytesToHex(value.toBytes())}`;
117
- }
118
- if (value instanceof Uint8Array) {
119
- return `0x${bytesToHex(value)}`;
120
- }
121
- throw new Error(`알 없는 값 타입: ${typeof value}`);
122
- }
123
-
124
- /** DataType → SQL 타입 */
125
- renderDataType(dataType: DataType): string {
126
- switch (dataType.type) {
127
- case "int":
128
- return "INT";
129
- case "bigint":
130
- return "BIGINT";
131
- case "float":
132
- return "FLOAT";
133
- case "double":
134
- return "DOUBLE";
135
- case "decimal":
136
- return dataType.scale != null
137
- ? `DECIMAL(${dataType.precision}, ${dataType.scale})`
138
- : `DECIMAL(${dataType.precision})`;
139
- case "varchar":
140
- return `VARCHAR(${dataType.length})`;
141
- case "char":
142
- return `CHAR(${dataType.length})`;
143
- case "text":
144
- return "LONGTEXT";
145
- case "binary":
146
- return "LONGBLOB";
147
- case "boolean":
148
- return "BOOLEAN";
149
- case "datetime":
150
- return "DATETIME";
151
- case "date":
152
- return "DATE";
153
- case "time":
154
- return "TIME";
155
- case "uuid":
156
- return "BINARY(16)";
157
- }
158
- }
159
-
160
- //#endregion
161
-
162
- //#region ========== ==========
163
-
164
- protected column(expr: ExprColumn): string {
165
- return expr.path.map((p) => this.wrap(p)).join(".");
166
- }
167
-
168
- protected value(expr: ExprValue): string {
169
- return this.escapeValue(expr.value);
170
- }
171
-
172
- protected raw(expr: ExprRaw): string {
173
- return expr.sql.replace(/\$(\d+)/g, (_, num) => {
174
- const idx = parseInt(num) - 1;
175
- return idx < expr.params.length ? this.render(expr.params[idx]) : `$${num}`;
176
- });
177
- }
178
-
179
- //#endregion
180
-
181
- //#region ========== 비교 (null-safe) ==========
182
-
183
- protected eq(expr: ExprEq): string {
184
- // MySQL: <=> 연산자 (null-safe equal)
185
- return `${this.render(expr.source)} <=> ${this.render(expr.target)}`;
186
- }
187
-
188
- protected gt(expr: ExprGt): string {
189
- return `${this.render(expr.source)} > ${this.render(expr.target)}`;
190
- }
191
-
192
- protected lt(expr: ExprLt): string {
193
- return `${this.render(expr.source)} < ${this.render(expr.target)}`;
194
- }
195
-
196
- protected gte(expr: ExprGte): string {
197
- return `${this.render(expr.source)} >= ${this.render(expr.target)}`;
198
- }
199
-
200
- protected lte(expr: ExprLte): string {
201
- return `${this.render(expr.source)} <= ${this.render(expr.target)}`;
202
- }
203
-
204
- protected between(expr: ExprBetween): string {
205
- const source = this.render(expr.source);
206
- if (expr.from != null && expr.to != null) {
207
- return `${source} BETWEEN ${this.render(expr.from)} AND ${this.render(expr.to)}`;
208
- }
209
- if (expr.from != null) {
210
- return `${source} >= ${this.render(expr.from)}`;
211
- }
212
- if (expr.to != null) {
213
- return `${source} <= ${this.render(expr.to)}`;
214
- }
215
- return "1=1";
216
- }
217
-
218
- protected null(expr: ExprIsNull): string {
219
- return `${this.render(expr.arg)} IS NULL`;
220
- }
221
-
222
- protected like(expr: ExprLike): string {
223
- // ESCAPE '\' 항상 추가
224
- return `${this.render(expr.source)} LIKE ${this.render(expr.pattern)} ESCAPE '\\\\'`;
225
- }
226
-
227
- protected regexp(expr: ExprRegexp): string {
228
- return `${this.render(expr.source)} REGEXP ${this.render(expr.pattern)}`;
229
- }
230
-
231
- protected in(expr: ExprIn): string {
232
- if (expr.values.length === 0) {
233
- return "1=0"; // 빈 IN은 항상 false
234
- }
235
- const values = expr.values.map((v) => this.render(v)).join(", ");
236
- return `${this.render(expr.source)} IN (${values})`;
237
- }
238
-
239
- protected inQuery(expr: ExprInQuery): string {
240
- return `${this.render(expr.source)} IN (${this.buildSelect(expr.query)})`;
241
- }
242
-
243
- protected exists(expr: ExprExists): string {
244
- // SELECT 1로 렌더링
245
- const subquery = this.buildSelect({
246
- ...expr.query,
247
- select: { _: { type: "value", value: 1 } },
248
- });
249
- return `EXISTS (${subquery})`;
250
- }
251
-
252
- //#endregion
253
-
254
- //#region ========== 논리 ==========
255
-
256
- protected not(expr: ExprNot): string {
257
- return `NOT (${this.render(expr.arg)})`;
258
- }
259
-
260
- protected and(expr: ExprAnd): string {
261
- if (expr.conditions.length === 0) return "1=1";
262
- return `(${expr.conditions.map((c) => this.render(c)).join(" AND ")})`;
263
- }
264
-
265
- protected or(expr: ExprOr): string {
266
- if (expr.conditions.length === 0) return "1=0";
267
- return `(${expr.conditions.map((c) => this.render(c)).join(" OR ")})`;
268
- }
269
-
270
- //#endregion
271
-
272
- //#region ========== 문자열 (null 처리) ==========
273
-
274
- protected concat(expr: ExprConcat): string {
275
- // null 처리: IFNULL(arg, '')
276
- const args = expr.args.map((a) => `IFNULL(${this.render(a)}, '')`);
277
- return `CONCAT(${args.join(", ")})`;
278
- }
279
-
280
- protected left(expr: ExprLeft): string {
281
- return `LEFT(${this.render(expr.source)}, ${this.render(expr.length)})`;
282
- }
283
-
284
- protected right(expr: ExprRight): string {
285
- return `RIGHT(${this.render(expr.source)}, ${this.render(expr.length)})`;
286
- }
287
-
288
- protected trim(expr: ExprTrim): string {
289
- return `TRIM(${this.render(expr.arg)})`;
290
- }
291
-
292
- protected padStart(expr: ExprPadStart): string {
293
- return `LPAD(${this.render(expr.source)}, ${this.render(expr.length)}, ${this.render(expr.fillString)})`;
294
- }
295
-
296
- protected replace(expr: ExprReplace): string {
297
- return `REPLACE(${this.render(expr.source)}, ${this.render(expr.from)}, ${this.render(expr.to)})`;
298
- }
299
-
300
- protected upper(expr: ExprUpper): string {
301
- return `UPPER(${this.render(expr.arg)})`;
302
- }
303
-
304
- protected lower(expr: ExprLower): string {
305
- return `LOWER(${this.render(expr.arg)})`;
306
- }
307
-
308
- protected length(expr: ExprLength): string {
309
- // null 처리: IFNULL(arg, '')
310
- return `CHAR_LENGTH(IFNULL(${this.render(expr.arg)}, ''))`;
311
- }
312
-
313
- protected byteLength(expr: ExprByteLength): string {
314
- // null 처리: IFNULL(arg, '')
315
- return `LENGTH(IFNULL(${this.render(expr.arg)}, ''))`;
316
- }
317
-
318
- protected substring(expr: ExprSubstring): string {
319
- if (expr.length != null) {
320
- return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)}, ${this.render(expr.length)})`;
321
- }
322
- return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)})`;
323
- }
324
-
325
- protected indexOf(expr: ExprIndexOf): string {
326
- return `LOCATE(${this.render(expr.search)}, ${this.render(expr.source)})`;
327
- }
328
-
329
- //#endregion
330
-
331
- //#region ========== 숫자 ==========
332
-
333
- protected abs(expr: ExprAbs): string {
334
- return `ABS(${this.render(expr.arg)})`;
335
- }
336
-
337
- protected round(expr: ExprRound): string {
338
- return `ROUND(${this.render(expr.arg)}, ${expr.digits})`;
339
- }
340
-
341
- protected ceil(expr: ExprCeil): string {
342
- return `CEIL(${this.render(expr.arg)})`;
343
- }
344
-
345
- protected floor(expr: ExprFloor): string {
346
- return `FLOOR(${this.render(expr.arg)})`;
347
- }
348
-
349
- //#endregion
350
-
351
- //#region ========== 날짜 ==========
352
-
353
- protected year(expr: ExprYear): string {
354
- return `YEAR(${this.render(expr.arg)})`;
355
- }
356
-
357
- protected month(expr: ExprMonth): string {
358
- return `MONTH(${this.render(expr.arg)})`;
359
- }
360
-
361
- protected day(expr: ExprDay): string {
362
- return `DAY(${this.render(expr.arg)})`;
363
- }
364
-
365
- protected hour(expr: ExprHour): string {
366
- return `HOUR(${this.render(expr.arg)})`;
367
- }
368
-
369
- protected minute(expr: ExprMinute): string {
370
- return `MINUTE(${this.render(expr.arg)})`;
371
- }
372
-
373
- protected second(expr: ExprSecond): string {
374
- return `SECOND(${this.render(expr.arg)})`;
375
- }
376
-
377
- protected isoWeek(expr: ExprIsoWeek): string {
378
- return `WEEK(${this.render(expr.arg)}, 1)`;
379
- }
380
-
381
- protected isoWeekStartDate(expr: ExprIsoWeekStartDate): string {
382
- // ISO 주의 시작일 (월요일)
383
- return `DATE_SUB(${this.render(expr.arg)}, INTERVAL (WEEKDAY(${this.render(expr.arg)})) DAY)`;
384
- }
385
-
386
- protected isoYearMonth(expr: ExprIsoYearMonth): string {
387
- return `DATE_FORMAT(${this.render(expr.arg)}, '%Y%m')`;
388
- }
389
-
390
- protected dateDiff(expr: ExprDateDiff): string {
391
- const from = this.render(expr.from);
392
- const to = this.render(expr.to);
393
- switch (expr.separator) {
394
- case "year":
395
- return `TIMESTAMPDIFF(YEAR, ${from}, ${to})`;
396
- case "month":
397
- return `TIMESTAMPDIFF(MONTH, ${from}, ${to})`;
398
- case "day":
399
- return `TIMESTAMPDIFF(DAY, ${from}, ${to})`;
400
- case "hour":
401
- return `TIMESTAMPDIFF(HOUR, ${from}, ${to})`;
402
- case "minute":
403
- return `TIMESTAMPDIFF(MINUTE, ${from}, ${to})`;
404
- case "second":
405
- return `TIMESTAMPDIFF(SECOND, ${from}, ${to})`;
406
- }
407
- }
408
-
409
- protected dateAdd(expr: ExprDateAdd): string {
410
- const source = this.render(expr.source);
411
- const value = this.render(expr.value);
412
- const unit = this.dateSeparatorToUnit(expr.separator);
413
- return `DATE_ADD(${source}, INTERVAL ${value} ${unit})`;
414
- }
415
-
416
- protected formatDate(expr: ExprFormatDate): string {
417
- // JS format → MySQL format
418
- const mysqlFormat = this.convertDateFormat(expr.format);
419
- return `DATE_FORMAT(${this.render(expr.source)}, '${mysqlFormat}')`;
420
- }
421
-
422
- private dateSeparatorToUnit(sep: DateSeparator): string {
423
- switch (sep) {
424
- case "year":
425
- return "YEAR";
426
- case "month":
427
- return "MONTH";
428
- case "day":
429
- return "DAY";
430
- case "hour":
431
- return "HOUR";
432
- case "minute":
433
- return "MINUTE";
434
- case "second":
435
- return "SECOND";
436
- }
437
- }
438
-
439
- private convertDateFormat(format: string): string {
440
- // 간단한 변환 (yyyy-MM-dd HH:mm:ss 형식)
441
- return format
442
- .replace(/yyyy/g, "%Y")
443
- .replace(/MM/g, "%m")
444
- .replace(/dd/g, "%d")
445
- .replace(/HH/g, "%H")
446
- .replace(/mm/g, "%i")
447
- .replace(/ss/g, "%s");
448
- }
449
-
450
- //#endregion
451
-
452
- //#region ========== 조건 ==========
453
-
454
- protected ifNull(expr: ExprIfNull): string {
455
- if (expr.args.length === 0) return "NULL";
456
- if (expr.args.length === 1) return this.render(expr.args[0]);
457
- // COALESCE로 렌더링 (여러 중 첫 번째 non-null)
458
- return `COALESCE(${expr.args.map((a) => this.render(a)).join(", ")})`;
459
- }
460
-
461
- protected nullIf(expr: ExprNullIf): string {
462
- return `NULLIF(${this.render(expr.source)}, ${this.render(expr.value)})`;
463
- }
464
-
465
- protected is(expr: ExprIs): string {
466
- return `(${this.render(expr.condition)})`;
467
- }
468
-
469
- protected switch(expr: ExprSwitch): string {
470
- const cases = expr.cases
471
- .map((c) => `WHEN ${this.render(c.when)} THEN ${this.render(c.then)}`)
472
- .join(" ");
473
- return `CASE ${cases} ELSE ${this.render(expr.else)} END`;
474
- }
475
-
476
- protected if(expr: ExprIf): string {
477
- const elseVal = expr.else != null ? this.render(expr.else) : "NULL";
478
- return `IF(${this.render(expr.condition)}, ${this.render(expr.then)}, ${elseVal})`;
479
- }
480
-
481
- //#endregion
482
-
483
- //#region ========== 집계 ==========
484
-
485
- protected count(expr: ExprCount): string {
486
- if (expr.arg != null) {
487
- const distinct = expr.distinct ? "DISTINCT " : "";
488
- return `COUNT(${distinct}${this.render(expr.arg)})`;
489
- }
490
- return "COUNT(*)";
491
- }
492
-
493
- protected sum(expr: ExprSum): string {
494
- return `SUM(${this.render(expr.arg)})`;
495
- }
496
-
497
- protected avg(expr: ExprAvg): string {
498
- return `AVG(${this.render(expr.arg)})`;
499
- }
500
-
501
- protected max(expr: ExprMax): string {
502
- return `MAX(${this.render(expr.arg)})`;
503
- }
504
-
505
- protected min(expr: ExprMin): string {
506
- return `MIN(${this.render(expr.arg)})`;
507
- }
508
-
509
- //#endregion
510
-
511
- //#region ========== 기타 ==========
512
-
513
- protected greatest(expr: ExprGreatest): string {
514
- if (expr.args.length === 0) throw new Error("greatest 최소 하나의 인자가 필요합니다.");
515
- return `GREATEST(${expr.args.map((a) => this.render(a)).join(", ")})`;
516
- }
517
-
518
- protected least(expr: ExprLeast): string {
519
- if (expr.args.length === 0) throw new Error("least 최소 하나의 인자가 필요합니다.");
520
- return `LEAST(${expr.args.map((a) => this.render(a)).join(", ")})`;
521
- }
522
-
523
- protected rowNum(_expr: ExprRowNum): string {
524
- // MySQL에서는 변수 사용 또는 ROW_NUMBER() 윈도우 함수 사용
525
- // 여기서는 ROW_NUMBER()로 구현 (MySQL 8.0+)
526
- return "ROW_NUMBER() OVER ()";
527
- }
528
-
529
- protected random(): string {
530
- return "RAND()";
531
- }
532
-
533
- protected cast(expr: ExprCast): string {
534
- return `CAST(${this.render(expr.source)} AS ${this.renderDataType(expr.targetType)})`;
535
- }
536
-
537
- //#endregion
538
-
539
- //#region ========== 윈도우 ==========
540
-
541
- protected window(expr: ExprWindow): string {
542
- const fn = this.renderWindowFn(expr.fn);
543
- let over = this.renderWindowSpec(expr.spec);
544
-
545
- // LAST_VALUE는 기본 프레임이 CURRENT ROW까지만 보므로 전체 프레임 명시 필요
546
- if (expr.fn.type === "lastValue" && over.length > 0) {
547
- over += " ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING";
548
- }
549
-
550
- return `${fn} OVER (${over})`;
551
- }
552
-
553
- private renderWindowFn(fn: ExprWindow["fn"]): string {
554
- switch (fn.type) {
555
- case "rowNumber":
556
- return "ROW_NUMBER()";
557
- case "rank":
558
- return "RANK()";
559
- case "denseRank":
560
- return "DENSE_RANK()";
561
- case "ntile":
562
- return `NTILE(${fn.n})`;
563
- case "lag": {
564
- const offset = fn.offset ?? 1;
565
- const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
566
- return `LAG(${this.render(fn.column)}, ${offset}${def})`;
567
- }
568
- case "lead": {
569
- const offset = fn.offset ?? 1;
570
- const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
571
- return `LEAD(${this.render(fn.column)}, ${offset}${def})`;
572
- }
573
- case "firstValue":
574
- return `FIRST_VALUE(${this.render(fn.column)})`;
575
- case "lastValue":
576
- return `LAST_VALUE(${this.render(fn.column)})`;
577
- case "sum":
578
- return `SUM(${this.render(fn.column)})`;
579
- case "avg":
580
- return `AVG(${this.render(fn.column)})`;
581
- case "count":
582
- return fn.column != null ? `COUNT(${this.render(fn.column)})` : "COUNT(*)";
583
- case "min":
584
- return `MIN(${this.render(fn.column)})`;
585
- case "max":
586
- return `MAX(${this.render(fn.column)})`;
587
- }
588
- }
589
-
590
- private renderWindowSpec(spec: ExprWindow["spec"]): string {
591
- const parts: string[] = [];
592
- if (spec.partitionBy != null && spec.partitionBy.length > 0) {
593
- parts.push(`PARTITION BY ${spec.partitionBy.map((p) => this.render(p)).join(", ")}`);
594
- }
595
- if (spec.orderBy != null && spec.orderBy.length > 0) {
596
- const orderParts = spec.orderBy.map(
597
- ([expr, dir]) => `${this.render(expr)}${dir != null ? ` ${dir}` : ""}`,
598
- );
599
- parts.push(`ORDER BY ${orderParts.join(", ")}`);
600
- }
601
- return parts.join(" ");
602
- }
603
-
604
- //#endregion
605
-
606
- //#region ========== 시스템 ==========
607
-
608
- protected subquery(expr: ExprSubquery): string {
609
- return `(${this.buildSelect(expr.queryDef)})`;
610
- }
611
-
612
- //#endregion
613
- }
1
+ import { DateOnly, DateTime, Time, Uuid, bytesToHex } from "@simplysm/core-common";
2
+ import type {
3
+ ExprColumn,
4
+ ExprValue,
5
+ ExprRaw,
6
+ ExprEq,
7
+ ExprGt,
8
+ ExprLt,
9
+ ExprGte,
10
+ ExprLte,
11
+ ExprBetween,
12
+ ExprIsNull,
13
+ ExprLike,
14
+ ExprRegexp,
15
+ ExprIn,
16
+ ExprInQuery,
17
+ ExprExists,
18
+ ExprNot,
19
+ ExprAnd,
20
+ ExprOr,
21
+ ExprConcat,
22
+ ExprLeft,
23
+ ExprRight,
24
+ ExprTrim,
25
+ ExprPadStart,
26
+ ExprReplace,
27
+ ExprUpper,
28
+ ExprLower,
29
+ ExprLength,
30
+ ExprByteLength,
31
+ ExprSubstring,
32
+ ExprIndexOf,
33
+ ExprAbs,
34
+ ExprRound,
35
+ ExprCeil,
36
+ ExprFloor,
37
+ ExprYear,
38
+ ExprMonth,
39
+ ExprDay,
40
+ ExprHour,
41
+ ExprMinute,
42
+ ExprSecond,
43
+ ExprIsoWeek,
44
+ ExprIsoWeekStartDate,
45
+ ExprIsoYearMonth,
46
+ ExprDateDiff,
47
+ ExprDateAdd,
48
+ ExprFormatDate,
49
+ ExprIfNull,
50
+ ExprNullIf,
51
+ ExprIs,
52
+ ExprSwitch,
53
+ ExprIf,
54
+ ExprCount,
55
+ ExprSum,
56
+ ExprAvg,
57
+ ExprMax,
58
+ ExprMin,
59
+ ExprGreatest,
60
+ ExprLeast,
61
+ ExprRowNum,
62
+ ExprCast,
63
+ ExprWindow,
64
+ ExprSubquery,
65
+ DateSeparator,
66
+ } from "../../types/expr";
67
+ import type { DataType } from "../../types/column";
68
+ import { ExprRendererBase } from "../base/expr-renderer-base";
69
+
70
+ /**
71
+ * MySQL expression renderer
72
+ */
73
+ export class MysqlExprRenderer extends ExprRendererBase {
74
+ //#region ========== 유틸리티 (public - QueryBuilder에서도 사용) ==========
75
+
76
+ /** 식별자 감싸기 */
77
+ wrap(name: string): string {
78
+ return `\`${name.replace(/`/g, "``")}\``;
79
+ }
80
+
81
+ /** SQL 문자열 리터럴용 escape (따옴표 없이 return) */
82
+ escapeString(value: string): string {
83
+ return value
84
+ .replace(/\\/g, "\\\\") // 백슬래시 (최우선)
85
+ .replace(/'/g, "''") // 따옴표
86
+ .replace(/\0/g, "\\0") // NULL 바이트
87
+ .replace(/\n/g, "\\n") // 줄바꿈
88
+ .replace(/\r/g, "\\r") // 캐리지 리턴
89
+ .replace(/\t/g, "\\t"); // 탭
90
+ }
91
+
92
+ /** value escape */
93
+ escapeValue(value: unknown): string {
94
+ if (value == null) {
95
+ return "NULL";
96
+ }
97
+ if (typeof value === "string") {
98
+ return `'${this.escapeString(value)}'`;
99
+ }
100
+ if (typeof value === "number") {
101
+ return String(value);
102
+ }
103
+ if (typeof value === "boolean") {
104
+ return value ? "TRUE" : "FALSE";
105
+ }
106
+ if (value instanceof DateTime) {
107
+ return `'${value.toFormatString("yyyy-MM-dd HH:mm:ss")}'`;
108
+ }
109
+ if (value instanceof DateOnly) {
110
+ return `'${value.toFormatString("yyyy-MM-dd")}'`;
111
+ }
112
+ if (value instanceof Time) {
113
+ return `'${value.toFormatString("HH:mm:ss")}'`;
114
+ }
115
+ if (value instanceof Uuid) {
116
+ return `0x${bytesToHex(value.toBytes())}`;
117
+ }
118
+ if (value instanceof Uint8Array) {
119
+ return `0x${bytesToHex(value)}`;
120
+ }
121
+ throw new Error(`Unknown value type: ${typeof value}`);
122
+ }
123
+
124
+ /** DataType → SQL type */
125
+ renderDataType(dataType: DataType): string {
126
+ switch (dataType.type) {
127
+ case "int":
128
+ return "INT";
129
+ case "bigint":
130
+ return "BIGINT";
131
+ case "float":
132
+ return "FLOAT";
133
+ case "double":
134
+ return "DOUBLE";
135
+ case "decimal":
136
+ return dataType.scale != null
137
+ ? `DECIMAL(${dataType.precision}, ${dataType.scale})`
138
+ : `DECIMAL(${dataType.precision})`;
139
+ case "varchar":
140
+ return `VARCHAR(${dataType.length})`;
141
+ case "char":
142
+ return `CHAR(${dataType.length})`;
143
+ case "text":
144
+ return "LONGTEXT";
145
+ case "binary":
146
+ return "LONGBLOB";
147
+ case "boolean":
148
+ return "BOOLEAN";
149
+ case "datetime":
150
+ return "DATETIME";
151
+ case "date":
152
+ return "DATE";
153
+ case "time":
154
+ return "TIME";
155
+ case "uuid":
156
+ return "BINARY(16)";
157
+ }
158
+ }
159
+
160
+ //#endregion
161
+
162
+ //#region ========== value ==========
163
+
164
+ protected column(expr: ExprColumn): string {
165
+ return expr.path.map((p) => this.wrap(p)).join(".");
166
+ }
167
+
168
+ protected value(expr: ExprValue): string {
169
+ return this.escapeValue(expr.value);
170
+ }
171
+
172
+ protected raw(expr: ExprRaw): string {
173
+ return expr.sql.replace(/\$(\d+)/g, (_, num) => {
174
+ const idx = parseInt(num) - 1;
175
+ return idx < expr.params.length ? this.render(expr.params[idx]) : `$${num}`;
176
+ });
177
+ }
178
+
179
+ //#endregion
180
+
181
+ //#region ========== comparison (null-safe) ==========
182
+
183
+ protected eq(expr: ExprEq): string {
184
+ // MySQL: <=> operator (null-safe equal)
185
+ return `${this.render(expr.source)} <=> ${this.render(expr.target)}`;
186
+ }
187
+
188
+ protected gt(expr: ExprGt): string {
189
+ return `${this.render(expr.source)} > ${this.render(expr.target)}`;
190
+ }
191
+
192
+ protected lt(expr: ExprLt): string {
193
+ return `${this.render(expr.source)} < ${this.render(expr.target)}`;
194
+ }
195
+
196
+ protected gte(expr: ExprGte): string {
197
+ return `${this.render(expr.source)} >= ${this.render(expr.target)}`;
198
+ }
199
+
200
+ protected lte(expr: ExprLte): string {
201
+ return `${this.render(expr.source)} <= ${this.render(expr.target)}`;
202
+ }
203
+
204
+ protected between(expr: ExprBetween): string {
205
+ const source = this.render(expr.source);
206
+ if (expr.from != null && expr.to != null) {
207
+ return `${source} BETWEEN ${this.render(expr.from)} AND ${this.render(expr.to)}`;
208
+ }
209
+ if (expr.from != null) {
210
+ return `${source} >= ${this.render(expr.from)}`;
211
+ }
212
+ if (expr.to != null) {
213
+ return `${source} <= ${this.render(expr.to)}`;
214
+ }
215
+ return "1=1";
216
+ }
217
+
218
+ protected null(expr: ExprIsNull): string {
219
+ return `${this.render(expr.arg)} IS NULL`;
220
+ }
221
+
222
+ protected like(expr: ExprLike): string {
223
+ // ESCAPE '\' 항상 Add
224
+ return `${this.render(expr.source)} LIKE ${this.render(expr.pattern)} ESCAPE '\\\\'`;
225
+ }
226
+
227
+ protected regexp(expr: ExprRegexp): string {
228
+ return `${this.render(expr.source)} REGEXP ${this.render(expr.pattern)}`;
229
+ }
230
+
231
+ protected in(expr: ExprIn): string {
232
+ if (expr.values.length === 0) {
233
+ return "1=0"; // 빈 IN은 항상 false
234
+ }
235
+ const values = expr.values.map((v) => this.render(v)).join(", ");
236
+ return `${this.render(expr.source)} IN (${values})`;
237
+ }
238
+
239
+ protected inQuery(expr: ExprInQuery): string {
240
+ return `${this.render(expr.source)} IN (${this.buildSelect(expr.query)})`;
241
+ }
242
+
243
+ protected exists(expr: ExprExists): string {
244
+ // SELECT 1로 Render
245
+ const subquery = this.buildSelect({
246
+ ...expr.query,
247
+ select: { _: { type: "value", value: 1 } },
248
+ });
249
+ return `EXISTS (${subquery})`;
250
+ }
251
+
252
+ //#endregion
253
+
254
+ //#region ========== logic ==========
255
+
256
+ protected not(expr: ExprNot): string {
257
+ return `NOT (${this.render(expr.arg)})`;
258
+ }
259
+
260
+ protected and(expr: ExprAnd): string {
261
+ if (expr.conditions.length === 0) return "1=1";
262
+ return `(${expr.conditions.map((c) => this.render(c)).join(" AND ")})`;
263
+ }
264
+
265
+ protected or(expr: ExprOr): string {
266
+ if (expr.conditions.length === 0) return "1=0";
267
+ return `(${expr.conditions.map((c) => this.render(c)).join(" OR ")})`;
268
+ }
269
+
270
+ //#endregion
271
+
272
+ //#region ========== 문자열 (null Process) ==========
273
+
274
+ protected concat(expr: ExprConcat): string {
275
+ // null processing: IFNULL(arg, '')
276
+ const args = expr.args.map((a) => `IFNULL(${this.render(a)}, '')`);
277
+ return `CONCAT(${args.join(", ")})`;
278
+ }
279
+
280
+ protected left(expr: ExprLeft): string {
281
+ return `LEFT(${this.render(expr.source)}, ${this.render(expr.length)})`;
282
+ }
283
+
284
+ protected right(expr: ExprRight): string {
285
+ return `RIGHT(${this.render(expr.source)}, ${this.render(expr.length)})`;
286
+ }
287
+
288
+ protected trim(expr: ExprTrim): string {
289
+ return `TRIM(${this.render(expr.arg)})`;
290
+ }
291
+
292
+ protected padStart(expr: ExprPadStart): string {
293
+ return `LPAD(${this.render(expr.source)}, ${this.render(expr.length)}, ${this.render(expr.fillString)})`;
294
+ }
295
+
296
+ protected replace(expr: ExprReplace): string {
297
+ return `REPLACE(${this.render(expr.source)}, ${this.render(expr.from)}, ${this.render(expr.to)})`;
298
+ }
299
+
300
+ protected upper(expr: ExprUpper): string {
301
+ return `UPPER(${this.render(expr.arg)})`;
302
+ }
303
+
304
+ protected lower(expr: ExprLower): string {
305
+ return `LOWER(${this.render(expr.arg)})`;
306
+ }
307
+
308
+ protected length(expr: ExprLength): string {
309
+ // null processing: IFNULL(arg, '')
310
+ return `CHAR_LENGTH(IFNULL(${this.render(expr.arg)}, ''))`;
311
+ }
312
+
313
+ protected byteLength(expr: ExprByteLength): string {
314
+ // null processing: IFNULL(arg, '')
315
+ return `LENGTH(IFNULL(${this.render(expr.arg)}, ''))`;
316
+ }
317
+
318
+ protected substring(expr: ExprSubstring): string {
319
+ if (expr.length != null) {
320
+ return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)}, ${this.render(expr.length)})`;
321
+ }
322
+ return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)})`;
323
+ }
324
+
325
+ protected indexOf(expr: ExprIndexOf): string {
326
+ return `LOCATE(${this.render(expr.search)}, ${this.render(expr.source)})`;
327
+ }
328
+
329
+ //#endregion
330
+
331
+ //#region ========== Number ==========
332
+
333
+ protected abs(expr: ExprAbs): string {
334
+ return `ABS(${this.render(expr.arg)})`;
335
+ }
336
+
337
+ protected round(expr: ExprRound): string {
338
+ return `ROUND(${this.render(expr.arg)}, ${expr.digits})`;
339
+ }
340
+
341
+ protected ceil(expr: ExprCeil): string {
342
+ return `CEIL(${this.render(expr.arg)})`;
343
+ }
344
+
345
+ protected floor(expr: ExprFloor): string {
346
+ return `FLOOR(${this.render(expr.arg)})`;
347
+ }
348
+
349
+ //#endregion
350
+
351
+ //#region ========== Date ==========
352
+
353
+ protected year(expr: ExprYear): string {
354
+ return `YEAR(${this.render(expr.arg)})`;
355
+ }
356
+
357
+ protected month(expr: ExprMonth): string {
358
+ return `MONTH(${this.render(expr.arg)})`;
359
+ }
360
+
361
+ protected day(expr: ExprDay): string {
362
+ return `DAY(${this.render(expr.arg)})`;
363
+ }
364
+
365
+ protected hour(expr: ExprHour): string {
366
+ return `HOUR(${this.render(expr.arg)})`;
367
+ }
368
+
369
+ protected minute(expr: ExprMinute): string {
370
+ return `MINUTE(${this.render(expr.arg)})`;
371
+ }
372
+
373
+ protected second(expr: ExprSecond): string {
374
+ return `SECOND(${this.render(expr.arg)})`;
375
+ }
376
+
377
+ protected isoWeek(expr: ExprIsoWeek): string {
378
+ return `WEEK(${this.render(expr.arg)}, 1)`;
379
+ }
380
+
381
+ protected isoWeekStartDate(expr: ExprIsoWeekStartDate): string {
382
+ // ISO 주의 시작일 (월요일)
383
+ return `DATE_SUB(${this.render(expr.arg)}, INTERVAL (WEEKDAY(${this.render(expr.arg)})) DAY)`;
384
+ }
385
+
386
+ protected isoYearMonth(expr: ExprIsoYearMonth): string {
387
+ return `DATE_FORMAT(${this.render(expr.arg)}, '%Y%m')`;
388
+ }
389
+
390
+ protected dateDiff(expr: ExprDateDiff): string {
391
+ const from = this.render(expr.from);
392
+ const to = this.render(expr.to);
393
+ switch (expr.separator) {
394
+ case "year":
395
+ return `TIMESTAMPDIFF(YEAR, ${from}, ${to})`;
396
+ case "month":
397
+ return `TIMESTAMPDIFF(MONTH, ${from}, ${to})`;
398
+ case "day":
399
+ return `TIMESTAMPDIFF(DAY, ${from}, ${to})`;
400
+ case "hour":
401
+ return `TIMESTAMPDIFF(HOUR, ${from}, ${to})`;
402
+ case "minute":
403
+ return `TIMESTAMPDIFF(MINUTE, ${from}, ${to})`;
404
+ case "second":
405
+ return `TIMESTAMPDIFF(SECOND, ${from}, ${to})`;
406
+ }
407
+ }
408
+
409
+ protected dateAdd(expr: ExprDateAdd): string {
410
+ const source = this.render(expr.source);
411
+ const value = this.render(expr.value);
412
+ const unit = this.dateSeparatorToUnit(expr.separator);
413
+ return `DATE_ADD(${source}, INTERVAL ${value} ${unit})`;
414
+ }
415
+
416
+ protected formatDate(expr: ExprFormatDate): string {
417
+ // JS format → MySQL format
418
+ const mysqlFormat = this.convertDateFormat(expr.format);
419
+ return `DATE_FORMAT(${this.render(expr.source)}, '${mysqlFormat}')`;
420
+ }
421
+
422
+ private dateSeparatorToUnit(sep: DateSeparator): string {
423
+ switch (sep) {
424
+ case "year":
425
+ return "YEAR";
426
+ case "month":
427
+ return "MONTH";
428
+ case "day":
429
+ return "DAY";
430
+ case "hour":
431
+ return "HOUR";
432
+ case "minute":
433
+ return "MINUTE";
434
+ case "second":
435
+ return "SECOND";
436
+ }
437
+ }
438
+
439
+ private convertDateFormat(format: string): string {
440
+ // 간단한 Transform (yyyy-MM-dd HH:mm:ss 형식)
441
+ return format
442
+ .replace(/yyyy/g, "%Y")
443
+ .replace(/MM/g, "%m")
444
+ .replace(/dd/g, "%d")
445
+ .replace(/HH/g, "%H")
446
+ .replace(/mm/g, "%i")
447
+ .replace(/ss/g, "%s");
448
+ }
449
+
450
+ //#endregion
451
+
452
+ //#region ========== condition ==========
453
+
454
+ protected ifNull(expr: ExprIfNull): string {
455
+ if (expr.args.length === 0) return "NULL";
456
+ if (expr.args.length === 1) return this.render(expr.args[0]);
457
+ // COALESCE로 Render (여러 value 중 첫 번째 non-null)
458
+ return `COALESCE(${expr.args.map((a) => this.render(a)).join(", ")})`;
459
+ }
460
+
461
+ protected nullIf(expr: ExprNullIf): string {
462
+ return `NULLIF(${this.render(expr.source)}, ${this.render(expr.value)})`;
463
+ }
464
+
465
+ protected is(expr: ExprIs): string {
466
+ return `(${this.render(expr.condition)})`;
467
+ }
468
+
469
+ protected switch(expr: ExprSwitch): string {
470
+ const cases = expr.cases
471
+ .map((c) => `WHEN ${this.render(c.when)} THEN ${this.render(c.then)}`)
472
+ .join(" ");
473
+ return `CASE ${cases} ELSE ${this.render(expr.else)} END`;
474
+ }
475
+
476
+ protected if(expr: ExprIf): string {
477
+ const elseVal = expr.else != null ? this.render(expr.else) : "NULL";
478
+ return `IF(${this.render(expr.condition)}, ${this.render(expr.then)}, ${elseVal})`;
479
+ }
480
+
481
+ //#endregion
482
+
483
+ //#region ========== aggregation ==========
484
+
485
+ protected count(expr: ExprCount): string {
486
+ if (expr.arg != null) {
487
+ const distinct = expr.distinct ? "DISTINCT " : "";
488
+ return `COUNT(${distinct}${this.render(expr.arg)})`;
489
+ }
490
+ return "COUNT(*)";
491
+ }
492
+
493
+ protected sum(expr: ExprSum): string {
494
+ return `SUM(${this.render(expr.arg)})`;
495
+ }
496
+
497
+ protected avg(expr: ExprAvg): string {
498
+ return `AVG(${this.render(expr.arg)})`;
499
+ }
500
+
501
+ protected max(expr: ExprMax): string {
502
+ return `MAX(${this.render(expr.arg)})`;
503
+ }
504
+
505
+ protected min(expr: ExprMin): string {
506
+ return `MIN(${this.render(expr.arg)})`;
507
+ }
508
+
509
+ //#endregion
510
+
511
+ //#region ========== Other ==========
512
+
513
+ protected greatest(expr: ExprGreatest): string {
514
+ if (expr.args.length === 0) throw new Error("greatest requires at least one argument.");
515
+ return `GREATEST(${expr.args.map((a) => this.render(a)).join(", ")})`;
516
+ }
517
+
518
+ protected least(expr: ExprLeast): string {
519
+ if (expr.args.length === 0) throw new Error("least requires at least one argument.");
520
+ return `LEAST(${expr.args.map((a) => this.render(a)).join(", ")})`;
521
+ }
522
+
523
+ protected rowNum(_expr: ExprRowNum): string {
524
+ // MySQL에서는 변수 사용 또는 ROW_NUMBER() Window function 사용
525
+ // 여기서는 ROW_NUMBER()로 구현 (MySQL 8.0+)
526
+ return "ROW_NUMBER() OVER ()";
527
+ }
528
+
529
+ protected random(): string {
530
+ return "RAND()";
531
+ }
532
+
533
+ protected cast(expr: ExprCast): string {
534
+ return `CAST(${this.render(expr.source)} AS ${this.renderDataType(expr.targetType)})`;
535
+ }
536
+
537
+ //#endregion
538
+
539
+ //#region ========== Window ==========
540
+
541
+ protected window(expr: ExprWindow): string {
542
+ const fn = this.renderWindowFn(expr.fn);
543
+ let over = this.renderWindowSpec(expr.spec);
544
+
545
+ // LAST_VALUE는 Basic 프레임이 CURRENT ROW까지만 보므로 전체 프레임 명시 필요
546
+ if (expr.fn.type === "lastValue" && over.length > 0) {
547
+ over += " ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING";
548
+ }
549
+
550
+ return `${fn} OVER (${over})`;
551
+ }
552
+
553
+ private renderWindowFn(fn: ExprWindow["fn"]): string {
554
+ switch (fn.type) {
555
+ case "rowNumber":
556
+ return "ROW_NUMBER()";
557
+ case "rank":
558
+ return "RANK()";
559
+ case "denseRank":
560
+ return "DENSE_RANK()";
561
+ case "ntile":
562
+ return `NTILE(${fn.n})`;
563
+ case "lag": {
564
+ const offset = fn.offset ?? 1;
565
+ const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
566
+ return `LAG(${this.render(fn.column)}, ${offset}${def})`;
567
+ }
568
+ case "lead": {
569
+ const offset = fn.offset ?? 1;
570
+ const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
571
+ return `LEAD(${this.render(fn.column)}, ${offset}${def})`;
572
+ }
573
+ case "firstValue":
574
+ return `FIRST_VALUE(${this.render(fn.column)})`;
575
+ case "lastValue":
576
+ return `LAST_VALUE(${this.render(fn.column)})`;
577
+ case "sum":
578
+ return `SUM(${this.render(fn.column)})`;
579
+ case "avg":
580
+ return `AVG(${this.render(fn.column)})`;
581
+ case "count":
582
+ return fn.column != null ? `COUNT(${this.render(fn.column)})` : "COUNT(*)";
583
+ case "min":
584
+ return `MIN(${this.render(fn.column)})`;
585
+ case "max":
586
+ return `MAX(${this.render(fn.column)})`;
587
+ }
588
+ }
589
+
590
+ private renderWindowSpec(spec: ExprWindow["spec"]): string {
591
+ const parts: string[] = [];
592
+ if (spec.partitionBy != null && spec.partitionBy.length > 0) {
593
+ parts.push(`PARTITION BY ${spec.partitionBy.map((p) => this.render(p)).join(", ")}`);
594
+ }
595
+ if (spec.orderBy != null && spec.orderBy.length > 0) {
596
+ const orderParts = spec.orderBy.map(
597
+ ([expr, dir]) => `${this.render(expr)}${dir != null ? ` ${dir}` : ""}`,
598
+ );
599
+ parts.push(`ORDER BY ${orderParts.join(", ")}`);
600
+ }
601
+ return parts.join(" ");
602
+ }
603
+
604
+ //#endregion
605
+
606
+ //#region ========== System ==========
607
+
608
+ protected subquery(expr: ExprSubquery): string {
609
+ return `(${this.buildSelect(expr.queryDef)})`;
610
+ }
611
+
612
+ //#endregion
613
+ }