@fuzionx/framework 0.1.43 → 0.1.44

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 (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +100 -100
  4. package/cli/index.js +494 -494
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +227 -227
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +331 -331
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. package/testing/index.js +232 -232
@@ -1,332 +1,332 @@
1
- /**
2
- * SqlQueryBuilder — SQL 쿼리 빌더 (SQLite / MariaDB / PostgreSQL 공통)
3
- *
4
- * QueryBuilder를 상속하여 터미널 메서드(get/count/update/delete)에서
5
- * 실제 SQL을 생성하고 실행합니다.
6
- *
7
- * @see docs/framework/02-database-orm.md
8
- */
9
- import QueryBuilder from './QueryBuilder.js';
10
- import Pagination from './Pagination.js';
11
-
12
- export default class SqlQueryBuilder extends QueryBuilder {
13
- constructor(model) {
14
- super(model);
15
- }
16
-
17
- /**
18
- * SQL 식별자(컬럼명/테이블명) 유효성 검증 — SQL Injection 방지
19
- * @param {string} name
20
- * @returns {string}
21
- * @private
22
- */
23
- static _sanitizeName(name) {
24
- if (typeof name !== 'string' || !/^[\w.]+$/.test(name)) {
25
- throw new Error(`Invalid SQL identifier: '${name}'`);
26
- }
27
- return name;
28
- }
29
-
30
- /**
31
- * 결과 조회 — SQL SELECT 실행
32
- * @returns {Promise<Array<import('./Model.js').default>>}
33
- */
34
- async get() {
35
- const conn = this._model.getConnection();
36
-
37
- if (conn.type === 'sqlite' && conn.db) {
38
- return this._executeSqlite(conn.db, 'select');
39
- }
40
-
41
- if (conn.type === 'knex' && conn.db) {
42
- return this._executeKnex(conn.db, 'select');
43
- }
44
-
45
- // Stub: no connection
46
- return [];
47
- }
48
-
49
- /**
50
- * 단일 결과
51
- * @returns {Promise<object|null>}
52
- */
53
- async first() {
54
- this._limit = 1;
55
- const results = await this.get();
56
- return results[0] || null;
57
- }
58
-
59
- /**
60
- * 카운트
61
- * @returns {Promise<number>}
62
- */
63
- async count() {
64
- const conn = this._model.getConnection();
65
-
66
- if (conn.type === 'sqlite' && conn.db) {
67
- const rows = this._executeSqlite(conn.db, 'count');
68
- return rows[0]?.cnt ?? 0;
69
- }
70
-
71
- if (conn.type === 'knex' && conn.db) {
72
- let query = conn.db(this._model.table);
73
- query = this._applyWheres(query);
74
- query = this._applySoftDelete(query);
75
- const [result] = await query.count('* as cnt');
76
- // PostgreSQL returns string, MySQL returns number; key can vary
77
- const cnt = result?.cnt ?? result?.['count(*)'] ?? result?.count ?? 0;
78
- return Number(cnt);
79
- }
80
-
81
- return 0;
82
- }
83
-
84
- /**
85
- * 조건부 수정
86
- * @param {object} data
87
- * @returns {Promise<number>} 수정된 행 수
88
- */
89
- async update(data) {
90
- if (this._model.timestamps && !data.updated_at) {
91
- const conn = this._model.getConnection();
92
- const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
93
- data.updated_at = isMysql
94
- ? new Date().toISOString().replace('T', ' ').replace('Z', '')
95
- : new Date().toISOString();
96
- }
97
-
98
- const conn = this._model.getConnection();
99
-
100
- if (conn.type === 'sqlite' && conn.db) {
101
- const { sql, params } = this._buildUpdateSql(data);
102
- const result = conn.db.prepare(sql).run(...params);
103
- return result.changes;
104
- }
105
-
106
- if (conn.type === 'knex' && conn.db) {
107
- let query = conn.db(this._model.table);
108
- query = this._applyWheres(query);
109
- query = this._applySoftDelete(query);
110
- return query.update(data);
111
- }
112
-
113
- return 0;
114
- }
115
-
116
- /**
117
- * 조건부 삭제
118
- * @returns {Promise<number>}
119
- */
120
- async delete() {
121
- const conn = this._model.getConnection();
122
-
123
- // softDelete → UPDATE deleted_at
124
- if (this._softDelete) {
125
- const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
126
- const now = isMysql
127
- ? new Date().toISOString().replace('T', ' ').replace('Z', '')
128
- : new Date().toISOString();
129
- return this.update({ deleted_at: now });
130
- }
131
-
132
- if (conn.type === 'sqlite' && conn.db) {
133
- const { whereClause, whereParams } = this._buildWhereSql();
134
- const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
135
- const sql = `DELETE FROM ${safeTable}${whereClause}`;
136
- const result = conn.db.prepare(sql).run(...whereParams);
137
- return result.changes;
138
- }
139
-
140
- if (conn.type === 'knex' && conn.db) {
141
- let query = conn.db(this._model.table);
142
- query = this._applyWheres(query);
143
- return query.delete();
144
- }
145
-
146
- return 0;
147
- }
148
-
149
- /**
150
- * 페이지네이션
151
- * @param {number} page
152
- * @param {number} perPage
153
- */
154
- async paginate(page = 1, perPage = 20) {
155
- const total = await this.count();
156
- this._limit = perPage;
157
- this._offset = (page - 1) * perPage;
158
- const data = await this.get();
159
- return new Pagination(data, total, page, perPage);
160
- }
161
-
162
- // ━━━━━━━━━━ SQLite 실행 ━━━━━━━━━━
163
-
164
- /**
165
- * SQLite SELECT / COUNT 실행
166
- * @private
167
- */
168
- _executeSqlite(db, mode = 'select') {
169
- const { whereClause, whereParams } = this._buildWhereSql();
170
-
171
- const selectColumns = mode === 'count'
172
- ? 'COUNT(*) as cnt'
173
- : (this._selects ? this._selects.map(c => SqlQueryBuilder._sanitizeName(c)).join(', ') : '*');
174
-
175
- const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
176
-
177
- let sql = `SELECT ${selectColumns} FROM ${safeTable}${whereClause}`;
178
-
179
- // ORDER BY
180
- if (this._orders.length > 0 && mode !== 'count') {
181
- const orderParts = this._orders.map(o => `${SqlQueryBuilder._sanitizeName(o.column)} ${o.direction.toUpperCase()}`);
182
- sql += ` ORDER BY ${orderParts.join(', ')}`;
183
- }
184
-
185
- // LIMIT / OFFSET
186
- if (this._limit != null && mode !== 'count') {
187
- sql += ` LIMIT ${this._limit}`;
188
- }
189
- if (this._offset != null && mode !== 'count') {
190
- sql += ` OFFSET ${this._offset}`;
191
- }
192
-
193
- const rows = db.prepare(sql).all(...whereParams);
194
-
195
- if (mode === 'count') return rows;
196
- return rows.map(row => new this._model(row));
197
- }
198
-
199
- /**
200
- * SQL WHERE절 생성
201
- * @private
202
- * @returns {{ whereClause: string, whereParams: Array }}
203
- */
204
- _buildWhereSql() {
205
- const parts = [];
206
- const params = [];
207
-
208
- // softDelete 자동 필터
209
- if (this._softDelete && !this._withTrashed) {
210
- if (this._onlyTrashed) {
211
- parts.push('deleted_at IS NOT NULL');
212
- } else {
213
- parts.push('deleted_at IS NULL');
214
- }
215
- }
216
-
217
- for (const w of this._wheres) {
218
- const safeKey = SqlQueryBuilder._sanitizeName(w.key);
219
- let expr;
220
-
221
- if (w.op === 'IN') {
222
- const placeholders = w.value.map(() => '?').join(', ');
223
- expr = `${safeKey} IN (${placeholders})`;
224
- params.push(...w.value);
225
- } else if (w.op === 'IS NULL') {
226
- expr = `${safeKey} IS NULL`;
227
- } else if (w.op === 'IS NOT NULL') {
228
- expr = `${safeKey} IS NOT NULL`;
229
- } else {
230
- expr = `${safeKey} ${w.op} ?`;
231
- params.push(w.value);
232
- }
233
-
234
- if (w.type === 'or' && parts.length > 0) {
235
- // OR 조건: 이전 항목과 괄호로 그룹핑
236
- const prev = parts.pop();
237
- // 이전 항목이 이미 괄호 그룹이면 내부에 추가
238
- if (prev.startsWith('(') && prev.endsWith(')')) {
239
- parts.push(`${prev.slice(0, -1)} OR ${expr})`);
240
- } else {
241
- parts.push(`(${prev} OR ${expr})`);
242
- }
243
- } else {
244
- parts.push(expr);
245
- }
246
- }
247
-
248
- const whereClause = parts.length > 0 ? ` WHERE ${parts.join(' AND ')}` : '';
249
- return { whereClause, whereParams: params };
250
- }
251
-
252
- /**
253
- * SQL UPDATE문 생성
254
- * @private
255
- */
256
- _buildUpdateSql(data) {
257
- const { whereClause, whereParams } = this._buildWhereSql();
258
- const columns = Object.keys(data);
259
- const sets = columns.map(c => `${SqlQueryBuilder._sanitizeName(c)} = ?`).join(', ');
260
- const sql = `UPDATE ${SqlQueryBuilder._sanitizeName(this._model.table)} SET ${sets}${whereClause}`;
261
- const params = [...Object.values(data), ...whereParams];
262
- return { sql, params };
263
- }
264
-
265
- // ━━━━━━━━━━ Knex 실행 ━━━━━━━━━━
266
-
267
- /**
268
- * Knex SELECT 실행
269
- * @private
270
- */
271
- async _executeKnex(knex, mode = 'select') {
272
- let query = knex(this._model.table);
273
-
274
- // SELECT columns
275
- if (this._selects) {
276
- query = query.select(...this._selects);
277
- }
278
-
279
- // WHERE
280
- query = this._applyWheres(query);
281
-
282
- // softDelete
283
- query = this._applySoftDelete(query);
284
-
285
- // ORDER BY
286
- for (const o of this._orders) {
287
- query = query.orderBy(o.column, o.direction);
288
- }
289
-
290
- // LIMIT / OFFSET
291
- if (this._limit != null) query = query.limit(this._limit);
292
- if (this._offset != null) query = query.offset(this._offset);
293
-
294
- const rows = await query;
295
- return rows.map(row => new this._model(row));
296
- }
297
-
298
- /**
299
- * Knex에 WHERE 조건 적용
300
- * @private
301
- */
302
- _applyWheres(query) {
303
- for (const w of this._wheres) {
304
- const method = w.type === 'or' ? 'orWhere' : 'where';
305
- if (w.op === 'IN') {
306
- query = query.whereIn(w.key, w.value);
307
- } else if (w.op === 'IS NULL') {
308
- query = query.whereNull(w.key);
309
- } else if (w.op === 'IS NOT NULL') {
310
- query = query.whereNotNull(w.key);
311
- } else {
312
- query = query[method](w.key, w.op, w.value);
313
- }
314
- }
315
- return query;
316
- }
317
-
318
- /**
319
- * softDelete 필터 적용
320
- * @private
321
- */
322
- _applySoftDelete(query) {
323
- if (this._softDelete && !this._withTrashed) {
324
- if (this._onlyTrashed) {
325
- query = query.whereNotNull('deleted_at');
326
- } else {
327
- query = query.whereNull('deleted_at');
328
- }
329
- }
330
- return query;
331
- }
332
- }
1
+ /**
2
+ * SqlQueryBuilder — SQL 쿼리 빌더 (SQLite / MariaDB / PostgreSQL 공통)
3
+ *
4
+ * QueryBuilder를 상속하여 터미널 메서드(get/count/update/delete)에서
5
+ * 실제 SQL을 생성하고 실행합니다.
6
+ *
7
+ * @see docs/framework/02-database-orm.md
8
+ */
9
+ import QueryBuilder from './QueryBuilder.js';
10
+ import Pagination from './Pagination.js';
11
+
12
+ export default class SqlQueryBuilder extends QueryBuilder {
13
+ constructor(model) {
14
+ super(model);
15
+ }
16
+
17
+ /**
18
+ * SQL 식별자(컬럼명/테이블명) 유효성 검증 — SQL Injection 방지
19
+ * @param {string} name
20
+ * @returns {string}
21
+ * @private
22
+ */
23
+ static _sanitizeName(name) {
24
+ if (typeof name !== 'string' || !/^[\w.]+$/.test(name)) {
25
+ throw new Error(`Invalid SQL identifier: '${name}'`);
26
+ }
27
+ return name;
28
+ }
29
+
30
+ /**
31
+ * 결과 조회 — SQL SELECT 실행
32
+ * @returns {Promise<Array<import('./Model.js').default>>}
33
+ */
34
+ async get() {
35
+ const conn = this._model.getConnection();
36
+
37
+ if (conn.type === 'sqlite' && conn.db) {
38
+ return this._executeSqlite(conn.db, 'select');
39
+ }
40
+
41
+ if (conn.type === 'knex' && conn.db) {
42
+ return this._executeKnex(conn.db, 'select');
43
+ }
44
+
45
+ // Stub: no connection
46
+ return [];
47
+ }
48
+
49
+ /**
50
+ * 단일 결과
51
+ * @returns {Promise<object|null>}
52
+ */
53
+ async first() {
54
+ this._limit = 1;
55
+ const results = await this.get();
56
+ return results[0] || null;
57
+ }
58
+
59
+ /**
60
+ * 카운트
61
+ * @returns {Promise<number>}
62
+ */
63
+ async count() {
64
+ const conn = this._model.getConnection();
65
+
66
+ if (conn.type === 'sqlite' && conn.db) {
67
+ const rows = this._executeSqlite(conn.db, 'count');
68
+ return rows[0]?.cnt ?? 0;
69
+ }
70
+
71
+ if (conn.type === 'knex' && conn.db) {
72
+ let query = conn.db(this._model.table);
73
+ query = this._applyWheres(query);
74
+ query = this._applySoftDelete(query);
75
+ const [result] = await query.count('* as cnt');
76
+ // PostgreSQL returns string, MySQL returns number; key can vary
77
+ const cnt = result?.cnt ?? result?.['count(*)'] ?? result?.count ?? 0;
78
+ return Number(cnt);
79
+ }
80
+
81
+ return 0;
82
+ }
83
+
84
+ /**
85
+ * 조건부 수정
86
+ * @param {object} data
87
+ * @returns {Promise<number>} 수정된 행 수
88
+ */
89
+ async update(data) {
90
+ if (this._model.timestamps && !data.updated_at) {
91
+ const conn = this._model.getConnection();
92
+ const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
93
+ data.updated_at = isMysql
94
+ ? new Date().toISOString().replace('T', ' ').replace('Z', '')
95
+ : new Date().toISOString();
96
+ }
97
+
98
+ const conn = this._model.getConnection();
99
+
100
+ if (conn.type === 'sqlite' && conn.db) {
101
+ const { sql, params } = this._buildUpdateSql(data);
102
+ const result = conn.db.prepare(sql).run(...params);
103
+ return result.changes;
104
+ }
105
+
106
+ if (conn.type === 'knex' && conn.db) {
107
+ let query = conn.db(this._model.table);
108
+ query = this._applyWheres(query);
109
+ query = this._applySoftDelete(query);
110
+ return query.update(data);
111
+ }
112
+
113
+ return 0;
114
+ }
115
+
116
+ /**
117
+ * 조건부 삭제
118
+ * @returns {Promise<number>}
119
+ */
120
+ async delete() {
121
+ const conn = this._model.getConnection();
122
+
123
+ // softDelete → UPDATE deleted_at
124
+ if (this._softDelete) {
125
+ const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
126
+ const now = isMysql
127
+ ? new Date().toISOString().replace('T', ' ').replace('Z', '')
128
+ : new Date().toISOString();
129
+ return this.update({ deleted_at: now });
130
+ }
131
+
132
+ if (conn.type === 'sqlite' && conn.db) {
133
+ const { whereClause, whereParams } = this._buildWhereSql();
134
+ const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
135
+ const sql = `DELETE FROM ${safeTable}${whereClause}`;
136
+ const result = conn.db.prepare(sql).run(...whereParams);
137
+ return result.changes;
138
+ }
139
+
140
+ if (conn.type === 'knex' && conn.db) {
141
+ let query = conn.db(this._model.table);
142
+ query = this._applyWheres(query);
143
+ return query.delete();
144
+ }
145
+
146
+ return 0;
147
+ }
148
+
149
+ /**
150
+ * 페이지네이션
151
+ * @param {number} page
152
+ * @param {number} perPage
153
+ */
154
+ async paginate(page = 1, perPage = 20) {
155
+ const total = await this.count();
156
+ this._limit = perPage;
157
+ this._offset = (page - 1) * perPage;
158
+ const data = await this.get();
159
+ return new Pagination(data, total, page, perPage);
160
+ }
161
+
162
+ // ━━━━━━━━━━ SQLite 실행 ━━━━━━━━━━
163
+
164
+ /**
165
+ * SQLite SELECT / COUNT 실행
166
+ * @private
167
+ */
168
+ _executeSqlite(db, mode = 'select') {
169
+ const { whereClause, whereParams } = this._buildWhereSql();
170
+
171
+ const selectColumns = mode === 'count'
172
+ ? 'COUNT(*) as cnt'
173
+ : (this._selects ? this._selects.map(c => SqlQueryBuilder._sanitizeName(c)).join(', ') : '*');
174
+
175
+ const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
176
+
177
+ let sql = `SELECT ${selectColumns} FROM ${safeTable}${whereClause}`;
178
+
179
+ // ORDER BY
180
+ if (this._orders.length > 0 && mode !== 'count') {
181
+ const orderParts = this._orders.map(o => `${SqlQueryBuilder._sanitizeName(o.column)} ${o.direction.toUpperCase()}`);
182
+ sql += ` ORDER BY ${orderParts.join(', ')}`;
183
+ }
184
+
185
+ // LIMIT / OFFSET
186
+ if (this._limit != null && mode !== 'count') {
187
+ sql += ` LIMIT ${this._limit}`;
188
+ }
189
+ if (this._offset != null && mode !== 'count') {
190
+ sql += ` OFFSET ${this._offset}`;
191
+ }
192
+
193
+ const rows = db.prepare(sql).all(...whereParams);
194
+
195
+ if (mode === 'count') return rows;
196
+ return rows.map(row => new this._model(row));
197
+ }
198
+
199
+ /**
200
+ * SQL WHERE절 생성
201
+ * @private
202
+ * @returns {{ whereClause: string, whereParams: Array }}
203
+ */
204
+ _buildWhereSql() {
205
+ const parts = [];
206
+ const params = [];
207
+
208
+ // softDelete 자동 필터
209
+ if (this._softDelete && !this._withTrashed) {
210
+ if (this._onlyTrashed) {
211
+ parts.push('deleted_at IS NOT NULL');
212
+ } else {
213
+ parts.push('deleted_at IS NULL');
214
+ }
215
+ }
216
+
217
+ for (const w of this._wheres) {
218
+ const safeKey = SqlQueryBuilder._sanitizeName(w.key);
219
+ let expr;
220
+
221
+ if (w.op === 'IN') {
222
+ const placeholders = w.value.map(() => '?').join(', ');
223
+ expr = `${safeKey} IN (${placeholders})`;
224
+ params.push(...w.value);
225
+ } else if (w.op === 'IS NULL') {
226
+ expr = `${safeKey} IS NULL`;
227
+ } else if (w.op === 'IS NOT NULL') {
228
+ expr = `${safeKey} IS NOT NULL`;
229
+ } else {
230
+ expr = `${safeKey} ${w.op} ?`;
231
+ params.push(w.value);
232
+ }
233
+
234
+ if (w.type === 'or' && parts.length > 0) {
235
+ // OR 조건: 이전 항목과 괄호로 그룹핑
236
+ const prev = parts.pop();
237
+ // 이전 항목이 이미 괄호 그룹이면 내부에 추가
238
+ if (prev.startsWith('(') && prev.endsWith(')')) {
239
+ parts.push(`${prev.slice(0, -1)} OR ${expr})`);
240
+ } else {
241
+ parts.push(`(${prev} OR ${expr})`);
242
+ }
243
+ } else {
244
+ parts.push(expr);
245
+ }
246
+ }
247
+
248
+ const whereClause = parts.length > 0 ? ` WHERE ${parts.join(' AND ')}` : '';
249
+ return { whereClause, whereParams: params };
250
+ }
251
+
252
+ /**
253
+ * SQL UPDATE문 생성
254
+ * @private
255
+ */
256
+ _buildUpdateSql(data) {
257
+ const { whereClause, whereParams } = this._buildWhereSql();
258
+ const columns = Object.keys(data);
259
+ const sets = columns.map(c => `${SqlQueryBuilder._sanitizeName(c)} = ?`).join(', ');
260
+ const sql = `UPDATE ${SqlQueryBuilder._sanitizeName(this._model.table)} SET ${sets}${whereClause}`;
261
+ const params = [...Object.values(data), ...whereParams];
262
+ return { sql, params };
263
+ }
264
+
265
+ // ━━━━━━━━━━ Knex 실행 ━━━━━━━━━━
266
+
267
+ /**
268
+ * Knex SELECT 실행
269
+ * @private
270
+ */
271
+ async _executeKnex(knex, mode = 'select') {
272
+ let query = knex(this._model.table);
273
+
274
+ // SELECT columns
275
+ if (this._selects) {
276
+ query = query.select(...this._selects);
277
+ }
278
+
279
+ // WHERE
280
+ query = this._applyWheres(query);
281
+
282
+ // softDelete
283
+ query = this._applySoftDelete(query);
284
+
285
+ // ORDER BY
286
+ for (const o of this._orders) {
287
+ query = query.orderBy(o.column, o.direction);
288
+ }
289
+
290
+ // LIMIT / OFFSET
291
+ if (this._limit != null) query = query.limit(this._limit);
292
+ if (this._offset != null) query = query.offset(this._offset);
293
+
294
+ const rows = await query;
295
+ return rows.map(row => new this._model(row));
296
+ }
297
+
298
+ /**
299
+ * Knex에 WHERE 조건 적용
300
+ * @private
301
+ */
302
+ _applyWheres(query) {
303
+ for (const w of this._wheres) {
304
+ const method = w.type === 'or' ? 'orWhere' : 'where';
305
+ if (w.op === 'IN') {
306
+ query = query.whereIn(w.key, w.value);
307
+ } else if (w.op === 'IS NULL') {
308
+ query = query.whereNull(w.key);
309
+ } else if (w.op === 'IS NOT NULL') {
310
+ query = query.whereNotNull(w.key);
311
+ } else {
312
+ query = query[method](w.key, w.op, w.value);
313
+ }
314
+ }
315
+ return query;
316
+ }
317
+
318
+ /**
319
+ * softDelete 필터 적용
320
+ * @private
321
+ */
322
+ _applySoftDelete(query) {
323
+ if (this._softDelete && !this._withTrashed) {
324
+ if (this._onlyTrashed) {
325
+ query = query.whereNotNull('deleted_at');
326
+ } else {
327
+ query = query.whereNull('deleted_at');
328
+ }
329
+ }
330
+ return query;
331
+ }
332
+ }