millas 0.2.21 → 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/events/EventEmitter.js +12 -1
- package/src/facades/Database.js +55 -34
- 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 +18 -0
- package/src/orm/model/Model.js +3 -2
- package/src/orm/query/QueryBuilder.js +53 -0
package/package.json
CHANGED
|
@@ -81,7 +81,18 @@ class EventEmitter {
|
|
|
81
81
|
async _invoke(handler, event) {
|
|
82
82
|
// Listener class (has handle() on prototype)
|
|
83
83
|
if (typeof handler === 'function' && typeof handler.prototype?.handle === 'function') {
|
|
84
|
-
|
|
84
|
+
// Resolve static inject dependencies from the container
|
|
85
|
+
let inst;
|
|
86
|
+
if (handler.inject && Array.isArray(handler.inject) && handler.inject.length) {
|
|
87
|
+
const Facade = require('../facades/Facade');
|
|
88
|
+
const container = Facade._container;
|
|
89
|
+
const deps = handler.inject.map(key => {
|
|
90
|
+
try { return container ? container.make(key) : undefined; } catch { return undefined; }
|
|
91
|
+
});
|
|
92
|
+
inst = new handler(...deps);
|
|
93
|
+
} else {
|
|
94
|
+
inst = new handler();
|
|
95
|
+
}
|
|
85
96
|
if (handler.queue && this._queue) {
|
|
86
97
|
const Job = require('../queue/Job');
|
|
87
98
|
const q = this._queue;
|
package/src/facades/Database.js
CHANGED
|
@@ -1,43 +1,64 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const Facade = require('./Facade');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* Database facade — direct access to the knex connection.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const Database = require('millas/facades/Database');
|
|
5
10
|
*
|
|
6
|
-
*
|
|
11
|
+
* // Raw SQL
|
|
12
|
+
* const result = await Database.raw('SELECT NOW()');
|
|
7
13
|
*
|
|
8
|
-
*
|
|
14
|
+
* // Knex query builder
|
|
15
|
+
* const rows = await Database.table('posts').where('published', true).select('*');
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* static fields = {
|
|
13
|
-
* id: fields.id(),
|
|
14
|
-
* title: fields.string({ max: 255 }),
|
|
15
|
-
* author: fields.ForeignKey('User', { relatedName: 'posts' }),
|
|
16
|
-
* published: fields.boolean({ default: false }),
|
|
17
|
-
* created_at: fields.timestamp(),
|
|
18
|
-
* updated_at: fields.timestamp(),
|
|
19
|
-
* };
|
|
20
|
-
* }
|
|
17
|
+
* // Named connection
|
|
18
|
+
* const rows = await Database.connection('replica').raw('SELECT 1');
|
|
21
19
|
*/
|
|
20
|
+
class Database {
|
|
21
|
+
static _resolveInstance() {
|
|
22
|
+
const DatabaseManager = require('../orm/drivers/DatabaseManager');
|
|
23
|
+
return DatabaseManager.connection();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Proxy every static call to the knex connection
|
|
28
|
+
module.exports = new Proxy(Database, {
|
|
29
|
+
get(target, prop) {
|
|
30
|
+
// Let real static members through
|
|
31
|
+
if (prop in target || prop === 'then' || prop === 'catch') {
|
|
32
|
+
return target[prop];
|
|
33
|
+
}
|
|
34
|
+
if (typeof prop === 'symbol') return target[prop];
|
|
35
|
+
|
|
36
|
+
// Special case: raw() — normalize result across dialects
|
|
37
|
+
if (prop === 'raw') {
|
|
38
|
+
return async (sql, bindings) => {
|
|
39
|
+
const db = Database._resolveInstance();
|
|
40
|
+
const result = await db.raw(sql, bindings);
|
|
41
|
+
// Postgres returns { rows: [...], command, rowCount, ... }
|
|
42
|
+
// SQLite/MySQL return [rows, fields] or just rows
|
|
43
|
+
if (result && result.rows) return result.rows;
|
|
44
|
+
if (Array.isArray(result)) return result[0] ?? result;
|
|
45
|
+
return result;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
22
48
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ModelInspector,
|
|
31
|
-
DatabaseServiceProvider,
|
|
32
|
-
} = require('../core');
|
|
49
|
+
// Special case: connection(name) returns a named knex instance
|
|
50
|
+
if (prop === 'connection') {
|
|
51
|
+
return (name) => {
|
|
52
|
+
const DatabaseManager = require('../orm/drivers/DatabaseManager');
|
|
53
|
+
return DatabaseManager.connection(name || null);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
DatabaseServiceProvider,
|
|
43
|
-
};
|
|
57
|
+
// Proxy everything else to the default knex connection
|
|
58
|
+
return (...args) => {
|
|
59
|
+
const db = Database._resolveInstance();
|
|
60
|
+
if (typeof db[prop] !== 'function') return db[prop];
|
|
61
|
+
return db[prop](...args);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
package/src/orm/fields/index.js
CHANGED
|
@@ -101,6 +101,24 @@ const fields = {
|
|
|
101
101
|
return new FieldDefinition('json', options);
|
|
102
102
|
},
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Array field — stores an ordered list of values.
|
|
106
|
+
*
|
|
107
|
+
* On PostgreSQL: uses native ARRAY type (text[], integer[], etc.)
|
|
108
|
+
* On SQLite/MySQL: falls back to JSON column (same as fields.json())
|
|
109
|
+
*
|
|
110
|
+
* @param {string} [of='text'] — item type: 'text' | 'integer' | 'float' | 'boolean'
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* tags: fields.array() // text[] on PG, json on SQLite
|
|
114
|
+
* scores: fields.array('integer') // integer[] on PG
|
|
115
|
+
* media_ids: fields.array('integer', { nullable: true, default: [] })
|
|
116
|
+
*/
|
|
117
|
+
array(of = 'text', options = {}) {
|
|
118
|
+
if (typeof of === 'object') { options = of; of = 'text'; }
|
|
119
|
+
return new FieldDefinition('array', { arrayOf: of, nullable: true, default: [], ...options });
|
|
120
|
+
},
|
|
121
|
+
|
|
104
122
|
date(options = {}) {
|
|
105
123
|
return new FieldDefinition('date', options);
|
|
106
124
|
},
|
|
@@ -408,6 +408,12 @@ ${opsCode},
|
|
|
408
408
|
case 'id':
|
|
409
409
|
return 'fields.id()';
|
|
410
410
|
|
|
411
|
+
case 'array': {
|
|
412
|
+
const of_ = def.arrayOf || 'text';
|
|
413
|
+
const optsStr = Object.keys(opts).length ? `, ${this._renderOpts(opts)}` : '';
|
|
414
|
+
return `fields.array('${of_}'${optsStr})`;
|
|
415
|
+
}
|
|
416
|
+
|
|
411
417
|
case 'enum': {
|
|
412
418
|
const vals = JSON.stringify(def.enumValues || []);
|
|
413
419
|
const optsStr = Object.keys(opts).length
|
|
@@ -523,6 +523,10 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
|
|
|
523
523
|
line = `t.json('${name}')`;
|
|
524
524
|
break;
|
|
525
525
|
|
|
526
|
+
case 'array':
|
|
527
|
+
line = `t.specificType('${name}', '${field.arrayOf || 'text'}[]')`;
|
|
528
|
+
break;
|
|
529
|
+
|
|
526
530
|
case 'date':
|
|
527
531
|
line = `t.date('${name}')`;
|
|
528
532
|
break;
|
|
@@ -93,6 +93,24 @@ function _buildColumn(t, name, def, opts = {}) {
|
|
|
93
93
|
case 'json':
|
|
94
94
|
return t.json(name);
|
|
95
95
|
|
|
96
|
+
case 'array': {
|
|
97
|
+
const client = t.client?.config?.client || '';
|
|
98
|
+
if (client.includes('pg') || client.includes('postgres')) {
|
|
99
|
+
// Native Postgres ARRAY type
|
|
100
|
+
const pgTypeMap = {
|
|
101
|
+
text: 'text', string: 'text',
|
|
102
|
+
integer: 'integer', int: 'integer',
|
|
103
|
+
float: 'float', decimal: 'decimal',
|
|
104
|
+
boolean: 'boolean',
|
|
105
|
+
uuid: 'uuid',
|
|
106
|
+
};
|
|
107
|
+
const pgType = pgTypeMap[def.arrayOf || 'text'] || 'text';
|
|
108
|
+
return t.specificType(name, `${pgType}[]`);
|
|
109
|
+
}
|
|
110
|
+
// SQLite / MySQL — fall back to JSON
|
|
111
|
+
return t.json(name);
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
case 'date':
|
|
97
115
|
return t.date(name);
|
|
98
116
|
|
package/src/orm/model/Model.js
CHANGED
|
@@ -957,7 +957,8 @@ class Model {
|
|
|
957
957
|
case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
958
958
|
case 'float':
|
|
959
959
|
case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
|
|
960
|
-
case 'json':
|
|
960
|
+
case 'json':
|
|
961
|
+
case 'array': return typeof val === 'string' ? JSON.parse(val) : (Array.isArray(val) ? val : (val ?? []));
|
|
961
962
|
case 'date':
|
|
962
963
|
case 'timestamp': {
|
|
963
964
|
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
|
|
@@ -981,7 +982,7 @@ class Model {
|
|
|
981
982
|
|
|
982
983
|
static _serializeValue(val, type) {
|
|
983
984
|
if (val == null) return val;
|
|
984
|
-
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);
|
|
985
986
|
if (type === 'boolean') return val ? 1 : 0;
|
|
986
987
|
if ((type === 'date' || type === 'timestamp') && val instanceof Date) return val.toISOString();
|
|
987
988
|
return val;
|
|
@@ -253,6 +253,27 @@ class QueryBuilder {
|
|
|
253
253
|
return this;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
join(table, col1, col2, col3) {
|
|
257
|
+
this._query = col3
|
|
258
|
+
? this._query.join(table, col1, col2, col3)
|
|
259
|
+
: this._query.join(table, col1, col2);
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
leftJoin(table, col1, col2, col3) {
|
|
264
|
+
this._query = col3
|
|
265
|
+
? this._query.leftJoin(table, col1, col2, col3)
|
|
266
|
+
: this._query.leftJoin(table, col1, col2);
|
|
267
|
+
return this;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
rightJoin(table, col1, col2, col3) {
|
|
271
|
+
this._query = col3
|
|
272
|
+
? this._query.rightJoin(table, col1, col2, col3)
|
|
273
|
+
: this._query.rightJoin(table, col1, col2);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
having(column, operatorOrValue, value) {
|
|
257
278
|
if (value !== undefined) {
|
|
258
279
|
this._query = this._query.having(column, operatorOrValue, value);
|
|
@@ -561,6 +582,38 @@ class QueryBuilder {
|
|
|
561
582
|
continue;
|
|
562
583
|
}
|
|
563
584
|
|
|
585
|
+
// -- Nested dot notation: 'documents.document_images'
|
|
586
|
+
// Matches Django's prefetch_related('documents__document_images')
|
|
587
|
+
if (name.includes('.')) {
|
|
588
|
+
const [parentRel, ...rest] = name.split('.');
|
|
589
|
+
const nestedName = rest.join('.');
|
|
590
|
+
const parentRelDef = relations[parentRel];
|
|
591
|
+
if (!parentRelDef) {
|
|
592
|
+
MillasLog.w('ORM', `Relation "${parentRel}" not defined on ${this._model.name} -- skipping nested eager load`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const alreadyLoaded = instances[0]?.[parentRel] !== undefined &&
|
|
596
|
+
typeof instances[0]?.[parentRel] !== 'function';
|
|
597
|
+
if (!alreadyLoaded) {
|
|
598
|
+
await parentRelDef.eagerLoad(instances, parentRel, null);
|
|
599
|
+
}
|
|
600
|
+
const parentInstances = instances.flatMap(i => {
|
|
601
|
+
const val = i[parentRel];
|
|
602
|
+
if (!val) return [];
|
|
603
|
+
return Array.isArray(val) ? val : [val];
|
|
604
|
+
});
|
|
605
|
+
if (parentInstances.length) {
|
|
606
|
+
const RelatedModel = parentRelDef._related;
|
|
607
|
+
if (RelatedModel) {
|
|
608
|
+
const QB = require('./QueryBuilder');
|
|
609
|
+
const subQb = new QB(RelatedModel._db(), RelatedModel);
|
|
610
|
+
subQb._withs = [{ name: nestedName, constraint: null }];
|
|
611
|
+
await subQb._eagerLoad(parentInstances);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
|
|
564
617
|
// ── Normal eager load ─────────────────────────────────────────────────
|
|
565
618
|
const rel = relations[name];
|
|
566
619
|
if (!rel) {
|