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