millas 0.2.20 → 0.2.23
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/core/db.js +9 -8
- package/src/events/EventEmitter.js +12 -1
- package/src/facades/Database.js +55 -34
- package/src/orm/drivers/DatabaseManager.js +12 -0
- package/src/orm/fields/index.js +18 -0
- package/src/orm/migration/MigrationWriter.js +6 -0
- package/src/orm/migration/ModelInspector.js +4 -0
- package/src/orm/migration/operations/column.js +59 -95
- package/src/orm/migration/operations/fields.js +6 -6
- package/src/orm/migration/operations/models.js +3 -3
- package/src/orm/model/Model.js +293 -61
- package/src/orm/query/F.js +98 -0
- package/src/orm/query/LookupParser.js +316 -157
- package/src/orm/query/QueryBuilder.js +230 -7
- package/src/providers/DatabaseServiceProvider.js +2 -2
package/src/orm/model/Model.js
CHANGED
|
@@ -189,6 +189,14 @@ class Model {
|
|
|
189
189
|
merged = { id: fields.id(), ...merged };
|
|
190
190
|
}
|
|
191
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
|
+
|
|
192
200
|
Object.defineProperty(this, '_cachedFields', {
|
|
193
201
|
value: merged, writable: true, configurable: true, enumerable: false,
|
|
194
202
|
});
|
|
@@ -293,14 +301,14 @@ class Model {
|
|
|
293
301
|
const now = new Date().toISOString();
|
|
294
302
|
let payload = {
|
|
295
303
|
...this._applyDefaults(data),
|
|
296
|
-
...
|
|
304
|
+
...this._timestampPayload(data),
|
|
297
305
|
};
|
|
298
306
|
|
|
299
307
|
payload = await this.beforeCreate(payload) ?? payload;
|
|
300
308
|
payload = this._serializeForDb(payload);
|
|
301
309
|
|
|
302
|
-
const q
|
|
303
|
-
const
|
|
310
|
+
const q = trx ? trx(this.table) : this._db();
|
|
311
|
+
const id = await this._insert(q, payload);
|
|
304
312
|
const instance = await (trx
|
|
305
313
|
? this._hydrateFromTrx(id, trx)
|
|
306
314
|
: this.find(id));
|
|
@@ -336,12 +344,19 @@ class Model {
|
|
|
336
344
|
}
|
|
337
345
|
|
|
338
346
|
static async insert(rows) {
|
|
339
|
-
|
|
347
|
+
if (!rows || rows.length === 0) return;
|
|
340
348
|
const payload = rows.map(r => ({
|
|
341
349
|
...this._applyDefaults(r),
|
|
342
|
-
...
|
|
350
|
+
...this._timestampPayload(r),
|
|
351
|
+
...this._serializeForDb(r),
|
|
343
352
|
}));
|
|
344
|
-
|
|
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);
|
|
345
360
|
}
|
|
346
361
|
|
|
347
362
|
static async destroy(...ids) {
|
|
@@ -397,14 +412,80 @@ class Model {
|
|
|
397
412
|
for (const row of rows) {
|
|
398
413
|
const { [pk]: keyValue, ...data } = row;
|
|
399
414
|
if (keyValue == null) continue;
|
|
400
|
-
const now = new Date().toISOString();
|
|
401
415
|
await trx(this.table)
|
|
402
416
|
.where(pk, keyValue)
|
|
403
|
-
.update({ ...data, ...
|
|
417
|
+
.update({ ...this._serializeForDb(data), ...this._updatedAtPayload() });
|
|
404
418
|
}
|
|
405
419
|
});
|
|
406
420
|
}
|
|
407
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
|
+
|
|
408
489
|
// ─── only() / defer() ────────────────────────────────────────────────────
|
|
409
490
|
|
|
410
491
|
/**
|
|
@@ -531,6 +612,53 @@ class Model {
|
|
|
531
612
|
return new QueryBuilder(this._db(), this);
|
|
532
613
|
}
|
|
533
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
|
+
|
|
534
662
|
static async paginate(page = 1, perPage = 15) {
|
|
535
663
|
return new QueryBuilder(this._db(), this).paginate(page, perPage);
|
|
536
664
|
}
|
|
@@ -539,7 +667,12 @@ class Model {
|
|
|
539
667
|
|
|
540
668
|
constructor(attributes = {}) {
|
|
541
669
|
Object.assign(this, attributes);
|
|
542
|
-
this
|
|
670
|
+
Object.defineProperty(this, '_original', {
|
|
671
|
+
value: { ...attributes },
|
|
672
|
+
writable: true,
|
|
673
|
+
enumerable: false,
|
|
674
|
+
configurable: true,
|
|
675
|
+
});
|
|
543
676
|
|
|
544
677
|
// Use effective relations: explicit static relations PLUS those
|
|
545
678
|
// auto-inferred from ForeignKey / OneToOne / ManyToMany fields.
|
|
@@ -570,81 +703,91 @@ class Model {
|
|
|
570
703
|
|
|
571
704
|
const BelongsTo = require('../relations/BelongsTo');
|
|
572
705
|
const HasOne = require('../relations/HasOne');
|
|
706
|
+
const HasMany = require('../relations/HasMany');
|
|
573
707
|
const BelongsToMany = require('../relations/BelongsToMany');
|
|
574
708
|
|
|
575
|
-
// Start with explicitly declared relations
|
|
576
709
|
const merged = { ...(this.relations || {}) };
|
|
577
710
|
|
|
578
711
|
for (const [fieldName, fieldDef] of Object.entries(this.getFields())) {
|
|
579
712
|
|
|
580
|
-
// ── ForeignKey / OneToOne ────────────────────────────────────────────
|
|
581
713
|
if (fieldDef._isForeignKey) {
|
|
582
|
-
|
|
583
|
-
// author_id → author
|
|
584
|
-
// author → author (column will be author_id in migration)
|
|
585
|
-
const accessorName = fieldName.endsWith('_id')
|
|
586
|
-
? fieldName.slice(0, -3)
|
|
587
|
-
: fieldName;
|
|
588
|
-
|
|
589
|
-
// Don't overwrite an explicitly declared relation
|
|
714
|
+
const accessorName = fieldName.endsWith('_id') ? fieldName.slice(0, -3) : fieldName;
|
|
590
715
|
if (!merged[accessorName]) {
|
|
591
|
-
const modelRef
|
|
592
|
-
const toField
|
|
593
|
-
const self
|
|
594
|
-
|
|
595
|
-
// self-referential: 'self' means this very model
|
|
716
|
+
const modelRef = fieldDef._fkModelRef;
|
|
717
|
+
const toField = fieldDef._fkToField || 'id';
|
|
718
|
+
const self = this;
|
|
596
719
|
const resolveModel = () => {
|
|
597
720
|
if (fieldDef._fkModel === 'self') return self;
|
|
598
|
-
|
|
599
|
-
return M;
|
|
721
|
+
return typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
600
722
|
};
|
|
601
|
-
|
|
602
|
-
// Django convention: declared as 'landlord' → DB column 'landlord_id'.
|
|
603
|
-
// If already ends with _id (e.g. declared as 'landlord_id'), use as-is.
|
|
604
723
|
const colName = fieldName.endsWith('_id') ? fieldName : fieldName + '_id';
|
|
605
|
-
|
|
606
|
-
if (fieldDef._isOneToOne) {
|
|
607
|
-
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
608
|
-
} else {
|
|
609
|
-
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
610
|
-
}
|
|
724
|
+
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
611
725
|
}
|
|
612
726
|
}
|
|
613
727
|
|
|
614
|
-
// ── ManyToMany ────────────────────────────────────────────────────────
|
|
615
728
|
if (fieldDef._isManyToMany && !merged[fieldName]) {
|
|
616
729
|
const thisTableBase = (this.table || this.name.toLowerCase()).replace(/s$/, '');
|
|
617
730
|
const modelRef = fieldDef._fkModelRef;
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
const M = typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
621
|
-
return M;
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
// Infer pivot table: sort both singular table names alphabetically
|
|
625
|
-
const relatedName = typeof fieldDef._fkModel === 'string'
|
|
731
|
+
const resolveRelated = () => typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
732
|
+
const relatedName = typeof fieldDef._fkModel === 'string'
|
|
626
733
|
? fieldDef._fkModel.toLowerCase().replace(/s$/, '')
|
|
627
734
|
: fieldName.replace(/s$/, '');
|
|
628
|
-
|
|
629
|
-
const pivotTable = fieldDef._m2mThrough
|
|
735
|
+
const pivotTable = fieldDef._m2mThrough
|
|
630
736
|
|| [thisTableBase, relatedName].sort().join('_') + 's';
|
|
631
|
-
|
|
632
|
-
const thisFk = thisTableBase + '_id';
|
|
633
|
-
const relatedFk = relatedName + '_id';
|
|
634
|
-
|
|
635
737
|
merged[fieldName] = new BelongsToMany(
|
|
636
|
-
resolveRelated,
|
|
637
|
-
|
|
638
|
-
thisFk,
|
|
639
|
-
relatedFk,
|
|
738
|
+
resolveRelated, pivotTable,
|
|
739
|
+
thisTableBase + '_id', relatedName + '_id',
|
|
640
740
|
);
|
|
641
741
|
}
|
|
642
742
|
}
|
|
643
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
|
+
|
|
644
786
|
this._cachedRelations = merged;
|
|
645
787
|
return merged;
|
|
646
788
|
}
|
|
647
789
|
|
|
790
|
+
|
|
648
791
|
/** Clear the cached relations (call if fields are modified at runtime). */
|
|
649
792
|
static _clearRelationCache() {
|
|
650
793
|
this._cachedRelations = null;
|
|
@@ -658,10 +801,9 @@ class Model {
|
|
|
658
801
|
async update(data = {}, { trx } = {}) {
|
|
659
802
|
this.constructor.validate(data);
|
|
660
803
|
|
|
661
|
-
const now = new Date().toISOString();
|
|
662
804
|
let payload = {
|
|
663
805
|
...data,
|
|
664
|
-
...
|
|
806
|
+
...this.constructor._updatedAtPayload(),
|
|
665
807
|
};
|
|
666
808
|
|
|
667
809
|
payload = await this.constructor.beforeUpdate(payload) ?? payload;
|
|
@@ -740,6 +882,31 @@ class Model {
|
|
|
740
882
|
return this;
|
|
741
883
|
}
|
|
742
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
|
+
|
|
743
910
|
get isNew() { return !this[this.constructor.primaryKey]; }
|
|
744
911
|
get isTrashed() { return !!this.deleted_at; }
|
|
745
912
|
|
|
@@ -747,8 +914,13 @@ class Model {
|
|
|
747
914
|
const hidden = new Set(this.constructor.hidden || []);
|
|
748
915
|
const obj = {};
|
|
749
916
|
for (const key of Object.keys(this)) {
|
|
750
|
-
if (
|
|
751
|
-
|
|
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;
|
|
752
924
|
}
|
|
753
925
|
}
|
|
754
926
|
return obj;
|
|
@@ -785,9 +957,19 @@ class Model {
|
|
|
785
957
|
case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
786
958
|
case 'float':
|
|
787
959
|
case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
|
|
788
|
-
case 'json':
|
|
960
|
+
case 'json':
|
|
961
|
+
case 'array': return typeof val === 'string' ? JSON.parse(val) : (Array.isArray(val) ? val : (val ?? []));
|
|
789
962
|
case 'date':
|
|
790
|
-
case 'timestamp':
|
|
963
|
+
case 'timestamp': {
|
|
964
|
+
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
|
|
965
|
+
if (val !== null && typeof val === 'object') {
|
|
966
|
+
// pg driver returns internal timestamp objects — coerce via valueOf
|
|
967
|
+
const d = new Date(val.valueOf?.() ?? val);
|
|
968
|
+
return isNaN(d.getTime()) ? null : d;
|
|
969
|
+
}
|
|
970
|
+
const d = new Date(val);
|
|
971
|
+
return isNaN(d.getTime()) ? null : d;
|
|
972
|
+
}
|
|
791
973
|
// string-backed types — no casting needed
|
|
792
974
|
case 'string':
|
|
793
975
|
case 'email':
|
|
@@ -800,7 +982,7 @@ class Model {
|
|
|
800
982
|
|
|
801
983
|
static _serializeValue(val, type) {
|
|
802
984
|
if (val == null) return val;
|
|
803
|
-
if (type === 'json') return typeof val === 'string' ? val : JSON.stringify(val);
|
|
985
|
+
if (type === 'json' || type === 'array') return typeof val === 'string' ? val : JSON.stringify(val);
|
|
804
986
|
if (type === 'boolean') return val ? 1 : 0;
|
|
805
987
|
if ((type === 'date' || type === 'timestamp') && val instanceof Date) return val.toISOString();
|
|
806
988
|
return val;
|
|
@@ -832,6 +1014,56 @@ class Model {
|
|
|
832
1014
|
return result;
|
|
833
1015
|
}
|
|
834
1016
|
|
|
1017
|
+
static _timestampPayload(existing = {}) {
|
|
1018
|
+
if (!this.timestamps) return {};
|
|
1019
|
+
const fieldKeys = Object.keys(this.getFields());
|
|
1020
|
+
const now = new Date().toISOString();
|
|
1021
|
+
const result = {};
|
|
1022
|
+
if (fieldKeys.includes('created_at') && !existing.created_at) result.created_at = now;
|
|
1023
|
+
if (fieldKeys.includes('updated_at')) result.updated_at = now;
|
|
1024
|
+
return result;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
static _updatedAtPayload() {
|
|
1028
|
+
if (!this.timestamps) return {};
|
|
1029
|
+
const fieldKeys = Object.keys(this.getFields());
|
|
1030
|
+
if (!fieldKeys.includes('updated_at')) return {};
|
|
1031
|
+
return { updated_at: new Date().toISOString() };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
static _isPostgres() {
|
|
1035
|
+
try {
|
|
1036
|
+
const client = DatabaseManager.connection(this.connection || null).client?.config?.client || '';
|
|
1037
|
+
return client.includes('pg');
|
|
1038
|
+
} catch { return false; }
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Insert a row and return the inserted primary key — dialect-aware.
|
|
1043
|
+
* SQLite: insert returns [lastId]
|
|
1044
|
+
* Postgres: requires .returning(pk), returns [{ pk: val }] or [val]
|
|
1045
|
+
* MySQL: insert returns [{ insertId }]
|
|
1046
|
+
*/
|
|
1047
|
+
static async _insert(q, payload) {
|
|
1048
|
+
const pk = this.primaryKey;
|
|
1049
|
+
const client = q.client?.config?.client || '';
|
|
1050
|
+
|
|
1051
|
+
if (client.includes('pg')) {
|
|
1052
|
+
const rows = await q.insert(payload).returning(pk);
|
|
1053
|
+
const row = rows[0];
|
|
1054
|
+
return typeof row === 'object' ? row[pk] : row;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (client.includes('mysql')) {
|
|
1058
|
+
const result = await q.insert(payload);
|
|
1059
|
+
return result[0]?.insertId ?? result[0];
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// SQLite — returns [lastInsertRowid]
|
|
1063
|
+
const result = await q.insert(payload);
|
|
1064
|
+
return Array.isArray(result) ? result[0] : result;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
835
1067
|
static _defaultTable() {
|
|
836
1068
|
// Convert PascalCase class name to snake_case plural table name.
|
|
837
1069
|
// 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;
|