millas 0.1.7 → 0.1.9

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.
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BelongsToMany
5
+ *
6
+ * Many-to-many through a pivot table.
7
+ *
8
+ * class Post extends Model {
9
+ * static relations = {
10
+ * tags: new BelongsToMany(() => Tag, 'post_tag', 'post_id', 'tag_id'),
11
+ * };
12
+ * }
13
+ *
14
+ * class Tag extends Model {
15
+ * static relations = {
16
+ * posts: new BelongsToMany(() => Post, 'post_tag', 'tag_id', 'post_id'),
17
+ * };
18
+ * }
19
+ *
20
+ * const post = await Post.find(1);
21
+ *
22
+ * // Lazy
23
+ * const tags = await post.tags();
24
+ *
25
+ * // Eager
26
+ * const posts = await Post.with('tags').get();
27
+ *
28
+ * // Attach / detach / sync
29
+ * await post.tags.attach(5)
30
+ * await post.tags.attach([5, 6], { approved: true }) // with pivot data
31
+ * await post.tags.detach(5)
32
+ * await post.tags.detach() // detach all
33
+ * await post.tags.sync([1, 2, 3]) // replace pivot rows
34
+ * await post.tags.toggle([1, 2])
35
+ */
36
+ class BelongsToMany {
37
+ /**
38
+ * @param {Function} relatedFn — () => RelatedModelClass
39
+ * @param {string} pivotTable — name of the join table
40
+ * @param {string} foreignPivotKey — pivot column pointing to this model
41
+ * @param {string} relatedPivotKey — pivot column pointing to the related model
42
+ * @param {string} localKey — PK on this model (default: 'id')
43
+ * @param {string} relatedKey — PK on related model (default: 'id')
44
+ */
45
+ constructor(relatedFn, pivotTable, foreignPivotKey, relatedPivotKey, localKey = 'id', relatedKey = 'id') {
46
+ this._relatedFn = relatedFn;
47
+ this._pivotTable = pivotTable;
48
+ this._foreignPivotKey = foreignPivotKey;
49
+ this._relatedPivotKey = relatedPivotKey;
50
+ this._localKey = localKey;
51
+ this._relatedKey = relatedKey;
52
+ }
53
+
54
+ get _related() { return this._relatedFn(); }
55
+
56
+ // ─── Lazy load ────────────────────────────────────────────────────────────
57
+
58
+ async load(ownerInstance) {
59
+ const db = this._related._db;
60
+ const key = ownerInstance[this._localKey];
61
+
62
+ const rows = await this._related._db()
63
+ .join(
64
+ this._pivotTable,
65
+ `${this._related.table}.${this._relatedKey}`,
66
+ '=',
67
+ `${this._pivotTable}.${this._relatedPivotKey}`,
68
+ )
69
+ .where(`${this._pivotTable}.${this._foreignPivotKey}`, key)
70
+ .select(`${this._related.table}.*`);
71
+
72
+ return rows.map(r => this._related._hydrate(r));
73
+ }
74
+
75
+ // ─── Eager load ───────────────────────────────────────────────────────────
76
+
77
+ async eagerLoad(instances, relationName, constraint) {
78
+ const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
79
+ if (!keys.length) {
80
+ for (const i of instances) i[relationName] = [];
81
+ return;
82
+ }
83
+
84
+ let q = this._related._db()
85
+ .join(
86
+ this._pivotTable,
87
+ `${this._related.table}.${this._relatedKey}`,
88
+ '=',
89
+ `${this._pivotTable}.${this._relatedPivotKey}`,
90
+ )
91
+ .whereIn(`${this._pivotTable}.${this._foreignPivotKey}`, keys)
92
+ .select(
93
+ `${this._related.table}.*`,
94
+ `${this._pivotTable}.${this._foreignPivotKey} as _pivot_owner_id`,
95
+ );
96
+
97
+ if (constraint) {
98
+ const QueryBuilder = require('../query/QueryBuilder');
99
+ const qb = new QueryBuilder(q, this._related);
100
+ constraint(qb);
101
+ q = qb._query;
102
+ }
103
+
104
+ const rows = await q;
105
+ const related = rows.map(r => {
106
+ const instance = this._related._hydrate(r);
107
+ instance._pivotOwnerId = r._pivot_owner_id;
108
+ return instance;
109
+ });
110
+
111
+ const map = new Map();
112
+ for (const r of related) {
113
+ const ownerId = r._pivotOwnerId;
114
+ if (!map.has(ownerId)) map.set(ownerId, []);
115
+ map.get(ownerId).push(r);
116
+ }
117
+
118
+ for (const instance of instances) {
119
+ instance[relationName] = map.get(instance[this._localKey]) ?? [];
120
+ }
121
+ }
122
+
123
+ // ─── Pivot management (returned as methods on the instance proxy) ─────────
124
+
125
+ /**
126
+ * Build pivot manager bound to a specific owner instance.
127
+ * Called internally — results attached as instance[relationName].
128
+ */
129
+ _pivotManager(ownerInstance) {
130
+ const self = this;
131
+ const db = () => ownerInstance.constructor._db().client; // knex instance
132
+
133
+ // We need raw knex for pivot table operations
134
+ const knex = () => {
135
+ const DatabaseManager = require('../drivers/DatabaseManager');
136
+ return DatabaseManager.connection();
137
+ };
138
+
139
+ return {
140
+ /** Attach related IDs to the pivot table */
141
+ async attach(ids, pivotData = {}) {
142
+ const ownerId = ownerInstance[self._localKey];
143
+ const idArray = Array.isArray(ids) ? ids : [ids];
144
+ const rows = idArray.map(id => ({
145
+ [self._foreignPivotKey]: ownerId,
146
+ [self._relatedPivotKey]: id,
147
+ ...pivotData,
148
+ }));
149
+ await knex()(self._pivotTable).insert(rows).onConflict().ignore();
150
+ },
151
+
152
+ /** Remove related IDs from the pivot table */
153
+ async detach(ids) {
154
+ const ownerId = ownerInstance[self._localKey];
155
+ let q = knex()(self._pivotTable).where(self._foreignPivotKey, ownerId);
156
+ if (ids != null) {
157
+ const idArray = Array.isArray(ids) ? ids : [ids];
158
+ q = q.whereIn(self._relatedPivotKey, idArray);
159
+ }
160
+ await q.delete();
161
+ },
162
+
163
+ /** Replace all pivot rows with the given set of IDs */
164
+ async sync(ids, pivotData = {}) {
165
+ await this.detach();
166
+ if (ids.length) await this.attach(ids, pivotData);
167
+ },
168
+
169
+ /** Toggle IDs — attach if not present, detach if present */
170
+ async toggle(ids) {
171
+ const ownerId = ownerInstance[self._localKey];
172
+ const idArray = Array.isArray(ids) ? ids : [ids];
173
+ const existing = await knex()(self._pivotTable)
174
+ .where(self._foreignPivotKey, ownerId)
175
+ .whereIn(self._relatedPivotKey, idArray)
176
+ .pluck(self._relatedPivotKey);
177
+
178
+ const toAttach = idArray.filter(id => !existing.includes(id));
179
+ const toDetach = existing;
180
+
181
+ if (toAttach.length) await this.attach(toAttach);
182
+ if (toDetach.length) await this.detach(toDetach);
183
+ },
184
+ };
185
+ }
186
+ }
187
+
188
+ module.exports = BelongsToMany;
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HasMany
5
+ *
6
+ * One-to-many: the foreign key lives on the related table.
7
+ *
8
+ * class User extends Model {
9
+ * static relations = {
10
+ * posts: new HasMany(() => Post, 'user_id'),
11
+ * };
12
+ * }
13
+ *
14
+ * const user = await User.find(1);
15
+ * const posts = await user.posts(); // lazy
16
+ * const users = await User.with('posts').get(); // eager
17
+ * const users = await User.with({ // eager + constrained
18
+ * posts: q => q.where('published', true).latest()
19
+ * }).get();
20
+ */
21
+ class HasMany {
22
+ constructor(relatedFn, foreignKey, localKey = 'id') {
23
+ this._relatedFn = relatedFn;
24
+ this._foreignKey = foreignKey;
25
+ this._localKey = localKey;
26
+ }
27
+
28
+ get _related() { return this._relatedFn(); }
29
+
30
+ // ─── Lazy load ────────────────────────────────────────────────────────────
31
+
32
+ async load(ownerInstance) {
33
+ const key = ownerInstance[this._localKey];
34
+ const rows = await this._related._db().where(this._foreignKey, key);
35
+ return rows.map(r => this._related._hydrate(r));
36
+ }
37
+
38
+ // ─── Eager load ───────────────────────────────────────────────────────────
39
+
40
+ async eagerLoad(instances, relationName, constraint) {
41
+ const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
42
+ if (!keys.length) {
43
+ for (const i of instances) i[relationName] = [];
44
+ return;
45
+ }
46
+
47
+ let q = this._related._db().whereIn(this._foreignKey, keys);
48
+ if (constraint) {
49
+ const QueryBuilder = require('../query/QueryBuilder');
50
+ const qb = new QueryBuilder(q, this._related);
51
+ constraint(qb);
52
+ q = qb._query;
53
+ }
54
+
55
+ const rows = await q;
56
+ const related = rows.map(r => this._related._hydrate(r));
57
+
58
+ // Group by foreign key
59
+ const map = new Map();
60
+ for (const r of related) {
61
+ const fkVal = r[this._foreignKey];
62
+ if (!map.has(fkVal)) map.set(fkVal, []);
63
+ map.get(fkVal).push(r);
64
+ }
65
+
66
+ for (const instance of instances) {
67
+ instance[relationName] = map.get(instance[this._localKey]) ?? [];
68
+ }
69
+ }
70
+ }
71
+
72
+ module.exports = HasMany;
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HasOne
5
+ *
6
+ * Represents a one-to-one relation where the foreign key lives on the
7
+ * related model's table.
8
+ *
9
+ * class User extends Model {
10
+ * static relations = {
11
+ * profile: new HasOne(() => Profile, 'user_id'),
12
+ * };
13
+ * }
14
+ *
15
+ * const user = await User.find(1);
16
+ * const profile = await user.profile(); // single instance or null
17
+ * const users = await User.with('profile').get(); // eager loaded
18
+ */
19
+ class HasOne {
20
+ /**
21
+ * @param {Function} relatedFn — () => RelatedModelClass (thunk avoids circular requires)
22
+ * @param {string} foreignKey — column on the related table pointing back here
23
+ * @param {string} localKey — column on this table (default: 'id')
24
+ */
25
+ constructor(relatedFn, foreignKey, localKey = 'id') {
26
+ this._relatedFn = relatedFn;
27
+ this._foreignKey = foreignKey;
28
+ this._localKey = localKey;
29
+ }
30
+
31
+ get _related() { return this._relatedFn(); }
32
+
33
+ // ─── Lazy load (instance method) ─────────────────────────────────────────
34
+
35
+ async load(ownerInstance) {
36
+ const key = ownerInstance[this._localKey];
37
+ const row = await this._related._db()
38
+ .where(this._foreignKey, key)
39
+ .first();
40
+ return row ? this._related._hydrate(row) : null;
41
+ }
42
+
43
+ // ─── Eager load ───────────────────────────────────────────────────────────
44
+
45
+ async eagerLoad(instances, relationName, constraint) {
46
+ const keys = [...new Set(instances.map(i => i[this._localKey]).filter(v => v != null))];
47
+ if (!keys.length) return;
48
+
49
+ let q = this._related._db().whereIn(this._foreignKey, keys);
50
+ if (constraint) {
51
+ const QueryBuilder = require('../query/QueryBuilder');
52
+ const qb = new QueryBuilder(q, this._related);
53
+ constraint(qb);
54
+ q = qb._query;
55
+ }
56
+
57
+ const rows = await q;
58
+ const related = rows.map(r => this._related._hydrate(r));
59
+ const map = new Map(related.map(r => [r[this._foreignKey], r]));
60
+
61
+ for (const instance of instances) {
62
+ instance[relationName] = map.get(instance[this._localKey]) ?? null;
63
+ }
64
+ }
65
+ }
66
+
67
+ module.exports = HasOne;
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ const HasOne = require('./HasOne');
4
+ const HasMany = require('./HasMany');
5
+ const BelongsTo = require('./BelongsTo');
6
+ const BelongsToMany = require('./BelongsToMany');
7
+
8
+ module.exports = { HasOne, HasMany, BelongsTo, BelongsToMany };
@@ -105,23 +105,58 @@ async function makeModel(name, options = {}) {
105
105
 
106
106
  const content = `'use strict';
107
107
 
108
- const { Model, fields } = require('millas');
108
+ const { Model, fields, HasMany, HasOne, BelongsTo, BelongsToMany } = require('millas');
109
109
 
110
110
  /**
111
111
  * ${className} Model
112
112
  *
113
- * Represents the "${tableName}" table.
114
- * Run: millas makemigrations — to generate the migration.
115
- * Run: millas migrate — to apply it.
113
+ * Table: ${tableName}
114
+ *
115
+ * Edit this file, then run:
116
+ * millas makemigrations — generates migration files from your changes
117
+ * millas migrate — applies them to the database
116
118
  */
117
119
  class ${className} extends Model {
118
120
  static table = '${tableName}';
119
121
 
122
+ // ── Fields ──────────────────────────────────────────────────────
120
123
  static fields = {
121
124
  id: fields.id(),
122
125
  created_at: fields.timestamp(),
123
126
  updated_at: fields.timestamp(),
127
+ // deleted_at: fields.timestamp({ nullable: true }), // uncomment + set softDeletes = true
124
128
  };
129
+
130
+ // ── Soft deletes ─────────────────────────────────────────────────
131
+ // static softDeletes = true;
132
+
133
+ // ── Relations ─────────────────────────────────────────────────────
134
+ // static relations = {
135
+ // posts: new HasMany(() => require('./Post'), 'user_id'),
136
+ // profile: new HasOne(() => require('./Profile'), 'user_id'),
137
+ // role: new BelongsTo(() => require('./Role'), 'role_id'),
138
+ // tags: new BelongsToMany(() => require('./Tag'), '${tableName.replace(/s$/, '')}_tag', '${tableName.replace(/s$/, '')}_id', 'tag_id'),
139
+ // };
140
+
141
+ // ── Scopes ───────────────────────────────────────────────────────
142
+ // static scopes = {
143
+ // active: qb => qb.where('active', true),
144
+ // recent: qb => qb.latest().limit(10),
145
+ // byUser: (qb, userId) => qb.where('user_id', userId),
146
+ // };
147
+
148
+ // ── Validation ───────────────────────────────────────────────────
149
+ // static validate(data) {
150
+ // if (!data.name) throw new Error('name is required');
151
+ // }
152
+
153
+ // ── Lifecycle hooks ──────────────────────────────────────────────
154
+ // static async beforeCreate(data) { return data; }
155
+ // static async afterCreate(instance) {}
156
+ // static async beforeUpdate(data) { return data; }
157
+ // static async afterUpdate(instance) {}
158
+ // static async beforeDelete(instance){}
159
+ // static async afterDelete(instance) {}
125
160
  }
126
161
 
127
162
  module.exports = ${className};
@@ -66,6 +66,8 @@ storage/logs/*.log
66
66
  storage/uploads/*
67
67
  !storage/uploads/.gitkeep
68
68
  database/database.sqlite
69
+ # Millas migration snapshot — auto-generated, do not commit
70
+ .millas/
69
71
  `,
70
72
 
71
73
  // ─── millas.config.js ─────────────────────────────────────────
@@ -356,17 +358,37 @@ millas make:controller UserController
356
358
 
357
359
  # Generate a model
358
360
  millas make:model User
361
+ \`\`\`
362
+
363
+ ## Database Migrations (Django-style)
364
+
365
+ Millas handles migrations automatically — you only edit your model files.
366
+
367
+ \`\`\`bash
368
+ # 1. Edit app/models/User.js — add, remove, or change fields
369
+ # 2. Generate migration files from your changes
370
+ millas makemigrations
359
371
 
360
- # Run migrations
372
+ # 3. Apply pending migrations to the database
361
373
  millas migrate
362
374
  \`\`\`
363
375
 
376
+ Other migration commands:
377
+
378
+ \`\`\`bash
379
+ millas migrate:status # Show which migrations have run
380
+ millas migrate:rollback # Undo the last batch
381
+ millas migrate:fresh # Drop everything and re-run all migrations
382
+ millas migrate:reset # Roll back all migrations
383
+ millas migrate:refresh # Reset + re-run (like fresh but using down() methods)
384
+ \`\`\`
385
+
364
386
  ## Project Structure
365
387
 
366
388
  \`\`\`
367
389
  app/
368
390
  controllers/ # HTTP controllers
369
- models/ # ORM models
391
+ models/ # ORM models ← only file you edit for schema changes
370
392
  services/ # Business logic
371
393
  middleware/ # HTTP middleware
372
394
  jobs/ # Background jobs
@@ -374,13 +396,14 @@ bootstrap/
374
396
  app.js # Application entry point
375
397
  config/ # Configuration files
376
398
  database/
377
- migrations/ # Database migrations
399
+ migrations/ # Auto-generated — do not edit by hand
378
400
  seeders/ # Database seeders
379
401
  routes/
380
402
  web.js # Web routes
381
403
  api.js # API routes
382
404
  storage/ # Logs, uploads
383
405
  providers/ # Service providers
406
+ .millas/ # Migration snapshot (gitignored)
384
407
  \`\`\`
385
408
  `,
386
409
  };