@simplysm/orm-common 13.0.68 → 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,611 +1,611 @@
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
- * PostgreSQL Expr 렌더러
72
- */
73
- export class PostgresqlExprRenderer 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 `'${value.replace(/'/g, "''")}'`;
93
- }
94
- if (typeof value === "number") {
95
- return String(value);
96
- }
97
- if (typeof value === "boolean") {
98
- return value ? "TRUE" : "FALSE";
99
- }
100
- if (value instanceof DateTime) {
101
- return `'${value.toFormatString("yyyy-MM-dd HH:mm:ss")}'::timestamp`;
102
- }
103
- if (value instanceof DateOnly) {
104
- return `'${value.toFormatString("yyyy-MM-dd")}'::date`;
105
- }
106
- if (value instanceof Time) {
107
- return `'${value.toFormatString("HH:mm:ss")}'::time`;
108
- }
109
- if (value instanceof Uuid) {
110
- return `'${value.toString()}'::uuid`;
111
- }
112
- if (value instanceof Uint8Array) {
113
- return `'\\x${bytesToHex(value)}'::bytea`;
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 "INTEGER";
123
- case "bigint":
124
- return "BIGINT";
125
- case "float":
126
- return "REAL";
127
- case "double":
128
- return "DOUBLE PRECISION";
129
- case "decimal":
130
- return dataType.scale != null
131
- ? `NUMERIC(${dataType.precision}, ${dataType.scale})`
132
- : `NUMERIC(${dataType.precision})`;
133
- case "varchar":
134
- return `VARCHAR(${dataType.length})`;
135
- case "char":
136
- return `CHAR(${dataType.length})`;
137
- case "text":
138
- return "TEXT";
139
- case "binary":
140
- return "BYTEA";
141
- case "boolean":
142
- return "BOOLEAN";
143
- case "datetime":
144
- return "TIMESTAMP";
145
- case "date":
146
- return "DATE";
147
- case "time":
148
- return "TIME";
149
- case "uuid":
150
- return "UUID";
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
- // PostgreSQL: null-safe equal (IS NOT DISTINCT FROM 연산자 사용)
179
- const left = this.render(expr.source);
180
- const right = this.render(expr.target);
181
- return `${left} IS NOT DISTINCT FROM ${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 "TRUE";
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
- // PostgreSQL: ~ 연산자
225
- return `${this.render(expr.source)} ~ ${this.render(expr.pattern)}`;
226
- }
227
-
228
- protected in(expr: ExprIn): string {
229
- if (expr.values.length === 0) {
230
- return "FALSE"; // 빈 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 "TRUE";
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 "FALSE";
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
- // PostgreSQL: || 연산자와 COALESCE 사용
273
- const args = expr.args.map((a) => `COALESCE(${this.render(a)}, '')`);
274
- return args.join(" || ");
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 `TRIM(${this.render(expr.arg)})`;
287
- }
288
-
289
- protected padStart(expr: ExprPadStart): string {
290
- return `LPAD(${this.render(expr.source)}, ${this.render(expr.length)}, ${this.render(expr.fillString)})`;
291
- }
292
-
293
- protected replace(expr: ExprReplace): string {
294
- return `REPLACE(${this.render(expr.source)}, ${this.render(expr.from)}, ${this.render(expr.to)})`;
295
- }
296
-
297
- protected upper(expr: ExprUpper): string {
298
- return `UPPER(${this.render(expr.arg)})`;
299
- }
300
-
301
- protected lower(expr: ExprLower): string {
302
- return `LOWER(${this.render(expr.arg)})`;
303
- }
304
-
305
- protected length(expr: ExprLength): string {
306
- // PostgreSQL: LENGTH() (null 처리)
307
- return `LENGTH(COALESCE(${this.render(expr.arg)}, ''))`;
308
- }
309
-
310
- protected byteLength(expr: ExprByteLength): string {
311
- // PostgreSQL: OCTET_LENGTH() (null 처리)
312
- return `OCTET_LENGTH(COALESCE(${this.render(expr.arg)}, ''))`;
313
- }
314
-
315
- protected substring(expr: ExprSubstring): string {
316
- if (expr.length != null) {
317
- return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)}, ${this.render(expr.length)})`;
318
- }
319
- return `SUBSTRING(${this.render(expr.source)} FROM ${this.render(expr.start)})`;
320
- }
321
-
322
- protected indexOf(expr: ExprIndexOf): string {
323
- return `POSITION(${this.render(expr.search)} IN ${this.render(expr.source)})`;
324
- }
325
-
326
- //#endregion
327
-
328
- //#region ========== 숫자 ==========
329
-
330
- protected abs(expr: ExprAbs): string {
331
- return `ABS(${this.render(expr.arg)})`;
332
- }
333
-
334
- protected round(expr: ExprRound): string {
335
- return `ROUND(${this.render(expr.arg)}, ${expr.digits})`;
336
- }
337
-
338
- protected ceil(expr: ExprCeil): string {
339
- return `CEIL(${this.render(expr.arg)})`;
340
- }
341
-
342
- protected floor(expr: ExprFloor): string {
343
- return `FLOOR(${this.render(expr.arg)})`;
344
- }
345
-
346
- //#endregion
347
-
348
- //#region ========== 날짜 ==========
349
-
350
- protected year(expr: ExprYear): string {
351
- return `EXTRACT(YEAR FROM ${this.render(expr.arg)})::INTEGER`;
352
- }
353
-
354
- protected month(expr: ExprMonth): string {
355
- return `EXTRACT(MONTH FROM ${this.render(expr.arg)})::INTEGER`;
356
- }
357
-
358
- protected day(expr: ExprDay): string {
359
- return `EXTRACT(DAY FROM ${this.render(expr.arg)})::INTEGER`;
360
- }
361
-
362
- protected hour(expr: ExprHour): string {
363
- return `EXTRACT(HOUR FROM ${this.render(expr.arg)})::INTEGER`;
364
- }
365
-
366
- protected minute(expr: ExprMinute): string {
367
- return `EXTRACT(MINUTE FROM ${this.render(expr.arg)})::INTEGER`;
368
- }
369
-
370
- protected second(expr: ExprSecond): string {
371
- return `EXTRACT(SECOND FROM ${this.render(expr.arg)})::INTEGER`;
372
- }
373
-
374
- protected isoWeek(expr: ExprIsoWeek): string {
375
- return `EXTRACT(WEEK FROM ${this.render(expr.arg)})::INTEGER`;
376
- }
377
-
378
- protected isoWeekStartDate(expr: ExprIsoWeekStartDate): string {
379
- const src = this.render(expr.arg);
380
- // ISO 주의 시작일 (월요일)
381
- return `DATE_TRUNC('week', ${src})::DATE`;
382
- }
383
-
384
- protected isoYearMonth(expr: ExprIsoYearMonth): string {
385
- return `TO_CHAR(${this.render(expr.arg)}, 'YYYYMM')`;
386
- }
387
-
388
- protected dateDiff(expr: ExprDateDiff): string {
389
- const from = this.render(expr.from);
390
- const to = this.render(expr.to);
391
- switch (expr.separator) {
392
- case "year":
393
- return `EXTRACT(YEAR FROM AGE(${to}, ${from}))::INTEGER`;
394
- case "month":
395
- return `(EXTRACT(YEAR FROM AGE(${to}, ${from})) * 12 + EXTRACT(MONTH FROM AGE(${to}, ${from})))::INTEGER`;
396
- case "day":
397
- return `(${to}::DATE - ${from}::DATE)`;
398
- case "hour":
399
- return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER / 3600`;
400
- case "minute":
401
- return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER / 60`;
402
- case "second":
403
- return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER`;
404
- }
405
- }
406
-
407
- protected dateAdd(expr: ExprDateAdd): string {
408
- const source = this.render(expr.source);
409
- const value = this.render(expr.value);
410
- const unit = this.dateSeparatorToUnit(expr.separator);
411
- return `${source} + INTERVAL '1 ${unit}' * ${value}`;
412
- }
413
-
414
- protected formatDate(expr: ExprFormatDate): string {
415
- // JS format → PostgreSQL TO_CHAR format
416
- const pgFormat = this.convertDateFormat(expr.format);
417
- return `TO_CHAR(${this.render(expr.source)}, '${pgFormat}')`;
418
- }
419
-
420
- private dateSeparatorToUnit(sep: DateSeparator): string {
421
- switch (sep) {
422
- case "year":
423
- return "year";
424
- case "month":
425
- return "month";
426
- case "day":
427
- return "day";
428
- case "hour":
429
- return "hour";
430
- case "minute":
431
- return "minute";
432
- case "second":
433
- return "second";
434
- }
435
- }
436
-
437
- private convertDateFormat(format: string): string {
438
- // JS format → PostgreSQL TO_CHAR format
439
- return format
440
- .replace(/yyyy/g, "YYYY")
441
- .replace(/MM/g, "MM")
442
- .replace(/dd/g, "DD")
443
- .replace(/HH/g, "HH24")
444
- .replace(/mm/g, "MI")
445
- .replace(/ss/g, "SS");
446
- }
447
-
448
- //#endregion
449
-
450
- //#region ========== 조건 ==========
451
-
452
- protected ifNull(expr: ExprIfNull): string {
453
- if (expr.args.length === 0) return "NULL";
454
- if (expr.args.length === 1) return this.render(expr.args[0]);
455
- // PostgreSQL: COALESCE
456
- return `COALESCE(${expr.args.map((a) => this.render(a)).join(", ")})`;
457
- }
458
-
459
- protected nullIf(expr: ExprNullIf): string {
460
- return `NULLIF(${this.render(expr.source)}, ${this.render(expr.value)})`;
461
- }
462
-
463
- protected is(expr: ExprIs): string {
464
- return `(${this.render(expr.condition)})::INTEGER`;
465
- }
466
-
467
- protected switch(expr: ExprSwitch): string {
468
- const cases = expr.cases
469
- .map((c) => `WHEN ${this.render(c.when)} THEN ${this.render(c.then)}`)
470
- .join(" ");
471
- return `CASE ${cases} ELSE ${this.render(expr.else)} END`;
472
- }
473
-
474
- protected if(expr: ExprIf): string {
475
- const elseVal = expr.else != null ? this.render(expr.else) : "NULL";
476
- return `CASE WHEN ${this.render(expr.condition)} THEN ${this.render(expr.then)} ELSE ${elseVal} END`;
477
- }
478
-
479
- //#endregion
480
-
481
- //#region ========== 집계 ==========
482
-
483
- protected count(expr: ExprCount): string {
484
- if (expr.arg != null) {
485
- const distinct = expr.distinct ? "DISTINCT " : "";
486
- return `COUNT(${distinct}${this.render(expr.arg)})`;
487
- }
488
- return "COUNT(*)";
489
- }
490
-
491
- protected sum(expr: ExprSum): string {
492
- return `SUM(${this.render(expr.arg)})`;
493
- }
494
-
495
- protected avg(expr: ExprAvg): string {
496
- return `AVG(${this.render(expr.arg)})`;
497
- }
498
-
499
- protected max(expr: ExprMax): string {
500
- return `MAX(${this.render(expr.arg)})`;
501
- }
502
-
503
- protected min(expr: ExprMin): string {
504
- return `MIN(${this.render(expr.arg)})`;
505
- }
506
-
507
- //#endregion
508
-
509
- //#region ========== 기타 ==========
510
-
511
- protected greatest(expr: ExprGreatest): string {
512
- if (expr.args.length === 0) throw new Error("greatest 최소 하나의 인자가 필요합니다.");
513
- // PostgreSQL: GREATEST 네이티브 지원
514
- return `GREATEST(${expr.args.map((a) => this.render(a)).join(", ")})`;
515
- }
516
-
517
- protected least(expr: ExprLeast): string {
518
- if (expr.args.length === 0) throw new Error("least 최소 하나의 인자가 필요합니다.");
519
- // PostgreSQL: LEAST 네이티브 지원
520
- return `LEAST(${expr.args.map((a) => this.render(a)).join(", ")})`;
521
- }
522
-
523
- protected rowNum(_expr: ExprRowNum): string {
524
- return "ROW_NUMBER() OVER ()";
525
- }
526
-
527
- protected random(): string {
528
- return "RANDOM()";
529
- }
530
-
531
- protected cast(expr: ExprCast): string {
532
- return `CAST(${this.render(expr.source)} AS ${this.renderDataType(expr.targetType)})`;
533
- }
534
-
535
- //#endregion
536
-
537
- //#region ========== 윈도우 ==========
538
-
539
- protected window(expr: ExprWindow): string {
540
- const fn = this.renderWindowFn(expr.fn);
541
- let over = this.renderWindowSpec(expr.spec);
542
-
543
- // LAST_VALUE는 기본 프레임이 CURRENT ROW까지만 보므로 전체 프레임 명시 필요
544
- if (expr.fn.type === "lastValue" && over.length > 0) {
545
- over += " ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING";
546
- }
547
-
548
- return `${fn} OVER (${over})`;
549
- }
550
-
551
- private renderWindowFn(fn: ExprWindow["fn"]): string {
552
- switch (fn.type) {
553
- case "rowNumber":
554
- return "ROW_NUMBER()";
555
- case "rank":
556
- return "RANK()";
557
- case "denseRank":
558
- return "DENSE_RANK()";
559
- case "ntile":
560
- return `NTILE(${fn.n})`;
561
- case "lag": {
562
- const offset = fn.offset ?? 1;
563
- const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
564
- return `LAG(${this.render(fn.column)}, ${offset}${def})`;
565
- }
566
- case "lead": {
567
- const offset = fn.offset ?? 1;
568
- const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
569
- return `LEAD(${this.render(fn.column)}, ${offset}${def})`;
570
- }
571
- case "firstValue":
572
- return `FIRST_VALUE(${this.render(fn.column)})`;
573
- case "lastValue":
574
- return `LAST_VALUE(${this.render(fn.column)})`;
575
- case "sum":
576
- return `SUM(${this.render(fn.column)})`;
577
- case "avg":
578
- return `AVG(${this.render(fn.column)})`;
579
- case "count":
580
- return fn.column != null ? `COUNT(${this.render(fn.column)})` : "COUNT(*)";
581
- case "min":
582
- return `MIN(${this.render(fn.column)})`;
583
- case "max":
584
- return `MAX(${this.render(fn.column)})`;
585
- }
586
- }
587
-
588
- private renderWindowSpec(spec: ExprWindow["spec"]): string {
589
- const parts: string[] = [];
590
- if (spec.partitionBy != null && spec.partitionBy.length > 0) {
591
- parts.push(`PARTITION BY ${spec.partitionBy.map((p) => this.render(p)).join(", ")}`);
592
- }
593
- if (spec.orderBy != null && spec.orderBy.length > 0) {
594
- const orderParts = spec.orderBy.map(
595
- ([expr, dir]) => `${this.render(expr)}${dir != null ? ` ${dir}` : ""}`,
596
- );
597
- parts.push(`ORDER BY ${orderParts.join(", ")}`);
598
- }
599
- return parts.join(" ");
600
- }
601
-
602
- //#endregion
603
-
604
- //#region ========== 시스템 ==========
605
-
606
- protected subquery(expr: ExprSubquery): string {
607
- return `(${this.buildSelect(expr.queryDef)})`;
608
- }
609
-
610
- //#endregion
611
- }
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
+ * PostgreSQL expression renderer
72
+ */
73
+ export class PostgresqlExprRenderer 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 `'${value.replace(/'/g, "''")}'`;
93
+ }
94
+ if (typeof value === "number") {
95
+ return String(value);
96
+ }
97
+ if (typeof value === "boolean") {
98
+ return value ? "TRUE" : "FALSE";
99
+ }
100
+ if (value instanceof DateTime) {
101
+ return `'${value.toFormatString("yyyy-MM-dd HH:mm:ss")}'::timestamp`;
102
+ }
103
+ if (value instanceof DateOnly) {
104
+ return `'${value.toFormatString("yyyy-MM-dd")}'::date`;
105
+ }
106
+ if (value instanceof Time) {
107
+ return `'${value.toFormatString("HH:mm:ss")}'::time`;
108
+ }
109
+ if (value instanceof Uuid) {
110
+ return `'${value.toString()}'::uuid`;
111
+ }
112
+ if (value instanceof Uint8Array) {
113
+ return `'\\x${bytesToHex(value)}'::bytea`;
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 "INTEGER";
123
+ case "bigint":
124
+ return "BIGINT";
125
+ case "float":
126
+ return "REAL";
127
+ case "double":
128
+ return "DOUBLE PRECISION";
129
+ case "decimal":
130
+ return dataType.scale != null
131
+ ? `NUMERIC(${dataType.precision}, ${dataType.scale})`
132
+ : `NUMERIC(${dataType.precision})`;
133
+ case "varchar":
134
+ return `VARCHAR(${dataType.length})`;
135
+ case "char":
136
+ return `CHAR(${dataType.length})`;
137
+ case "text":
138
+ return "TEXT";
139
+ case "binary":
140
+ return "BYTEA";
141
+ case "boolean":
142
+ return "BOOLEAN";
143
+ case "datetime":
144
+ return "TIMESTAMP";
145
+ case "date":
146
+ return "DATE";
147
+ case "time":
148
+ return "TIME";
149
+ case "uuid":
150
+ return "UUID";
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
+ // PostgreSQL: null-safe equal (IS NOT DISTINCT FROM operator 사용)
179
+ const left = this.render(expr.source);
180
+ const right = this.render(expr.target);
181
+ return `${left} IS NOT DISTINCT FROM ${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 "TRUE";
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
+ // PostgreSQL: ~ operator
225
+ return `${this.render(expr.source)} ~ ${this.render(expr.pattern)}`;
226
+ }
227
+
228
+ protected in(expr: ExprIn): string {
229
+ if (expr.values.length === 0) {
230
+ return "FALSE"; // 빈 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 "TRUE";
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 "FALSE";
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
+ // PostgreSQL: || 연산자와 COALESCE 사용
273
+ const args = expr.args.map((a) => `COALESCE(${this.render(a)}, '')`);
274
+ return args.join(" || ");
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 `TRIM(${this.render(expr.arg)})`;
287
+ }
288
+
289
+ protected padStart(expr: ExprPadStart): string {
290
+ return `LPAD(${this.render(expr.source)}, ${this.render(expr.length)}, ${this.render(expr.fillString)})`;
291
+ }
292
+
293
+ protected replace(expr: ExprReplace): string {
294
+ return `REPLACE(${this.render(expr.source)}, ${this.render(expr.from)}, ${this.render(expr.to)})`;
295
+ }
296
+
297
+ protected upper(expr: ExprUpper): string {
298
+ return `UPPER(${this.render(expr.arg)})`;
299
+ }
300
+
301
+ protected lower(expr: ExprLower): string {
302
+ return `LOWER(${this.render(expr.arg)})`;
303
+ }
304
+
305
+ protected length(expr: ExprLength): string {
306
+ // PostgreSQL: LENGTH() (null Process)
307
+ return `LENGTH(COALESCE(${this.render(expr.arg)}, ''))`;
308
+ }
309
+
310
+ protected byteLength(expr: ExprByteLength): string {
311
+ // PostgreSQL: OCTET_LENGTH() (null Process)
312
+ return `OCTET_LENGTH(COALESCE(${this.render(expr.arg)}, ''))`;
313
+ }
314
+
315
+ protected substring(expr: ExprSubstring): string {
316
+ if (expr.length != null) {
317
+ return `SUBSTRING(${this.render(expr.source)}, ${this.render(expr.start)}, ${this.render(expr.length)})`;
318
+ }
319
+ return `SUBSTRING(${this.render(expr.source)} FROM ${this.render(expr.start)})`;
320
+ }
321
+
322
+ protected indexOf(expr: ExprIndexOf): string {
323
+ return `POSITION(${this.render(expr.search)} IN ${this.render(expr.source)})`;
324
+ }
325
+
326
+ //#endregion
327
+
328
+ //#region ========== Number ==========
329
+
330
+ protected abs(expr: ExprAbs): string {
331
+ return `ABS(${this.render(expr.arg)})`;
332
+ }
333
+
334
+ protected round(expr: ExprRound): string {
335
+ return `ROUND(${this.render(expr.arg)}, ${expr.digits})`;
336
+ }
337
+
338
+ protected ceil(expr: ExprCeil): string {
339
+ return `CEIL(${this.render(expr.arg)})`;
340
+ }
341
+
342
+ protected floor(expr: ExprFloor): string {
343
+ return `FLOOR(${this.render(expr.arg)})`;
344
+ }
345
+
346
+ //#endregion
347
+
348
+ //#region ========== Date ==========
349
+
350
+ protected year(expr: ExprYear): string {
351
+ return `EXTRACT(YEAR FROM ${this.render(expr.arg)})::INTEGER`;
352
+ }
353
+
354
+ protected month(expr: ExprMonth): string {
355
+ return `EXTRACT(MONTH FROM ${this.render(expr.arg)})::INTEGER`;
356
+ }
357
+
358
+ protected day(expr: ExprDay): string {
359
+ return `EXTRACT(DAY FROM ${this.render(expr.arg)})::INTEGER`;
360
+ }
361
+
362
+ protected hour(expr: ExprHour): string {
363
+ return `EXTRACT(HOUR FROM ${this.render(expr.arg)})::INTEGER`;
364
+ }
365
+
366
+ protected minute(expr: ExprMinute): string {
367
+ return `EXTRACT(MINUTE FROM ${this.render(expr.arg)})::INTEGER`;
368
+ }
369
+
370
+ protected second(expr: ExprSecond): string {
371
+ return `EXTRACT(SECOND FROM ${this.render(expr.arg)})::INTEGER`;
372
+ }
373
+
374
+ protected isoWeek(expr: ExprIsoWeek): string {
375
+ return `EXTRACT(WEEK FROM ${this.render(expr.arg)})::INTEGER`;
376
+ }
377
+
378
+ protected isoWeekStartDate(expr: ExprIsoWeekStartDate): string {
379
+ const src = this.render(expr.arg);
380
+ // ISO 주의 시작일 (월요일)
381
+ return `DATE_TRUNC('week', ${src})::DATE`;
382
+ }
383
+
384
+ protected isoYearMonth(expr: ExprIsoYearMonth): string {
385
+ return `TO_CHAR(${this.render(expr.arg)}, 'YYYYMM')`;
386
+ }
387
+
388
+ protected dateDiff(expr: ExprDateDiff): string {
389
+ const from = this.render(expr.from);
390
+ const to = this.render(expr.to);
391
+ switch (expr.separator) {
392
+ case "year":
393
+ return `EXTRACT(YEAR FROM AGE(${to}, ${from}))::INTEGER`;
394
+ case "month":
395
+ return `(EXTRACT(YEAR FROM AGE(${to}, ${from})) * 12 + EXTRACT(MONTH FROM AGE(${to}, ${from})))::INTEGER`;
396
+ case "day":
397
+ return `(${to}::DATE - ${from}::DATE)`;
398
+ case "hour":
399
+ return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER / 3600`;
400
+ case "minute":
401
+ return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER / 60`;
402
+ case "second":
403
+ return `EXTRACT(EPOCH FROM (${to} - ${from}))::INTEGER`;
404
+ }
405
+ }
406
+
407
+ protected dateAdd(expr: ExprDateAdd): string {
408
+ const source = this.render(expr.source);
409
+ const value = this.render(expr.value);
410
+ const unit = this.dateSeparatorToUnit(expr.separator);
411
+ return `${source} + INTERVAL '1 ${unit}' * ${value}`;
412
+ }
413
+
414
+ protected formatDate(expr: ExprFormatDate): string {
415
+ // JS format → PostgreSQL TO_CHAR format
416
+ const pgFormat = this.convertDateFormat(expr.format);
417
+ return `TO_CHAR(${this.render(expr.source)}, '${pgFormat}')`;
418
+ }
419
+
420
+ private dateSeparatorToUnit(sep: DateSeparator): string {
421
+ switch (sep) {
422
+ case "year":
423
+ return "year";
424
+ case "month":
425
+ return "month";
426
+ case "day":
427
+ return "day";
428
+ case "hour":
429
+ return "hour";
430
+ case "minute":
431
+ return "minute";
432
+ case "second":
433
+ return "second";
434
+ }
435
+ }
436
+
437
+ private convertDateFormat(format: string): string {
438
+ // JS format → PostgreSQL TO_CHAR format
439
+ return format
440
+ .replace(/yyyy/g, "YYYY")
441
+ .replace(/MM/g, "MM")
442
+ .replace(/dd/g, "DD")
443
+ .replace(/HH/g, "HH24")
444
+ .replace(/mm/g, "MI")
445
+ .replace(/ss/g, "SS");
446
+ }
447
+
448
+ //#endregion
449
+
450
+ //#region ========== condition ==========
451
+
452
+ protected ifNull(expr: ExprIfNull): string {
453
+ if (expr.args.length === 0) return "NULL";
454
+ if (expr.args.length === 1) return this.render(expr.args[0]);
455
+ // PostgreSQL: COALESCE
456
+ return `COALESCE(${expr.args.map((a) => this.render(a)).join(", ")})`;
457
+ }
458
+
459
+ protected nullIf(expr: ExprNullIf): string {
460
+ return `NULLIF(${this.render(expr.source)}, ${this.render(expr.value)})`;
461
+ }
462
+
463
+ protected is(expr: ExprIs): string {
464
+ return `(${this.render(expr.condition)})::INTEGER`;
465
+ }
466
+
467
+ protected switch(expr: ExprSwitch): string {
468
+ const cases = expr.cases
469
+ .map((c) => `WHEN ${this.render(c.when)} THEN ${this.render(c.then)}`)
470
+ .join(" ");
471
+ return `CASE ${cases} ELSE ${this.render(expr.else)} END`;
472
+ }
473
+
474
+ protected if(expr: ExprIf): string {
475
+ const elseVal = expr.else != null ? this.render(expr.else) : "NULL";
476
+ return `CASE WHEN ${this.render(expr.condition)} THEN ${this.render(expr.then)} ELSE ${elseVal} END`;
477
+ }
478
+
479
+ //#endregion
480
+
481
+ //#region ========== aggregation ==========
482
+
483
+ protected count(expr: ExprCount): string {
484
+ if (expr.arg != null) {
485
+ const distinct = expr.distinct ? "DISTINCT " : "";
486
+ return `COUNT(${distinct}${this.render(expr.arg)})`;
487
+ }
488
+ return "COUNT(*)";
489
+ }
490
+
491
+ protected sum(expr: ExprSum): string {
492
+ return `SUM(${this.render(expr.arg)})`;
493
+ }
494
+
495
+ protected avg(expr: ExprAvg): string {
496
+ return `AVG(${this.render(expr.arg)})`;
497
+ }
498
+
499
+ protected max(expr: ExprMax): string {
500
+ return `MAX(${this.render(expr.arg)})`;
501
+ }
502
+
503
+ protected min(expr: ExprMin): string {
504
+ return `MIN(${this.render(expr.arg)})`;
505
+ }
506
+
507
+ //#endregion
508
+
509
+ //#region ========== Other ==========
510
+
511
+ protected greatest(expr: ExprGreatest): string {
512
+ if (expr.args.length === 0) throw new Error("greatest requires at least one argument.");
513
+ // PostgreSQL: GREATEST 네이티브 지원
514
+ return `GREATEST(${expr.args.map((a) => this.render(a)).join(", ")})`;
515
+ }
516
+
517
+ protected least(expr: ExprLeast): string {
518
+ if (expr.args.length === 0) throw new Error("least requires at least one argument.");
519
+ // PostgreSQL: LEAST 네이티브 지원
520
+ return `LEAST(${expr.args.map((a) => this.render(a)).join(", ")})`;
521
+ }
522
+
523
+ protected rowNum(_expr: ExprRowNum): string {
524
+ return "ROW_NUMBER() OVER ()";
525
+ }
526
+
527
+ protected random(): string {
528
+ return "RANDOM()";
529
+ }
530
+
531
+ protected cast(expr: ExprCast): string {
532
+ return `CAST(${this.render(expr.source)} AS ${this.renderDataType(expr.targetType)})`;
533
+ }
534
+
535
+ //#endregion
536
+
537
+ //#region ========== Window ==========
538
+
539
+ protected window(expr: ExprWindow): string {
540
+ const fn = this.renderWindowFn(expr.fn);
541
+ let over = this.renderWindowSpec(expr.spec);
542
+
543
+ // LAST_VALUE는 Basic 프레임이 CURRENT ROW까지만 보므로 전체 프레임 명시 필요
544
+ if (expr.fn.type === "lastValue" && over.length > 0) {
545
+ over += " ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING";
546
+ }
547
+
548
+ return `${fn} OVER (${over})`;
549
+ }
550
+
551
+ private renderWindowFn(fn: ExprWindow["fn"]): string {
552
+ switch (fn.type) {
553
+ case "rowNumber":
554
+ return "ROW_NUMBER()";
555
+ case "rank":
556
+ return "RANK()";
557
+ case "denseRank":
558
+ return "DENSE_RANK()";
559
+ case "ntile":
560
+ return `NTILE(${fn.n})`;
561
+ case "lag": {
562
+ const offset = fn.offset ?? 1;
563
+ const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
564
+ return `LAG(${this.render(fn.column)}, ${offset}${def})`;
565
+ }
566
+ case "lead": {
567
+ const offset = fn.offset ?? 1;
568
+ const def = fn.default != null ? `, ${this.render(fn.default)}` : "";
569
+ return `LEAD(${this.render(fn.column)}, ${offset}${def})`;
570
+ }
571
+ case "firstValue":
572
+ return `FIRST_VALUE(${this.render(fn.column)})`;
573
+ case "lastValue":
574
+ return `LAST_VALUE(${this.render(fn.column)})`;
575
+ case "sum":
576
+ return `SUM(${this.render(fn.column)})`;
577
+ case "avg":
578
+ return `AVG(${this.render(fn.column)})`;
579
+ case "count":
580
+ return fn.column != null ? `COUNT(${this.render(fn.column)})` : "COUNT(*)";
581
+ case "min":
582
+ return `MIN(${this.render(fn.column)})`;
583
+ case "max":
584
+ return `MAX(${this.render(fn.column)})`;
585
+ }
586
+ }
587
+
588
+ private renderWindowSpec(spec: ExprWindow["spec"]): string {
589
+ const parts: string[] = [];
590
+ if (spec.partitionBy != null && spec.partitionBy.length > 0) {
591
+ parts.push(`PARTITION BY ${spec.partitionBy.map((p) => this.render(p)).join(", ")}`);
592
+ }
593
+ if (spec.orderBy != null && spec.orderBy.length > 0) {
594
+ const orderParts = spec.orderBy.map(
595
+ ([expr, dir]) => `${this.render(expr)}${dir != null ? ` ${dir}` : ""}`,
596
+ );
597
+ parts.push(`ORDER BY ${orderParts.join(", ")}`);
598
+ }
599
+ return parts.join(" ");
600
+ }
601
+
602
+ //#endregion
603
+
604
+ //#region ========== System ==========
605
+
606
+ protected subquery(expr: ExprSubquery): string {
607
+ return `(${this.buildSelect(expr.queryDef)})`;
608
+ }
609
+
610
+ //#endregion
611
+ }