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.
@@ -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
  },
@@ -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); // ancestor first → child wins in Object.assign
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
- const merged = Object.assign({}, ...chain);
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
- ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
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 = trx ? trx(this.table) : this._db();
297
- const [id] = await q.insert(payload);
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
- const now = new Date().toISOString();
347
+ if (!rows || rows.length === 0) return;
334
348
  const payload = rows.map(r => ({
335
349
  ...this._applyDefaults(r),
336
- ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
350
+ ...this._timestampPayload(r),
351
+ ...this._serializeForDb(r),
337
352
  }));
338
- return this._db().insert(payload);
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, ...(this.timestamps ? { updated_at: now } : {}) });
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._original = { ...attributes };
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
- // Infer accessor name:
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 = fieldDef._fkModelRef;
586
- const toField = fieldDef._fkToField || 'id';
587
- const self = this;
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
- const M = typeof modelRef === 'function' ? modelRef() : modelRef;
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 resolveRelated = () => {
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
- pivotTable,
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
- ...(this.constructor.timestamps ? { updated_at: now } : {}),
806
+ ...this.constructor._updatedAtPayload(),
659
807
  };
660
808
 
661
809
  payload = await this.constructor.beforeUpdate(payload) ?? payload;
662
- payload = this.constructor._serializeForDb(payload);
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(payload);
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 (!key.startsWith('_') && typeof this[key] !== 'function' && !hidden.has(key)) {
745
- obj[key] = this[key];
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': return Boolean(val);
778
- case 'integer': return Number.isInteger(val) ? val : parseInt(val, 10);
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': return typeof val === 'number' ? val : parseFloat(val);
782
- case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
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': return val instanceof Date ? val : new Date(val);
785
- default: return val;
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;