lamix 4.2.6
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/LICENSE +5 -0
- package/README.md +118 -0
- package/artisan.js +613 -0
- package/examples/Alternative.md +23 -0
- package/examples/CRUD.md +51 -0
- package/examples/Post.md +28 -0
- package/examples/PostController.md +93 -0
- package/examples/Query Builder.md +55 -0
- package/examples/Relations.md +16 -0
- package/examples/Role.md +26 -0
- package/examples/RoleController.md +65 -0
- package/examples/Usages.md +132 -0
- package/examples/User.md +40 -0
- package/examples/UserController.md +98 -0
- package/index.d.ts +580 -0
- package/index.js +3718 -0
- package/package.json +63 -0
package/index.js
ADDED
|
@@ -0,0 +1,3718 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const util = require('util');
|
|
4
|
+
const { performance } = require('perf_hooks');
|
|
5
|
+
require('dotenv').config();
|
|
6
|
+
|
|
7
|
+
/* ---------------- Utilities ---------------- */
|
|
8
|
+
|
|
9
|
+
function tryRequire(name) {
|
|
10
|
+
try { return require(name); } catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ---------------- Errors ---------------- */
|
|
14
|
+
|
|
15
|
+
class DBError extends Error {
|
|
16
|
+
constructor(message, meta = {}) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'DBError';
|
|
19
|
+
this.meta = meta;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ---------------- Cache ---------------- */
|
|
24
|
+
|
|
25
|
+
class SimpleCache {
|
|
26
|
+
constructor() { this.map = new Map(); }
|
|
27
|
+
get(k) {
|
|
28
|
+
const e = this.map.get(k);
|
|
29
|
+
if (!e) return null;
|
|
30
|
+
if (e.ttl && Date.now() > e.ts + e.ttl) {
|
|
31
|
+
this.map.delete(k);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return e.v;
|
|
35
|
+
}
|
|
36
|
+
set(k, v, ttl = 0) { this.map.set(k, { v, ts: Date.now(), ttl }); }
|
|
37
|
+
del(k) { this.map.delete(k); }
|
|
38
|
+
clear() { this.map.clear(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ---------------- DB Core ---------------- */
|
|
42
|
+
|
|
43
|
+
class DB {
|
|
44
|
+
static driver = null;
|
|
45
|
+
static config = null;
|
|
46
|
+
static pool = null;
|
|
47
|
+
static cache = new SimpleCache();
|
|
48
|
+
static retryAttempts = 1;
|
|
49
|
+
static eventHandlers = { query: [], error: [] };
|
|
50
|
+
|
|
51
|
+
/* ---------- Init ---------- */
|
|
52
|
+
|
|
53
|
+
static initFromEnv({
|
|
54
|
+
driver = process.env.DB_CONNECTION || 'mysql',
|
|
55
|
+
config = null,
|
|
56
|
+
retryAttempts = 1
|
|
57
|
+
} = {}) {
|
|
58
|
+
this.driver = driver.toLowerCase();
|
|
59
|
+
this.retryAttempts = Math.max(1, Number(retryAttempts));
|
|
60
|
+
|
|
61
|
+
if (config) {
|
|
62
|
+
this.config = config;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this.driver === 'sqlite') {
|
|
67
|
+
this.config = {
|
|
68
|
+
filename: process.env.DB_DATABASE || ':memory:'
|
|
69
|
+
};
|
|
70
|
+
} else if (this.driver === 'pg') {
|
|
71
|
+
this.config = {
|
|
72
|
+
host: process.env.DB_HOST || '127.0.0.1',
|
|
73
|
+
user: process.env.DB_USER || 'postgres',
|
|
74
|
+
password: process.env.DB_PASS || '',
|
|
75
|
+
database: process.env.DB_NAME || 'postgres',
|
|
76
|
+
port: Number(process.env.DB_PORT || 5432),
|
|
77
|
+
max: Number(process.env.DB_CONNECTION_LIMIT || 10)
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
this.config = {
|
|
81
|
+
host: process.env.DB_HOST || '127.0.0.1',
|
|
82
|
+
user: process.env.DB_USER || 'root',
|
|
83
|
+
password: process.env.DB_PASS || '',
|
|
84
|
+
database: process.env.DB_NAME,
|
|
85
|
+
port: Number(process.env.DB_PORT || 3306),
|
|
86
|
+
waitForConnections: true,
|
|
87
|
+
connectionLimit: Number(process.env.DB_CONNECTION_LIMIT || 10)
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ---------- Events ---------- */
|
|
93
|
+
|
|
94
|
+
static on(event, fn) {
|
|
95
|
+
if (!this.eventHandlers[event]) this.eventHandlers[event] = [];
|
|
96
|
+
this.eventHandlers[event].push(fn);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static _emit(event, payload) {
|
|
100
|
+
for (const fn of this.eventHandlers[event] || []) {
|
|
101
|
+
try { fn(payload); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* ---------- Driver ---------- */
|
|
106
|
+
|
|
107
|
+
static _ensureModule() {
|
|
108
|
+
if (!this.driver) this.initFromEnv();
|
|
109
|
+
|
|
110
|
+
if (this.driver === 'mysql') {
|
|
111
|
+
const m = tryRequire('mysql2/promise');
|
|
112
|
+
if (!m) throw new DBError('Missing mysql2');
|
|
113
|
+
return m;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (this.driver === 'pg') {
|
|
117
|
+
const m = tryRequire('pg');
|
|
118
|
+
if (!m) throw new DBError('Missing pg');
|
|
119
|
+
return m;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (this.driver === 'sqlite') {
|
|
123
|
+
const m = tryRequire('sqlite3');
|
|
124
|
+
if (!m) throw new DBError('Missing sqlite3');
|
|
125
|
+
return m;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new DBError(`Unsupported driver: ${this.driver}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---------- Connect ---------- */
|
|
132
|
+
|
|
133
|
+
static async connect() {
|
|
134
|
+
if (this.pool) return this.pool;
|
|
135
|
+
|
|
136
|
+
const driver = this._ensureModule();
|
|
137
|
+
|
|
138
|
+
if (this.driver === 'mysql') {
|
|
139
|
+
this.pool = driver.createPool(this.config);
|
|
140
|
+
return this.pool;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.driver === 'pg') {
|
|
144
|
+
const { Pool } = driver;
|
|
145
|
+
this.pool = new Pool(this.config);
|
|
146
|
+
return this.pool;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.driver === 'sqlite') {
|
|
150
|
+
const db = new driver.Database(this.config.filename);
|
|
151
|
+
|
|
152
|
+
db.runAsync = util.promisify(db.run.bind(db));
|
|
153
|
+
db.getAsync = util.promisify(db.get.bind(db));
|
|
154
|
+
db.allAsync = util.promisify(db.all.bind(db));
|
|
155
|
+
db.execAsync = util.promisify(db.exec.bind(db));
|
|
156
|
+
|
|
157
|
+
this.pool = {
|
|
158
|
+
__sqlite_db: db,
|
|
159
|
+
query: async (sql, params = []) => {
|
|
160
|
+
const isSelect = /^\s*(select|pragma)/i.test(sql);
|
|
161
|
+
if (isSelect) return [await db.allAsync(sql, params)];
|
|
162
|
+
const r = await db.runAsync(sql, params);
|
|
163
|
+
return [{ insertId: r.lastID, affectedRows: r.changes }];
|
|
164
|
+
},
|
|
165
|
+
close: () => new Promise((r, j) => db.close(e => e ? j(e) : r()))
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return this.pool;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
static async end() {
|
|
173
|
+
if (!this.pool) return;
|
|
174
|
+
try {
|
|
175
|
+
if (this.driver !== 'sqlite') await this.pool.end();
|
|
176
|
+
else await this.pool.close();
|
|
177
|
+
} finally {
|
|
178
|
+
this.pool = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ---------- Query ---------- */
|
|
183
|
+
|
|
184
|
+
static _pgConvert(sql, params) {
|
|
185
|
+
let i = 0;
|
|
186
|
+
return {
|
|
187
|
+
text: sql.replace(/\?/g, () => `$${++i}`),
|
|
188
|
+
values: params
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
static _extractTable(sql) {
|
|
193
|
+
if (typeof sql !== 'string') return null;
|
|
194
|
+
|
|
195
|
+
const cleaned = sql
|
|
196
|
+
.replace(/`|"|\[|\]/g, '')
|
|
197
|
+
.replace(/\s+/g, ' ')
|
|
198
|
+
.trim()
|
|
199
|
+
.toLowerCase();
|
|
200
|
+
|
|
201
|
+
const match =
|
|
202
|
+
cleaned.match(/\bfrom\s+([a-z0-9_]+)/) ||
|
|
203
|
+
cleaned.match(/\binto\s+([a-z0-9_]+)/) ||
|
|
204
|
+
cleaned.match(/\bupdate\s+([a-z0-9_]+)/);
|
|
205
|
+
|
|
206
|
+
return match ? match[1] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
static async _tableExists(table) {
|
|
210
|
+
if (!table) return true;
|
|
211
|
+
|
|
212
|
+
const pool = await this.connect();
|
|
213
|
+
|
|
214
|
+
if (this.driver === 'mysql') {
|
|
215
|
+
const [rows] = await pool.query(
|
|
216
|
+
`SELECT 1 FROM information_schema.tables
|
|
217
|
+
WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1`,
|
|
218
|
+
[table]
|
|
219
|
+
);
|
|
220
|
+
return rows.length > 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.driver === 'pg') {
|
|
224
|
+
const res = await pool.query(
|
|
225
|
+
`SELECT 1 FROM information_schema.tables
|
|
226
|
+
WHERE table_schema = 'public' AND table_name = $1 LIMIT 1`,
|
|
227
|
+
[table]
|
|
228
|
+
);
|
|
229
|
+
return res.rows.length > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (this.driver === 'sqlite') {
|
|
233
|
+
const [rows] = await pool.query(
|
|
234
|
+
`SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1`,
|
|
235
|
+
[table]
|
|
236
|
+
);
|
|
237
|
+
return rows.length > 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
static async raw(sql, params = [], options = {}) {
|
|
244
|
+
const { checkTable = false } = options;
|
|
245
|
+
if (checkTable) {
|
|
246
|
+
const table = this._extractTable(sql);
|
|
247
|
+
if (table) {
|
|
248
|
+
const exists = await this._tableExists(table);
|
|
249
|
+
if (!exists) {
|
|
250
|
+
throw new DBError(`Table does not exist: ${table}`, { sql });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let attempt = 0;
|
|
256
|
+
|
|
257
|
+
while (++attempt <= this.retryAttempts) {
|
|
258
|
+
const pool = await this.connect();
|
|
259
|
+
const start = performance.now();
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
this._emit('query', { sql, params });
|
|
263
|
+
|
|
264
|
+
if (this.driver === 'pg') {
|
|
265
|
+
const q = this._pgConvert(sql, params);
|
|
266
|
+
const r = await pool.query(q.text, q.values);
|
|
267
|
+
return r.rows;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const [rows] = await pool.query(sql, params);
|
|
271
|
+
return rows;
|
|
272
|
+
|
|
273
|
+
} catch (err) {
|
|
274
|
+
this._emit('error', { err, sql, params, attempt });
|
|
275
|
+
|
|
276
|
+
if (attempt < this.retryAttempts &&
|
|
277
|
+
/dead|lost|timeout|reset/i.test(err.message)) {
|
|
278
|
+
await this.end();
|
|
279
|
+
await new Promise(r => setTimeout(r, 100 * attempt));
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw new DBError('DB raw() failed', { sql, params, err });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------- Transactions ---------- */
|
|
289
|
+
|
|
290
|
+
static async transaction(fn) {
|
|
291
|
+
const pool = await this.connect();
|
|
292
|
+
|
|
293
|
+
if (this.driver === 'mysql') {
|
|
294
|
+
const c = await pool.getConnection();
|
|
295
|
+
try {
|
|
296
|
+
await c.beginTransaction();
|
|
297
|
+
const r = await fn(c);
|
|
298
|
+
await c.commit();
|
|
299
|
+
return r;
|
|
300
|
+
} catch (e) {
|
|
301
|
+
await c.rollback();
|
|
302
|
+
throw e;
|
|
303
|
+
} finally {
|
|
304
|
+
c.release();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (this.driver === 'pg') {
|
|
309
|
+
const c = await pool.connect();
|
|
310
|
+
try {
|
|
311
|
+
await c.query('BEGIN');
|
|
312
|
+
const r = await fn(c);
|
|
313
|
+
await c.query('COMMIT');
|
|
314
|
+
return r;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
await c.query('ROLLBACK');
|
|
317
|
+
throw e;
|
|
318
|
+
} finally {
|
|
319
|
+
c.release();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.driver === 'sqlite') {
|
|
324
|
+
const db = pool.__sqlite_db;
|
|
325
|
+
try {
|
|
326
|
+
await db.execAsync('BEGIN');
|
|
327
|
+
const r = await fn(pool);
|
|
328
|
+
await db.execAsync('COMMIT');
|
|
329
|
+
return r;
|
|
330
|
+
} catch (e) {
|
|
331
|
+
await db.execAsync('ROLLBACK');
|
|
332
|
+
throw e;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* ---------- Helpers ---------- */
|
|
338
|
+
|
|
339
|
+
static escapeId(id) {
|
|
340
|
+
if (id === '*') return '*';
|
|
341
|
+
if (/\s|\(|\)| as /i.test(id)) return id;
|
|
342
|
+
return this.driver === 'pg'
|
|
343
|
+
? `"${id.replace(/"/g, '""')}"`
|
|
344
|
+
: `\`${id.replace(/`/g, '``')}\``;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
static async cached(sql, params = [], ttl = 0) {
|
|
348
|
+
const key = JSON.stringify([sql, params]);
|
|
349
|
+
const hit = this.cache.get(key);
|
|
350
|
+
if (hit) return hit;
|
|
351
|
+
const rows = await this.raw(sql, params);
|
|
352
|
+
this.cache.set(key, rows, ttl);
|
|
353
|
+
return rows;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const escapeId = s => DB.escapeId(s);
|
|
358
|
+
|
|
359
|
+
// -----------------------------
|
|
360
|
+
// MAIN Validator
|
|
361
|
+
// -----------------------------
|
|
362
|
+
class Validator {
|
|
363
|
+
constructor(data = {}, id = null, table = null, rules = {}, customMessages = {}, db = null) {
|
|
364
|
+
this.data = data;
|
|
365
|
+
this.id = id === undefined || id === null ? null : id;
|
|
366
|
+
this.table = table || null;
|
|
367
|
+
this.rules = rules || {};
|
|
368
|
+
this.customMessages = customMessages || {};
|
|
369
|
+
this.errorBag = {};
|
|
370
|
+
this.primaryKey = 'id';
|
|
371
|
+
this.db = db || (typeof DB !== 'undefined' ? DB : null);
|
|
372
|
+
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// -----------------------------
|
|
376
|
+
// MAIN
|
|
377
|
+
// -----------------------------
|
|
378
|
+
async fails() {
|
|
379
|
+
this.errorBag = {};
|
|
380
|
+
|
|
381
|
+
for (const field in this.rules) {
|
|
382
|
+
let rulesArray = this.rules[field];
|
|
383
|
+
|
|
384
|
+
if (typeof rulesArray === 'string') rulesArray = rulesArray.split('|');
|
|
385
|
+
else if (!Array.isArray(rulesArray)) {
|
|
386
|
+
this.addError(field, `Rules for field "${field}" must be string or array.`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const isNullable = rulesArray.includes('nullable');
|
|
391
|
+
rulesArray = rulesArray.filter(r => r !== 'nullable');
|
|
392
|
+
|
|
393
|
+
if (rulesArray.includes('sometimes')) {
|
|
394
|
+
rulesArray = rulesArray.filter(r => r !== 'sometimes');
|
|
395
|
+
if (!this.data.hasOwnProperty(field)) continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (isNullable && (this.data[field] === '' || this.data[field] === null || this.data[field] === undefined)) continue;
|
|
399
|
+
|
|
400
|
+
for (let rule of rulesArray) {
|
|
401
|
+
const [ruleName, ...paramParts] = rule.split(':');
|
|
402
|
+
const param = paramParts.join(':');
|
|
403
|
+
const params = param ? param.split(',') : [];
|
|
404
|
+
|
|
405
|
+
const method = `validate${ruleName.charAt(0).toUpperCase() + ruleName.slice(1)}`;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
if (typeof this[method] !== 'function') {
|
|
409
|
+
this.addError(field, `Validation rule "${ruleName}" does not exist.`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (['unique', 'exists'].includes(ruleName)) await this[method](field, ...params);
|
|
414
|
+
else this[method](field, ...params);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
this.addError(field, `Error validating ${field} with ${ruleName}: ${err.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return Object.keys(this.errorBag).length > 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
passes() {
|
|
425
|
+
return !Object.keys(this.errorBag).length;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
getErrors() {
|
|
429
|
+
return this.errorBag;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
addError(field, message) {
|
|
433
|
+
if (!this.errorBag[field]) this.errorBag[field] = [];
|
|
434
|
+
this.errorBag[field].push(message);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
msg(field, rule, fallback) {
|
|
438
|
+
return this.customMessages[`${field}.${rule}`] || this.customMessages[rule] || fallback;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
toNumber(val) {
|
|
442
|
+
return val === undefined || val === null || val === "" ? null : Number(val);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// -----------------------------
|
|
446
|
+
// CORE RULES
|
|
447
|
+
// -----------------------------
|
|
448
|
+
validateRequired(field) {
|
|
449
|
+
const value = this.data[field];
|
|
450
|
+
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
|
451
|
+
this.addError(field, this.msg(field, 'required', `${field} is required.`));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
validateString(field) {
|
|
456
|
+
const value = this.data[field];
|
|
457
|
+
if (typeof value !== 'string') {
|
|
458
|
+
this.addError(field, this.msg(field, 'string', `${field} must be a string.`));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
validateBoolean(field) {
|
|
463
|
+
const value = this.data[field];
|
|
464
|
+
const allowed = [true, false, 1, 0, "1", "0", "true", "false"];
|
|
465
|
+
|
|
466
|
+
if (!allowed.includes(value)) {
|
|
467
|
+
this.addError(field, this.msg(field, 'boolean', `${field} must be boolean`));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
validateNumeric(field) {
|
|
472
|
+
const value = this.data[field];
|
|
473
|
+
if (isNaN(Number(value))) {
|
|
474
|
+
this.addError(field, this.msg(field, 'numeric', `${field} must be numeric.`));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
validateInteger(field) {
|
|
479
|
+
if (!Number.isInteger(Number(this.data[field]))) {
|
|
480
|
+
this.addError(field, this.msg(field, 'integer', `${field} must be integer.`));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
validateEmail(field) {
|
|
485
|
+
const value = String(this.data[field]);
|
|
486
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
487
|
+
this.addError(field, this.msg(field, 'email', `${field} must be valid email.`));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
validateMin(field, min) {
|
|
492
|
+
const val = this.data[field];
|
|
493
|
+
if (val === undefined || val === null || val === '') return; // skip nullable
|
|
494
|
+
|
|
495
|
+
// Ensure min is numeric
|
|
496
|
+
const limit = Math.max(...[min].flat().map(Number));
|
|
497
|
+
|
|
498
|
+
// For strings or arrays, use length
|
|
499
|
+
if (typeof val === 'string' || Array.isArray(val)) {
|
|
500
|
+
if (val.length < limit) {
|
|
501
|
+
this.addError(field, 'min', `${field} must be at least ${limit} characters.`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// For numbers, compare numerically
|
|
505
|
+
else if (!isNaN(Number(val))) {
|
|
506
|
+
if (Number(val) < limit) {
|
|
507
|
+
this.addError(field, 'min', `${field} must be at least ${limit}.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
validateMax(field, max) {
|
|
513
|
+
const val = this.data[field];
|
|
514
|
+
if (val === undefined || val === null || val === '') return; // skip nullable
|
|
515
|
+
|
|
516
|
+
// Ensure max is numeric
|
|
517
|
+
const limit = Math.min(...[max].flat().map(Number));
|
|
518
|
+
|
|
519
|
+
// For strings or arrays, use length
|
|
520
|
+
if (typeof val === 'string' || Array.isArray(val)) {
|
|
521
|
+
if (val.length > limit) {
|
|
522
|
+
this.addError(field, 'max', `${field} must be no more than ${limit} characters.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// For numbers, compare numerically
|
|
526
|
+
else if (!isNaN(Number(val))) {
|
|
527
|
+
if (Number(val) > limit) {
|
|
528
|
+
this.addError(field, 'max', `${field} must be no more than ${limit}.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
validateConfirmed(field) {
|
|
534
|
+
const value = this.data[field];
|
|
535
|
+
const confirm = this.data[field + '_confirmation'];
|
|
536
|
+
if (value !== confirm) {
|
|
537
|
+
this.addError(field, this.msg(field, 'confirmed', `${field} confirmation does not match.`));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
validateDate(field) {
|
|
542
|
+
if (isNaN(Date.parse(this.data[field]))) {
|
|
543
|
+
this.addError(field, this.msg(field, 'date', `${field} must be valid date.`));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
validateUrl(field) {
|
|
548
|
+
try { new URL(String(this.data[field])); }
|
|
549
|
+
catch { this.addError(field, this.msg(field, 'url', `${field} must be valid URL.`)); }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
validateIp(field) {
|
|
553
|
+
const value = String(this.data[field]);
|
|
554
|
+
const regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/;
|
|
555
|
+
if (!regex.test(value)) {
|
|
556
|
+
this.addError(field, this.msg(field, 'ip', `${field} must be valid IPv4 address.`));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
validateUuid(field) {
|
|
561
|
+
const val = this.data[field];
|
|
562
|
+
if (!val) return;
|
|
563
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(val))) {
|
|
564
|
+
this.addError(field, this.msg(field, 'uuid', `${field} must be a valid UUID.`));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
validateSlug(field) {
|
|
569
|
+
const val = this.data[field];
|
|
570
|
+
if (!val) return;
|
|
571
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(val))) {
|
|
572
|
+
this.addError(field, this.msg(field, 'slug', `${field} must be a valid slug.`));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
validateAfter(field, dateField) {
|
|
577
|
+
const val = this.data[field];
|
|
578
|
+
const other = this.data[dateField];
|
|
579
|
+
if (!val || !other) return;
|
|
580
|
+
const a = Date.parse(val);
|
|
581
|
+
const b = Date.parse(other);
|
|
582
|
+
if (isNaN(a) || isNaN(b)) return;
|
|
583
|
+
if (a <= b) {
|
|
584
|
+
this.addError(field, this.msg(field, 'after', `${field} must be a date after ${dateField}.`));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
validateBefore(field, dateField) {
|
|
589
|
+
const val = this.data[field];
|
|
590
|
+
const other = this.data[dateField];
|
|
591
|
+
if (!val || !other) return;
|
|
592
|
+
const a = Date.parse(val);
|
|
593
|
+
const b = Date.parse(other);
|
|
594
|
+
if (isNaN(a) || isNaN(b)) return;
|
|
595
|
+
if (a >= b) {
|
|
596
|
+
this.addError(field, this.msg(field, 'before', `${field} must be a date before ${dateField}.`));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
validateRegex(field, pattern) {
|
|
601
|
+
const val = this.data[field];
|
|
602
|
+
if (!val) return;
|
|
603
|
+
let regex;
|
|
604
|
+
try {
|
|
605
|
+
if (/^\/.*\/[gimsuy]*$/.test(pattern)) {
|
|
606
|
+
const lastSlash = pattern.lastIndexOf('/');
|
|
607
|
+
const body = pattern.slice(1, lastSlash);
|
|
608
|
+
const flags = pattern.slice(lastSlash + 1);
|
|
609
|
+
regex = new RegExp(body, flags);
|
|
610
|
+
} else {
|
|
611
|
+
regex = new RegExp(pattern);
|
|
612
|
+
}
|
|
613
|
+
} catch (e) {
|
|
614
|
+
throw new Error(`Invalid regex pattern for ${field}: ${pattern}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!regex.test(String(val))) {
|
|
618
|
+
this.addError(field, this.msg(field, 'regex', `${field} format is invalid.`));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
validateIn(field, ...values) {
|
|
623
|
+
const val = this.data[field];
|
|
624
|
+
if (val === undefined || val === null || val === '') return;
|
|
625
|
+
const normalizedValues = values.map(v => String(v));
|
|
626
|
+
if (!normalizedValues.includes(String(val))) {
|
|
627
|
+
this.addError(field, this.msg(field, 'in', `${field} must be one of [${values.join(', ')}].`));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
validateNotIn(field, ...values) {
|
|
632
|
+
const val = this.data[field];
|
|
633
|
+
if (val === undefined || val === null || val === '') return;
|
|
634
|
+
const normalizedValues = values.map(v => String(v));
|
|
635
|
+
if (normalizedValues.includes(String(val))) {
|
|
636
|
+
this.addError(field, this.msg(field, 'not_in', `${field} must not be one of [${values.join(', ')}].`));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// -----------------------------
|
|
641
|
+
// HARDENED DB RULES
|
|
642
|
+
// -----------------------------
|
|
643
|
+
async validateUnique(field, table = null, column = null, ignore = null, pk = null) {
|
|
644
|
+
if (!this.db) throw new Error("Database required for unique rule");
|
|
645
|
+
const value = this.data[field];
|
|
646
|
+
if (value === undefined || value === null || value === '') return;
|
|
647
|
+
|
|
648
|
+
table = table || this.table;
|
|
649
|
+
column = column || field;
|
|
650
|
+
ignore = (ignore === undefined || ignore === null || ignore === '') ? this.id : ignore;
|
|
651
|
+
pk = pk || this.primaryKey;
|
|
652
|
+
|
|
653
|
+
if (!table) throw new Error(`Unique rule requires a table. Example: unique:users,email`);
|
|
654
|
+
|
|
655
|
+
const qb = new QueryBuilder(table)
|
|
656
|
+
.select('1')
|
|
657
|
+
.where(column, value);
|
|
658
|
+
|
|
659
|
+
// Only ignore when we have an ID
|
|
660
|
+
if (ignore !== null) {
|
|
661
|
+
qb.whereNot(pk, ignore);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const exists = await qb.exists();
|
|
665
|
+
if (exists === true) {
|
|
666
|
+
this.addError(field, this.msg(field, 'unique', `${field} has already been taken.`));
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async validateExists(field, table = null, column = null, whereColumn = null, whereValue = null) {
|
|
671
|
+
if (!this.db) throw new Error("Database required for exists rule");
|
|
672
|
+
const value = this.data[field];
|
|
673
|
+
if (value === undefined || value === null || value === '') return;
|
|
674
|
+
|
|
675
|
+
table = table || this.table;
|
|
676
|
+
column = column || field;
|
|
677
|
+
|
|
678
|
+
if (!table) throw new Error(`Exists validation for "${field}" requires a table. Example: exists:users,id`);
|
|
679
|
+
|
|
680
|
+
const qb = new QueryBuilder(table)
|
|
681
|
+
.select('1')
|
|
682
|
+
.where(column, value);
|
|
683
|
+
|
|
684
|
+
if (whereColumn && whereValue !== undefined && whereValue !== null) {
|
|
685
|
+
qb.where(whereColumn, whereValue);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const found = await qb.exists();
|
|
689
|
+
if (!found) {
|
|
690
|
+
this.addError(field, this.msg(field, 'exists', `${field} does not exist in ${table}.`));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
validatePhone(field) {
|
|
695
|
+
const val = this.data[field];
|
|
696
|
+
if (!val) return;
|
|
697
|
+
const s = String(val);
|
|
698
|
+
const phoneRegex = /^(0\d{9}|\+[1-9]\d{6,14})$/;
|
|
699
|
+
if (!phoneRegex.test(s)) {
|
|
700
|
+
this.addError(field, this.msg(field, 'phone', `${field} must be a valid phone number.`));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
validateAlpha(field) {
|
|
705
|
+
const val = this.data[field];
|
|
706
|
+
if (!val) return;
|
|
707
|
+
if (!/^[A-Za-z]+$/.test(String(val))) {
|
|
708
|
+
this.addError(field, this.msg(field, 'alpha', `${field} must contain only letters.`));
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
validateAlphaNum(field) {
|
|
713
|
+
const val = this.data[field];
|
|
714
|
+
if (!val) return;
|
|
715
|
+
if (!/^[A-Za-z0-9]+$/.test(String(val))) {
|
|
716
|
+
this.addError(field, this.msg(field, 'alpha_num', `${field} must contain only letters and numbers.`));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
validateArray(field) {
|
|
721
|
+
const val = this.data[field];
|
|
722
|
+
if (val === undefined) return;
|
|
723
|
+
if (!Array.isArray(val)) this.addError(field, this.msg(field, 'array', `${field} must be an array.`));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
validateJson(field) {
|
|
727
|
+
const val = this.data[field];
|
|
728
|
+
if (!val) return;
|
|
729
|
+
try { JSON.parse(String(val)); }
|
|
730
|
+
catch (e) { this.addError(field, this.msg(field, 'json', `${field} must be valid JSON.`)); }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
validateBetween(field, min, max) {
|
|
734
|
+
const val = this.data[field];
|
|
735
|
+
if (val === undefined || val === null || val === '') return;
|
|
736
|
+
const nMin = Number(min), nMax = Number(max);
|
|
737
|
+
if ((typeof val === 'number' && (val < nMin || val > nMax)) ||
|
|
738
|
+
((typeof val === 'string' || Array.isArray(val)) && (val.length < nMin || val.length > nMax)) ||
|
|
739
|
+
(!isNaN(Number(val)) && (Number(val) < nMin || Number(val) > nMax))) {
|
|
740
|
+
this.addError(field, this.msg(field, 'between', `${field} must be between ${min} and ${max}.`));
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// -----------------------------
|
|
745
|
+
// FILE RULES HARDENED
|
|
746
|
+
// -----------------------------
|
|
747
|
+
validateFile(field) {
|
|
748
|
+
const value = this.data[field];
|
|
749
|
+
if (!value || typeof value !== 'object' || (!value.name && !value.size)) {
|
|
750
|
+
this.addError(field, this.msg(field, 'file', `${field} must be valid file upload.`));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
validateImage(field) {
|
|
755
|
+
const value = this.data[field];
|
|
756
|
+
if (!value || !value.name) {
|
|
757
|
+
this.addError(field, this.msg(field, 'image', `${field} must be valid image file.`));
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
const ext = value.name.split('.').pop().toLowerCase();
|
|
761
|
+
if (!['jpg','jpeg','png','gif','webp','bmp','svg'].includes(ext)) {
|
|
762
|
+
this.addError(field, this.msg(field, 'image', `${field} must be valid image file.`));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
validateMimes(field, types) {
|
|
767
|
+
const value = this.data[field];
|
|
768
|
+
if (!value || !value.name) return;
|
|
769
|
+
|
|
770
|
+
const allowed = types.split(',').map(t => t.trim().toLowerCase());
|
|
771
|
+
const ext = value.name.split('.').pop().toLowerCase();
|
|
772
|
+
|
|
773
|
+
if (!allowed.includes(ext)) {
|
|
774
|
+
this.addError(field, this.msg(field, 'mimes', `${field} must be of type: ${types}.`));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
validateSize(field, maxKB) {
|
|
779
|
+
const value = this.data[field];
|
|
780
|
+
if (!value || !value.size) return;
|
|
781
|
+
const max = Number(maxKB) * 1024;
|
|
782
|
+
if (value.size > max) {
|
|
783
|
+
this.addError(field, this.msg(field, 'size', `${field} must not exceed ${maxKB} KB.`));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ------------------------------------------------------------
|
|
789
|
+
// Collection Class (Safe Array Extension)
|
|
790
|
+
// ------------------------------------------------------------
|
|
791
|
+
class Collection extends Array {
|
|
792
|
+
constructor(items = []) {
|
|
793
|
+
super(...(Array.isArray(items) ? items : [items]));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// -------------------------
|
|
797
|
+
// Meta
|
|
798
|
+
// -------------------------
|
|
799
|
+
count() { return this.length; }
|
|
800
|
+
isEmpty() { return this.length === 0; }
|
|
801
|
+
isNotEmpty() { return this.length > 0; }
|
|
802
|
+
first() { return this[0] ?? null; }
|
|
803
|
+
last() { return this[this.length - 1] ?? null; }
|
|
804
|
+
nth(index) { return this[index] ?? null; }
|
|
805
|
+
|
|
806
|
+
// -------------------------
|
|
807
|
+
// Conversion
|
|
808
|
+
// -------------------------
|
|
809
|
+
toArray() { return [...this]; }
|
|
810
|
+
toJSON() { return this.toArray(); }
|
|
811
|
+
clone() { return new Collection(this); }
|
|
812
|
+
|
|
813
|
+
// -------------------------
|
|
814
|
+
// Iteration
|
|
815
|
+
// -------------------------
|
|
816
|
+
async each(fn) {
|
|
817
|
+
for (let i = 0; i < this.length; i++) {
|
|
818
|
+
await fn(this[i], i);
|
|
819
|
+
}
|
|
820
|
+
return this;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async mapAsync(fn) {
|
|
824
|
+
const out = [];
|
|
825
|
+
for (let i = 0; i < this.length; i++) {
|
|
826
|
+
out.push(await fn(this[i], i));
|
|
827
|
+
}
|
|
828
|
+
return new Collection(out);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// -------------------------
|
|
832
|
+
// Filtering
|
|
833
|
+
// -------------------------
|
|
834
|
+
where(key, value) {
|
|
835
|
+
return new Collection(this.filter(item => item?.[key] === value));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
whereNot(key, value) {
|
|
839
|
+
return new Collection(this.filter(item => item?.[key] !== value));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
filterNull() {
|
|
843
|
+
return new Collection(this.filter(v => v !== null && v !== undefined));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
onlyKeys(keys) {
|
|
847
|
+
return new Collection(this.map(item => {
|
|
848
|
+
const o = {};
|
|
849
|
+
keys.forEach(k => { if (k in item) o[k] = item[k]; });
|
|
850
|
+
return o;
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
exceptKeys(keys) {
|
|
855
|
+
return new Collection(this.map(item => {
|
|
856
|
+
const o = { ...item };
|
|
857
|
+
keys.forEach(k => delete o[k]);
|
|
858
|
+
return o;
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// -------------------------
|
|
863
|
+
// Sorting
|
|
864
|
+
// -------------------------
|
|
865
|
+
sortBy(key) {
|
|
866
|
+
return new Collection([...this].sort((a, b) =>
|
|
867
|
+
a?.[key] > b?.[key] ? 1 : -1
|
|
868
|
+
));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
sortByDesc(key) {
|
|
872
|
+
return new Collection([...this].sort((a, b) =>
|
|
873
|
+
a?.[key] < b?.[key] ? 1 : -1
|
|
874
|
+
));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// -------------------------
|
|
878
|
+
// Mapping & Transform
|
|
879
|
+
// -------------------------
|
|
880
|
+
mapToArray(fn) {
|
|
881
|
+
return super.map(fn);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
pluck(key) {
|
|
885
|
+
return new Collection(this.map(item => item?.[key]));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
toObject() {
|
|
889
|
+
return this.toArray().map(item => item.toObject ? item.toObject() : { ...item });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
compact() {
|
|
893
|
+
return new Collection(this.filter(Boolean));
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
flatten(depth = 1) {
|
|
897
|
+
return new Collection(this.flat(depth));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
flattenDeep() {
|
|
901
|
+
return new Collection(this.flat(Infinity));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
unique(fnOrKey = null) {
|
|
905
|
+
if (typeof fnOrKey === 'string') {
|
|
906
|
+
const seen = new Set();
|
|
907
|
+
return new Collection(this.filter(item => {
|
|
908
|
+
const val = item?.[fnOrKey];
|
|
909
|
+
if (seen.has(val)) return false;
|
|
910
|
+
seen.add(val);
|
|
911
|
+
return true;
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
if (typeof fnOrKey === 'function') {
|
|
915
|
+
const seen = new Set();
|
|
916
|
+
return new Collection(this.filter(item => {
|
|
917
|
+
const val = fnOrKey(item);
|
|
918
|
+
if (seen.has(val)) return false;
|
|
919
|
+
seen.add(val);
|
|
920
|
+
return true;
|
|
921
|
+
}));
|
|
922
|
+
}
|
|
923
|
+
return new Collection([...new Set(this)]);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// -------------------------
|
|
927
|
+
// Reducing & Aggregates
|
|
928
|
+
// -------------------------
|
|
929
|
+
sum(key = null) {
|
|
930
|
+
return this.reduce((acc, val) => acc + (key ? val[key] : val), 0);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
avg(key = null) {
|
|
934
|
+
return this.length ? this.sum(key) / this.length : 0;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
max(key = null) {
|
|
938
|
+
if (this.isEmpty()) return null;
|
|
939
|
+
return key
|
|
940
|
+
? this.reduce((a, b) => (b[key] > a[key] ? b : a))
|
|
941
|
+
: Math.max(...this);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
min(key = null) {
|
|
945
|
+
if (this.isEmpty()) return null;
|
|
946
|
+
return key
|
|
947
|
+
? this.reduce((a, b) => (b[key] < a[key] ? b : a))
|
|
948
|
+
: Math.min(...this);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// -------------------------
|
|
952
|
+
// Grouping
|
|
953
|
+
// -------------------------
|
|
954
|
+
groupBy(keyOrFn) {
|
|
955
|
+
const fn = typeof keyOrFn === 'function' ? keyOrFn : (item) => item?.[keyOrFn];
|
|
956
|
+
const groups = {};
|
|
957
|
+
for (const item of this) {
|
|
958
|
+
const group = fn(item);
|
|
959
|
+
if (!groups[group]) groups[group] = new Collection();
|
|
960
|
+
groups[group].push(item);
|
|
961
|
+
}
|
|
962
|
+
return groups; // object of collections
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// -------------------------
|
|
966
|
+
// Selecting random items
|
|
967
|
+
// -------------------------
|
|
968
|
+
random(n = 1) {
|
|
969
|
+
if (n === 1) {
|
|
970
|
+
return this[Math.floor(Math.random() * this.length)] ?? null;
|
|
971
|
+
}
|
|
972
|
+
return new Collection(
|
|
973
|
+
[...this].sort(() => Math.random() - 0.5).slice(0, n)
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
shuffle() {
|
|
978
|
+
return new Collection([...this].sort(() => Math.random() - 0.5));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// -------------------------
|
|
982
|
+
// Chunking
|
|
983
|
+
// -------------------------
|
|
984
|
+
chunk(size) {
|
|
985
|
+
const out = new Collection();
|
|
986
|
+
for (let i = 0; i < this.length; i += size) {
|
|
987
|
+
out.push(new Collection(this.slice(i, i + size)));
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// -------------------------
|
|
993
|
+
// Pagination helpers
|
|
994
|
+
// -------------------------
|
|
995
|
+
take(n) {
|
|
996
|
+
return new Collection(this.slice(0, n));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
skip(n) {
|
|
1000
|
+
return new Collection(this.slice(n));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// -------------------------
|
|
1004
|
+
// Find / Search
|
|
1005
|
+
// -------------------------
|
|
1006
|
+
find(fn) {
|
|
1007
|
+
return this.filter(fn)[0] ?? null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
includesWhere(key, value) {
|
|
1011
|
+
return this.some(item => item?.[key] === value);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
has(fn) {
|
|
1015
|
+
return this.some(fn);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// -------------------------
|
|
1019
|
+
// Set operations
|
|
1020
|
+
// -------------------------
|
|
1021
|
+
intersect(otherCollection) {
|
|
1022
|
+
return new Collection(this.filter(i => otherCollection.includes(i)));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
diff(otherCollection) {
|
|
1026
|
+
return new Collection(this.filter(i => !otherCollection.includes(i)));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
union(otherCollection) {
|
|
1030
|
+
return new Collection([...new Set([...this, ...otherCollection])]);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// -------------------------
|
|
1034
|
+
// Pipe (functional style)
|
|
1035
|
+
// -------------------------
|
|
1036
|
+
pipe(fn) {
|
|
1037
|
+
return fn(this);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// -------------------------
|
|
1041
|
+
// Static helpers
|
|
1042
|
+
// -------------------------
|
|
1043
|
+
static make(items = []) {
|
|
1044
|
+
return new Collection(items);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
static range(start, end) {
|
|
1048
|
+
const arr = [];
|
|
1049
|
+
for (let i = start; i <= end; i++) arr.push(i);
|
|
1050
|
+
return new Collection(arr);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Simple Paginator class — returns JSON-friendly output
|
|
1055
|
+
class Paginator {
|
|
1056
|
+
constructor(data, total, page, perPage) {
|
|
1057
|
+
this.data = data; // likely a Collection or Array
|
|
1058
|
+
this.total = Number(total) || 0;
|
|
1059
|
+
this.page = Math.max(1, Number(page));
|
|
1060
|
+
this.perPage = Math.max(1, Number(perPage));
|
|
1061
|
+
this.lastPage = Math.max(1, Math.ceil(this.total / this.perPage));
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// try common conversions: Collection -> array, toJSON, toArray, or fallback
|
|
1065
|
+
_dataToArray() {
|
|
1066
|
+
const d = this.data;
|
|
1067
|
+
|
|
1068
|
+
// If it's already a plain array
|
|
1069
|
+
if (Array.isArray(d)) return d;
|
|
1070
|
+
|
|
1071
|
+
// If object has toJSON()
|
|
1072
|
+
if (d && typeof d.toJSON === 'function') {
|
|
1073
|
+
try { return d.toJSON(); } catch(e) { /* fallthrough */ }
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// If object has toArray()
|
|
1077
|
+
if (d && typeof d.toArray === 'function') {
|
|
1078
|
+
try { return d.toArray(); } catch(e) { /* fallthrough */ }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// If it's iterable (like a Collection), try Array.from
|
|
1082
|
+
try {
|
|
1083
|
+
return Array.from(d);
|
|
1084
|
+
} catch (e) { /* fallthrough */ }
|
|
1085
|
+
|
|
1086
|
+
// Last resort: return as single-item array
|
|
1087
|
+
return [d];
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
toJSON() {
|
|
1091
|
+
return {
|
|
1092
|
+
data: this._dataToArray(),
|
|
1093
|
+
total: this.total,
|
|
1094
|
+
page: this.page,
|
|
1095
|
+
perPage: this.perPage,
|
|
1096
|
+
lastPage: this.lastPage
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
const VALID_OPERATORS = ['=', '<', '<=', '>', '>=', '<>', '!=', 'LIKE', 'ILIKE'];
|
|
1103
|
+
/******************************************************************************
|
|
1104
|
+
* QueryBuilder (Bug-Free)
|
|
1105
|
+
*****************************************************************************/
|
|
1106
|
+
class QueryBuilder {
|
|
1107
|
+
constructor(table, modelClass = null, dialect = 'mysql') {
|
|
1108
|
+
this.table = table;
|
|
1109
|
+
this.tableAlias = null;
|
|
1110
|
+
this.modelClass = modelClass;
|
|
1111
|
+
|
|
1112
|
+
this._select = ['*'];
|
|
1113
|
+
this._joins = [];
|
|
1114
|
+
this._wheres = [];
|
|
1115
|
+
this._group = [];
|
|
1116
|
+
this._having = [];
|
|
1117
|
+
this._orders = [];
|
|
1118
|
+
this._limit = null;
|
|
1119
|
+
this._offset = null;
|
|
1120
|
+
this._forUpdate = false;
|
|
1121
|
+
this._distinct = false;
|
|
1122
|
+
this.dialect = dialect;
|
|
1123
|
+
|
|
1124
|
+
this._with = [];
|
|
1125
|
+
this._ignoreSoftDeletes = false;
|
|
1126
|
+
|
|
1127
|
+
this._ctes = [];
|
|
1128
|
+
this._unions = [];
|
|
1129
|
+
this._fromRaw = null;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Helper to normalize operator
|
|
1133
|
+
_normalizeOperator(operator) {
|
|
1134
|
+
const op = operator ? operator.toUpperCase() : '=';
|
|
1135
|
+
if (!VALID_OPERATORS.includes(op)) {
|
|
1136
|
+
throw new Error(`Invalid SQL operator: ${operator}`);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Convert ILIKE to proper operator for MySQL
|
|
1140
|
+
if (op === 'ILIKE' && this.dialect === 'mysql') {
|
|
1141
|
+
return 'LIKE';
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return op;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**************************************************************************
|
|
1148
|
+
* BASIC CONFIG
|
|
1149
|
+
**************************************************************************/
|
|
1150
|
+
alias(a) { this.tableAlias = a; return this; }
|
|
1151
|
+
distinct() { this._distinct = true; return this; }
|
|
1152
|
+
|
|
1153
|
+
/**************************************************************************
|
|
1154
|
+
* SELECT
|
|
1155
|
+
**************************************************************************/
|
|
1156
|
+
select(...cols) {
|
|
1157
|
+
if (!cols.length) return this;
|
|
1158
|
+
|
|
1159
|
+
const flat = cols.flat();
|
|
1160
|
+
const normalized = flat.flatMap(col => {
|
|
1161
|
+
if (typeof col === 'object' && !Array.isArray(col)) {
|
|
1162
|
+
return Object.entries(col).map(([k, v]) =>
|
|
1163
|
+
`${escapeId(k)} AS ${escapeId(v)}`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return col;
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
this._select = normalized;
|
|
1170
|
+
return this;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
addSelect(...cols) {
|
|
1174
|
+
this._select.push(...cols.flat());
|
|
1175
|
+
return this;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**************************************************************************
|
|
1179
|
+
* JOINS
|
|
1180
|
+
**************************************************************************/
|
|
1181
|
+
join(type, table, first, operator, second) {
|
|
1182
|
+
this._joins.push({
|
|
1183
|
+
type: type.toUpperCase(),
|
|
1184
|
+
table,
|
|
1185
|
+
first,
|
|
1186
|
+
operator,
|
|
1187
|
+
second
|
|
1188
|
+
});
|
|
1189
|
+
return this;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
innerJoin(t, f, o, s) { return this.join('INNER', t, f, o, s); }
|
|
1193
|
+
leftJoin(t, f, o, s) { return this.join('LEFT', t, f, o, s); }
|
|
1194
|
+
rightJoin(t, f, o, s) { return this.join('RIGHT', t, f, o, s); }
|
|
1195
|
+
crossJoin(t) { return this.join('CROSS', t, null, null, null); }
|
|
1196
|
+
|
|
1197
|
+
/**************************************************************************
|
|
1198
|
+
* WHERE HELPERS
|
|
1199
|
+
**************************************************************************/
|
|
1200
|
+
_pushWhere(w) {
|
|
1201
|
+
if (!this._wheres) this._wheres = [];
|
|
1202
|
+
|
|
1203
|
+
if (w.boolean == null) w.boolean = "AND";
|
|
1204
|
+
if (w.bindings === undefined) w.bindings = [];
|
|
1205
|
+
|
|
1206
|
+
this._wheres.push(w);
|
|
1207
|
+
return this;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
then(resolve, reject) {
|
|
1211
|
+
return this.get().then(resolve, reject);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
where(columnOrObject, operator, value) {
|
|
1216
|
+
if (typeof columnOrObject === 'function') {
|
|
1217
|
+
columnOrObject(this);
|
|
1218
|
+
return this;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (typeof columnOrObject === 'object' && columnOrObject !== null) {
|
|
1222
|
+
let query = this;
|
|
1223
|
+
for (const [col, val] of Object.entries(columnOrObject)) {
|
|
1224
|
+
query = query.where(col, val);
|
|
1225
|
+
}
|
|
1226
|
+
return query;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (arguments.length === 2) {
|
|
1230
|
+
value = operator;
|
|
1231
|
+
operator = '=';
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const op = this._normalizeOperator(operator);
|
|
1235
|
+
|
|
1236
|
+
// For MySQL + LIKE case-insensitive, wrap with LOWER()
|
|
1237
|
+
const useLower = this.dialect === 'mysql' && operator.toUpperCase() === 'ILIKE';
|
|
1238
|
+
return this._pushWhere({
|
|
1239
|
+
type: 'basic',
|
|
1240
|
+
boolean: 'AND',
|
|
1241
|
+
column: useLower ? `LOWER(${columnOrObject})` : columnOrObject,
|
|
1242
|
+
operator: op,
|
|
1243
|
+
value: useLower ? value.toLowerCase() : value,
|
|
1244
|
+
bindings: [useLower ? value.toLowerCase() : value],
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
orWhere(columnOrObject, operatorOrValue, value) {
|
|
1249
|
+
if (typeof columnOrObject === 'object' && columnOrObject !== null) {
|
|
1250
|
+
let query = this;
|
|
1251
|
+
for (const [col, val] of Object.entries(columnOrObject)) {
|
|
1252
|
+
query = query.orWhere(col, val);
|
|
1253
|
+
}
|
|
1254
|
+
return query;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (arguments.length === 2) {
|
|
1258
|
+
value = operatorOrValue;
|
|
1259
|
+
operatorOrValue = '=';
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const op = this._normalizeOperator(operatorOrValue);
|
|
1263
|
+
|
|
1264
|
+
const useLower = this.dialect === 'mysql' && operatorOrValue.toUpperCase() === 'ILIKE';
|
|
1265
|
+
return this._pushWhere({
|
|
1266
|
+
type: 'basic',
|
|
1267
|
+
boolean: 'OR',
|
|
1268
|
+
column: useLower ? `LOWER(${columnOrObject})` : columnOrObject,
|
|
1269
|
+
operator: op,
|
|
1270
|
+
value: useLower ? value.toLowerCase() : value,
|
|
1271
|
+
bindings: [useLower ? value.toLowerCase() : value],
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Example method to generate SQL
|
|
1276
|
+
toSQL() {
|
|
1277
|
+
if (!this.wheres.length) return '';
|
|
1278
|
+
const sql = this.wheres.map((w, i) => {
|
|
1279
|
+
const prefix = i === 0 ? '' : ` ${w.boolean} `;
|
|
1280
|
+
return `${prefix}\`${w.column}\` ${w.operator} ?`;
|
|
1281
|
+
}).join('');
|
|
1282
|
+
return 'WHERE ' + sql;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
whereRaw(sql, bindings = []) {
|
|
1287
|
+
return this._pushWhere({
|
|
1288
|
+
type: 'raw',
|
|
1289
|
+
raw: sql,
|
|
1290
|
+
bindings: Array.isArray(bindings) ? bindings : [bindings]
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
orWhereRaw(sql, bindings = []) {
|
|
1295
|
+
return this._pushWhere({
|
|
1296
|
+
type: 'raw',
|
|
1297
|
+
boolean: 'OR',
|
|
1298
|
+
raw: sql,
|
|
1299
|
+
bindings: Array.isArray(bindings) ? bindings : [bindings]
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
whereColumn(a, op, b) {
|
|
1304
|
+
if (arguments.length === 2) { b = op; op = '='; }
|
|
1305
|
+
return this._pushWhere({
|
|
1306
|
+
type: 'columns',
|
|
1307
|
+
first: a,
|
|
1308
|
+
operator: op,
|
|
1309
|
+
second: b
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
orWhereColumn(a, op, b) {
|
|
1314
|
+
if (arguments.length === 2) { b = op; op = '='; }
|
|
1315
|
+
return this._pushWhere({
|
|
1316
|
+
type: 'columns',
|
|
1317
|
+
boolean: 'OR',
|
|
1318
|
+
first: a,
|
|
1319
|
+
operator: op,
|
|
1320
|
+
second: b
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
whereNested(cb) {
|
|
1325
|
+
const qb = new QueryBuilder(this.table, this.modelClass);
|
|
1326
|
+
cb(qb);
|
|
1327
|
+
|
|
1328
|
+
return this._pushWhere({
|
|
1329
|
+
type: 'nested',
|
|
1330
|
+
query: qb,
|
|
1331
|
+
bindings: qb._gatherBindings()
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
whereIn(column, values = []) {
|
|
1336
|
+
if (!Array.isArray(values)) throw new Error('whereIn expects array');
|
|
1337
|
+
if (!values.length) {
|
|
1338
|
+
return this._pushWhere({ type: 'raw', raw: '0 = 1', bindings: [] });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return this._pushWhere({
|
|
1342
|
+
type: 'in',
|
|
1343
|
+
column,
|
|
1344
|
+
values,
|
|
1345
|
+
bindings: [...values]
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** COMPLETELY FIXED VERSION */
|
|
1350
|
+
whereNot(column, operatorOrValue, value) {
|
|
1351
|
+
if (typeof column === 'object' && column !== null) {
|
|
1352
|
+
for (const [k, v] of Object.entries(column))
|
|
1353
|
+
this.whereNot(k, v);
|
|
1354
|
+
return this;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
let operator = '=';
|
|
1358
|
+
let val = operatorOrValue;
|
|
1359
|
+
|
|
1360
|
+
if (arguments.length === 3) {
|
|
1361
|
+
operator = operatorOrValue;
|
|
1362
|
+
val = value;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (operator === '=') operator = '!=';
|
|
1366
|
+
if (operator.toUpperCase() === 'IN') operator = 'NOT IN';
|
|
1367
|
+
|
|
1368
|
+
return this._pushWhere({
|
|
1369
|
+
type: 'basic',
|
|
1370
|
+
not: true,
|
|
1371
|
+
column,
|
|
1372
|
+
operator,
|
|
1373
|
+
value: val,
|
|
1374
|
+
bindings: [val]
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
whereNotIn(column, values = []) {
|
|
1379
|
+
if (!Array.isArray(values)) throw new Error('whereNotIn expects array');
|
|
1380
|
+
if (!values.length) {
|
|
1381
|
+
return this._pushWhere({ type: 'raw', raw: '1 = 1', bindings: [] });
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return this._pushWhere({
|
|
1385
|
+
type: 'notIn',
|
|
1386
|
+
column,
|
|
1387
|
+
values,
|
|
1388
|
+
bindings: [...values]
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
whereNull(col) {
|
|
1393
|
+
return this._pushWhere({ type: 'null', column: col, not: false });
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
whereNotNull(col) {
|
|
1397
|
+
return this._pushWhere({ type: 'null', column: col, not: true });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
whereBetween(col, [a, b]) {
|
|
1401
|
+
return this._pushWhere({
|
|
1402
|
+
type: 'between',
|
|
1403
|
+
column: col,
|
|
1404
|
+
bounds: [a, b],
|
|
1405
|
+
not: false,
|
|
1406
|
+
bindings: [a, b]
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
whereNotBetween(col, [a, b]) {
|
|
1411
|
+
return this._pushWhere({
|
|
1412
|
+
type: 'between',
|
|
1413
|
+
column: col,
|
|
1414
|
+
bounds: [a, b],
|
|
1415
|
+
not: true,
|
|
1416
|
+
bindings: [a, b]
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
whereExists(builderOrRaw) {
|
|
1421
|
+
return this._existsHelper(builderOrRaw, false);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
whereNotExists(builderOrRaw) {
|
|
1425
|
+
return this._existsHelper(builderOrRaw, true);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
_existsHelper(builderOrRaw, neg) {
|
|
1429
|
+
if (typeof builderOrRaw === 'function') {
|
|
1430
|
+
const qb = new QueryBuilder(this.table, this.modelClass);
|
|
1431
|
+
builderOrRaw(qb);
|
|
1432
|
+
|
|
1433
|
+
return this._pushWhere({
|
|
1434
|
+
type: neg ? 'notExists' : 'exists',
|
|
1435
|
+
query: qb,
|
|
1436
|
+
bindings: qb._gatherBindings()
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (builderOrRaw instanceof QueryBuilder) {
|
|
1441
|
+
return this._pushWhere({
|
|
1442
|
+
type: neg ? 'notExists' : 'exists',
|
|
1443
|
+
query: builderOrRaw,
|
|
1444
|
+
bindings: builderOrRaw._gatherBindings()
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
return this._pushWhere({
|
|
1449
|
+
type: neg ? 'rawNotExists' : 'rawExists',
|
|
1450
|
+
raw: builderOrRaw
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**************************************************************************
|
|
1455
|
+
* JSON
|
|
1456
|
+
**************************************************************************/
|
|
1457
|
+
whereJsonPath(column, path, operator, value) {
|
|
1458
|
+
if (arguments.length === 3) { value = operator; operator = '='; }
|
|
1459
|
+
|
|
1460
|
+
return this._pushWhere({
|
|
1461
|
+
type: 'raw',
|
|
1462
|
+
raw: `JSON_EXTRACT(${escapeId(column)}, '${path}') ${operator} ?`,
|
|
1463
|
+
bindings: [value]
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
whereJsonContains(column, value) {
|
|
1468
|
+
return this._pushWhere({
|
|
1469
|
+
type: 'raw',
|
|
1470
|
+
raw: `JSON_CONTAINS(${escapeId(column)}, ?)`,
|
|
1471
|
+
bindings: [JSON.stringify(value)]
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**************************************************************************
|
|
1476
|
+
* GROUP / HAVING
|
|
1477
|
+
**************************************************************************/
|
|
1478
|
+
groupBy(...cols) {
|
|
1479
|
+
this._group.push(...cols.flat());
|
|
1480
|
+
return this;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
having(column, operatorOrValue, value) {
|
|
1484
|
+
if (arguments.length === 2) {
|
|
1485
|
+
return this._pushHaving(column, '=', operatorOrValue, 'AND');
|
|
1486
|
+
}
|
|
1487
|
+
return this._pushHaving(column, operatorOrValue, value, 'AND');
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
orHaving(column, operatorOrValue, value) {
|
|
1491
|
+
if (arguments.length === 2) {
|
|
1492
|
+
return this._pushHaving(column, '=', operatorOrValue, 'OR');
|
|
1493
|
+
}
|
|
1494
|
+
return this._pushHaving(column, operatorOrValue, value, 'OR');
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
_pushHaving(column, op, value, bool) {
|
|
1498
|
+
this._having.push({
|
|
1499
|
+
column,
|
|
1500
|
+
operator: op,
|
|
1501
|
+
value,
|
|
1502
|
+
boolean: bool,
|
|
1503
|
+
bindings: [value]
|
|
1504
|
+
});
|
|
1505
|
+
return this;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**************************************************************************
|
|
1509
|
+
* ORDER / LIMIT
|
|
1510
|
+
**************************************************************************/
|
|
1511
|
+
orderBy(col, dir = 'ASC') {
|
|
1512
|
+
this._orders.push([col, dir.toUpperCase() === 'DESC' ? 'DESC' : 'ASC']);
|
|
1513
|
+
return this;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
limit(n) { this._limit = Number(n); return this; }
|
|
1517
|
+
offset(n) { this._offset = Number(n); return this; }
|
|
1518
|
+
forUpdate() { this._forUpdate = true; return this; }
|
|
1519
|
+
|
|
1520
|
+
/**************************************************************************
|
|
1521
|
+
* CTE
|
|
1522
|
+
**************************************************************************/
|
|
1523
|
+
withCTE(name, query, { recursive = false } = {}) {
|
|
1524
|
+
this._ctes.push({ name, query, recursive });
|
|
1525
|
+
return this;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**************************************************************************
|
|
1529
|
+
* UNION
|
|
1530
|
+
**************************************************************************/
|
|
1531
|
+
union(q) { this._unions.push({ type: 'UNION', query: q }); return this; }
|
|
1532
|
+
unionAll(q) { this._unions.push({ type: 'UNION ALL', query: q }); return this; }
|
|
1533
|
+
|
|
1534
|
+
/**************************************************************************
|
|
1535
|
+
* RAW FROM
|
|
1536
|
+
**************************************************************************/
|
|
1537
|
+
fromRaw(raw) { this._fromRaw = raw; return this; }
|
|
1538
|
+
|
|
1539
|
+
/**************************************************************************
|
|
1540
|
+
* WITH (Eager Load)
|
|
1541
|
+
**************************************************************************/
|
|
1542
|
+
with(relations) {
|
|
1543
|
+
if (!Array.isArray(relations)) relations = [relations];
|
|
1544
|
+
|
|
1545
|
+
for (const r of relations) {
|
|
1546
|
+
if (!this._with.includes(r)) this._with.push(r);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
return this;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
preload(relations) {
|
|
1553
|
+
return this.with(relations);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
ignoreSoftDeletes() {
|
|
1557
|
+
this._ignoreSoftDeletes = true;
|
|
1558
|
+
return this;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
whereHas(relationName, callback, boolean = 'AND') {
|
|
1562
|
+
const relation = this.modelClass.relations()?.[relationName];
|
|
1563
|
+
if (!relation) {
|
|
1564
|
+
throw new Error(`Relation '${relationName}' is not defined on ${this.modelClass.name}`);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const RelatedModel = relation.model();
|
|
1568
|
+
const relatedQuery = RelatedModel.query();
|
|
1569
|
+
|
|
1570
|
+
// let user modify the related query
|
|
1571
|
+
callback(relatedQuery);
|
|
1572
|
+
|
|
1573
|
+
// Build EXISTS() JOIN based on relation type
|
|
1574
|
+
const parentTable = this.table;
|
|
1575
|
+
const relatedTable = RelatedModel.table;
|
|
1576
|
+
const parentKey = relation.localKey || this.modelClass.primaryKey;
|
|
1577
|
+
const foreignKey = relation.foreignKey;
|
|
1578
|
+
|
|
1579
|
+
// The related subquery should NOT include SELECT columns.
|
|
1580
|
+
// Instead, wrap it as an EXISTS with WHEREs + join condition.
|
|
1581
|
+
const existsQuery = RelatedModel.query();
|
|
1582
|
+
|
|
1583
|
+
// copy user-added wheres
|
|
1584
|
+
existsQuery._wheres = relatedQuery._wheres.slice();
|
|
1585
|
+
|
|
1586
|
+
// add the relation join constraint
|
|
1587
|
+
existsQuery.whereRaw(
|
|
1588
|
+
`${escapeId(relatedTable)}.${escapeId(foreignKey)} = ${escapeId(parentTable)}.${escapeId(parentKey)}`
|
|
1589
|
+
);
|
|
1590
|
+
|
|
1591
|
+
// Now push EXISTS into parent _wheres
|
|
1592
|
+
this._wheres.push({
|
|
1593
|
+
type: 'exists',
|
|
1594
|
+
boolean,
|
|
1595
|
+
query: existsQuery
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
return this;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**************************************************************************
|
|
1602
|
+
* COMPILERS
|
|
1603
|
+
**************************************************************************/
|
|
1604
|
+
_compileSelect() {
|
|
1605
|
+
const parts = [];
|
|
1606
|
+
|
|
1607
|
+
/* CTEs */
|
|
1608
|
+
if (this._ctes.length) {
|
|
1609
|
+
const rec = this._ctes.some(x => x.recursive)
|
|
1610
|
+
? 'WITH RECURSIVE '
|
|
1611
|
+
: 'WITH ';
|
|
1612
|
+
|
|
1613
|
+
const cteSql = this._ctes
|
|
1614
|
+
.map(cte => {
|
|
1615
|
+
const q =
|
|
1616
|
+
cte.query instanceof QueryBuilder
|
|
1617
|
+
? `(${cte.query._compileSelect()})`
|
|
1618
|
+
: `(${cte.query})`;
|
|
1619
|
+
return `${escapeId(cte.name)} AS ${q}`;
|
|
1620
|
+
})
|
|
1621
|
+
.join(', ');
|
|
1622
|
+
|
|
1623
|
+
parts.push(rec + cteSql);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
parts.push('SELECT');
|
|
1627
|
+
if (this._distinct) parts.push('DISTINCT');
|
|
1628
|
+
|
|
1629
|
+
parts.push(this._select.length ? this._select.join(', ') : '*');
|
|
1630
|
+
|
|
1631
|
+
if (this._fromRaw) {
|
|
1632
|
+
parts.push('FROM ' + this._fromRaw);
|
|
1633
|
+
} else {
|
|
1634
|
+
parts.push(
|
|
1635
|
+
'FROM ' +
|
|
1636
|
+
escapeId(this.table) +
|
|
1637
|
+
(this.tableAlias ? ' AS ' + escapeId(this.tableAlias) : '')
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/* JOINS */
|
|
1642
|
+
for (const j of this._joins) {
|
|
1643
|
+
if (j.type === 'CROSS') {
|
|
1644
|
+
parts.push(`CROSS JOIN ${escapeId(j.table)}`);
|
|
1645
|
+
} else {
|
|
1646
|
+
parts.push(
|
|
1647
|
+
`${j.type} JOIN ${escapeId(j.table)} ON ${escapeId(j.first)} ${j.operator} ${escapeId(j.second)}`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/* WHERE */
|
|
1653
|
+
const whereSql = this._compileWheres();
|
|
1654
|
+
if (whereSql) parts.push(whereSql);
|
|
1655
|
+
|
|
1656
|
+
/* GROUP */
|
|
1657
|
+
if (this._group.length) {
|
|
1658
|
+
parts.push('GROUP BY ' + this._group.map(escapeId).join(', '));
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/* HAVING */
|
|
1662
|
+
if (this._having.length) {
|
|
1663
|
+
const has = this._having
|
|
1664
|
+
.map((h, i) => {
|
|
1665
|
+
const pre = i === 0 ? 'HAVING ' : `${h.boolean} `;
|
|
1666
|
+
return pre + `${escapeId(h.column)} ${h.operator} ?`;
|
|
1667
|
+
})
|
|
1668
|
+
.join(' ');
|
|
1669
|
+
parts.push(has);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/* ORDER */
|
|
1673
|
+
if (this._orders.length) {
|
|
1674
|
+
parts.push(
|
|
1675
|
+
'ORDER BY ' +
|
|
1676
|
+
this._orders
|
|
1677
|
+
.map(([c, d]) => `${escapeId(c)} ${d}`)
|
|
1678
|
+
.join(', ')
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/* LIMIT / OFFSET */
|
|
1683
|
+
if (this._limit != null) parts.push(`LIMIT ${this._limit}`);
|
|
1684
|
+
if (this._offset != null) parts.push(`OFFSET ${this._offset}`);
|
|
1685
|
+
|
|
1686
|
+
if (this._forUpdate) parts.push('FOR UPDATE');
|
|
1687
|
+
|
|
1688
|
+
let sql = parts.join(' ');
|
|
1689
|
+
|
|
1690
|
+
/* UNION */
|
|
1691
|
+
if (this._unions.length) {
|
|
1692
|
+
for (const u of this._unions) {
|
|
1693
|
+
const other =
|
|
1694
|
+
u.query instanceof QueryBuilder
|
|
1695
|
+
? u.query._compileSelect()
|
|
1696
|
+
: u.query;
|
|
1697
|
+
sql = `(${sql}) ${u.type} (${other})`;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return sql;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
_compileWheres() {
|
|
1705
|
+
if (!this._wheres.length) return '';
|
|
1706
|
+
|
|
1707
|
+
const out = [];
|
|
1708
|
+
|
|
1709
|
+
this._wheres.forEach((w, i) => {
|
|
1710
|
+
const pre = i === 0 ? 'WHERE ' : w.boolean + ' ';
|
|
1711
|
+
|
|
1712
|
+
switch (w.type) {
|
|
1713
|
+
case 'raw':
|
|
1714
|
+
out.push(pre + w.raw);
|
|
1715
|
+
break;
|
|
1716
|
+
|
|
1717
|
+
case 'basic':
|
|
1718
|
+
out.push(pre + `${escapeId(w.column)} ${w.operator} ?`);
|
|
1719
|
+
break;
|
|
1720
|
+
|
|
1721
|
+
case 'columns':
|
|
1722
|
+
out.push(pre + `${escapeId(w.first)} ${w.operator} ${escapeId(w.second)}`);
|
|
1723
|
+
break;
|
|
1724
|
+
|
|
1725
|
+
case 'nested': {
|
|
1726
|
+
const inner = w.query._compileWheres().replace(/^WHERE\s*/i, '');
|
|
1727
|
+
out.push(pre + `(${inner})`);
|
|
1728
|
+
break;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
case 'in':
|
|
1732
|
+
out.push(
|
|
1733
|
+
pre + `${escapeId(w.column)} IN (${w.values.map(() => '?').join(', ')})`
|
|
1734
|
+
);
|
|
1735
|
+
break;
|
|
1736
|
+
|
|
1737
|
+
case 'notIn':
|
|
1738
|
+
out.push(
|
|
1739
|
+
pre + `${escapeId(w.column)} NOT IN (${w.values.map(() => '?').join(', ')})`
|
|
1740
|
+
);
|
|
1741
|
+
break;
|
|
1742
|
+
|
|
1743
|
+
case 'null':
|
|
1744
|
+
out.push(
|
|
1745
|
+
pre + `${escapeId(w.column)} IS ${w.not ? 'NOT ' : ''}NULL`
|
|
1746
|
+
);
|
|
1747
|
+
break;
|
|
1748
|
+
|
|
1749
|
+
case 'between':
|
|
1750
|
+
out.push(
|
|
1751
|
+
pre +
|
|
1752
|
+
`${escapeId(w.column)} ${w.not ? 'NOT BETWEEN' : 'BETWEEN'} ? AND ?`
|
|
1753
|
+
);
|
|
1754
|
+
break;
|
|
1755
|
+
|
|
1756
|
+
case 'exists':
|
|
1757
|
+
out.push(pre + `EXISTS (${w.query._compileSelect()})`);
|
|
1758
|
+
break;
|
|
1759
|
+
|
|
1760
|
+
case 'notExists':
|
|
1761
|
+
out.push(pre + `NOT EXISTS (${w.query._compileSelect()})`);
|
|
1762
|
+
break;
|
|
1763
|
+
|
|
1764
|
+
case 'rawExists':
|
|
1765
|
+
out.push(pre + `EXISTS (${w.raw})`);
|
|
1766
|
+
break;
|
|
1767
|
+
|
|
1768
|
+
case 'rawNotExists':
|
|
1769
|
+
out.push(pre + `NOT EXISTS (${w.raw})`);
|
|
1770
|
+
break;
|
|
1771
|
+
|
|
1772
|
+
default:
|
|
1773
|
+
throw new Error('Unknown where type: ' + w.type);
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
return out.join(' ');
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
_gatherBindings() {
|
|
1781
|
+
const out = [];
|
|
1782
|
+
|
|
1783
|
+
for (const c of this._ctes) {
|
|
1784
|
+
if (c.query instanceof QueryBuilder)
|
|
1785
|
+
out.push(...c.query._gatherBindings());
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
for (const w of this._wheres) {
|
|
1789
|
+
if (w.bindings?.length) out.push(...w.bindings);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
for (const h of this._having) {
|
|
1793
|
+
if (h.bindings?.length) out.push(...h.bindings);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
for (const u of this._unions) {
|
|
1797
|
+
if (u.query instanceof QueryBuilder)
|
|
1798
|
+
out.push(...u.query._gatherBindings());
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
return out;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**************************************************************************
|
|
1805
|
+
* READ METHODS
|
|
1806
|
+
**************************************************************************/
|
|
1807
|
+
async get() {
|
|
1808
|
+
const sql = this._compileSelect();
|
|
1809
|
+
const binds = this._gatherBindings();
|
|
1810
|
+
|
|
1811
|
+
const rows = await DB.raw(sql, binds);
|
|
1812
|
+
|
|
1813
|
+
if (this.modelClass) {
|
|
1814
|
+
const models = rows.map(r => new this.modelClass(r, true));
|
|
1815
|
+
|
|
1816
|
+
if (this._with.length) {
|
|
1817
|
+
const loaded = await this._eagerLoad(models);
|
|
1818
|
+
return new Collection(loaded);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
return new Collection(models);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
return new Collection(rows);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
async first() {
|
|
1828
|
+
const c = this._clone();
|
|
1829
|
+
c.limit(1);
|
|
1830
|
+
|
|
1831
|
+
const rows = await c.get();
|
|
1832
|
+
return Array.isArray(rows) ? rows[0] || null : null;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async firstOrFail() {
|
|
1836
|
+
const r = await this.first();
|
|
1837
|
+
if (!r) throw new Error('Record not found');
|
|
1838
|
+
return r;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
async exists() {
|
|
1842
|
+
const c = this._clone();
|
|
1843
|
+
c._select = ['1'];
|
|
1844
|
+
c._orders = [];
|
|
1845
|
+
c.limit(1);
|
|
1846
|
+
|
|
1847
|
+
const sql = c._compileSelect();
|
|
1848
|
+
const b = c._gatherBindings();
|
|
1849
|
+
|
|
1850
|
+
const rows = await DB.raw(sql, b);
|
|
1851
|
+
return rows.length > 0;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async doesntExist() {
|
|
1855
|
+
return !(await this.exists());
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
async count(column = '*') {
|
|
1859
|
+
const c = this._clone();
|
|
1860
|
+
c._select = [`COUNT(${column}) AS aggregate`];
|
|
1861
|
+
c._orders = [];
|
|
1862
|
+
c._limit = null;
|
|
1863
|
+
c._offset = null;
|
|
1864
|
+
|
|
1865
|
+
const sql = c._compileSelect();
|
|
1866
|
+
const b = c._gatherBindings();
|
|
1867
|
+
|
|
1868
|
+
const rows = await DB.raw(sql, b);
|
|
1869
|
+
|
|
1870
|
+
return rows[0] ? Number(rows[0].aggregate) : 0;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
async _aggregate(expr) {
|
|
1874
|
+
const c = this._clone();
|
|
1875
|
+
c._select = [`${expr} AS aggregate`];
|
|
1876
|
+
c._orders = [];
|
|
1877
|
+
c._limit = null;
|
|
1878
|
+
c._offset = null;
|
|
1879
|
+
|
|
1880
|
+
const sql = c._compileSelect();
|
|
1881
|
+
const b = c._gatherBindings();
|
|
1882
|
+
|
|
1883
|
+
const rows = await DB.raw(sql, b);
|
|
1884
|
+
|
|
1885
|
+
return rows[0] ? Number(rows[0].aggregate) : 0;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
sum(c) { return this._aggregate(`SUM(${escapeId(c)})`); }
|
|
1889
|
+
avg(c) { return this._aggregate(`AVG(${escapeId(c)})`); }
|
|
1890
|
+
min(c) { return this._aggregate(`MIN(${escapeId(c)})`); }
|
|
1891
|
+
max(c) { return this._aggregate(`MAX(${escapeId(c)})`); }
|
|
1892
|
+
countDistinct(c) { return this._aggregate(`COUNT(DISTINCT ${escapeId(c)})`); }
|
|
1893
|
+
|
|
1894
|
+
async pluck(col) {
|
|
1895
|
+
const c = this._clone();
|
|
1896
|
+
c._select = [escapeId(col)];
|
|
1897
|
+
|
|
1898
|
+
const sql = c._compileSelect();
|
|
1899
|
+
const b = c._gatherBindings();
|
|
1900
|
+
|
|
1901
|
+
const rows = await DB.raw(sql, b);
|
|
1902
|
+
|
|
1903
|
+
return rows.map(r => r[col]);
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
async paginate(page = 1, perPage = 15) {
|
|
1907
|
+
page = Math.max(1, Number(page));
|
|
1908
|
+
perPage = Math.max(1, Number(perPage));
|
|
1909
|
+
|
|
1910
|
+
// Build a clone to compute total (safe — uses your existing _clone and count)
|
|
1911
|
+
const countClone = this._clone();
|
|
1912
|
+
countClone._select = [`COUNT(*) AS aggregate`];
|
|
1913
|
+
countClone._orders = [];
|
|
1914
|
+
countClone._limit = null;
|
|
1915
|
+
countClone._offset = null;
|
|
1916
|
+
|
|
1917
|
+
const total = await countClone.count('*');
|
|
1918
|
+
|
|
1919
|
+
const offset = (page - 1) * perPage;
|
|
1920
|
+
|
|
1921
|
+
// Use a clone to fetch rows so we don't mutate caller's builder
|
|
1922
|
+
const rows = await this._clone()
|
|
1923
|
+
.limit(perPage)
|
|
1924
|
+
.offset(offset)
|
|
1925
|
+
.get();
|
|
1926
|
+
|
|
1927
|
+
// Return Paginator instance with .toJSON()
|
|
1928
|
+
return new Paginator(rows, total, page, perPage);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**************************************************************************
|
|
1932
|
+
* WRITE METHODS
|
|
1933
|
+
**************************************************************************/
|
|
1934
|
+
async insert(values) {
|
|
1935
|
+
const keys = Object.keys(values);
|
|
1936
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
1937
|
+
const sql =
|
|
1938
|
+
`INSERT INTO ${escapeId(this.table)} (` +
|
|
1939
|
+
keys.map(escapeId).join(', ') +
|
|
1940
|
+
`) VALUES (${placeholders})`;
|
|
1941
|
+
|
|
1942
|
+
const bindings = Object.values(values);
|
|
1943
|
+
|
|
1944
|
+
const result = await DB.raw(sql, bindings);
|
|
1945
|
+
|
|
1946
|
+
return result.affectedRows || 0;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
async insertGetId(values) {
|
|
1950
|
+
const keys = Object.keys(values);
|
|
1951
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
1952
|
+
const sql =
|
|
1953
|
+
`INSERT INTO ${escapeId(this.table)} (` +
|
|
1954
|
+
keys.map(escapeId).join(', ') +
|
|
1955
|
+
`) VALUES (${placeholders})`;
|
|
1956
|
+
|
|
1957
|
+
const bindings = Object.values(values);
|
|
1958
|
+
|
|
1959
|
+
const result = await DB.raw(sql, bindings);
|
|
1960
|
+
|
|
1961
|
+
return result.insertId ?? null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async update(values) {
|
|
1965
|
+
if (!Object.keys(values).length) return 0;
|
|
1966
|
+
|
|
1967
|
+
const setClause = Object.keys(values)
|
|
1968
|
+
.map(k => `${escapeId(k)} = ?`)
|
|
1969
|
+
.join(', ');
|
|
1970
|
+
|
|
1971
|
+
const whereSql = this._compileWhereOnly();
|
|
1972
|
+
const sql =
|
|
1973
|
+
`UPDATE ${escapeId(this.table)} SET ${setClause} ${whereSql}`;
|
|
1974
|
+
|
|
1975
|
+
const bindings = [...Object.values(values), ...this._gatherBindings()];
|
|
1976
|
+
|
|
1977
|
+
const result = await DB.raw(sql, bindings);
|
|
1978
|
+
|
|
1979
|
+
return result.affectedRows || 0;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
async increment(col, by = 1) {
|
|
1983
|
+
const sql =
|
|
1984
|
+
`UPDATE ${escapeId(this.table)} ` +
|
|
1985
|
+
`SET ${escapeId(col)} = ${escapeId(col)} + ? ` +
|
|
1986
|
+
this._compileWhereOnly();
|
|
1987
|
+
|
|
1988
|
+
const b = [by, ...this._gatherBindings()];
|
|
1989
|
+
|
|
1990
|
+
const res = await DB.raw(sql, b);
|
|
1991
|
+
|
|
1992
|
+
return res.affectedRows || 0;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
async decrement(col, by = 1) {
|
|
1996
|
+
const sql =
|
|
1997
|
+
`UPDATE ${escapeId(this.table)} ` +
|
|
1998
|
+
`SET ${escapeId(col)} = ${escapeId(col)} - ? ` +
|
|
1999
|
+
this._compileWhereOnly();
|
|
2000
|
+
|
|
2001
|
+
const b = [by, ...this._gatherBindings()];
|
|
2002
|
+
const res = await DB.raw(sql, b);
|
|
2003
|
+
return res.affectedRows || 0;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async delete() {
|
|
2007
|
+
const sql =
|
|
2008
|
+
`DELETE FROM ${escapeId(this.table)} ` +
|
|
2009
|
+
this._compileWhereOnly();
|
|
2010
|
+
|
|
2011
|
+
const b = this._gatherBindings();
|
|
2012
|
+
|
|
2013
|
+
const res = await DB.raw(sql, b);
|
|
2014
|
+
return res.affectedRows || 0;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
async truncate() {
|
|
2018
|
+
const sql = `TRUNCATE TABLE ${escapeId(this.table)}`;
|
|
2019
|
+
await DB.raw(sql);
|
|
2020
|
+
return true;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
_compileWhereOnly() {
|
|
2024
|
+
const w = this._compileWheres();
|
|
2025
|
+
return w ? w : '';
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**************************************************************************
|
|
2029
|
+
* EAGER LOAD (unchanged except robust checks)
|
|
2030
|
+
**************************************************************************/
|
|
2031
|
+
async _eagerLoad(models) {
|
|
2032
|
+
for (const relName of this._with) {
|
|
2033
|
+
const sample = models[0];
|
|
2034
|
+
if (!sample) return models;
|
|
2035
|
+
|
|
2036
|
+
const relationMethod = sample[relName];
|
|
2037
|
+
if (typeof relationMethod !== 'function') {
|
|
2038
|
+
throw new Error(`Relation "${relName}" is not a method on ${sample.constructor.name}`);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const relation = relationMethod.call(sample);
|
|
2042
|
+
|
|
2043
|
+
if (!relation || typeof relation.eagerLoad !== 'function') {
|
|
2044
|
+
throw new Error(`Relation "${relName}" does not have a valid eagerLoad method`);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
await relation.eagerLoad(models, relName);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
return models;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/**************************************************************************
|
|
2054
|
+
* CLONE
|
|
2055
|
+
**************************************************************************/
|
|
2056
|
+
_clone() {
|
|
2057
|
+
const c = new QueryBuilder(this.table, this.modelClass);
|
|
2058
|
+
|
|
2059
|
+
c.tableAlias = this.tableAlias;
|
|
2060
|
+
c._select = [...this._select];
|
|
2061
|
+
c._joins = JSON.parse(JSON.stringify(this._joins));
|
|
2062
|
+
c._group = [...this._group];
|
|
2063
|
+
c._orders = [...this._orders];
|
|
2064
|
+
c._limit = this._limit;
|
|
2065
|
+
c._offset = this._offset;
|
|
2066
|
+
c._forUpdate = this._forUpdate;
|
|
2067
|
+
c._distinct = this._distinct;
|
|
2068
|
+
c._with = [...this._with];
|
|
2069
|
+
c._ignoreSoftDeletes = this._ignoreSoftDeletes;
|
|
2070
|
+
c._fromRaw = this._fromRaw;
|
|
2071
|
+
|
|
2072
|
+
// rehydrate nested queries
|
|
2073
|
+
c._wheres = this._rehydrateWheres(this._wheres);
|
|
2074
|
+
|
|
2075
|
+
// rehydrate CTEs
|
|
2076
|
+
c._ctes = this._rehydrateCTEs(this._ctes);
|
|
2077
|
+
|
|
2078
|
+
// rehydrate unions
|
|
2079
|
+
c._unions = this._rehydrateUnions(this._unions);
|
|
2080
|
+
|
|
2081
|
+
// having is simple
|
|
2082
|
+
c._having = JSON.parse(JSON.stringify(this._having));
|
|
2083
|
+
|
|
2084
|
+
return c;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
_rehydrateWheres(ws) {
|
|
2088
|
+
return ws.map(w => {
|
|
2089
|
+
if (w.type === 'nested' && w.query) {
|
|
2090
|
+
const qb = new QueryBuilder(this.table, this.modelClass);
|
|
2091
|
+
qb._wheres = this._rehydrateWheres(w.query._wheres);
|
|
2092
|
+
qb._joins = w.query._joins ? [...w.query._joins] : [];
|
|
2093
|
+
qb._group = w.query._group ? [...w.query._group] : [];
|
|
2094
|
+
qb._having = w.query._having ? [...w.query._having] : [];
|
|
2095
|
+
qb._orders = w.query._orders ? [...w.query._orders] : [];
|
|
2096
|
+
qb._limit = w.query._limit;
|
|
2097
|
+
qb._offset = w.query._offset;
|
|
2098
|
+
qb._forUpdate = w.query._forUpdate;
|
|
2099
|
+
qb._select = [...w.query._select];
|
|
2100
|
+
|
|
2101
|
+
return {
|
|
2102
|
+
...w,
|
|
2103
|
+
query: qb,
|
|
2104
|
+
bindings: qb._gatherBindings()
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
if ((w.type === 'exists' || w.type === 'notExists') && w.query) {
|
|
2109
|
+
const qb = new QueryBuilder(w.query.table, w.query.modelClass);
|
|
2110
|
+
qb._wheres = this._rehydrateWheres(w.query._wheres);
|
|
2111
|
+
qb._select = [...w.query._select];
|
|
2112
|
+
return {
|
|
2113
|
+
...w,
|
|
2114
|
+
query: qb,
|
|
2115
|
+
bindings: qb._gatherBindings()
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return JSON.parse(JSON.stringify(w));
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
_rehydrateCTEs(ctes) {
|
|
2124
|
+
return ctes.map(cte => {
|
|
2125
|
+
if (cte.query instanceof QueryBuilder) {
|
|
2126
|
+
const qb = new QueryBuilder(cte.query.table, cte.query.modelClass);
|
|
2127
|
+
qb._wheres = this._rehydrateWheres(cte.query._wheres);
|
|
2128
|
+
qb._select = [...cte.query._select];
|
|
2129
|
+
return {
|
|
2130
|
+
...cte,
|
|
2131
|
+
query: qb
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
return { ...cte };
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
_rehydrateUnions(unions) {
|
|
2139
|
+
return unions.map(u => {
|
|
2140
|
+
if (u.query instanceof QueryBuilder) {
|
|
2141
|
+
const qb = new QueryBuilder(u.query.table, u.query.modelClass);
|
|
2142
|
+
qb._wheres = this._rehydrateWheres(u.query._wheres);
|
|
2143
|
+
qb._select = [...u.query._select];
|
|
2144
|
+
return {
|
|
2145
|
+
...u,
|
|
2146
|
+
query: qb
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
return { ...u };
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
toJSON() {
|
|
2154
|
+
return {
|
|
2155
|
+
table: this.table,
|
|
2156
|
+
tableAlias: this.tableAlias,
|
|
2157
|
+
modelClass: this.modelClass ? this.modelClass.name : null,
|
|
2158
|
+
dialect: this.dialect,
|
|
2159
|
+
|
|
2160
|
+
select: this._select,
|
|
2161
|
+
joins: this._joins,
|
|
2162
|
+
wheres: this._wheres.map(w => {
|
|
2163
|
+
const copy = { ...w };
|
|
2164
|
+
// nested queries convert safely
|
|
2165
|
+
if (w.query instanceof QueryBuilder) {
|
|
2166
|
+
copy.query = w.query.toJSON();
|
|
2167
|
+
}
|
|
2168
|
+
return copy;
|
|
2169
|
+
}),
|
|
2170
|
+
|
|
2171
|
+
group: this._group,
|
|
2172
|
+
having: this._having,
|
|
2173
|
+
orders: this._orders,
|
|
2174
|
+
|
|
2175
|
+
limit: this._limit,
|
|
2176
|
+
offset: this._offset,
|
|
2177
|
+
distinct: this._distinct,
|
|
2178
|
+
forUpdate: this._forUpdate,
|
|
2179
|
+
|
|
2180
|
+
with: this._with,
|
|
2181
|
+
ignoreSoftDeletes: this._ignoreSoftDeletes,
|
|
2182
|
+
|
|
2183
|
+
ctes: this._ctes.map(c => ({
|
|
2184
|
+
name: c.name,
|
|
2185
|
+
recursive: c.recursive,
|
|
2186
|
+
query:
|
|
2187
|
+
c.query instanceof QueryBuilder
|
|
2188
|
+
? c.query.toJSON()
|
|
2189
|
+
: c.query
|
|
2190
|
+
})),
|
|
2191
|
+
|
|
2192
|
+
unions: this._unions.map(u => ({
|
|
2193
|
+
type: u.type,
|
|
2194
|
+
query: u.query instanceof QueryBuilder
|
|
2195
|
+
? u.query.toJSON()
|
|
2196
|
+
: u.query
|
|
2197
|
+
})),
|
|
2198
|
+
|
|
2199
|
+
fromRaw: this._fromRaw
|
|
2200
|
+
};
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
static fromJSON(json) {
|
|
2204
|
+
const qb = new QueryBuilder(json.table, null, json.dialect);
|
|
2205
|
+
|
|
2206
|
+
qb.tableAlias = json.tableAlias;
|
|
2207
|
+
qb._select = [...json.select];
|
|
2208
|
+
qb._joins = JSON.parse(JSON.stringify(json.joins));
|
|
2209
|
+
|
|
2210
|
+
qb._group = [...json.group];
|
|
2211
|
+
qb._having = JSON.parse(JSON.stringify(json.having));
|
|
2212
|
+
qb._orders = JSON.parse(JSON.stringify(json.orders));
|
|
2213
|
+
qb._limit = json.limit;
|
|
2214
|
+
qb._offset = json.offset;
|
|
2215
|
+
qb._distinct = json.distinct;
|
|
2216
|
+
qb._forUpdate = json.forUpdate;
|
|
2217
|
+
|
|
2218
|
+
qb._with = [...json.with];
|
|
2219
|
+
qb._ignoreSoftDeletes = json.ignoreSoftDeletes;
|
|
2220
|
+
|
|
2221
|
+
qb._fromRaw = json.fromRaw;
|
|
2222
|
+
|
|
2223
|
+
// rebuild CTEs
|
|
2224
|
+
qb._ctes = json.ctes.map(c => ({
|
|
2225
|
+
name: c.name,
|
|
2226
|
+
recursive: c.recursive,
|
|
2227
|
+
query: typeof c.query === 'object'
|
|
2228
|
+
? QueryBuilder.fromJSON(c.query)
|
|
2229
|
+
: c.query
|
|
2230
|
+
}));
|
|
2231
|
+
|
|
2232
|
+
// rebuild unions
|
|
2233
|
+
qb._unions = json.unions.map(u => ({
|
|
2234
|
+
type: u.type,
|
|
2235
|
+
query: typeof u.query === 'object'
|
|
2236
|
+
? QueryBuilder.fromJSON(u.query)
|
|
2237
|
+
: u.query
|
|
2238
|
+
}));
|
|
2239
|
+
|
|
2240
|
+
// rebuild wheres (with nested query reconstruction)
|
|
2241
|
+
qb._wheres = json.wheres.map(w => {
|
|
2242
|
+
const copy = { ...w };
|
|
2243
|
+
|
|
2244
|
+
if (w.query && typeof w.query === 'object') {
|
|
2245
|
+
copy.query = QueryBuilder.fromJSON(w.query);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
return copy;
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
return qb;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
/**************************************************************************
|
|
2257
|
+
* TO SQL
|
|
2258
|
+
**************************************************************************/
|
|
2259
|
+
toSQL() {
|
|
2260
|
+
return {
|
|
2261
|
+
sql: this._compileSelect(),
|
|
2262
|
+
bindings: this._gatherBindings()
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
toSQLJSON() {
|
|
2267
|
+
const sql = this._compileSelect();
|
|
2268
|
+
const bindings = this._gatherBindings();
|
|
2269
|
+
return { sql, bindings };
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
toSQLWhereOnly() {
|
|
2274
|
+
return {
|
|
2275
|
+
sql: this._compileWhereOnly(),
|
|
2276
|
+
bindings: this._gatherBindings()
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// --- Relations ---
|
|
2282
|
+
class Relation {
|
|
2283
|
+
constructor(parent, relatedClass, foreignKey = null, localKey = null) {
|
|
2284
|
+
this.parent = parent;
|
|
2285
|
+
this.relatedClass = relatedClass;
|
|
2286
|
+
this.foreignKey = foreignKey;
|
|
2287
|
+
this.localKey = localKey || (parent.constructor.primaryKey || "id");
|
|
2288
|
+
this.deleteBehavior = null;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
onDelete(behavior) {
|
|
2292
|
+
this.deleteBehavior = behavior;
|
|
2293
|
+
return this;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
class BelongsTo extends Relation {
|
|
2299
|
+
constructor(parent, relatedClass, foreignKey = null, ownerKey = null) {
|
|
2300
|
+
super(parent, relatedClass, foreignKey, ownerKey || relatedClass.primaryKey || "id");
|
|
2301
|
+
|
|
2302
|
+
this.foreignKey = foreignKey || `${relatedClass.table.replace(/s$/, "")}_id`;
|
|
2303
|
+
this.ownerKey = ownerKey || relatedClass.primaryKey || "id";
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
async get() {
|
|
2307
|
+
const fkValue = this.parent[this.foreignKey];
|
|
2308
|
+
return await this.relatedClass.query().where(this.ownerKey, fkValue).first();
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
async eagerLoad(parents, relName) {
|
|
2312
|
+
const fkValues = parents.map(p => p[this.foreignKey]).filter(Boolean);
|
|
2313
|
+
|
|
2314
|
+
const relatedRows = await this.relatedClass
|
|
2315
|
+
.query()
|
|
2316
|
+
.whereIn(this.ownerKey, fkValues)
|
|
2317
|
+
.get();
|
|
2318
|
+
|
|
2319
|
+
const map = new Map();
|
|
2320
|
+
relatedRows.forEach(r => map.set(r[this.ownerKey], r));
|
|
2321
|
+
|
|
2322
|
+
parents.forEach(parent => {
|
|
2323
|
+
parent[relName] = map.get(parent[this.foreignKey]) || null;
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
|
|
2329
|
+
/* ---------------- HasOne ---------------- */
|
|
2330
|
+
class HasOne extends Relation {
|
|
2331
|
+
async get() {
|
|
2332
|
+
return await this.relatedClass
|
|
2333
|
+
.query()
|
|
2334
|
+
.where(this.foreignKey, this.parent[this.localKey])
|
|
2335
|
+
.first();
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async eagerLoad(parents, relName) {
|
|
2339
|
+
const parentIds = parents.map(p => p[this.localKey]);
|
|
2340
|
+
|
|
2341
|
+
const relatedRows = await this.relatedClass
|
|
2342
|
+
.query()
|
|
2343
|
+
.whereIn(this.foreignKey, parentIds)
|
|
2344
|
+
.get();
|
|
2345
|
+
|
|
2346
|
+
const grouped = new Map();
|
|
2347
|
+
parents.forEach(p => grouped.set(p[this.localKey], null));
|
|
2348
|
+
|
|
2349
|
+
relatedRows.forEach(r => grouped.set(r[this.foreignKey], r));
|
|
2350
|
+
|
|
2351
|
+
parents.forEach(p => {
|
|
2352
|
+
p[relName] = grouped.get(p[this.localKey]) || null;
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
/* ---------------- BelongsTo ---------------- */
|
|
2359
|
+
class HasMany extends Relation {
|
|
2360
|
+
async get() {
|
|
2361
|
+
return await this.relatedClass
|
|
2362
|
+
.query()
|
|
2363
|
+
.where(this.foreignKey, this.parent[this.localKey])
|
|
2364
|
+
.get();
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
async eagerLoad(parents, relName) {
|
|
2368
|
+
const parentIds = parents.map(p => p[this.localKey]);
|
|
2369
|
+
|
|
2370
|
+
const rows = await this.relatedClass
|
|
2371
|
+
.query()
|
|
2372
|
+
.whereIn(this.foreignKey, parentIds)
|
|
2373
|
+
.get();
|
|
2374
|
+
|
|
2375
|
+
const grouped = new Map();
|
|
2376
|
+
parents.forEach(p => grouped.set(p[this.localKey], []));
|
|
2377
|
+
|
|
2378
|
+
rows.forEach(r => grouped.get(r[this.foreignKey]).push(r));
|
|
2379
|
+
|
|
2380
|
+
parents.forEach(p => {
|
|
2381
|
+
p[relName] = grouped.get(p[this.localKey]);
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
class HasManyThrough extends Relation {
|
|
2387
|
+
constructor(parent, relatedClass, throughClass, firstKey, secondKey, localKey, secondLocalKey) {
|
|
2388
|
+
super(parent, relatedClass, firstKey, localKey);
|
|
2389
|
+
this.throughClass = throughClass;
|
|
2390
|
+
this.firstKey = firstKey || parent.constructor.primaryKey || "id";
|
|
2391
|
+
this.secondKey = secondKey || `${throughClass.table.replace(/s$/, "")}_id`;
|
|
2392
|
+
this.localKey = localKey || (parent.constructor.primaryKey || "id");
|
|
2393
|
+
this.secondLocalKey = secondLocalKey || relatedClass.primaryKey || "id";
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
async get() {
|
|
2397
|
+
const throughRows = await this.throughClass
|
|
2398
|
+
.query()
|
|
2399
|
+
.where(this.firstKey, this.parent[this.localKey])
|
|
2400
|
+
.get();
|
|
2401
|
+
|
|
2402
|
+
const throughIds = throughRows.map(r => r[this.secondKey]);
|
|
2403
|
+
|
|
2404
|
+
return await this.relatedClass
|
|
2405
|
+
.query()
|
|
2406
|
+
.whereIn(this.secondLocalKey, throughIds)
|
|
2407
|
+
.get();
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
class MorphOne extends Relation {
|
|
2412
|
+
constructor(parent, relatedClass, morphName, localKey = null) {
|
|
2413
|
+
super(parent, relatedClass, `${morphName}_id`, localKey);
|
|
2414
|
+
this.morphType = `${morphName}_type`;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
async get() {
|
|
2418
|
+
return await this.relatedClass
|
|
2419
|
+
.query()
|
|
2420
|
+
.where(this.foreignKey, this.parent[this.localKey])
|
|
2421
|
+
.where(this.morphType, this.parent.constructor.name)
|
|
2422
|
+
.first();
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
class MorphMany extends Relation {
|
|
2427
|
+
constructor(parent, relatedClass, morphName, localKey = null) {
|
|
2428
|
+
super(parent, relatedClass, `${morphName}_id`, localKey);
|
|
2429
|
+
this.morphType = `${morphName}_type`;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
async get() {
|
|
2433
|
+
return await this.relatedClass
|
|
2434
|
+
.query()
|
|
2435
|
+
.where(this.foreignKey, this.parent[this.localKey])
|
|
2436
|
+
.where(this.morphType, this.parent.constructor.name)
|
|
2437
|
+
.get();
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
class MorphTo {
|
|
2442
|
+
constructor(parent, typeField = "morph_type", idField = "morph_id") {
|
|
2443
|
+
this.parent = parent;
|
|
2444
|
+
this.typeField = typeField;
|
|
2445
|
+
this.idField = idField;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
async get() {
|
|
2449
|
+
const klass = globalThis[this.parent[this.typeField]];
|
|
2450
|
+
const id = this.parent[this.idField];
|
|
2451
|
+
return await klass.find(id);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
class MorphToMany extends Relation {
|
|
2456
|
+
constructor(parent, relatedClass, morphName, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
2457
|
+
const morphId = `${morphName}_id`;
|
|
2458
|
+
const morphType = `${morphName}_type`;
|
|
2459
|
+
|
|
2460
|
+
super(parent, relatedClass, morphId, parent.constructor.primaryKey);
|
|
2461
|
+
|
|
2462
|
+
this.morphTypeColumn = morphType;
|
|
2463
|
+
|
|
2464
|
+
this.pivotTable =
|
|
2465
|
+
pivotTable || `${morphName}_${relatedClass.table}`;
|
|
2466
|
+
|
|
2467
|
+
this.foreignKey = foreignKey || morphId;
|
|
2468
|
+
this.relatedKey = relatedKey || `${relatedClass.table.replace(/s$/, "")}_id`;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
class MorphedByMany extends MorphToMany {
|
|
2473
|
+
constructor(parent, relatedClass, morphName, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
2474
|
+
super(parent, relatedClass, morphName, pivotTable, foreignKey, relatedKey);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
/* ---------------- BelongsToMany ---------------- */
|
|
2480
|
+
|
|
2481
|
+
class BelongsToMany extends Relation {
|
|
2482
|
+
constructor(parent, relatedClass, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
2483
|
+
const parentTable = parent.constructor.table;
|
|
2484
|
+
const relatedTable = relatedClass.table;
|
|
2485
|
+
|
|
2486
|
+
const parentPK = parent.constructor.primaryKey || "id";
|
|
2487
|
+
const relatedPK = relatedClass.primaryKey || "id";
|
|
2488
|
+
|
|
2489
|
+
if (!pivotTable) {
|
|
2490
|
+
const sorted = [parentTable, relatedTable].sort();
|
|
2491
|
+
pivotTable = sorted.join("_");
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
if (!foreignKey) foreignKey = `${parentTable.replace(/s$/, "")}_id`;
|
|
2495
|
+
if (!relatedKey) relatedKey = `${relatedTable.replace(/s$/, "")}_id`;
|
|
2496
|
+
|
|
2497
|
+
super(parent, relatedClass, foreignKey, parentPK);
|
|
2498
|
+
|
|
2499
|
+
this.pivotTable = pivotTable;
|
|
2500
|
+
this.relatedKey = relatedKey;
|
|
2501
|
+
|
|
2502
|
+
this.parentPK = parentPK;
|
|
2503
|
+
this.relatedPK = relatedPK;
|
|
2504
|
+
|
|
2505
|
+
// Optional pivot options
|
|
2506
|
+
this._pivotColumns = [];
|
|
2507
|
+
this._withTimestamps = false;
|
|
2508
|
+
this._pivotOrder = null;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// -----------------------------------------------------
|
|
2512
|
+
// CONFIGURATION HELPERS
|
|
2513
|
+
// -----------------------------------------------------
|
|
2514
|
+
|
|
2515
|
+
withPivot(...columns) {
|
|
2516
|
+
this._pivotColumns.push(...columns);
|
|
2517
|
+
return this;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
withTimestamps() {
|
|
2521
|
+
this._withTimestamps = true;
|
|
2522
|
+
return this;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
orderByPivot(column, direction = "asc") {
|
|
2526
|
+
this._pivotOrder = { column, direction };
|
|
2527
|
+
return this;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// -----------------------------------------------------
|
|
2531
|
+
// LAZY LOAD RELATIONSHIP
|
|
2532
|
+
// -----------------------------------------------------
|
|
2533
|
+
async get() {
|
|
2534
|
+
const parentId = this.parent[this.parentPK];
|
|
2535
|
+
|
|
2536
|
+
const pivotCols = [
|
|
2537
|
+
`${this.pivotTable}.${this.foreignKey}`,
|
|
2538
|
+
`${this.pivotTable}.${this.relatedKey}`,
|
|
2539
|
+
...this._pivotColumns.map(c => `${this.pivotTable}.${c}`)
|
|
2540
|
+
];
|
|
2541
|
+
|
|
2542
|
+
if (this._withTimestamps) {
|
|
2543
|
+
pivotCols.push(`${this.pivotTable}.created_at`);
|
|
2544
|
+
pivotCols.push(`${this.pivotTable}.updated_at`);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const query = this.relatedClass
|
|
2548
|
+
.query()
|
|
2549
|
+
.join(
|
|
2550
|
+
this.pivotTable,
|
|
2551
|
+
`${this.relatedClass.table}.${this.relatedPK}`,
|
|
2552
|
+
"=",
|
|
2553
|
+
`${this.pivotTable}.${this.relatedKey}`
|
|
2554
|
+
)
|
|
2555
|
+
.where(`${this.pivotTable}.${this.foreignKey}`, parentId)
|
|
2556
|
+
.select(`${this.relatedClass.table}.*`, ...pivotCols);
|
|
2557
|
+
|
|
2558
|
+
if (this._pivotOrder) {
|
|
2559
|
+
query.orderBy(
|
|
2560
|
+
`${this.pivotTable}.${this._pivotOrder.column}`,
|
|
2561
|
+
this._pivotOrder.direction
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
const rows = await query.get();
|
|
2566
|
+
|
|
2567
|
+
return this._hydratePivot(rows);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// -----------------------------------------------------
|
|
2571
|
+
// EAGER LOADING
|
|
2572
|
+
// -----------------------------------------------------
|
|
2573
|
+
async eagerLoad(parents, relName) {
|
|
2574
|
+
if (!parents.length) {
|
|
2575
|
+
parents.forEach(p => (p[relName] = []));
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
const parentIds = parents.map(p => p[this.parentPK]);
|
|
2580
|
+
|
|
2581
|
+
const pivotRows = await new QueryBuilder(this.pivotTable)
|
|
2582
|
+
.whereIn(this.foreignKey, parentIds)
|
|
2583
|
+
.get();
|
|
2584
|
+
|
|
2585
|
+
if (!pivotRows.length) {
|
|
2586
|
+
parents.forEach(p => (p[relName] = []));
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
const relatedIds = pivotRows.map(p => p[this.relatedKey]);
|
|
2591
|
+
|
|
2592
|
+
const relatedRows = await new QueryBuilder(
|
|
2593
|
+
this.relatedClass.table,
|
|
2594
|
+
this.relatedClass
|
|
2595
|
+
)
|
|
2596
|
+
.whereIn(this.relatedPK, relatedIds)
|
|
2597
|
+
.get();
|
|
2598
|
+
|
|
2599
|
+
const pivotByParent = new Map();
|
|
2600
|
+
parents.forEach(p => pivotByParent.set(p[this.parentPK], []));
|
|
2601
|
+
pivotRows.forEach(p => pivotByParent.get(p[this.foreignKey]).push(p));
|
|
2602
|
+
|
|
2603
|
+
const relatedById = new Map();
|
|
2604
|
+
relatedRows.forEach(r => relatedById.set(r[this.relatedPK], r));
|
|
2605
|
+
|
|
2606
|
+
parents.forEach(parent => {
|
|
2607
|
+
const pivots = pivotByParent.get(parent[this.parentPK]) || [];
|
|
2608
|
+
parent[relName] = pivots.map(pivot => {
|
|
2609
|
+
const related = { ...relatedById.get(pivot[this.relatedKey]) };
|
|
2610
|
+
related._pivot = pivot;
|
|
2611
|
+
return related;
|
|
2612
|
+
});
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// -----------------------------------------------------
|
|
2617
|
+
// MUTATORS (attach, detach, sync, toggle)
|
|
2618
|
+
// -----------------------------------------------------
|
|
2619
|
+
|
|
2620
|
+
async attach(ids, pivotData = {}) {
|
|
2621
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
2622
|
+
|
|
2623
|
+
const rows = ids.map(id => {
|
|
2624
|
+
const data = {
|
|
2625
|
+
[this.foreignKey]: this.parent[this.parentPK],
|
|
2626
|
+
[this.relatedKey]: id,
|
|
2627
|
+
...pivotData
|
|
2628
|
+
};
|
|
2629
|
+
|
|
2630
|
+
if (this._withTimestamps) {
|
|
2631
|
+
data.created_at = new Date();
|
|
2632
|
+
data.updated_at = new Date();
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
return data;
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
return await new QueryBuilder(this.pivotTable).insert(rows);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
async detach(ids = null) {
|
|
2642
|
+
const query = new QueryBuilder(this.pivotTable)
|
|
2643
|
+
.where(this.foreignKey, this.parent[this.parentPK]);
|
|
2644
|
+
|
|
2645
|
+
if (ids) {
|
|
2646
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
2647
|
+
query.whereIn(this.relatedKey, ids);
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
return await query.delete();
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
async sync(ids) {
|
|
2654
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
2655
|
+
|
|
2656
|
+
const current = await new QueryBuilder(this.pivotTable)
|
|
2657
|
+
.where(this.foreignKey, this.parent[this.parentPK])
|
|
2658
|
+
.get();
|
|
2659
|
+
|
|
2660
|
+
const currentIds = current.map(r => r[this.relatedKey]);
|
|
2661
|
+
|
|
2662
|
+
const toAttach = ids.filter(id => !currentIds.includes(id));
|
|
2663
|
+
const toDetach = currentIds.filter(id => !ids.includes(id));
|
|
2664
|
+
|
|
2665
|
+
await this.attach(toAttach);
|
|
2666
|
+
await this.detach(toDetach);
|
|
2667
|
+
|
|
2668
|
+
return { attached: toAttach, detached: toDetach };
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
async toggle(ids) {
|
|
2672
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
2673
|
+
|
|
2674
|
+
const current = await new QueryBuilder(this.pivotTable)
|
|
2675
|
+
.where(this.foreignKey, this.parent[this.parentPK])
|
|
2676
|
+
.get();
|
|
2677
|
+
|
|
2678
|
+
const currentIds = current.map(r => r[this.relatedKey]);
|
|
2679
|
+
|
|
2680
|
+
const toAttach = ids.filter(id => !currentIds.includes(id));
|
|
2681
|
+
const toDetach = ids.filter(id => currentIds.includes(id));
|
|
2682
|
+
|
|
2683
|
+
await this.attach(toAttach);
|
|
2684
|
+
await this.detach(toDetach);
|
|
2685
|
+
|
|
2686
|
+
return { attached: toAttach, detached: toDetach };
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// -----------------------------------------------------
|
|
2690
|
+
// Internal helper
|
|
2691
|
+
// -----------------------------------------------------
|
|
2692
|
+
_hydratePivot(rows) {
|
|
2693
|
+
return rows.map(row => {
|
|
2694
|
+
const model = { ...row };
|
|
2695
|
+
model._pivot = {};
|
|
2696
|
+
|
|
2697
|
+
model._pivot[this.foreignKey] = row[this.foreignKey];
|
|
2698
|
+
model._pivot[this.relatedKey] = row[this.relatedKey];
|
|
2699
|
+
|
|
2700
|
+
this._pivotColumns.forEach(col => {
|
|
2701
|
+
model._pivot[col] = row[col];
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
if (this._withTimestamps) {
|
|
2705
|
+
model._pivot.created_at = row.created_at;
|
|
2706
|
+
model._pivot.updated_at = row.updated_at;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
return model;
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
class ValidationError extends Error {
|
|
2715
|
+
/**
|
|
2716
|
+
* @param {string | string[] | Record<string, any>} messages - Validation messages
|
|
2717
|
+
* @param {ErrorOptions} [options] - Optional error options
|
|
2718
|
+
*/
|
|
2719
|
+
constructor(messages, options = {}) {
|
|
2720
|
+
// Convert messages into a human-readable string
|
|
2721
|
+
const formattedMessage = ValidationError.formatMessages(messages);
|
|
2722
|
+
|
|
2723
|
+
super(formattedMessage, options);
|
|
2724
|
+
|
|
2725
|
+
// Preserve proper prototype chain
|
|
2726
|
+
Object.setPrototypeOf(this, ValidationError.prototype);
|
|
2727
|
+
|
|
2728
|
+
this.name = 'ValidationError';
|
|
2729
|
+
this.messages = messages; // raw messages (can be string, array, or object)
|
|
2730
|
+
this.status = 422; // HTTP status for Unprocessable Entity
|
|
2731
|
+
this.code = 'VALIDATION_ERROR';
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
/**
|
|
2735
|
+
* Converts messages to a string suitable for web users
|
|
2736
|
+
*/
|
|
2737
|
+
static formatMessages(messages) {
|
|
2738
|
+
if (!messages) return 'Validation failed.';
|
|
2739
|
+
if (typeof messages === 'string') return messages;
|
|
2740
|
+
if (Array.isArray(messages)) return messages.join(', ');
|
|
2741
|
+
if (typeof messages === 'object') {
|
|
2742
|
+
// Flatten object values and join them
|
|
2743
|
+
return Object.values(messages)
|
|
2744
|
+
.flat()
|
|
2745
|
+
.map(String)
|
|
2746
|
+
.join(', ');
|
|
2747
|
+
}
|
|
2748
|
+
return String(messages);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// Symbol.toStringTag for TypeScript-like behavior
|
|
2752
|
+
get [Symbol.toStringTag]() {
|
|
2753
|
+
return 'ValidationError';
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
toString() {
|
|
2757
|
+
return `${this.name}: ${ValidationError.formatMessages(this.messages)}`;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Convenient method to get a user-friendly message
|
|
2761
|
+
get errors() {
|
|
2762
|
+
return ValidationError.formatMessages(this.messages);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
|
|
2767
|
+
// --- The Model class (fixed / cleaned) ---
|
|
2768
|
+
class Model {
|
|
2769
|
+
// class-level defaults
|
|
2770
|
+
static table = null;
|
|
2771
|
+
static primaryKey = 'id';
|
|
2772
|
+
static slugKey = 'slug';
|
|
2773
|
+
static timestamps = false;
|
|
2774
|
+
static fillable = null;
|
|
2775
|
+
static tableSingular = null;
|
|
2776
|
+
|
|
2777
|
+
static softDeletes = false;
|
|
2778
|
+
static deletedAt = 'deleted_at';
|
|
2779
|
+
static hidden = [];
|
|
2780
|
+
static visible = null;
|
|
2781
|
+
static rules = {}; // define default validation rules
|
|
2782
|
+
static customMessages = {};
|
|
2783
|
+
|
|
2784
|
+
constructor(attributes = {}, fresh = false, data = {}) {
|
|
2785
|
+
this._attributes = {};
|
|
2786
|
+
this._original = {};
|
|
2787
|
+
this._relations = {};
|
|
2788
|
+
this._exists = !!fresh;
|
|
2789
|
+
|
|
2790
|
+
// Only store keys with defined values
|
|
2791
|
+
for (const [k, v] of Object.entries(attributes)) {
|
|
2792
|
+
if (v !== undefined) this._attributes[k] = v;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
this._original = { ...this._attributes, ...data };
|
|
2796
|
+
|
|
2797
|
+
// Define getters for attributes
|
|
2798
|
+
for (const k of Object.keys(this._attributes)) {
|
|
2799
|
+
if (!(k in this)) {
|
|
2800
|
+
Object.defineProperty(this, k, {
|
|
2801
|
+
get: function() {
|
|
2802
|
+
return this._attributes[k];
|
|
2803
|
+
},
|
|
2804
|
+
enumerable: true
|
|
2805
|
+
});
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
static async validate(data, id, ignoreId = null) {
|
|
2811
|
+
if (!Validator) throw new Error('Validator not found.');
|
|
2812
|
+
|
|
2813
|
+
const rules = this.rules || {};
|
|
2814
|
+
|
|
2815
|
+
// Inject ignoreId into unique rules automatically
|
|
2816
|
+
const preparedRules = {};
|
|
2817
|
+
|
|
2818
|
+
for (const field in rules) {
|
|
2819
|
+
let r = rules[field];
|
|
2820
|
+
|
|
2821
|
+
if (typeof r === 'string') r = r.split('|');
|
|
2822
|
+
|
|
2823
|
+
// auto attach ignoreId to unique rules
|
|
2824
|
+
r = r.map(rule => {
|
|
2825
|
+
if (rule.startsWith('unique:') && ignoreId) {
|
|
2826
|
+
const [name, table, col] = rule.split(':')[1].split(',');
|
|
2827
|
+
return `unique:${table},${col || field},${ignoreId}`;
|
|
2828
|
+
}
|
|
2829
|
+
return rule;
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
preparedRules[field] = r;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const validator = new Validator(data, id, this.table, preparedRules, this.customMessages, DB);
|
|
2836
|
+
const failed = await validator.fails();
|
|
2837
|
+
|
|
2838
|
+
if (failed) {
|
|
2839
|
+
throw new ValidationError(validator.getErrors());
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
return validator;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// recommended: length as property
|
|
2846
|
+
get length() {
|
|
2847
|
+
return Object.keys(this._attributes).length;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// legacy compatibility: keep a count() method instead of duplicating 'length'
|
|
2851
|
+
count() { return this.length; }
|
|
2852
|
+
|
|
2853
|
+
// ──────────────────────────────
|
|
2854
|
+
// Core static getters & booting
|
|
2855
|
+
// ──────────────────────────────
|
|
2856
|
+
static get tableName() {
|
|
2857
|
+
if (!this.table) throw new DBError('Model.table must be set for ' + this.name);
|
|
2858
|
+
return this.table;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
static boot() {
|
|
2862
|
+
if (!this._booted) {
|
|
2863
|
+
this._events = { creating: [], updating: [], deleting: [] };
|
|
2864
|
+
this._booted = true;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
static on(event, handler) {
|
|
2869
|
+
this.boot();
|
|
2870
|
+
if (this._events[event]) this._events[event].push(handler);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// convenience aliases
|
|
2874
|
+
static before(event, handler) { return this.on(event, handler); }
|
|
2875
|
+
static after(event, handler) { return this.on(event, handler); }
|
|
2876
|
+
|
|
2877
|
+
async trigger(event) {
|
|
2878
|
+
const events = this.constructor._events?.[event] || [];
|
|
2879
|
+
for (const fn of events) {
|
|
2880
|
+
// allow handlers that return promises or sync
|
|
2881
|
+
await fn(this);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
// ──────────────────────────────
|
|
2886
|
+
// Query builder accessors
|
|
2887
|
+
// ──────────────────────────────
|
|
2888
|
+
// static query({ withTrashed = false } = {}) {
|
|
2889
|
+
// // use tableName getter (throws if missing)
|
|
2890
|
+
// const qb = new QueryBuilder(this.tableName, this);
|
|
2891
|
+
// if (this.softDeletes && !withTrashed) {
|
|
2892
|
+
// // avoid mutating shared _wheres reference
|
|
2893
|
+
// qb._wheres = Array.isArray(qb._wheres) ? qb._wheres.slice() : [];
|
|
2894
|
+
// qb._wheres.push({ raw: `${DB.escapeId(this.deletedAt)} IS NULL` });
|
|
2895
|
+
// }
|
|
2896
|
+
// return qb;
|
|
2897
|
+
// }
|
|
2898
|
+
|
|
2899
|
+
static query({ withTrashed = false } = {}) {
|
|
2900
|
+
const qb = new QueryBuilder(this.tableName, this);
|
|
2901
|
+
|
|
2902
|
+
if (this.softDeletes && !withTrashed) {
|
|
2903
|
+
qb._wheres = Array.isArray(qb._wheres) ? qb._wheres.slice() : [];
|
|
2904
|
+
qb._wheres.push({ raw: `${DB.escapeId(this.deletedAt)} IS NULL` });
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Wrap with proxy so await Video.query() auto calls .get()
|
|
2908
|
+
let getCalled = false;
|
|
2909
|
+
|
|
2910
|
+
const proxy = new Proxy(qb, {
|
|
2911
|
+
get(target, prop, receiver) {
|
|
2912
|
+
if (prop === 'get') {
|
|
2913
|
+
return (...args) => {
|
|
2914
|
+
getCalled = true; // mark that user called .get() manually
|
|
2915
|
+
return target.get(...args);
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2918
|
+
return Reflect.get(target, prop, receiver);
|
|
2919
|
+
},
|
|
2920
|
+
|
|
2921
|
+
// Auto-run get() only if user didn't call get() explicitly
|
|
2922
|
+
then(resolve, reject) {
|
|
2923
|
+
if (getCalled) {
|
|
2924
|
+
// user already called .get()
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
target.get()
|
|
2928
|
+
.then(resolve)
|
|
2929
|
+
.catch(reject);
|
|
2930
|
+
}
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
return proxy;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
static setTable(table) {
|
|
2937
|
+
this.table = table;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
static withTrashed() { return this.query({ withTrashed: true }); }
|
|
2941
|
+
|
|
2942
|
+
// ──────────────────────────────
|
|
2943
|
+
// Retrieval methods
|
|
2944
|
+
// ──────────────────────────────
|
|
2945
|
+
static async all() { return await this.query().get(); }
|
|
2946
|
+
static where(...args) { return this.query().where(...args); }
|
|
2947
|
+
static whereIn(col, arr) { return this.query().whereIn(col, arr); }
|
|
2948
|
+
static whereNot(...args) { return this.query().whereNot(...args); }
|
|
2949
|
+
static whereNotIn(col, arr) { return this.query().whereNotIn(col, arr); }
|
|
2950
|
+
static whereNull(col) { return this.query().whereNull(col); }
|
|
2951
|
+
|
|
2952
|
+
static async find(value) {
|
|
2953
|
+
if (value === undefined || value === null) return null;
|
|
2954
|
+
|
|
2955
|
+
const query = this.query();
|
|
2956
|
+
|
|
2957
|
+
// If numeric → try primary key first
|
|
2958
|
+
if (!isNaN(value)) {
|
|
2959
|
+
const row = await query.where(this.primaryKey, value).first();
|
|
2960
|
+
if (row) return row;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// Fallback or non-numeric → try slug
|
|
2964
|
+
return await this.query()
|
|
2965
|
+
.where(this.slugKey, value)
|
|
2966
|
+
.first();
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
static async findOrFail(value) {
|
|
2970
|
+
const row = await this.find(value);
|
|
2971
|
+
if (!row) {
|
|
2972
|
+
// Better error message (handles both cases)
|
|
2973
|
+
throw new DBError(
|
|
2974
|
+
`${this.name} not found using ${this.primaryKey} or ${this.slugKey} = ${value}`
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
return row;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
static async findBy(col, value) { return await this.query().where(col, value).first(); }
|
|
2981
|
+
static async findByOrFail(col, value) {
|
|
2982
|
+
const row = await this.findBy(col, value);
|
|
2983
|
+
if (!row) throw new DBError(`${this.name} record not found where ${col} = ${value}`);
|
|
2984
|
+
return row;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
static async findManyBy(col, values = []) {
|
|
2988
|
+
if (!Array.isArray(values)) throw new Error('findManyBy expects an array of values');
|
|
2989
|
+
if (!values.length) return [];
|
|
2990
|
+
return await this.query().whereIn(col, values).get();
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// additional common accessors
|
|
2994
|
+
static async findMany(ids = []) {
|
|
2995
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
2996
|
+
if (!ids.length) return [];
|
|
2997
|
+
return await this.query().whereIn(this.primaryKey, ids).get();
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// Global .first() support
|
|
3001
|
+
static async first(...args) {
|
|
3002
|
+
const qb = args.length ? this.where(...args) : this.query();
|
|
3003
|
+
return await qb.first();
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
static async firstOrFail(...args) {
|
|
3007
|
+
const qb = args.length ? this.where(...args) : this.query();
|
|
3008
|
+
const row = await qb.first();
|
|
3009
|
+
if (!row) throw new DBError(`${this.name} record not found`);
|
|
3010
|
+
return row;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
static async firstOrNew(whereAttrs, defaults = {}) {
|
|
3014
|
+
const record = await this.query().where(whereAttrs).first();
|
|
3015
|
+
if (record) return record;
|
|
3016
|
+
return new this({ ...whereAttrs, ...defaults });
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
static async firstOrCreate(whereAttrs, defaults = {}) {
|
|
3020
|
+
const found = await this.query().where(whereAttrs).first();
|
|
3021
|
+
if (found) return found;
|
|
3022
|
+
return await this.create({ ...whereAttrs, ...defaults });
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
static async updateOrCreate(whereAttrs, values = {}) {
|
|
3026
|
+
const query = this.query();
|
|
3027
|
+
|
|
3028
|
+
for (const [col, val] of Object.entries(whereAttrs)) {
|
|
3029
|
+
query.where(col, val);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
const found = await query.first();
|
|
3033
|
+
|
|
3034
|
+
if (found) {
|
|
3035
|
+
found.fill(values);
|
|
3036
|
+
await found.save();
|
|
3037
|
+
return found;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
return await this.create({ ...whereAttrs, ...values });
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// ──────────────────────────────
|
|
3044
|
+
// CREATE (static)
|
|
3045
|
+
// ──────────────────────────────
|
|
3046
|
+
static async create(attrs = {}) {
|
|
3047
|
+
const clean = {};
|
|
3048
|
+
|
|
3049
|
+
// 1. Remove bad values
|
|
3050
|
+
for (const [key, val] of Object.entries(attrs)) {
|
|
3051
|
+
if (val !== undefined && !this.isBadValue(val)) {
|
|
3052
|
+
clean[key] = val;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// 2. Enforce fillable whitelist
|
|
3057
|
+
const payload = {};
|
|
3058
|
+
const fillable = this.fillable || Object.keys(clean);
|
|
3059
|
+
for (const key of fillable) {
|
|
3060
|
+
if (key in clean) payload[key] = clean[key];
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// 3. Block empty payload
|
|
3064
|
+
if (!Object.keys(payload).length) {
|
|
3065
|
+
throw new DBError('Attempted to create with empty payload');
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// 4. Create + save
|
|
3069
|
+
const model = new this();
|
|
3070
|
+
return model.saveNew(payload);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
static async createMany(arr = []) {
|
|
3074
|
+
if (!Array.isArray(arr)) throw new DBError('createMany expects an array');
|
|
3075
|
+
if (!arr.length) return [];
|
|
3076
|
+
const results = [];
|
|
3077
|
+
await DB.transaction(async () => {
|
|
3078
|
+
for (const attrs of arr) {
|
|
3079
|
+
const r = await this.create(attrs);
|
|
3080
|
+
results.push(r);
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
return results;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
static async fetchOrNewUpMany(list = [], defaults = {}) {
|
|
3087
|
+
if (!Array.isArray(list)) throw new DBError('fetchOrNewUpMany expects an array of where objects');
|
|
3088
|
+
const out = [];
|
|
3089
|
+
for (const whereObj of list) {
|
|
3090
|
+
const found = await this.query().where(whereObj).first();
|
|
3091
|
+
if (found) out.push(found);
|
|
3092
|
+
else out.push(new this({ ...whereObj, ...defaults }));
|
|
3093
|
+
}
|
|
3094
|
+
return out;
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
static async fetchOrCreateMany(list = [], defaults = {}) {
|
|
3098
|
+
if (!Array.isArray(list)) throw new DBError('fetchOrCreateMany expects an array of where objects');
|
|
3099
|
+
const out = [];
|
|
3100
|
+
await DB.transaction(async () => {
|
|
3101
|
+
for (const whereObj of list) {
|
|
3102
|
+
const found = await this.query().where(whereObj).first();
|
|
3103
|
+
if (found) out.push(found);
|
|
3104
|
+
else out.push(await this.create({ ...whereObj, ...defaults }));
|
|
3105
|
+
}
|
|
3106
|
+
});
|
|
3107
|
+
return out;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
static async updateOrCreateMany(items = []) {
|
|
3111
|
+
if (!Array.isArray(items)) throw new DBError('updateOrCreateMany expects an array');
|
|
3112
|
+
const out = [];
|
|
3113
|
+
await DB.transaction(async () => {
|
|
3114
|
+
for (const it of items) {
|
|
3115
|
+
const whereObj = it.where || {};
|
|
3116
|
+
const values = it.values || {};
|
|
3117
|
+
const found = await this.query().where(whereObj).first();
|
|
3118
|
+
if (found) {
|
|
3119
|
+
await found.fill(values).save();
|
|
3120
|
+
out.push(found);
|
|
3121
|
+
} else {
|
|
3122
|
+
out.push(await this.create({ ...whereObj, ...values }));
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
});
|
|
3126
|
+
return out;
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
static async truncate() {
|
|
3130
|
+
if (DB.driver === 'mysql') {
|
|
3131
|
+
await DB.raw(`TRUNCATE TABLE ${this.tableName}`);
|
|
3132
|
+
} else if (DB.driver === 'pg') {
|
|
3133
|
+
await DB.raw(`TRUNCATE TABLE ${this.tableName} RESTART IDENTITY CASCADE`);
|
|
3134
|
+
} else {
|
|
3135
|
+
await DB.raw(`DELETE FROM ${this.tableName}`);
|
|
3136
|
+
if (DB.driver === 'sqlite') {
|
|
3137
|
+
try {
|
|
3138
|
+
await DB.raw(`DELETE FROM sqlite_sequence WHERE name = ?`, [this.tableName]);
|
|
3139
|
+
} catch (e) { /* ignore */ }
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
static async raw(sql, params) { return await DB.raw(sql, params); }
|
|
3145
|
+
|
|
3146
|
+
static async transaction(fn) { return await DB.transaction(fn); }
|
|
3147
|
+
|
|
3148
|
+
// ──────────────────────────────
|
|
3149
|
+
// 🛡 SANITIZATION UTIL
|
|
3150
|
+
// ──────────────────────────────
|
|
3151
|
+
static isBadValue(value) {
|
|
3152
|
+
if (value === null || value === undefined || value === '') return true;
|
|
3153
|
+
if (typeof value === 'string' && !value.trim()) return true;
|
|
3154
|
+
return false;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
sanitize(attrs = {}) {
|
|
3158
|
+
const clean = {};
|
|
3159
|
+
const keepCols = this.constructor.columns || [];
|
|
3160
|
+
|
|
3161
|
+
for (const key of Object.keys(attrs)) {
|
|
3162
|
+
const val = attrs[key];
|
|
3163
|
+
|
|
3164
|
+
if (!this.constructor.isBadValue(val)) {
|
|
3165
|
+
clean[key] = val;
|
|
3166
|
+
} else if (keepCols.includes(key)) {
|
|
3167
|
+
clean[key] = null;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
return clean;
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
// ──────────────────────────────
|
|
3175
|
+
// SAFE fill() – allow only good values
|
|
3176
|
+
// ──────────────────────────────
|
|
3177
|
+
async fill(attrs = {}) {
|
|
3178
|
+
const allowed = this.constructor.fillable || Object.keys(attrs);
|
|
3179
|
+
|
|
3180
|
+
for (const key of Object.keys(attrs)) {
|
|
3181
|
+
const val = attrs[key];
|
|
3182
|
+
if (allowed.includes(key) && !this.constructor.isBadValue(val)) {
|
|
3183
|
+
this._attributes[key] = val;
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return this;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
async merge(attrs = {}) {
|
|
3190
|
+
return this.fill(attrs);
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// ──────────────────────────────
|
|
3194
|
+
// INSERT – validation first
|
|
3195
|
+
// ──────────────────────────────
|
|
3196
|
+
async saveNew(attrs) {
|
|
3197
|
+
const payload = this.sanitize(attrs || this._attributes);
|
|
3198
|
+
|
|
3199
|
+
// Validate BEFORE hooks/db
|
|
3200
|
+
await this.constructor.validate(payload);
|
|
3201
|
+
await this.trigger('creating');
|
|
3202
|
+
|
|
3203
|
+
// timestamps
|
|
3204
|
+
if (this.constructor.timestamps) {
|
|
3205
|
+
const now = new Date();
|
|
3206
|
+
payload.created_at = payload.created_at || now;
|
|
3207
|
+
payload.updated_at = payload.updated_at || now;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// soft deletes
|
|
3211
|
+
if (this.constructor.softDeletes) {
|
|
3212
|
+
const delCol = this.constructor.deletedAt;
|
|
3213
|
+
if (delCol in payload && this.constructor.isBadValue(payload[delCol])) {
|
|
3214
|
+
delete payload[delCol];
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
const qb = this.constructor.query();
|
|
3219
|
+
|
|
3220
|
+
const result = await qb.insert(payload);
|
|
3221
|
+
|
|
3222
|
+
// handle pk
|
|
3223
|
+
const pk = this.constructor.primaryKey;
|
|
3224
|
+
const insertId = Array.isArray(result) ? result[0] : result;
|
|
3225
|
+
|
|
3226
|
+
if (!(pk in payload) && insertId !== undefined) {
|
|
3227
|
+
payload[pk] = insertId;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
this._attributes = { ...payload };
|
|
3231
|
+
this._original = { ...payload };
|
|
3232
|
+
this._exists = true;
|
|
3233
|
+
|
|
3234
|
+
return this;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// ──────────────────────────────
|
|
3238
|
+
// UPDATE – only dirty fields
|
|
3239
|
+
// ──────────────────────────────
|
|
3240
|
+
async save() {
|
|
3241
|
+
if (!this._exists) return this.saveNew(this._attributes);
|
|
3242
|
+
|
|
3243
|
+
await this.trigger('updating');
|
|
3244
|
+
|
|
3245
|
+
const dirty = {};
|
|
3246
|
+
const attrs = this._attributes;
|
|
3247
|
+
const orig = this._original;
|
|
3248
|
+
|
|
3249
|
+
for (const key of Object.keys(attrs)) {
|
|
3250
|
+
const val = attrs[key];
|
|
3251
|
+
|
|
3252
|
+
if (val !== orig[key] && !this.constructor.isBadValue(val)) {
|
|
3253
|
+
if (this.constructor.softDeletes &&
|
|
3254
|
+
key === this.constructor.deletedAt) {
|
|
3255
|
+
continue;
|
|
3256
|
+
}
|
|
3257
|
+
dirty[key] = val;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
const payload = this.sanitize(dirty);
|
|
3262
|
+
if (!Object.keys(payload).length) return this;
|
|
3263
|
+
|
|
3264
|
+
// timestamps
|
|
3265
|
+
if (this.constructor.timestamps) {
|
|
3266
|
+
const now = new Date();
|
|
3267
|
+
payload.updated_at = now;
|
|
3268
|
+
this._attributes.updated_at = now;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
// validate BEFORE db write
|
|
3272
|
+
const pk = this.constructor.primaryKey;
|
|
3273
|
+
await this.constructor.validate(
|
|
3274
|
+
{ ...this._original, ...payload },
|
|
3275
|
+
this._attributes[pk]
|
|
3276
|
+
);
|
|
3277
|
+
|
|
3278
|
+
const id = this._attributes[pk];
|
|
3279
|
+
const qb = this.constructor.query();
|
|
3280
|
+
|
|
3281
|
+
await qb.where(pk, id).update(payload);
|
|
3282
|
+
|
|
3283
|
+
this._original = { ...this.sanitize(this._attributes) };
|
|
3284
|
+
|
|
3285
|
+
return this;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// ──────────────────────────────
|
|
3289
|
+
// update() → proxies fill + save()
|
|
3290
|
+
// ──────────────────────────────
|
|
3291
|
+
async update(attrs = {}) {
|
|
3292
|
+
const payload = this.sanitize(attrs);
|
|
3293
|
+
await this.fill(payload);
|
|
3294
|
+
return this.save();
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
static getRelations() {
|
|
3298
|
+
if (this._cachedRelations) return this._cachedRelations;
|
|
3299
|
+
|
|
3300
|
+
const proto = this.prototype;
|
|
3301
|
+
const relations = {};
|
|
3302
|
+
|
|
3303
|
+
// dummy instance for calling methods
|
|
3304
|
+
const dummy = new this({}, false);
|
|
3305
|
+
|
|
3306
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
3307
|
+
if (name === 'constructor') continue;
|
|
3308
|
+
const fn = proto[name];
|
|
3309
|
+
if (typeof fn !== 'function') continue;
|
|
3310
|
+
|
|
3311
|
+
let rel;
|
|
3312
|
+
try {
|
|
3313
|
+
rel = fn.call(dummy);
|
|
3314
|
+
} catch {
|
|
3315
|
+
continue;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
if (!rel) continue;
|
|
3319
|
+
|
|
3320
|
+
// Check if rel is one of your relation classes
|
|
3321
|
+
if (
|
|
3322
|
+
rel instanceof BelongsTo ||
|
|
3323
|
+
rel instanceof HasOne ||
|
|
3324
|
+
rel instanceof HasMany ||
|
|
3325
|
+
rel instanceof BelongsToMany ||
|
|
3326
|
+
rel instanceof MorphOne ||
|
|
3327
|
+
rel instanceof MorphMany ||
|
|
3328
|
+
rel instanceof MorphTo ||
|
|
3329
|
+
rel instanceof MorphToMany ||
|
|
3330
|
+
rel instanceof MorphedByMany ||
|
|
3331
|
+
rel instanceof HasManyThrough
|
|
3332
|
+
) {
|
|
3333
|
+
rel._name = name;
|
|
3334
|
+
relations[name] = rel;
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
this._cachedRelations = relations;
|
|
3339
|
+
return relations;
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
// Delete & Restore
|
|
3343
|
+
async delete() {
|
|
3344
|
+
if (!this._exists) return false;
|
|
3345
|
+
|
|
3346
|
+
// Run events
|
|
3347
|
+
await this.trigger('deleting');
|
|
3348
|
+
|
|
3349
|
+
const pk = this.constructor.primaryKey;
|
|
3350
|
+
|
|
3351
|
+
// Soft delete
|
|
3352
|
+
if (this.constructor.softDeletes) {
|
|
3353
|
+
this._attributes[this.constructor.deletedAt] = new Date();
|
|
3354
|
+
return this.save();
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// ────────────────────────────────────────
|
|
3358
|
+
// AUTO-DISCOVER RELATIONS
|
|
3359
|
+
// ────────────────────────────────────────
|
|
3360
|
+
const relations = this.constructor.getRelations();
|
|
3361
|
+
|
|
3362
|
+
for (const relName in relations) {
|
|
3363
|
+
const relMeta = relations[relName];
|
|
3364
|
+
|
|
3365
|
+
// Load related models (Array|Model|null)
|
|
3366
|
+
let related;
|
|
3367
|
+
try {
|
|
3368
|
+
related = await this[relName]();
|
|
3369
|
+
} catch {
|
|
3370
|
+
continue; // relation method may need instance values; skip if fails
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
if (!related) continue;
|
|
3374
|
+
|
|
3375
|
+
const behavior = relMeta.deleteBehavior || 'ignore';
|
|
3376
|
+
|
|
3377
|
+
switch (behavior) {
|
|
3378
|
+
|
|
3379
|
+
// ──────────────────────────────────
|
|
3380
|
+
// RESTRICT — block delete
|
|
3381
|
+
// ──────────────────────────────────
|
|
3382
|
+
case 'restrict':
|
|
3383
|
+
throw new Error(
|
|
3384
|
+
`Cannot delete ${this.constructor.name}: related ${relName} exists`
|
|
3385
|
+
);
|
|
3386
|
+
|
|
3387
|
+
// ──────────────────────────────────
|
|
3388
|
+
// DETACH — only for BelongsToMany / MorphToMany
|
|
3389
|
+
// ──────────────────────────────────
|
|
3390
|
+
case 'detach':
|
|
3391
|
+
// must be a pivot relation
|
|
3392
|
+
if (
|
|
3393
|
+
relMeta.pivotTable &&
|
|
3394
|
+
relMeta.foreignKey &&
|
|
3395
|
+
relMeta.relatedKey
|
|
3396
|
+
) {
|
|
3397
|
+
const parentId = this[this.constructor.primaryKey];
|
|
3398
|
+
|
|
3399
|
+
const relatedIds = Array.isArray(related)
|
|
3400
|
+
? related.map(r => r[r.constructor.primaryKey])
|
|
3401
|
+
: [related[related.constructor.primaryKey]];
|
|
3402
|
+
|
|
3403
|
+
await new QueryBuilder(relMeta.pivotTable)
|
|
3404
|
+
.where(relMeta.foreignKey, parentId)
|
|
3405
|
+
.whereIn(relMeta.relatedKey, relatedIds)
|
|
3406
|
+
.delete();
|
|
3407
|
+
}
|
|
3408
|
+
break;
|
|
3409
|
+
|
|
3410
|
+
// ──────────────────────────────────
|
|
3411
|
+
// CASCADE — delete related models
|
|
3412
|
+
// ──────────────────────────────────
|
|
3413
|
+
case 'cascade':
|
|
3414
|
+
if (Array.isArray(related)) {
|
|
3415
|
+
for (const child of related) {
|
|
3416
|
+
if (child && typeof child.delete === 'function')
|
|
3417
|
+
await child.delete();
|
|
3418
|
+
}
|
|
3419
|
+
} else if (typeof related.delete === 'function') {
|
|
3420
|
+
await related.delete();
|
|
3421
|
+
}
|
|
3422
|
+
break;
|
|
3423
|
+
|
|
3424
|
+
// ──────────────────────────────────
|
|
3425
|
+
// IGNORE — do nothing
|
|
3426
|
+
// ──────────────────────────────────
|
|
3427
|
+
case 'ignore':
|
|
3428
|
+
default:
|
|
3429
|
+
break;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
// ────────────────────────────────────────
|
|
3434
|
+
// PHYSICAL DELETE
|
|
3435
|
+
// ────────────────────────────────────────
|
|
3436
|
+
const qb = this.constructor.query().where(pk, this._attributes[pk]);
|
|
3437
|
+
|
|
3438
|
+
await qb.delete();
|
|
3439
|
+
|
|
3440
|
+
this._exists = false;
|
|
3441
|
+
return true;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
|
|
3445
|
+
static async destroy(ids) {
|
|
3446
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
3447
|
+
const pk = this.primaryKey;
|
|
3448
|
+
|
|
3449
|
+
// --- Load models so cascade works ---
|
|
3450
|
+
const models = await this.whereIn(pk, ids).get();
|
|
3451
|
+
|
|
3452
|
+
for (const model of models) {
|
|
3453
|
+
await model.delete(); // uses the patched cascade delete
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
return models.length;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
async restore() {
|
|
3460
|
+
if (!this.constructor.softDeletes) return this;
|
|
3461
|
+
this._attributes[this.constructor.deletedAt] = null;
|
|
3462
|
+
return await this.save();
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// Relationships
|
|
3466
|
+
// Give each instance ability to create relations
|
|
3467
|
+
belongsTo(RelatedClass, foreignKey = null, ownerKey = null) {
|
|
3468
|
+
return new BelongsTo(this, RelatedClass, foreignKey, ownerKey);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
hasOne(RelatedClass, foreignKey = null, localKey = null) {
|
|
3472
|
+
return new HasOne(this, RelatedClass, foreignKey, localKey);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
hasMany(RelatedClass, foreignKey = null, localKey = null) {
|
|
3476
|
+
return new HasMany(this, RelatedClass, foreignKey, localKey);
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
belongsToMany(RelatedClass, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
3480
|
+
return new BelongsToMany(this, RelatedClass, pivotTable, foreignKey, relatedKey);
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
hasManyThrough(RelatedClass, ThroughClass, firstKey = null, secondKey = null, localKey = null, secondLocalKey = null) {
|
|
3484
|
+
return new HasManyThrough(this, RelatedClass, ThroughClass, firstKey, secondKey, localKey, secondLocalKey);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
morphOne(RelatedClass, morphName, localKey = null) {
|
|
3488
|
+
return new MorphOne(this, RelatedClass, morphName, localKey);
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
morphMany(RelatedClass, morphName, localKey = null) {
|
|
3492
|
+
return new MorphMany(this, RelatedClass, morphName, localKey);
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
morphTo(typeField = "morph_type", idField = "morph_id") {
|
|
3496
|
+
return new MorphTo(this, typeField, idField);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
morphToMany(RelatedClass, morphName, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
3500
|
+
return new MorphToMany(this, RelatedClass, morphName, pivotTable, foreignKey, relatedKey);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
morphedByMany(RelatedClass, morphName, pivotTable = null, foreignKey = null, relatedKey = null) {
|
|
3504
|
+
return new MorphedByMany(this, RelatedClass, morphName, pivotTable, foreignKey, relatedKey);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
static with(relations) { return this.query().with(relations); }
|
|
3508
|
+
|
|
3509
|
+
// Serialization & Conversion
|
|
3510
|
+
toObject({ relations = true } = {}) {
|
|
3511
|
+
const base = {};
|
|
3512
|
+
|
|
3513
|
+
// copy attributes
|
|
3514
|
+
for (const [k, v] of Object.entries(this._attributes)) {
|
|
3515
|
+
if (v instanceof Date) base[k] = this.serializeDate(v);
|
|
3516
|
+
else if (v instanceof Model) base[k] = v.toObject();
|
|
3517
|
+
else base[k] = v;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
// relations
|
|
3521
|
+
if (relations && this._relations) {
|
|
3522
|
+
for (const [name, rel] of Object.entries(this._relations)) {
|
|
3523
|
+
if (Array.isArray(rel)) {
|
|
3524
|
+
base[name] = rel.map(r =>
|
|
3525
|
+
r instanceof Model ? r.toObject() : r
|
|
3526
|
+
);
|
|
3527
|
+
} else if (rel instanceof Model) {
|
|
3528
|
+
base[name] = rel.toObject();
|
|
3529
|
+
} else if (rel && typeof rel.then === "function") {
|
|
3530
|
+
// relation didn't resolve yet
|
|
3531
|
+
base[name] = null;
|
|
3532
|
+
} else {
|
|
3533
|
+
base[name] = rel;
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// apply hidden/visible properly
|
|
3539
|
+
const hidden = this.constructor.hidden || [];
|
|
3540
|
+
const visible = this.constructor.visible;
|
|
3541
|
+
|
|
3542
|
+
let out = { ...base };
|
|
3543
|
+
|
|
3544
|
+
if (visible && Array.isArray(visible)) {
|
|
3545
|
+
out = Object.fromEntries(
|
|
3546
|
+
Object.entries(out).filter(([k]) => visible.includes(k))
|
|
3547
|
+
);
|
|
3548
|
+
} else if (hidden.length) {
|
|
3549
|
+
for (const k of hidden) delete out[k];
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
return out;
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
toJSON() {
|
|
3556
|
+
if (typeof this.toObject === 'function') {
|
|
3557
|
+
return this.toObject();
|
|
3558
|
+
}
|
|
3559
|
+
return {};
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
toString() {
|
|
3563
|
+
return `${this.constructor.name} ${JSON.stringify(this.toObject(), null, 2)}`;
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
// Node's util.inspect custom symbol
|
|
3567
|
+
[util.inspect.custom]() {
|
|
3568
|
+
return this.toObject();
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
serializeDate(date) {
|
|
3572
|
+
return date.toLocaleString();
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
// Utility helpers
|
|
3576
|
+
clone(deep = false) {
|
|
3577
|
+
const attrs = deep
|
|
3578
|
+
? (globalThis.structuredClone
|
|
3579
|
+
? structuredClone(this._attributes)
|
|
3580
|
+
: JSON.parse(JSON.stringify(this._attributes)))
|
|
3581
|
+
: { ...this._attributes };
|
|
3582
|
+
|
|
3583
|
+
const m = new this.constructor(attrs, this._exists);
|
|
3584
|
+
m._original = { ...this._original };
|
|
3585
|
+
return m;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
only(keys = []) {
|
|
3589
|
+
const out = {};
|
|
3590
|
+
for (const k of keys) if (k in this._attributes) out[k] = this._attributes[k];
|
|
3591
|
+
return out;
|
|
3592
|
+
}
|
|
3593
|
+
|
|
3594
|
+
except(keys = []) {
|
|
3595
|
+
const out = {};
|
|
3596
|
+
for (const k of Object.keys(this._attributes))
|
|
3597
|
+
if (!keys.includes(k)) out[k] = this._attributes[k];
|
|
3598
|
+
return out;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
getAttribute(key) { return this._attributes[key]; }
|
|
3602
|
+
setAttribute(key, value) { this._attributes[key] = value; return this; }
|
|
3603
|
+
|
|
3604
|
+
async refresh() {
|
|
3605
|
+
const pk = this.constructor.primaryKey;
|
|
3606
|
+
if (!this._attributes[pk]) return this;
|
|
3607
|
+
const fresh = await this.constructor.find(this._attributes[pk]);
|
|
3608
|
+
if (fresh) {
|
|
3609
|
+
this._attributes = { ...fresh._attributes };
|
|
3610
|
+
this._original = { ...fresh._original };
|
|
3611
|
+
}
|
|
3612
|
+
return this;
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
get exists() { return this._exists; }
|
|
3616
|
+
|
|
3617
|
+
isDirty(key) {
|
|
3618
|
+
if (!key) return Object.keys(this._attributes).some(k => this._attributes[k] !== this._original[k]);
|
|
3619
|
+
return this._attributes[key] !== this._original[key];
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
getChanges() {
|
|
3623
|
+
const dirty = {};
|
|
3624
|
+
for (const k of Object.keys(this._attributes))
|
|
3625
|
+
if (this._attributes[k] !== this._original[k]) dirty[k] = this._attributes[k];
|
|
3626
|
+
return dirty;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
// --- BaseModel with bcrypt hashing ---
|
|
3631
|
+
const bcrypt = tryRequire('bcrypt');
|
|
3632
|
+
class BaseModel extends Model {
|
|
3633
|
+
static passwordField = 'password';
|
|
3634
|
+
static hashRounds = 10;
|
|
3635
|
+
|
|
3636
|
+
/**
|
|
3637
|
+
* Lifecycle hook placeholders
|
|
3638
|
+
* Subclasses can override these.
|
|
3639
|
+
*/
|
|
3640
|
+
async beforeCreate(attrs) {}
|
|
3641
|
+
async afterCreate(savedRecord) {}
|
|
3642
|
+
async beforeSave(attrs) {}
|
|
3643
|
+
async afterSave(savedRecord) {}
|
|
3644
|
+
|
|
3645
|
+
/**
|
|
3646
|
+
* Called when inserting a new record.
|
|
3647
|
+
*/
|
|
3648
|
+
async saveNew(attrs) {
|
|
3649
|
+
await this.beforeSave(attrs);
|
|
3650
|
+
await this.beforeCreate(attrs);
|
|
3651
|
+
|
|
3652
|
+
await this._maybeHashPassword(attrs);
|
|
3653
|
+
const saved = await super.saveNew(attrs);
|
|
3654
|
+
|
|
3655
|
+
await this.afterCreate(saved);
|
|
3656
|
+
await this.afterSave(saved);
|
|
3657
|
+
|
|
3658
|
+
return saved;
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* Called when updating an existing record.
|
|
3663
|
+
*/
|
|
3664
|
+
async save() {
|
|
3665
|
+
await this.beforeSave(this._attributes);
|
|
3666
|
+
await this._maybeHashPassword(this._attributes);
|
|
3667
|
+
|
|
3668
|
+
const saved = await super.save();
|
|
3669
|
+
|
|
3670
|
+
await this.afterSave(saved);
|
|
3671
|
+
return saved;
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* Hash password field if needed.
|
|
3676
|
+
*/
|
|
3677
|
+
async _maybeHashPassword(attrs) {
|
|
3678
|
+
const field = this.constructor.passwordField;
|
|
3679
|
+
if (!attrs[field]) return;
|
|
3680
|
+
|
|
3681
|
+
if (!bcrypt)
|
|
3682
|
+
throw new DBError('bcrypt module required. Install: npm i bcrypt');
|
|
3683
|
+
|
|
3684
|
+
const isHashed =
|
|
3685
|
+
typeof attrs[field] === 'string' && /^\$2[abxy]\$/.test(attrs[field]);
|
|
3686
|
+
|
|
3687
|
+
if (!isHashed) {
|
|
3688
|
+
const salt = await bcrypt.genSalt(this.constructor.hashRounds);
|
|
3689
|
+
attrs[field] = await bcrypt.hash(attrs[field], salt);
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
/**
|
|
3694
|
+
* Check a plain text password against the hashed one.
|
|
3695
|
+
*/
|
|
3696
|
+
async checkPassword(rawPassword) {
|
|
3697
|
+
const field = this.constructor.passwordField;
|
|
3698
|
+
const hashed = this._attributes[field];
|
|
3699
|
+
if (!hashed) return false;
|
|
3700
|
+
return await bcrypt.compare(rawPassword, hashed);
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/**
|
|
3704
|
+
* Serialize model data for output.
|
|
3705
|
+
* Override this to customize output (e.g. hide sensitive fields).
|
|
3706
|
+
*/
|
|
3707
|
+
serialize() {
|
|
3708
|
+
const data = { ...this._attributes };
|
|
3709
|
+
const passwordField = this.constructor.passwordField;
|
|
3710
|
+
|
|
3711
|
+
// Remove password or other sensitive data
|
|
3712
|
+
if (data[passwordField]) delete data[passwordField];
|
|
3713
|
+
|
|
3714
|
+
return data;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
module.exports = { DB, Model, Validator, ValidationError, Collection, QueryBuilder, HasMany, HasOne, BelongsTo, BelongsToMany, DBError, BaseModel};
|