millas 0.2.19 → 0.2.21
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/package.json +1 -1
- package/src/admin/QueryEngine.js +17 -13
- package/src/cli.js +3 -0
- package/src/commands/migrate.js +34 -2
- package/src/container/AppInitializer.js +43 -0
- package/src/core/db.js +9 -8
- package/src/orm/drivers/DatabaseManager.js +12 -0
- package/src/orm/fields/index.js +18 -11
- package/src/orm/migration/Makemigrations.js +34 -29
- package/src/orm/migration/MigrationWriter.js +117 -13
- package/src/orm/migration/ModelInspector.js +4 -0
- package/src/orm/migration/ModelScanner.js +12 -6
- package/src/orm/migration/ProjectState.js +41 -5
- package/src/orm/migration/operations/column.js +45 -95
- package/src/orm/migration/operations/fields.js +6 -6
- package/src/orm/migration/operations/index.js +7 -24
- package/src/orm/migration/operations/indexes.js +197 -0
- package/src/orm/migration/operations/models.js +35 -9
- package/src/orm/migration/operations/registry.js +24 -3
- package/src/orm/model/Model.js +315 -72
- package/src/orm/query/F.js +98 -0
- package/src/orm/query/LookupParser.js +316 -157
- package/src/orm/query/QueryBuilder.js +178 -8
- package/src/providers/DatabaseServiceProvider.js +2 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { CreateModel, DeleteModel, RenameModel } = require('./models');
|
|
4
4
|
const { AddField, RemoveField, AlterField, RenameField } = require('./fields');
|
|
5
|
+
const { AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex } = require('./indexes');
|
|
5
6
|
const { RunSQL } = require('./special');
|
|
6
7
|
const { modelNameToTable, isSnakeCase } = require('../utils');
|
|
7
8
|
|
|
@@ -42,13 +43,17 @@ const { modelNameToTable, isSnakeCase } = require('../utils');
|
|
|
42
43
|
*/
|
|
43
44
|
function deserialise(op) {
|
|
44
45
|
switch (op.type) {
|
|
45
|
-
case 'CreateModel': return new CreateModel(op.table, op.fields);
|
|
46
|
+
case 'CreateModel': return new CreateModel(op.table, op.fields, op.indexes || [], op.uniqueTogether || []);
|
|
46
47
|
case 'DeleteModel': return new DeleteModel(op.table, op.fields);
|
|
47
48
|
case 'RenameModel': return new RenameModel(op.oldTable, op.newTable);
|
|
48
49
|
case 'AddField': return new AddField(op.table, op.column, op.field, op.oneOffDefault);
|
|
49
50
|
case 'RemoveField': return new RemoveField(op.table, op.column, op.field);
|
|
50
51
|
case 'AlterField': return new AlterField(op.table, op.column, op.field, op.previousField);
|
|
51
52
|
case 'RenameField': return new RenameField(op.table, op.oldColumn, op.newColumn);
|
|
53
|
+
case 'AddIndex': return new AddIndex(op.table, op.index);
|
|
54
|
+
case 'RemoveIndex': return new RemoveIndex(op.table, op.index);
|
|
55
|
+
case 'RenameIndex': return new RenameIndex(op.table, op.oldName, op.newName);
|
|
56
|
+
case 'AlterUniqueTogether': return new AlterUniqueTogether(op.table, op.newUnique, op.oldUnique);
|
|
52
57
|
case 'RunSQL': return new RunSQL(op.sql, op.reverseSql);
|
|
53
58
|
default:
|
|
54
59
|
throw new Error(`Unknown migration operation type: "${op.type}"`);
|
|
@@ -63,10 +68,10 @@ function deserialise(op) {
|
|
|
63
68
|
*/
|
|
64
69
|
const migrations = {
|
|
65
70
|
|
|
66
|
-
CreateModel({ name, fields: fieldList = [] }) {
|
|
71
|
+
CreateModel({ name, fields: fieldList = [], indexes = [], uniqueTogether = [] }) {
|
|
67
72
|
const fields = {};
|
|
68
73
|
for (const [col, def] of fieldList) fields[col] = def;
|
|
69
|
-
return { type: 'CreateModel', table: _tableFromName(name), fields };
|
|
74
|
+
return { type: 'CreateModel', table: _tableFromName(name), fields, indexes, uniqueTogether };
|
|
70
75
|
},
|
|
71
76
|
|
|
72
77
|
DeleteModel({ name, fields: fieldList = [] }) {
|
|
@@ -101,6 +106,22 @@ const migrations = {
|
|
|
101
106
|
return { type: 'RenameField', table: modelName, oldColumn: oldName, newColumn: newName };
|
|
102
107
|
},
|
|
103
108
|
|
|
109
|
+
AddIndex({ modelName, index }) {
|
|
110
|
+
return { type: 'AddIndex', table: modelName, index };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
RemoveIndex({ modelName, index }) {
|
|
114
|
+
return { type: 'RemoveIndex', table: modelName, index };
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
RenameIndex({ modelName, oldName, newName }) {
|
|
118
|
+
return { type: 'RenameIndex', table: modelName, oldName, newName };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
AlterUniqueTogether({ modelName, newUnique, oldUnique = [] }) {
|
|
122
|
+
return { type: 'AlterUniqueTogether', table: modelName, newUnique, oldUnique };
|
|
123
|
+
},
|
|
124
|
+
|
|
104
125
|
RunSQL({ sql, reverseSql = null }) {
|
|
105
126
|
return { type: 'RunSQL', sql, reverseSql };
|
|
106
127
|
},
|
package/src/orm/model/Model.js
CHANGED
|
@@ -173,16 +173,30 @@ class Model {
|
|
|
173
173
|
|
|
174
174
|
while (cur && cur !== Function.prototype) {
|
|
175
175
|
if (Object.prototype.hasOwnProperty.call(cur, 'fields')) {
|
|
176
|
-
chain.unshift(cur.fields);
|
|
176
|
+
chain.unshift(cur.fields);
|
|
177
177
|
}
|
|
178
178
|
const curTable = cur.table || cur.name;
|
|
179
|
-
// Stop walking when we reach a non-abstract ancestor with a different table
|
|
180
|
-
// (that's a separate model with its own migration — don't merge its fields)
|
|
181
179
|
if (cur !== this && !cur.abstract && curTable !== myTable) break;
|
|
182
180
|
cur = Object.getPrototypeOf(cur);
|
|
183
181
|
}
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
let merged = Object.assign({}, ...chain);
|
|
184
|
+
|
|
185
|
+
// Auto-inject id if no primary key is declared — same as Django
|
|
186
|
+
const hasPk = Object.values(merged).some(f => f?.primary === true || f?.type === 'id');
|
|
187
|
+
if (!hasPk) {
|
|
188
|
+
const { fields } = require('../fields/index');
|
|
189
|
+
merged = { id: fields.id(), ...merged };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Auto-inject created_at/updated_at type info when timestamps = true
|
|
193
|
+
// so _castValue can correctly cast them even if not declared in static fields
|
|
194
|
+
if (this.timestamps) {
|
|
195
|
+
const { fields } = require('../fields/index');
|
|
196
|
+
if (!merged.created_at) merged.created_at = fields.timestamp({ nullable: true });
|
|
197
|
+
if (!merged.updated_at) merged.updated_at = fields.timestamp({ nullable: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
186
200
|
Object.defineProperty(this, '_cachedFields', {
|
|
187
201
|
value: merged, writable: true, configurable: true, enumerable: false,
|
|
188
202
|
});
|
|
@@ -287,14 +301,14 @@ class Model {
|
|
|
287
301
|
const now = new Date().toISOString();
|
|
288
302
|
let payload = {
|
|
289
303
|
...this._applyDefaults(data),
|
|
290
|
-
...
|
|
304
|
+
...this._timestampPayload(data),
|
|
291
305
|
};
|
|
292
306
|
|
|
293
307
|
payload = await this.beforeCreate(payload) ?? payload;
|
|
294
308
|
payload = this._serializeForDb(payload);
|
|
295
309
|
|
|
296
|
-
const q
|
|
297
|
-
const
|
|
310
|
+
const q = trx ? trx(this.table) : this._db();
|
|
311
|
+
const id = await this._insert(q, payload);
|
|
298
312
|
const instance = await (trx
|
|
299
313
|
? this._hydrateFromTrx(id, trx)
|
|
300
314
|
: this.find(id));
|
|
@@ -330,12 +344,19 @@ class Model {
|
|
|
330
344
|
}
|
|
331
345
|
|
|
332
346
|
static async insert(rows) {
|
|
333
|
-
|
|
347
|
+
if (!rows || rows.length === 0) return;
|
|
334
348
|
const payload = rows.map(r => ({
|
|
335
349
|
...this._applyDefaults(r),
|
|
336
|
-
...
|
|
350
|
+
...this._timestampPayload(r),
|
|
351
|
+
...this._serializeForDb(r),
|
|
337
352
|
}));
|
|
338
|
-
|
|
353
|
+
const q = this._db();
|
|
354
|
+
const client = q.client?.config?.client || '';
|
|
355
|
+
// Postgres requires .returning() to avoid errors on some configurations
|
|
356
|
+
if (client.includes('pg')) {
|
|
357
|
+
return q.insert(payload).returning(this.primaryKey);
|
|
358
|
+
}
|
|
359
|
+
return q.insert(payload);
|
|
339
360
|
}
|
|
340
361
|
|
|
341
362
|
static async destroy(...ids) {
|
|
@@ -391,14 +412,80 @@ class Model {
|
|
|
391
412
|
for (const row of rows) {
|
|
392
413
|
const { [pk]: keyValue, ...data } = row;
|
|
393
414
|
if (keyValue == null) continue;
|
|
394
|
-
const now = new Date().toISOString();
|
|
395
415
|
await trx(this.table)
|
|
396
416
|
.where(pk, keyValue)
|
|
397
|
-
.update({ ...data, ...
|
|
417
|
+
.update({ ...this._serializeForDb(data), ...this._updatedAtPayload() });
|
|
398
418
|
}
|
|
399
419
|
});
|
|
400
420
|
}
|
|
401
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Insert many rows at once in a single query.
|
|
424
|
+
* Applies defaults, timestamps, beforeCreate hook, and serialization.
|
|
425
|
+
* Returns array of created instances.
|
|
426
|
+
*
|
|
427
|
+
* await Post.bulkCreate([
|
|
428
|
+
* { title: 'One', body: 'Hello' },
|
|
429
|
+
* { title: 'Two', body: 'World' },
|
|
430
|
+
* ]);
|
|
431
|
+
*/
|
|
432
|
+
static async bulkCreate(rows, { trx, ignoreConflicts = false, updateConflicts = false, updateFields = [], uniqueFields = [] } = {}) {
|
|
433
|
+
if (!rows || rows.length === 0) return [];
|
|
434
|
+
let payload = rows.map(row => ({
|
|
435
|
+
...this._applyDefaults(row),
|
|
436
|
+
...this._timestampPayload(row),
|
|
437
|
+
}));
|
|
438
|
+
payload = await Promise.all(payload.map(row => this.beforeCreate(row).then(r => r ?? row)));
|
|
439
|
+
payload = payload.map(row => this._serializeForDb(row));
|
|
440
|
+
const q = trx ? trx(this.table) : this._db();
|
|
441
|
+
const client = q.client?.config?.client || '';
|
|
442
|
+
|
|
443
|
+
if (client.includes('pg')) {
|
|
444
|
+
let qInsert = q.insert(payload);
|
|
445
|
+
if (ignoreConflicts) {
|
|
446
|
+
qInsert = qInsert.onConflict().ignore();
|
|
447
|
+
} else if (updateConflicts && updateFields.length) {
|
|
448
|
+
const conflictTarget = uniqueFields.length ? uniqueFields : undefined;
|
|
449
|
+
qInsert = conflictTarget
|
|
450
|
+
? qInsert.onConflict(conflictTarget).merge(updateFields)
|
|
451
|
+
: qInsert.onConflict().merge(updateFields);
|
|
452
|
+
}
|
|
453
|
+
const result = await qInsert.returning('*');
|
|
454
|
+
return result.map(r => this._hydrate(r));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// SQLite / MySQL
|
|
458
|
+
let qInsert = q.insert(payload);
|
|
459
|
+
if (ignoreConflicts) {
|
|
460
|
+
qInsert = qInsert.onConflict().ignore();
|
|
461
|
+
} else if (updateConflicts && updateFields.length) {
|
|
462
|
+
qInsert = uniqueFields.length
|
|
463
|
+
? qInsert.onConflict(uniqueFields).merge(updateFields)
|
|
464
|
+
: qInsert.onConflict().merge(updateFields);
|
|
465
|
+
}
|
|
466
|
+
const ids = await qInsert;
|
|
467
|
+
const firstId = Array.isArray(ids) ? ids[0] : (ids?.insertId ?? ids);
|
|
468
|
+
if (firstId) {
|
|
469
|
+
const inserted = await this._db()
|
|
470
|
+
.whereIn(this.primaryKey, payload.map((_, i) => firstId + i))
|
|
471
|
+
.select('*');
|
|
472
|
+
return inserted.map(r => this._hydrate(r));
|
|
473
|
+
}
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Delete many rows by primary key in a single query.
|
|
479
|
+
*
|
|
480
|
+
* await Post.bulkDelete([1, 2, 3]);
|
|
481
|
+
* await Post.bulkDelete([1, 2, 3], { trx });
|
|
482
|
+
*/
|
|
483
|
+
static async bulkDelete(ids, { trx } = {}) {
|
|
484
|
+
if (!ids || ids.length === 0) return 0;
|
|
485
|
+
const q = trx ? trx(this.table) : this._db();
|
|
486
|
+
return q.whereIn(this.primaryKey, ids).delete();
|
|
487
|
+
}
|
|
488
|
+
|
|
402
489
|
// ─── only() / defer() ────────────────────────────────────────────────────
|
|
403
490
|
|
|
404
491
|
/**
|
|
@@ -525,6 +612,53 @@ class Model {
|
|
|
525
612
|
return new QueryBuilder(this._db(), this);
|
|
526
613
|
}
|
|
527
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Print the SQL for a query without executing it.
|
|
617
|
+
* Matches Django's str(Model.objects.filter(...).query)
|
|
618
|
+
*
|
|
619
|
+
* console.log(Post.sql({ published: true }))
|
|
620
|
+
* // select * from `posts` where `published` = true
|
|
621
|
+
*/
|
|
622
|
+
static sql(conditions = {}) {
|
|
623
|
+
let qb = new QueryBuilder(this._db(), this);
|
|
624
|
+
for (const [k, v] of Object.entries(conditions)) qb = qb.where(k, v);
|
|
625
|
+
return qb.sql();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get exactly one result — raises if 0 or >1 found.
|
|
630
|
+
* Matches Django's Model.objects.get()
|
|
631
|
+
*
|
|
632
|
+
* const user = await User.get({ email: 'alice@example.com' });
|
|
633
|
+
*/
|
|
634
|
+
static async get(conditions = {}) {
|
|
635
|
+
return new QueryBuilder(this._db(), this).where(conditions).get_one();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Return a dict mapping pk → instance.
|
|
640
|
+
* Matches Django's QuerySet.in_bulk()
|
|
641
|
+
*
|
|
642
|
+
* const map = await Post.inBulk([1, 2, 3]);
|
|
643
|
+
* map[1].title
|
|
644
|
+
*/
|
|
645
|
+
static async inBulk(ids = null, fieldName = null) {
|
|
646
|
+
return new QueryBuilder(this._db(), this).inBulk(ids, fieldName);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Lock rows for update inside a transaction.
|
|
651
|
+
* Matches Django's QuerySet.select_for_update()
|
|
652
|
+
*
|
|
653
|
+
* await Post.transaction(async (trx) => {
|
|
654
|
+
* const post = await Post.where('id', 1).selectForUpdate().first();
|
|
655
|
+
* await post.update({ views: post.views + 1 });
|
|
656
|
+
* });
|
|
657
|
+
*/
|
|
658
|
+
static selectForUpdate(options = {}) {
|
|
659
|
+
return new QueryBuilder(this._db(), this).selectForUpdate(options);
|
|
660
|
+
}
|
|
661
|
+
|
|
528
662
|
static async paginate(page = 1, perPage = 15) {
|
|
529
663
|
return new QueryBuilder(this._db(), this).paginate(page, perPage);
|
|
530
664
|
}
|
|
@@ -533,7 +667,12 @@ class Model {
|
|
|
533
667
|
|
|
534
668
|
constructor(attributes = {}) {
|
|
535
669
|
Object.assign(this, attributes);
|
|
536
|
-
this
|
|
670
|
+
Object.defineProperty(this, '_original', {
|
|
671
|
+
value: { ...attributes },
|
|
672
|
+
writable: true,
|
|
673
|
+
enumerable: false,
|
|
674
|
+
configurable: true,
|
|
675
|
+
});
|
|
537
676
|
|
|
538
677
|
// Use effective relations: explicit static relations PLUS those
|
|
539
678
|
// auto-inferred from ForeignKey / OneToOne / ManyToMany fields.
|
|
@@ -564,81 +703,91 @@ class Model {
|
|
|
564
703
|
|
|
565
704
|
const BelongsTo = require('../relations/BelongsTo');
|
|
566
705
|
const HasOne = require('../relations/HasOne');
|
|
706
|
+
const HasMany = require('../relations/HasMany');
|
|
567
707
|
const BelongsToMany = require('../relations/BelongsToMany');
|
|
568
708
|
|
|
569
|
-
// Start with explicitly declared relations
|
|
570
709
|
const merged = { ...(this.relations || {}) };
|
|
571
710
|
|
|
572
711
|
for (const [fieldName, fieldDef] of Object.entries(this.getFields())) {
|
|
573
712
|
|
|
574
|
-
// ── ForeignKey / OneToOne ────────────────────────────────────────────
|
|
575
713
|
if (fieldDef._isForeignKey) {
|
|
576
|
-
|
|
577
|
-
// author_id → author
|
|
578
|
-
// author → author (column will be author_id in migration)
|
|
579
|
-
const accessorName = fieldName.endsWith('_id')
|
|
580
|
-
? fieldName.slice(0, -3)
|
|
581
|
-
: fieldName;
|
|
582
|
-
|
|
583
|
-
// Don't overwrite an explicitly declared relation
|
|
714
|
+
const accessorName = fieldName.endsWith('_id') ? fieldName.slice(0, -3) : fieldName;
|
|
584
715
|
if (!merged[accessorName]) {
|
|
585
|
-
const modelRef
|
|
586
|
-
const toField
|
|
587
|
-
const self
|
|
588
|
-
|
|
589
|
-
// self-referential: 'self' means this very model
|
|
716
|
+
const modelRef = fieldDef._fkModelRef;
|
|
717
|
+
const toField = fieldDef._fkToField || 'id';
|
|
718
|
+
const self = this;
|
|
590
719
|
const resolveModel = () => {
|
|
591
720
|
if (fieldDef._fkModel === 'self') return self;
|
|
592
|
-
|
|
593
|
-
return M;
|
|
721
|
+
return typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
594
722
|
};
|
|
595
|
-
|
|
596
|
-
// Django convention: declared as 'landlord' → DB column 'landlord_id'.
|
|
597
|
-
// If already ends with _id (e.g. declared as 'landlord_id'), use as-is.
|
|
598
723
|
const colName = fieldName.endsWith('_id') ? fieldName : fieldName + '_id';
|
|
599
|
-
|
|
600
|
-
if (fieldDef._isOneToOne) {
|
|
601
|
-
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
602
|
-
} else {
|
|
603
|
-
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
604
|
-
}
|
|
724
|
+
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
605
725
|
}
|
|
606
726
|
}
|
|
607
727
|
|
|
608
|
-
// ── ManyToMany ────────────────────────────────────────────────────────
|
|
609
728
|
if (fieldDef._isManyToMany && !merged[fieldName]) {
|
|
610
729
|
const thisTableBase = (this.table || this.name.toLowerCase()).replace(/s$/, '');
|
|
611
730
|
const modelRef = fieldDef._fkModelRef;
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
const M = typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
615
|
-
return M;
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
// Infer pivot table: sort both singular table names alphabetically
|
|
619
|
-
const relatedName = typeof fieldDef._fkModel === 'string'
|
|
731
|
+
const resolveRelated = () => typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
732
|
+
const relatedName = typeof fieldDef._fkModel === 'string'
|
|
620
733
|
? fieldDef._fkModel.toLowerCase().replace(/s$/, '')
|
|
621
734
|
: fieldName.replace(/s$/, '');
|
|
622
|
-
|
|
623
|
-
const pivotTable = fieldDef._m2mThrough
|
|
735
|
+
const pivotTable = fieldDef._m2mThrough
|
|
624
736
|
|| [thisTableBase, relatedName].sort().join('_') + 's';
|
|
625
|
-
|
|
626
|
-
const thisFk = thisTableBase + '_id';
|
|
627
|
-
const relatedFk = relatedName + '_id';
|
|
628
|
-
|
|
629
737
|
merged[fieldName] = new BelongsToMany(
|
|
630
|
-
resolveRelated,
|
|
631
|
-
|
|
632
|
-
thisFk,
|
|
633
|
-
relatedFk,
|
|
738
|
+
resolveRelated, pivotTable,
|
|
739
|
+
thisTableBase + '_id', relatedName + '_id',
|
|
634
740
|
);
|
|
635
741
|
}
|
|
636
742
|
}
|
|
637
743
|
|
|
744
|
+
// Reverse relations via relatedName - like Django auto reverse accessors
|
|
745
|
+
// Scan app/models/index.js for any model with a ForeignKey pointing to this
|
|
746
|
+
// model with a relatedName set, then wire HasMany/HasOne back automatically.
|
|
747
|
+
try {
|
|
748
|
+
const path = require('path');
|
|
749
|
+
const allModels = require(path.join(process.cwd(), 'app', 'models', 'index.js'));
|
|
750
|
+
const thisTable = this.table;
|
|
751
|
+
|
|
752
|
+
for (const RelatedModel of Object.values(allModels)) {
|
|
753
|
+
if (typeof RelatedModel !== 'function') continue;
|
|
754
|
+
if (RelatedModel === this) continue;
|
|
755
|
+
if (!RelatedModel.fields) continue;
|
|
756
|
+
|
|
757
|
+
for (const [fName, fDef] of Object.entries(RelatedModel.fields || {})) {
|
|
758
|
+
if (!fDef || !fDef._isForeignKey || !fDef._fkRelatedName) continue;
|
|
759
|
+
if (fDef._fkRelatedName === '+') continue;
|
|
760
|
+
|
|
761
|
+
let targetTable = null;
|
|
762
|
+
try {
|
|
763
|
+
const ref = typeof fDef._fkModelRef === 'function' ? fDef._fkModelRef() : null;
|
|
764
|
+
targetTable = ref && ref.table ? ref.table : null;
|
|
765
|
+
} catch (e) {}
|
|
766
|
+
if (!targetTable && typeof fDef._fkModel === 'string') {
|
|
767
|
+
targetTable = allModels[fDef._fkModel] && allModels[fDef._fkModel].table
|
|
768
|
+
? allModels[fDef._fkModel].table : null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (targetTable !== thisTable) continue;
|
|
772
|
+
|
|
773
|
+
const accessorName = fDef._fkRelatedName;
|
|
774
|
+
if (merged[accessorName]) continue;
|
|
775
|
+
|
|
776
|
+
const fkColumn = fName.endsWith('_id') ? fName : fName + '_id';
|
|
777
|
+
const Rel = RelatedModel;
|
|
778
|
+
|
|
779
|
+
merged[accessorName] = fDef._isOneToOne
|
|
780
|
+
? new HasOne(() => Rel, fkColumn)
|
|
781
|
+
: new HasMany(() => Rel, fkColumn);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
} catch (e) { /* models index not available */ }
|
|
785
|
+
|
|
638
786
|
this._cachedRelations = merged;
|
|
639
787
|
return merged;
|
|
640
788
|
}
|
|
641
789
|
|
|
790
|
+
|
|
642
791
|
/** Clear the cached relations (call if fields are modified at runtime). */
|
|
643
792
|
static _clearRelationCache() {
|
|
644
793
|
this._cachedRelations = null;
|
|
@@ -652,14 +801,13 @@ class Model {
|
|
|
652
801
|
async update(data = {}, { trx } = {}) {
|
|
653
802
|
this.constructor.validate(data);
|
|
654
803
|
|
|
655
|
-
const now = new Date().toISOString();
|
|
656
804
|
let payload = {
|
|
657
805
|
...data,
|
|
658
|
-
...
|
|
806
|
+
...this.constructor._updatedAtPayload(),
|
|
659
807
|
};
|
|
660
808
|
|
|
661
809
|
payload = await this.constructor.beforeUpdate(payload) ?? payload;
|
|
662
|
-
|
|
810
|
+
const dbPayload = this.constructor._serializeForDb(payload);
|
|
663
811
|
|
|
664
812
|
const q = trx
|
|
665
813
|
? trx(this.constructor.table)
|
|
@@ -667,9 +815,9 @@ class Model {
|
|
|
667
815
|
|
|
668
816
|
await q
|
|
669
817
|
.where(this.constructor.primaryKey, this[this.constructor.primaryKey])
|
|
670
|
-
.update(
|
|
818
|
+
.update(dbPayload);
|
|
671
819
|
|
|
672
|
-
Object.assign(this, payload);
|
|
820
|
+
Object.assign(this, payload); // keep JS types on the instance, not serialized values
|
|
673
821
|
await this.constructor.afterUpdate(this);
|
|
674
822
|
return this;
|
|
675
823
|
}
|
|
@@ -734,6 +882,31 @@ class Model {
|
|
|
734
882
|
return this;
|
|
735
883
|
}
|
|
736
884
|
|
|
885
|
+
/**
|
|
886
|
+
* Atomically increment a column value.
|
|
887
|
+
* await post.increment('views_count');
|
|
888
|
+
* await post.increment('views_count', 5);
|
|
889
|
+
*/
|
|
890
|
+
async increment(column, amount = 1) {
|
|
891
|
+
await this.constructor._db()
|
|
892
|
+
.where(this.constructor.primaryKey, this[this.constructor.primaryKey])
|
|
893
|
+
.increment(column, amount);
|
|
894
|
+
this[column] = (this[column] || 0) + amount;
|
|
895
|
+
return this;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Atomically decrement a column value.
|
|
900
|
+
* await post.decrement('stock', 1);
|
|
901
|
+
*/
|
|
902
|
+
async decrement(column, amount = 1) {
|
|
903
|
+
await this.constructor._db()
|
|
904
|
+
.where(this.constructor.primaryKey, this[this.constructor.primaryKey])
|
|
905
|
+
.decrement(column, amount);
|
|
906
|
+
this[column] = (this[column] || 0) - amount;
|
|
907
|
+
return this;
|
|
908
|
+
}
|
|
909
|
+
|
|
737
910
|
get isNew() { return !this[this.constructor.primaryKey]; }
|
|
738
911
|
get isTrashed() { return !!this.deleted_at; }
|
|
739
912
|
|
|
@@ -741,8 +914,13 @@ class Model {
|
|
|
741
914
|
const hidden = new Set(this.constructor.hidden || []);
|
|
742
915
|
const obj = {};
|
|
743
916
|
for (const key of Object.keys(this)) {
|
|
744
|
-
if (
|
|
745
|
-
|
|
917
|
+
if (key.startsWith('_') || typeof this[key] === 'function' || hidden.has(key)) continue;
|
|
918
|
+
const val = this[key];
|
|
919
|
+
// Serialize Date objects to ISO strings
|
|
920
|
+
if (val instanceof Date) {
|
|
921
|
+
obj[key] = isNaN(val.getTime()) ? null : val.toISOString();
|
|
922
|
+
} else {
|
|
923
|
+
obj[key] = val;
|
|
746
924
|
}
|
|
747
925
|
}
|
|
748
926
|
return obj;
|
|
@@ -774,15 +952,30 @@ class Model {
|
|
|
774
952
|
static _castValue(val, type) {
|
|
775
953
|
if (val == null) return val;
|
|
776
954
|
switch (type) {
|
|
777
|
-
case 'boolean':
|
|
778
|
-
case 'integer':
|
|
779
|
-
case 'bigInteger':return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
955
|
+
case 'boolean': return Boolean(val);
|
|
956
|
+
case 'integer': return Number.isInteger(val) ? val : parseInt(val, 10);
|
|
957
|
+
case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
780
958
|
case 'float':
|
|
781
|
-
case 'decimal':
|
|
782
|
-
case 'json':
|
|
959
|
+
case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
|
|
960
|
+
case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
|
|
783
961
|
case 'date':
|
|
784
|
-
case 'timestamp':
|
|
785
|
-
|
|
962
|
+
case 'timestamp': {
|
|
963
|
+
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
|
|
964
|
+
if (val !== null && typeof val === 'object') {
|
|
965
|
+
// pg driver returns internal timestamp objects — coerce via valueOf
|
|
966
|
+
const d = new Date(val.valueOf?.() ?? val);
|
|
967
|
+
return isNaN(d.getTime()) ? null : d;
|
|
968
|
+
}
|
|
969
|
+
const d = new Date(val);
|
|
970
|
+
return isNaN(d.getTime()) ? null : d;
|
|
971
|
+
}
|
|
972
|
+
// string-backed types — no casting needed
|
|
973
|
+
case 'string':
|
|
974
|
+
case 'email':
|
|
975
|
+
case 'url':
|
|
976
|
+
case 'slug':
|
|
977
|
+
case 'ipAddress': return String(val);
|
|
978
|
+
default: return val;
|
|
786
979
|
}
|
|
787
980
|
}
|
|
788
981
|
|
|
@@ -820,6 +1013,56 @@ class Model {
|
|
|
820
1013
|
return result;
|
|
821
1014
|
}
|
|
822
1015
|
|
|
1016
|
+
static _timestampPayload(existing = {}) {
|
|
1017
|
+
if (!this.timestamps) return {};
|
|
1018
|
+
const fieldKeys = Object.keys(this.getFields());
|
|
1019
|
+
const now = new Date().toISOString();
|
|
1020
|
+
const result = {};
|
|
1021
|
+
if (fieldKeys.includes('created_at') && !existing.created_at) result.created_at = now;
|
|
1022
|
+
if (fieldKeys.includes('updated_at')) result.updated_at = now;
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
static _updatedAtPayload() {
|
|
1027
|
+
if (!this.timestamps) return {};
|
|
1028
|
+
const fieldKeys = Object.keys(this.getFields());
|
|
1029
|
+
if (!fieldKeys.includes('updated_at')) return {};
|
|
1030
|
+
return { updated_at: new Date().toISOString() };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
static _isPostgres() {
|
|
1034
|
+
try {
|
|
1035
|
+
const client = DatabaseManager.connection(this.connection || null).client?.config?.client || '';
|
|
1036
|
+
return client.includes('pg');
|
|
1037
|
+
} catch { return false; }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Insert a row and return the inserted primary key — dialect-aware.
|
|
1042
|
+
* SQLite: insert returns [lastId]
|
|
1043
|
+
* Postgres: requires .returning(pk), returns [{ pk: val }] or [val]
|
|
1044
|
+
* MySQL: insert returns [{ insertId }]
|
|
1045
|
+
*/
|
|
1046
|
+
static async _insert(q, payload) {
|
|
1047
|
+
const pk = this.primaryKey;
|
|
1048
|
+
const client = q.client?.config?.client || '';
|
|
1049
|
+
|
|
1050
|
+
if (client.includes('pg')) {
|
|
1051
|
+
const rows = await q.insert(payload).returning(pk);
|
|
1052
|
+
const row = rows[0];
|
|
1053
|
+
return typeof row === 'object' ? row[pk] : row;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (client.includes('mysql')) {
|
|
1057
|
+
const result = await q.insert(payload);
|
|
1058
|
+
return result[0]?.insertId ?? result[0];
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// SQLite — returns [lastInsertRowid]
|
|
1062
|
+
const result = await q.insert(payload);
|
|
1063
|
+
return Array.isArray(result) ? result[0] : result;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
823
1066
|
static _defaultTable() {
|
|
824
1067
|
// Convert PascalCase class name to snake_case plural table name.
|
|
825
1068
|
// BlogPost → blog_posts, Category → categories, User → users.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* F — column reference expression
|
|
5
|
+
*
|
|
6
|
+
* Allows referencing model field values in queries without pulling them
|
|
7
|
+
* into Python/JS first. Matches Django's F() expression exactly.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const { F } = require('millas/core/db');
|
|
11
|
+
*
|
|
12
|
+
* // Atomic increment — no race condition
|
|
13
|
+
* await Post.where('id', 1).update({ views: F('views').add(1) });
|
|
14
|
+
*
|
|
15
|
+
* // Compare two columns
|
|
16
|
+
* await Product.where(F('sale_price').lt(F('cost_price'))).get();
|
|
17
|
+
*
|
|
18
|
+
* // Order by expression
|
|
19
|
+
* await Post.orderByF(F('updated_at').desc()).get();
|
|
20
|
+
*
|
|
21
|
+
* // Arithmetic
|
|
22
|
+
* F('price').multiply(1.1) // price * 1.1
|
|
23
|
+
* F('stock').subtract(qty) // stock - qty
|
|
24
|
+
*/
|
|
25
|
+
class F {
|
|
26
|
+
constructor(column) {
|
|
27
|
+
this._column = column;
|
|
28
|
+
this._ops = []; // [{ op, value }]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Arithmetic ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
add(value) { return this._op('+', value); }
|
|
34
|
+
subtract(value) { return this._op('-', value); }
|
|
35
|
+
multiply(value) { return this._op('*', value); }
|
|
36
|
+
divide(value) { return this._op('/', value); }
|
|
37
|
+
|
|
38
|
+
// ── Ordering ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
asc() { this._order = 'asc'; return this; }
|
|
41
|
+
desc() { this._order = 'desc'; return this; }
|
|
42
|
+
|
|
43
|
+
// ── Comparison (for use in where()) ───────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
eq(value) { return this._compare('=', value); }
|
|
46
|
+
ne(value) { return this._compare('!=', value); }
|
|
47
|
+
gt(value) { return this._compare('>', value); }
|
|
48
|
+
gte(value) { return this._compare('>=', value); }
|
|
49
|
+
lt(value) { return this._compare('<', value); }
|
|
50
|
+
lte(value) { return this._compare('<=', value); }
|
|
51
|
+
|
|
52
|
+
// ── SQL rendering ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Render this F expression to a knex raw SQL fragment.
|
|
56
|
+
* @param {object} knexClient — knex client instance (for raw())
|
|
57
|
+
* @returns {object} knex raw expression
|
|
58
|
+
*/
|
|
59
|
+
toKnex(knexClient) {
|
|
60
|
+
let sql = this._quoteCol(this._column);
|
|
61
|
+
|
|
62
|
+
for (const { op, value } of this._ops) {
|
|
63
|
+
if (value instanceof F) {
|
|
64
|
+
sql = `(${sql} ${op} ${value._buildSQL()})`;
|
|
65
|
+
} else {
|
|
66
|
+
sql = `(${sql} ${op} ${typeof value === 'string' ? `'${value}'` : value})`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return knexClient.raw(sql);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_buildSQL() {
|
|
74
|
+
let sql = this._quoteCol(this._column);
|
|
75
|
+
for (const { op, value } of this._ops) {
|
|
76
|
+
const v = value instanceof F ? value._buildSQL() : (typeof value === 'string' ? `'${value}'` : value);
|
|
77
|
+
sql = `(${sql} ${op} ${v})`;
|
|
78
|
+
}
|
|
79
|
+
return sql;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_quoteCol(col) {
|
|
83
|
+
// Handle table.column notation
|
|
84
|
+
return col.includes('.') ? col.split('.').map(p => `"${p}"`).join('.') : `"${col}"`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_op(op, value) {
|
|
88
|
+
const clone = new F(this._column);
|
|
89
|
+
clone._ops = [...this._ops, { op, value }];
|
|
90
|
+
return clone;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_compare(op, value) {
|
|
94
|
+
return { _isF: true, _fExpr: this, _op: op, _value: value };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = F;
|