@fuzionx/framework 0.1.39 → 0.1.42
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.
- package/README.md +501 -501
- package/bin/fx.js +12 -12
- package/cli/db-sync.js +100 -99
- package/cli/index.js +494 -493
- package/cli/templates/make/app/controllers/HomeController.js +14 -14
- package/cli/templates/make/app/routes/api.js +7 -7
- package/cli/templates/make/app/routes/web.js +5 -5
- package/cli/templates/make/app/views/default/errors/404.html +11 -11
- package/cli/templates/make/app/views/default/errors/500.html +14 -14
- package/cli/templates/make/app/views/default/layouts/main.html +22 -22
- package/cli/templates/make/app/views/default/pages/home.html +11 -11
- package/cli/templates/make/controller.js.tpl +40 -40
- package/cli/templates/make/event.js.tpl +8 -8
- package/cli/templates/make/job.js.tpl +10 -10
- package/cli/templates/make/middleware.js.tpl +10 -10
- package/cli/templates/make/model.js.tpl +15 -15
- package/cli/templates/make/service.js.tpl +15 -15
- package/cli/templates/make/task.js.tpl +15 -15
- package/cli/templates/make/test.js.tpl +7 -7
- package/cli/templates/make/worker.js.tpl +14 -14
- package/cli/templates/make/ws.js.tpl +18 -18
- package/index.js +67 -67
- package/lib/core/AppError.js +46 -46
- package/lib/core/Application.js +1006 -1006
- package/lib/core/AutoLoader.js +227 -226
- package/lib/core/Base.js +64 -64
- package/lib/core/Config.js +228 -228
- package/lib/core/Context.js +484 -484
- package/lib/database/ConnectionManager.js +208 -208
- package/lib/database/MariaModel.js +29 -29
- package/lib/database/Model.js +247 -247
- package/lib/database/ModelRegistry.js +72 -72
- package/lib/database/MongoModel.js +232 -232
- package/lib/database/Pagination.js +37 -37
- package/lib/database/PostgreModel.js +29 -29
- package/lib/database/QueryBuilder.js +172 -172
- package/lib/database/SQLiteModel.js +27 -27
- package/lib/database/SqlModel.js +257 -257
- package/lib/database/SqlQueryBuilder.js +332 -332
- package/lib/helpers/CryptoHelper.js +48 -48
- package/lib/helpers/FileHelper.js +61 -61
- package/lib/helpers/HashHelper.js +39 -39
- package/lib/helpers/I18nHelper.js +174 -174
- package/lib/helpers/Logger.js +108 -108
- package/lib/helpers/MediaHelper.js +84 -84
- package/lib/http/Controller.js +34 -34
- package/lib/http/ErrorHandler.js +136 -136
- package/lib/http/Middleware.js +43 -43
- package/lib/http/Router.js +109 -109
- package/lib/http/Validation.js +125 -125
- package/lib/middleware/apiAuth.js +79 -79
- package/lib/middleware/auth.js +42 -42
- package/lib/middleware/bodyParser.js +19 -19
- package/lib/middleware/cors.js +47 -47
- package/lib/middleware/csrf.js +32 -32
- package/lib/middleware/index.js +13 -13
- package/lib/middleware/session.js +27 -27
- package/lib/middleware/theme.js +20 -20
- package/lib/realtime/RoomManager.js +85 -85
- package/lib/realtime/WsHandler.js +107 -107
- package/lib/schedule/Job.js +38 -38
- package/lib/schedule/Queue.js +103 -103
- package/lib/schedule/Scheduler.js +171 -171
- package/lib/schedule/Task.js +39 -39
- package/lib/schedule/WorkerPool.js +225 -225
- package/lib/services/EventBus.js +94 -94
- package/lib/services/Service.js +261 -261
- package/lib/services/Storage.js +112 -112
- package/lib/utilities/ArrUtil.js +112 -112
- package/lib/utilities/DateUtil.js +98 -98
- package/lib/utilities/FunctionUtil.js +119 -119
- package/lib/utilities/NumUtil.js +75 -75
- package/lib/utilities/ObjectUtil.js +170 -170
- package/lib/utilities/PaginationUtil.js +81 -81
- package/lib/utilities/StrUtil.js +105 -105
- package/lib/utilities/index.js +18 -18
- package/lib/view/OpenAPI.js +231 -231
- package/lib/view/View.js +83 -83
- package/package.json +2 -2
- 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
|
+
}
|