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/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};