millas 0.2.11 → 0.2.12-beta
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 +17 -3
- package/src/auth/AuthController.js +42 -133
- package/src/auth/AuthMiddleware.js +12 -23
- package/src/auth/RoleMiddleware.js +7 -17
- package/src/commands/migrate.js +46 -31
- package/src/commands/serve.js +266 -37
- package/src/container/Application.js +88 -8
- package/src/controller/Controller.js +79 -300
- package/src/errors/ErrorRenderer.js +640 -0
- package/src/facades/Admin.js +49 -0
- package/src/facades/Auth.js +46 -0
- package/src/facades/Cache.js +17 -0
- package/src/facades/Database.js +43 -0
- package/src/facades/Events.js +24 -0
- package/src/facades/Http.js +54 -0
- package/src/facades/Log.js +56 -0
- package/src/facades/Mail.js +40 -0
- package/src/facades/Queue.js +23 -0
- package/src/facades/Storage.js +17 -0
- package/src/facades/Validation.js +69 -0
- package/src/http/MillasRequest.js +253 -0
- package/src/http/MillasResponse.js +196 -0
- package/src/http/RequestContext.js +176 -0
- package/src/http/ResponseDispatcher.js +144 -0
- package/src/http/helpers.js +164 -0
- package/src/http/index.js +13 -0
- package/src/index.js +55 -2
- package/src/logger/internal.js +76 -0
- package/src/logger/patchConsole.js +135 -0
- package/src/middleware/CorsMiddleware.js +22 -30
- package/src/middleware/LogMiddleware.js +27 -59
- package/src/middleware/Middleware.js +24 -15
- package/src/middleware/MiddlewarePipeline.js +30 -67
- package/src/middleware/MiddlewareRegistry.js +126 -0
- package/src/middleware/ThrottleMiddleware.js +22 -26
- package/src/orm/fields/index.js +124 -56
- package/src/orm/migration/ModelInspector.js +7 -3
- package/src/orm/model/Model.js +96 -6
- package/src/orm/query/QueryBuilder.js +141 -3
- package/src/providers/LogServiceProvider.js +88 -18
- package/src/providers/ProviderRegistry.js +14 -1
- package/src/providers/ServiceProvider.js +40 -8
- package/src/router/Router.js +155 -223
- package/src/scaffold/maker.js +24 -59
- package/src/scaffold/templates.js +13 -12
- package/src/validation/BaseValidator.js +193 -0
- package/src/validation/Validator.js +680 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MillasRequest = require('../http/MillasRequest');
|
|
4
|
+
const MillasResponse = require('../http/MillasResponse');
|
|
5
|
+
const ResponseDispatcher = require('../http/ResponseDispatcher');
|
|
6
|
+
const RequestContext = require('../http/RequestContext');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* MiddlewareRegistry
|
|
10
|
+
*
|
|
11
|
+
* Maps string aliases → middleware handler classes or functions.
|
|
12
|
+
* Resolves them into Express-compatible functions that wrap with MillasRequest.
|
|
13
|
+
*/
|
|
14
|
+
class MiddlewareRegistry {
|
|
15
|
+
constructor(container = null) {
|
|
16
|
+
this._map = {};
|
|
17
|
+
this._container = container;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
register(alias, handler) {
|
|
21
|
+
this._map[alias] = handler;
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a middleware alias or function into an Express-compatible handler.
|
|
27
|
+
*
|
|
28
|
+
* Millas middleware (class with handle(req, next)):
|
|
29
|
+
* - Receives MillasRequest
|
|
30
|
+
* - Returns MillasResponse or calls next()
|
|
31
|
+
* - Kernel dispatches the MillasResponse if returned
|
|
32
|
+
*
|
|
33
|
+
* Raw Express functions (legacy/escape hatch):
|
|
34
|
+
* - Passed through as-is
|
|
35
|
+
*/
|
|
36
|
+
resolve(aliasOrFn) {
|
|
37
|
+
// Raw function — check if it's a Millas middleware class or legacy Express fn
|
|
38
|
+
if (typeof aliasOrFn === 'function') {
|
|
39
|
+
return this._wrapHandler(aliasOrFn);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Handler = this._map[aliasOrFn];
|
|
43
|
+
if (!Handler) {
|
|
44
|
+
throw new Error(`Middleware "${aliasOrFn}" is not registered.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return this._wrapHandler(Handler);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
resolveAll(list = []) {
|
|
51
|
+
return list.map(m => this.resolve(m));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
has(alias) {
|
|
55
|
+
return Object.prototype.hasOwnProperty.call(this._map, alias);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
all() {
|
|
59
|
+
return { ...this._map }; }
|
|
60
|
+
|
|
61
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
_wrapHandler(Handler) {
|
|
64
|
+
// Pre-instantiated Millas middleware object: { handle(req, next) }
|
|
65
|
+
if (typeof Handler === 'object' && Handler !== null &&
|
|
66
|
+
typeof Handler.handle === 'function') {
|
|
67
|
+
return this._buildMillasWrapper(Handler);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Millas middleware class (has handle on prototype, not Express signature)
|
|
71
|
+
if (typeof Handler === 'function' &&
|
|
72
|
+
Handler.prototype &&
|
|
73
|
+
typeof Handler.prototype.handle === 'function') {
|
|
74
|
+
const instance = new Handler();
|
|
75
|
+
return this._buildMillasWrapper(instance);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Legacy raw Express function: (req, res, next) => void
|
|
79
|
+
// Pass through unchanged — developers using old style still work
|
|
80
|
+
if (typeof Handler === 'function') {
|
|
81
|
+
return Handler;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(`Middleware must be a function or a class with handle().`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build an Express-compatible function from a Millas middleware instance.
|
|
89
|
+
*
|
|
90
|
+
* The middleware's handle(req, next) is called with a MillasRequest.
|
|
91
|
+
* If it returns a MillasResponse, that response is dispatched immediately.
|
|
92
|
+
* If it calls next(), Express continues down the chain.
|
|
93
|
+
*/
|
|
94
|
+
_buildMillasWrapper(instance) {
|
|
95
|
+
const container = this._container;
|
|
96
|
+
|
|
97
|
+
return (expressReq, expressRes, expressNext) => {
|
|
98
|
+
const millaReq = new MillasRequest(expressReq);
|
|
99
|
+
const ctx = new RequestContext(millaReq, container);
|
|
100
|
+
|
|
101
|
+
const next = () => {
|
|
102
|
+
expressNext();
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
new Promise((resolve, reject) => {
|
|
107
|
+
try {
|
|
108
|
+
resolve(instance.handle(ctx, next));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
reject(err);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.then(value => {
|
|
114
|
+
if (value !== undefined && value !== null && !expressRes.headersSent) {
|
|
115
|
+
const response = MillasResponse.isResponse(value)
|
|
116
|
+
? value
|
|
117
|
+
: ResponseDispatcher.autoWrap(value);
|
|
118
|
+
ResponseDispatcher.dispatch(response, expressRes);
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.catch(expressNext);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = MiddlewareRegistry;
|
|
@@ -1,35 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const Middleware
|
|
3
|
+
const Middleware = require('./Middleware');
|
|
4
|
+
const MillasResponse = require('../http/MillasResponse');
|
|
5
|
+
const { jsonify } = require('../http/helpers');
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* ThrottleMiddleware
|
|
7
9
|
*
|
|
8
10
|
* Simple in-memory rate limiter.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Register:
|
|
12
|
-
* middlewareRegistry.register('throttle', new ThrottleMiddleware({ max: 60, window: 60 }));
|
|
13
|
-
*
|
|
14
|
-
* Options:
|
|
15
|
-
* max — max requests per window (default: 60)
|
|
16
|
-
* window — window in seconds (default: 60)
|
|
17
|
-
* keyBy — function(req) => string, defaults to IP
|
|
11
|
+
* Uses the Millas middleware signature: handle(req, next).
|
|
18
12
|
*/
|
|
19
13
|
class ThrottleMiddleware extends Middleware {
|
|
20
14
|
constructor(options = {}) {
|
|
21
15
|
super();
|
|
22
16
|
this.max = options.max || 60;
|
|
23
|
-
this.window = options.window || 60;
|
|
17
|
+
this.window = options.window || 60;
|
|
24
18
|
this.keyBy = options.keyBy || ((req) => req.ip || 'anonymous');
|
|
25
|
-
this._store = new Map();
|
|
19
|
+
this._store = new Map();
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
async handle(req,
|
|
29
|
-
const key
|
|
30
|
-
const now
|
|
31
|
-
|
|
32
|
-
let record = this._store.get(key);
|
|
22
|
+
async handle(req, next) {
|
|
23
|
+
const key = this.keyBy(req);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
let record = this._store.get(key);
|
|
33
26
|
|
|
34
27
|
if (!record || now > record.resetAt) {
|
|
35
28
|
record = { count: 0, resetAt: now + this.window * 1000 };
|
|
@@ -41,20 +34,23 @@ class ThrottleMiddleware extends Middleware {
|
|
|
41
34
|
const remaining = Math.max(0, this.max - record.count);
|
|
42
35
|
const resetIn = Math.ceil((record.resetAt - now) / 1000);
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
res
|
|
46
|
-
|
|
37
|
+
// Rate limit headers — added to whatever response comes back
|
|
38
|
+
// We set them on the raw Express res since we don't have the final response yet.
|
|
39
|
+
// These headers will be present on all responses from throttled routes.
|
|
40
|
+
req.raw.res.setHeader('X-RateLimit-Limit', String(this.max));
|
|
41
|
+
req.raw.res.setHeader('X-RateLimit-Remaining', String(remaining));
|
|
42
|
+
req.raw.res.setHeader('X-RateLimit-Reset', String(Math.ceil(record.resetAt / 1000)));
|
|
47
43
|
|
|
48
44
|
if (record.count > this.max) {
|
|
49
|
-
return
|
|
50
|
-
error:
|
|
51
|
-
message:
|
|
52
|
-
status:
|
|
45
|
+
return jsonify({
|
|
46
|
+
error: 'Too Many Requests',
|
|
47
|
+
message: `Rate limit exceeded. Try again in ${resetIn}s.`,
|
|
48
|
+
status: 429,
|
|
53
49
|
retryAfter: resetIn,
|
|
54
|
-
});
|
|
50
|
+
}, { status: 429 });
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
next();
|
|
53
|
+
return next();
|
|
58
54
|
}
|
|
59
55
|
}
|
|
60
56
|
|
package/src/orm/fields/index.js
CHANGED
|
@@ -1,128 +1,196 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* fields
|
|
5
|
-
*
|
|
6
|
-
* Schema field definitions used inside Model.fields = { ... }
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* class User extends Model {
|
|
10
|
-
* static fields = {
|
|
11
|
-
* id: fields.id(),
|
|
12
|
-
* name: fields.string({ max: 100 }),
|
|
13
|
-
* email: fields.string({ unique: true }),
|
|
14
|
-
* age: fields.integer({ nullable: true }),
|
|
15
|
-
* score: fields.float({ default: 0.0 }),
|
|
16
|
-
* active: fields.boolean({ default: true }),
|
|
17
|
-
* bio: fields.text({ nullable: true }),
|
|
18
|
-
* data: fields.json({ nullable: true }),
|
|
19
|
-
* role: fields.enum(['admin', 'user', 'guest'], { default: 'user' }),
|
|
20
|
-
* created_at: fields.timestamp(),
|
|
21
|
-
* updated_at: fields.timestamp(),
|
|
22
|
-
* };
|
|
23
|
-
* }
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
3
|
class FieldDefinition {
|
|
27
4
|
constructor(type, options = {}) {
|
|
28
|
-
this.type
|
|
29
|
-
this.options
|
|
30
|
-
this.nullable
|
|
31
|
-
this.unique
|
|
32
|
-
this.default
|
|
33
|
-
this.primary
|
|
34
|
-
this.unsigned
|
|
35
|
-
this.max
|
|
36
|
-
this.enumValues
|
|
37
|
-
this.references
|
|
5
|
+
this.type = type;
|
|
6
|
+
this.options = options;
|
|
7
|
+
this.nullable = options.nullable ?? false;
|
|
8
|
+
this.unique = options.unique ?? false;
|
|
9
|
+
this.default = options.default !== undefined ? options.default : undefined;
|
|
10
|
+
this.primary = options.primary ?? false;
|
|
11
|
+
this.unsigned = options.unsigned ?? false;
|
|
12
|
+
this.max = options.max ?? null;
|
|
13
|
+
this.enumValues = options.enumValues ?? null;
|
|
14
|
+
this.references = options.references ?? null;
|
|
15
|
+
this._isForeignKey = options._isForeignKey ?? false;
|
|
16
|
+
this._isOneToOne = options._isOneToOne ?? false;
|
|
17
|
+
this._isManyToMany = options._isManyToMany ?? false;
|
|
18
|
+
this._fkModel = options._fkModel ?? null;
|
|
19
|
+
this._fkModelRef = options._fkModelRef ?? null;
|
|
20
|
+
this._fkToField = options._fkToField ?? 'id';
|
|
21
|
+
this._fkOnDelete = options._fkOnDelete ?? 'CASCADE';
|
|
22
|
+
this._fkRelatedName = options._fkRelatedName ?? null;
|
|
23
|
+
this._m2mThrough = options._m2mThrough ?? null;
|
|
38
24
|
}
|
|
39
25
|
|
|
40
|
-
|
|
26
|
+
nullable_(val = true) { this.nullable = val; return this; }
|
|
27
|
+
unique_(val = true) { this.unique = val; return this; }
|
|
28
|
+
default_(val) { this.default = val; return this; }
|
|
29
|
+
unsigned_(val = true) { this.unsigned = val; return this; }
|
|
30
|
+
references_(table, col) { this.references = { table, column: col }; return this; }
|
|
31
|
+
}
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
function _makeModelRef(model) {
|
|
34
|
+
if (typeof model === 'function') return model;
|
|
35
|
+
if (model === 'self') return null;
|
|
36
|
+
return () => {
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const modelsDir = path.join(process.cwd(), 'app', 'models');
|
|
39
|
+
try {
|
|
40
|
+
return require(path.join(modelsDir, model));
|
|
41
|
+
} catch {
|
|
42
|
+
try {
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const files = fs.readdirSync(modelsDir);
|
|
45
|
+
const match = files.find(f =>
|
|
46
|
+
f.replace(/\.js$/, '').toLowerCase() === model.toLowerCase()
|
|
47
|
+
);
|
|
48
|
+
if (match) return require(path.join(modelsDir, match));
|
|
49
|
+
} catch {}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
const fields = {
|
|
50
|
-
|
|
56
|
+
|
|
51
57
|
id(options = {}) {
|
|
52
58
|
return new FieldDefinition('id', { primary: true, unsigned: true, ...options });
|
|
53
59
|
},
|
|
54
60
|
|
|
55
|
-
/** VARCHAR / TEXT string */
|
|
56
61
|
string(options = {}) {
|
|
57
62
|
return new FieldDefinition('string', { max: 255, ...options });
|
|
58
63
|
},
|
|
59
64
|
|
|
60
|
-
/** LONGTEXT */
|
|
61
65
|
text(options = {}) {
|
|
62
66
|
return new FieldDefinition('text', options);
|
|
63
67
|
},
|
|
64
68
|
|
|
65
|
-
/** INT */
|
|
66
69
|
integer(options = {}) {
|
|
67
70
|
return new FieldDefinition('integer', options);
|
|
68
71
|
},
|
|
69
72
|
|
|
70
|
-
/** BIGINT */
|
|
71
73
|
bigInteger(options = {}) {
|
|
72
74
|
return new FieldDefinition('bigInteger', options);
|
|
73
75
|
},
|
|
74
76
|
|
|
75
|
-
/** FLOAT / DOUBLE */
|
|
76
77
|
float(options = {}) {
|
|
77
78
|
return new FieldDefinition('float', options);
|
|
78
79
|
},
|
|
79
80
|
|
|
80
|
-
/** DECIMAL(precision, scale) */
|
|
81
81
|
decimal(precision = 8, scale = 2, options = {}) {
|
|
82
82
|
return new FieldDefinition('decimal', { precision, scale, ...options });
|
|
83
83
|
},
|
|
84
84
|
|
|
85
|
-
/** TINYINT(1) boolean */
|
|
86
85
|
boolean(options = {}) {
|
|
87
86
|
return new FieldDefinition('boolean', options);
|
|
88
87
|
},
|
|
89
88
|
|
|
90
|
-
/** JSON blob */
|
|
91
89
|
json(options = {}) {
|
|
92
90
|
return new FieldDefinition('json', options);
|
|
93
91
|
},
|
|
94
92
|
|
|
95
|
-
/** DATE */
|
|
96
93
|
date(options = {}) {
|
|
97
94
|
return new FieldDefinition('date', options);
|
|
98
95
|
},
|
|
99
96
|
|
|
100
|
-
/** DATETIME / TIMESTAMP */
|
|
101
97
|
timestamp(options = {}) {
|
|
102
98
|
return new FieldDefinition('timestamp', { nullable: true, ...options });
|
|
103
99
|
},
|
|
104
100
|
|
|
105
|
-
/** ENUM */
|
|
106
101
|
enum(values, options = {}) {
|
|
107
102
|
return new FieldDefinition('enum', { enumValues: values, ...options });
|
|
108
103
|
},
|
|
109
104
|
|
|
110
|
-
/** UUID */
|
|
111
105
|
uuid(options = {}) {
|
|
112
106
|
return new FieldDefinition('uuid', options);
|
|
113
107
|
},
|
|
114
108
|
|
|
115
109
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
110
|
+
* ForeignKey — Django-style.
|
|
111
|
+
*
|
|
112
|
+
* Declares the integer column AND wires the BelongsTo relation automatically.
|
|
113
|
+
* No `static relations` block needed.
|
|
114
|
+
*
|
|
115
|
+
* Field name convention:
|
|
116
|
+
* author → accessor: book.author() column: author_id
|
|
117
|
+
* author_id → accessor: book.author() column: author_id
|
|
118
|
+
*
|
|
119
|
+
* @param {string|Function} model 'Author' | () => Author | 'self'
|
|
120
|
+
* @param {object} [opts]
|
|
121
|
+
* @param {boolean} [opts.nullable] allow NULL (default: false)
|
|
122
|
+
* @param {string} [opts.onDelete] CASCADE|SET NULL|RESTRICT|PROTECT|DO_NOTHING (default: CASCADE)
|
|
123
|
+
* @param {string} [opts.relatedName] reverse accessor on target, e.g. 'books' → author.books()
|
|
124
|
+
* pass '+' to suppress the reverse relation
|
|
125
|
+
* @param {string} [opts.toField] target column (default: 'id')
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* author: fields.ForeignKey('Author', { onDelete: 'CASCADE', relatedName: 'books' })
|
|
129
|
+
* editor: fields.ForeignKey('User', { nullable: true, onDelete: 'SET NULL' })
|
|
130
|
+
* parent: fields.ForeignKey('self', { nullable: true, relatedName: 'children' })
|
|
131
|
+
*/
|
|
132
|
+
ForeignKey(model, opts = {}) {
|
|
133
|
+
return new FieldDefinition('integer', {
|
|
134
|
+
unsigned: true,
|
|
135
|
+
nullable: opts.nullable ?? false,
|
|
136
|
+
_isForeignKey: true,
|
|
137
|
+
_fkModel: model,
|
|
138
|
+
_fkModelRef: _makeModelRef(model),
|
|
139
|
+
_fkToField: opts.toField ?? 'id',
|
|
140
|
+
_fkOnDelete: opts.onDelete ?? 'CASCADE',
|
|
141
|
+
_fkRelatedName: opts.relatedName ?? null,
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* OneToOne — unique ForeignKey. Both directions wired automatically.
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* user: fields.OneToOne('User', { relatedName: 'profile' })
|
|
150
|
+
* // profile.user() and user.profile() both work
|
|
118
151
|
*/
|
|
152
|
+
OneToOne(model, opts = {}) {
|
|
153
|
+
return new FieldDefinition('integer', {
|
|
154
|
+
unsigned: true,
|
|
155
|
+
unique: true,
|
|
156
|
+
nullable: opts.nullable ?? false,
|
|
157
|
+
_isForeignKey: true,
|
|
158
|
+
_isOneToOne: true,
|
|
159
|
+
_fkModel: model,
|
|
160
|
+
_fkModelRef: _makeModelRef(model),
|
|
161
|
+
_fkToField: opts.toField ?? 'id',
|
|
162
|
+
_fkOnDelete: opts.onDelete ?? 'CASCADE',
|
|
163
|
+
_fkRelatedName: opts.relatedName ?? null,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* ManyToMany — no DB column. Generates pivot table migration.
|
|
169
|
+
* Pivot table auto-named: sorted model names joined with underscore.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* tags: fields.ManyToMany('Tag', { relatedName: 'courses' })
|
|
173
|
+
* tags: fields.ManyToMany('Tag', { through: 'course_tags', relatedName: 'courses' })
|
|
174
|
+
*/
|
|
175
|
+
ManyToMany(model, opts = {}) {
|
|
176
|
+
return new FieldDefinition('m2m', {
|
|
177
|
+
nullable: true,
|
|
178
|
+
_isManyToMany: true,
|
|
179
|
+
_fkModel: model,
|
|
180
|
+
_fkModelRef: _makeModelRef(model),
|
|
181
|
+
_fkRelatedName: opts.relatedName ?? null,
|
|
182
|
+
_m2mThrough: opts.through ?? null,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/** Legacy — kept for backward compatibility. Prefer ForeignKey(). */
|
|
119
187
|
foreignId(column, options = {}) {
|
|
120
188
|
const [table, col] = column.endsWith('_id')
|
|
121
189
|
? [column.slice(0, -3) + 's', 'id']
|
|
122
190
|
: [null, null];
|
|
123
191
|
return new FieldDefinition('integer', {
|
|
124
|
-
unsigned:
|
|
125
|
-
nullable:
|
|
192
|
+
unsigned: true,
|
|
193
|
+
nullable: options.nullable ?? false,
|
|
126
194
|
references: table ? { table, column: col } : null,
|
|
127
195
|
...options,
|
|
128
196
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs
|
|
4
|
-
const path
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const MillasLog = require('../../logger/internal');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ModelInspector
|
|
@@ -94,7 +95,10 @@ class ModelInspector {
|
|
|
94
95
|
exported = require(fullPath);
|
|
95
96
|
} catch (err) {
|
|
96
97
|
// Skip files that fail to parse / have runtime errors
|
|
97
|
-
|
|
98
|
+
// Log at WARN level — a skipped model is worth knowing about
|
|
99
|
+
// but shouldn't stop the command. Falls back silently if the
|
|
100
|
+
// logger hasn't been configured yet (e.g. bare CLI usage).
|
|
101
|
+
MillasLog.w('makemigrations', `Skipping ${file}: ${err.message}`);
|
|
98
102
|
continue;
|
|
99
103
|
}
|
|
100
104
|
|
package/src/orm/model/Model.js
CHANGED
|
@@ -412,7 +412,7 @@ class Model {
|
|
|
412
412
|
return new QueryBuilder(this._db(), this).distinct(...cols);
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
-
/** Start an eager-load chain. */
|
|
415
|
+
/** Start an eager-load chain. Relations inferred from fields are included automatically. */
|
|
416
416
|
static with(...relations) {
|
|
417
417
|
return new QueryBuilder(this._db(), this).with(...relations);
|
|
418
418
|
}
|
|
@@ -461,15 +461,14 @@ class Model {
|
|
|
461
461
|
Object.assign(this, attributes);
|
|
462
462
|
this._original = { ...attributes };
|
|
463
463
|
|
|
464
|
-
//
|
|
465
|
-
|
|
464
|
+
// Use effective relations: explicit static relations PLUS those
|
|
465
|
+
// auto-inferred from ForeignKey / OneToOne / ManyToMany fields.
|
|
466
|
+
const relations = this.constructor._effectiveRelations();
|
|
467
|
+
|
|
466
468
|
for (const [name, rel] of Object.entries(relations)) {
|
|
467
|
-
// Skip if already set by eager load
|
|
468
469
|
if (!(name in this)) {
|
|
469
470
|
this[name] = () => rel.load(this);
|
|
470
471
|
}
|
|
471
|
-
|
|
472
|
-
// Attach pivot manager for BelongsToMany
|
|
473
472
|
const BelongsToMany = require('../relations/BelongsToMany');
|
|
474
473
|
if (rel instanceof BelongsToMany) {
|
|
475
474
|
Object.assign(this[name], rel._pivotManager(this));
|
|
@@ -477,6 +476,97 @@ class Model {
|
|
|
477
476
|
}
|
|
478
477
|
}
|
|
479
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Returns the merged relation map for this model class.
|
|
481
|
+
*
|
|
482
|
+
* Priority (highest → lowest):
|
|
483
|
+
* 1. Explicitly declared `static relations = {}`
|
|
484
|
+
* 2. Auto-inferred from ForeignKey / OneToOne / ManyToMany fields
|
|
485
|
+
*
|
|
486
|
+
* Result is cached per class after first call.
|
|
487
|
+
*/
|
|
488
|
+
static _effectiveRelations() {
|
|
489
|
+
if (this._cachedRelations) return this._cachedRelations;
|
|
490
|
+
|
|
491
|
+
const BelongsTo = require('../relations/BelongsTo');
|
|
492
|
+
const HasOne = require('../relations/HasOne');
|
|
493
|
+
const BelongsToMany = require('../relations/BelongsToMany');
|
|
494
|
+
|
|
495
|
+
// Start with explicitly declared relations
|
|
496
|
+
const merged = { ...(this.relations || {}) };
|
|
497
|
+
|
|
498
|
+
for (const [fieldName, fieldDef] of Object.entries(this.fields || {})) {
|
|
499
|
+
|
|
500
|
+
// ── ForeignKey / OneToOne ────────────────────────────────────────────
|
|
501
|
+
if (fieldDef._isForeignKey) {
|
|
502
|
+
// Infer accessor name:
|
|
503
|
+
// author_id → author
|
|
504
|
+
// author → author (column will be author_id in migration)
|
|
505
|
+
const accessorName = fieldName.endsWith('_id')
|
|
506
|
+
? fieldName.slice(0, -3)
|
|
507
|
+
: fieldName;
|
|
508
|
+
|
|
509
|
+
// Don't overwrite an explicitly declared relation
|
|
510
|
+
if (!merged[accessorName]) {
|
|
511
|
+
const modelRef = fieldDef._fkModelRef;
|
|
512
|
+
const toField = fieldDef._fkToField || 'id';
|
|
513
|
+
const self = this;
|
|
514
|
+
|
|
515
|
+
// self-referential: 'self' means this very model
|
|
516
|
+
const resolveModel = () => {
|
|
517
|
+
if (fieldDef._fkModel === 'self') return self;
|
|
518
|
+
const M = typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
519
|
+
return M;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
if (fieldDef._isOneToOne) {
|
|
523
|
+
// OneToOne: BelongsTo on the declaring side
|
|
524
|
+
merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
|
|
525
|
+
} else {
|
|
526
|
+
merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── ManyToMany ────────────────────────────────────────────────────────
|
|
532
|
+
if (fieldDef._isManyToMany && !merged[fieldName]) {
|
|
533
|
+
const thisTableBase = (this.table || this.name.toLowerCase()).replace(/s$/, '');
|
|
534
|
+
const modelRef = fieldDef._fkModelRef;
|
|
535
|
+
|
|
536
|
+
const resolveRelated = () => {
|
|
537
|
+
const M = typeof modelRef === 'function' ? modelRef() : modelRef;
|
|
538
|
+
return M;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
// Infer pivot table: sort both singular table names alphabetically
|
|
542
|
+
const relatedName = typeof fieldDef._fkModel === 'string'
|
|
543
|
+
? fieldDef._fkModel.toLowerCase().replace(/s$/, '')
|
|
544
|
+
: fieldName.replace(/s$/, '');
|
|
545
|
+
|
|
546
|
+
const pivotTable = fieldDef._m2mThrough
|
|
547
|
+
|| [thisTableBase, relatedName].sort().join('_') + 's';
|
|
548
|
+
|
|
549
|
+
const thisFk = thisTableBase + '_id';
|
|
550
|
+
const relatedFk = relatedName + '_id';
|
|
551
|
+
|
|
552
|
+
merged[fieldName] = new BelongsToMany(
|
|
553
|
+
resolveRelated,
|
|
554
|
+
pivotTable,
|
|
555
|
+
thisFk,
|
|
556
|
+
relatedFk,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
this._cachedRelations = merged;
|
|
562
|
+
return merged;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Clear the cached relations (call if fields are modified at runtime). */
|
|
566
|
+
static _clearRelationCache() {
|
|
567
|
+
this._cachedRelations = null;
|
|
568
|
+
}
|
|
569
|
+
|
|
480
570
|
/**
|
|
481
571
|
* Persist changes to this instance.
|
|
482
572
|
* @param {object} data
|