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.
@@ -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
- ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
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 = trx ? trx(this.table) : this._db();
303
- const [id] = await q.insert(payload);
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
- const now = new Date().toISOString();
347
+ if (!rows || rows.length === 0) return;
340
348
  const payload = rows.map(r => ({
341
349
  ...this._applyDefaults(r),
342
- ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
350
+ ...this._timestampPayload(r),
351
+ ...this._serializeForDb(r),
343
352
  }));
344
- 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);
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, ...(this.timestamps ? { updated_at: now } : {}) });
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._original = { ...attributes };
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
- // Infer accessor name:
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 = fieldDef._fkModelRef;
592
- const toField = fieldDef._fkToField || 'id';
593
- const self = this;
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
- const M = typeof modelRef === 'function' ? modelRef() : modelRef;
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 resolveRelated = () => {
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
- pivotTable,
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
- ...(this.constructor.timestamps ? { updated_at: now } : {}),
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 (!key.startsWith('_') && typeof this[key] !== 'function' && !hidden.has(key)) {
751
- 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;
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': return typeof val === 'string' ? JSON.parse(val) : val;
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': return val instanceof Date ? val : new Date(val);
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;