millas 0.2.12-beta-1 → 0.2.13
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 +3 -2
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +516 -199
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +318 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +393 -97
- package/src/admin/static/admin.css +1422 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +87 -1046
- package/src/admin/views/pages/detail.njk +56 -21
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +270 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +480 -0
- package/src/admin/views/partials/form-widget.njk +297 -0
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/auth/Auth.js +18 -2
- package/src/auth/AuthUser.js +65 -44
- package/src/cli.js +3 -1
- package/src/commands/createsuperuser.js +267 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +3 -4
- package/src/container/AppInitializer.js +101 -20
- package/src/container/Application.js +31 -1
- package/src/container/MillasApp.js +10 -3
- package/src/container/MillasConfig.js +35 -6
- package/src/core/admin.js +5 -0
- package/src/core/db.js +2 -1
- package/src/core/foundation.js +2 -10
- package/src/core/lang.js +1 -0
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +643 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +103 -65
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +143 -74
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/process/Process.js +333 -0
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +40 -5
- package/src/providers/CacheStorageServiceProvider.js +2 -2
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/LogServiceProvider.js +4 -1
- package/src/providers/MailServiceProvider.js +1 -1
- package/src/providers/QueueServiceProvider.js +1 -1
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +80 -21
- package/src/validation/Validator.js +348 -607
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CreateModel, DeleteModel, RenameModel } = require('./models');
|
|
4
|
+
const { AddField, RemoveField, AlterField, RenameField } = require('./fields');
|
|
5
|
+
const { RunSQL } = require('./special');
|
|
6
|
+
const { modelNameToTable, isSnakeCase } = require('../utils');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* registry.js
|
|
10
|
+
*
|
|
11
|
+
* Two responsibilities:
|
|
12
|
+
*
|
|
13
|
+
* 1. deserialise(op)
|
|
14
|
+
* Converts a plain JSON descriptor (loaded from a migration file) back
|
|
15
|
+
* into a live operation instance. Used by MigrationGraph.loadAll().
|
|
16
|
+
*
|
|
17
|
+
* 2. migrations proxy
|
|
18
|
+
* The named-argument API used inside generated migration files:
|
|
19
|
+
*
|
|
20
|
+
* const { migrations, fields } = require('millas/core/db');
|
|
21
|
+
* static operations = [
|
|
22
|
+
* migrations.CreateModel({ name: 'posts', fields: [...] }),
|
|
23
|
+
* migrations.AddField({ modelName: 'posts', name: 'slug', field: fields.string() }),
|
|
24
|
+
* ];
|
|
25
|
+
*
|
|
26
|
+
* Each proxy method returns a PLAIN DESCRIPTOR OBJECT — no live instances,
|
|
27
|
+
* no side-effects at require() time. MigrationGraph feeds these through
|
|
28
|
+
* deserialise() when it needs live operation objects.
|
|
29
|
+
*
|
|
30
|
+
* _tableFromName() handles the legacy PascalCase → snake_case conversion
|
|
31
|
+
* for any hand-written migrations that used model names instead of table names.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// ─── Deserialise ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert a plain operation descriptor into a live operation instance.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} op — plain descriptor with a `type` field
|
|
40
|
+
* @returns {BaseOperation}
|
|
41
|
+
* @throws {Error} if op.type is unrecognised
|
|
42
|
+
*/
|
|
43
|
+
function deserialise(op) {
|
|
44
|
+
switch (op.type) {
|
|
45
|
+
case 'CreateModel': return new CreateModel(op.table, op.fields);
|
|
46
|
+
case 'DeleteModel': return new DeleteModel(op.table, op.fields);
|
|
47
|
+
case 'RenameModel': return new RenameModel(op.oldTable, op.newTable);
|
|
48
|
+
case 'AddField': return new AddField(op.table, op.column, op.field, op.oneOffDefault);
|
|
49
|
+
case 'RemoveField': return new RemoveField(op.table, op.column, op.field);
|
|
50
|
+
case 'AlterField': return new AlterField(op.table, op.column, op.field, op.previousField);
|
|
51
|
+
case 'RenameField': return new RenameField(op.table, op.oldColumn, op.newColumn);
|
|
52
|
+
case 'RunSQL': return new RunSQL(op.sql, op.reverseSql);
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`Unknown migration operation type: "${op.type}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── migrations proxy ─────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Named-argument API used in generated migration files.
|
|
62
|
+
* Returns plain descriptor objects — zero side-effects at require() time.
|
|
63
|
+
*/
|
|
64
|
+
const migrations = {
|
|
65
|
+
|
|
66
|
+
CreateModel({ name, fields: fieldList = [] }) {
|
|
67
|
+
const fields = {};
|
|
68
|
+
for (const [col, def] of fieldList) fields[col] = def;
|
|
69
|
+
return { type: 'CreateModel', table: _tableFromName(name), fields };
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
DeleteModel({ name, fields: fieldList = [] }) {
|
|
73
|
+
const fields = {};
|
|
74
|
+
for (const [col, def] of (fieldList || [])) fields[col] = def;
|
|
75
|
+
return { type: 'DeleteModel', table: _tableFromName(name), fields };
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
RenameModel({ oldName, newName }) {
|
|
79
|
+
return {
|
|
80
|
+
type: 'RenameModel',
|
|
81
|
+
oldTable: _tableFromName(oldName),
|
|
82
|
+
newTable: _tableFromName(newName),
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
AddField({ modelName, name, field, oneOffDefault }) {
|
|
87
|
+
const d = { type: 'AddField', table: modelName, column: name, field };
|
|
88
|
+
if (oneOffDefault !== undefined) d.oneOffDefault = oneOffDefault;
|
|
89
|
+
return d;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
RemoveField({ modelName, name, field }) {
|
|
93
|
+
return { type: 'RemoveField', table: modelName, column: name, field };
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
AlterField({ modelName, name, field, previousField }) {
|
|
97
|
+
return { type: 'AlterField', table: modelName, column: name, field, previousField };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
RenameField({ modelName, oldName, newName }) {
|
|
101
|
+
return { type: 'RenameField', table: modelName, oldColumn: oldName, newColumn: newName };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
RunSQL({ sql, reverseSql = null }) {
|
|
105
|
+
return { type: 'RunSQL', sql, reverseSql };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ─── _tableFromName ───────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a migration file `name:` field to a table name.
|
|
114
|
+
*
|
|
115
|
+
* MigrationWriter now writes the actual table name directly
|
|
116
|
+
* (e.g. name: 'landlord_verification') so this is an identity function
|
|
117
|
+
* for all newly generated migrations.
|
|
118
|
+
*
|
|
119
|
+
* Kept for backward compatibility with hand-written migrations that used
|
|
120
|
+
* PascalCase model names (e.g. name: 'Post') — those get converted to
|
|
121
|
+
* snake_case plural table names.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} name
|
|
124
|
+
* @returns {string} table name
|
|
125
|
+
*/
|
|
126
|
+
function _tableFromName(name) {
|
|
127
|
+
// Already snake_case → return as-is. PascalCase → convert via utils.
|
|
128
|
+
return isSnakeCase(name) ? name : modelNameToTable(name);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { deserialise, migrations, _tableFromName };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BaseOperation } = require('./base');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* special.js
|
|
7
|
+
*
|
|
8
|
+
* Escape-hatch operations that don't fit the structured field/model pattern:
|
|
9
|
+
* RunSQL — execute arbitrary SQL (forward and optionally reverse)
|
|
10
|
+
*
|
|
11
|
+
* applyState() is a no-op for RunSQL — the migration system cannot know
|
|
12
|
+
* what arbitrary SQL does to the schema, so ProjectState is not mutated.
|
|
13
|
+
* This means RunSQL migrations are opaque to makemigrations diffing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── RunSQL ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
class RunSQL extends BaseOperation {
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} sql — SQL to run on migrate
|
|
21
|
+
* @param {string|null} reverseSql — SQL to run on rollback (optional)
|
|
22
|
+
*/
|
|
23
|
+
constructor(sql, reverseSql = null) {
|
|
24
|
+
super();
|
|
25
|
+
this.type = 'RunSQL';
|
|
26
|
+
this.sql = sql;
|
|
27
|
+
this.reverseSql = reverseSql;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Opaque — RunSQL does not mutate ProjectState.
|
|
31
|
+
// makemigrations cannot infer schema changes from raw SQL.
|
|
32
|
+
applyState(/* _state */) {}
|
|
33
|
+
|
|
34
|
+
async up(db) {
|
|
35
|
+
await db.raw(this.sql);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async down(db) {
|
|
39
|
+
if (this.reverseSql) await db.raw(this.reverseSql);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
toJSON() {
|
|
43
|
+
return {
|
|
44
|
+
type: 'RunSQL',
|
|
45
|
+
sql: this.sql,
|
|
46
|
+
reverseSql: this.reverseSql,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { RunSQL };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* utils.js — shared utilities for the migration system
|
|
8
|
+
*
|
|
9
|
+
* Every function here was previously duplicated across two or more files.
|
|
10
|
+
* Single source of truth — import from here, never re-implement.
|
|
11
|
+
*
|
|
12
|
+
* Exports:
|
|
13
|
+
* walkJs(dir) — recursively collect .js files
|
|
14
|
+
* extractClasses(exported) — pull class candidates from a module export
|
|
15
|
+
* isMillasModel(cls) — true if cls looks like a Millas Model
|
|
16
|
+
* resolveTable(cls) — table name for a Model class (convention or explicit)
|
|
17
|
+
* modelNameToTable(name) — PascalCase model name → snake_case plural table
|
|
18
|
+
* tableFromClass(cls) — walk prototype chain to find nearest static table
|
|
19
|
+
* isSnakeCase(str) — true if string is already snake_case
|
|
20
|
+
* fieldsEqual(a, b) — schema-key equality, ignores internal FK flags
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ─── File system ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively collect all .js files under a directory.
|
|
27
|
+
* Skips dotfiles and index.js at any depth.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} dir
|
|
30
|
+
* @returns {string[]} absolute paths
|
|
31
|
+
*/
|
|
32
|
+
function walkJs(dir) {
|
|
33
|
+
const results = [];
|
|
34
|
+
if (!fs.existsSync(dir)) return results;
|
|
35
|
+
|
|
36
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
if (entry.name.startsWith('.')) continue;
|
|
38
|
+
const fullPath = path.join(dir, entry.name);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
results.push(...walkJs(fullPath));
|
|
41
|
+
} else if (entry.name.endsWith('.js') && entry.name !== 'index.js') {
|
|
42
|
+
results.push(fullPath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Model class detection ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Given a module export (class, object, or anything), return every
|
|
53
|
+
* function/class value that could be a Model subclass.
|
|
54
|
+
*
|
|
55
|
+
* Handles:
|
|
56
|
+
* module.exports = MyModel → [MyModel]
|
|
57
|
+
* module.exports = { User, Post } → [User, Post]
|
|
58
|
+
*
|
|
59
|
+
* @param {*} exported
|
|
60
|
+
* @returns {Function[]}
|
|
61
|
+
*/
|
|
62
|
+
function extractClasses(exported) {
|
|
63
|
+
if (!exported) return [];
|
|
64
|
+
if (typeof exported === 'function') return [exported];
|
|
65
|
+
if (typeof exported === 'object') {
|
|
66
|
+
return Object.values(exported).filter(v => typeof v === 'function');
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A class qualifies as a Millas Model if:
|
|
73
|
+
* - It is a function (class)
|
|
74
|
+
* - It has a static `fields` property that is a non-null object with at least one key
|
|
75
|
+
*
|
|
76
|
+
* Intentionally avoids instanceof checks so this works regardless of which
|
|
77
|
+
* resolution path Model was loaded from (e.g. during tests or monorepo setups).
|
|
78
|
+
*
|
|
79
|
+
* @param {*} cls
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
function isMillasModel(cls) {
|
|
83
|
+
if (typeof cls !== 'function') return false;
|
|
84
|
+
if (!cls.fields || typeof cls.fields !== 'object') return false;
|
|
85
|
+
return Object.keys(cls.fields).length > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Table name resolution ────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the table name for a Model class.
|
|
92
|
+
*
|
|
93
|
+
* Rules (in priority order):
|
|
94
|
+
* 1. Abstract class (hasOwnProperty 'abstract' === true) → null (no table)
|
|
95
|
+
* 2. Explicitly declared via static set table(v) → stored in _table
|
|
96
|
+
* 3. Explicitly declared as static string property
|
|
97
|
+
* 4. Convention — auto-generated from class name via Model._defaultTable()
|
|
98
|
+
* (same as Laravel/Eloquent and Rails/ActiveRecord)
|
|
99
|
+
*
|
|
100
|
+
* @param {Function} cls — Model subclass
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
function resolveTable(cls) {
|
|
104
|
+
if (Object.prototype.hasOwnProperty.call(cls, 'abstract') && cls.abstract) return null;
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(cls, '_table') && cls._table) return cls._table;
|
|
106
|
+
if (Object.prototype.hasOwnProperty.call(cls, 'table') && typeof cls.table === 'string' && cls.table) return cls.table;
|
|
107
|
+
const generated = typeof cls.table === 'string' ? cls.table : null;
|
|
108
|
+
return generated || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk a Model class's prototype chain to find the nearest ancestor that
|
|
113
|
+
* explicitly declares a static table property via hasOwnProperty.
|
|
114
|
+
*
|
|
115
|
+
* Correctly resolves:
|
|
116
|
+
* Concrete model: User.table = 'users' → 'users'
|
|
117
|
+
* Same-table child: AdminUser extends User → 'users' (no own table)
|
|
118
|
+
* Multi-table child: Employee.table='employees'→ 'employees'
|
|
119
|
+
* Abstract base: AuthUser (abstract=true) → null
|
|
120
|
+
*
|
|
121
|
+
* @param {Function} cls
|
|
122
|
+
* @returns {string|null}
|
|
123
|
+
*/
|
|
124
|
+
function tableFromClass(cls) {
|
|
125
|
+
let current = cls;
|
|
126
|
+
while (current && current !== Function.prototype) {
|
|
127
|
+
if (Object.prototype.hasOwnProperty.call(current, 'table') && current.table) {
|
|
128
|
+
return current.table;
|
|
129
|
+
}
|
|
130
|
+
current = Object.getPrototypeOf(current);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convert a PascalCase model name to a snake_case plural table name.
|
|
137
|
+
*
|
|
138
|
+
* Used as a last resort when _fkModel is a string and no class reference
|
|
139
|
+
* is available. Handles irregular pluralisation:
|
|
140
|
+
*
|
|
141
|
+
* 'User' → 'users'
|
|
142
|
+
* 'Category' → 'categories'
|
|
143
|
+
* 'TaggedPost' → 'tagged_posts'
|
|
144
|
+
* 'UnitCategory' → 'unit_categories'
|
|
145
|
+
*
|
|
146
|
+
* @param {string} name — PascalCase model name
|
|
147
|
+
* @returns {string} snake_case plural table name
|
|
148
|
+
*/
|
|
149
|
+
function modelNameToTable(name) {
|
|
150
|
+
const snake = name
|
|
151
|
+
.replace(/([A-Z])/g, (m, c, i) => (i ? '_' : '') + c.toLowerCase())
|
|
152
|
+
.replace(/^_/, '');
|
|
153
|
+
if (snake.endsWith('y') && !/[aeiou]y$/.test(snake)) return snake.slice(0, -1) + 'ies';
|
|
154
|
+
if (/(?:s|sh|ch|x|z)$/.test(snake)) return snake + 'es';
|
|
155
|
+
return snake + 's';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Return true if the string is already a snake_case table name.
|
|
160
|
+
* Detected by: all lowercase (no uppercase letters).
|
|
161
|
+
*
|
|
162
|
+
* 'users' → true (table name — return as-is)
|
|
163
|
+
* 'unit_categories' → true (table name — return as-is)
|
|
164
|
+
* 'User' → false (PascalCase — needs conversion)
|
|
165
|
+
* 'UnitCategory' → false (PascalCase — needs conversion)
|
|
166
|
+
*
|
|
167
|
+
* @param {string} str
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
function isSnakeCase(str) {
|
|
171
|
+
return str === str.toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Field comparison ─────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compare two normalised field definitions for schema equality.
|
|
178
|
+
*
|
|
179
|
+
* Only compares the 10 schema-relevant keys — ignores internal FK metadata
|
|
180
|
+
* flags (_isForeignKey, _isOneToOne, _fkOnDelete, _fkRelatedName) which are
|
|
181
|
+
* present on live-scanned fields but absent on replayed migration state.
|
|
182
|
+
* Comparing those would cause phantom AlterField diffs on every makemigrations run.
|
|
183
|
+
*
|
|
184
|
+
* @param {object} a — normalised field def
|
|
185
|
+
* @param {object} b — normalised field def
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
function fieldsEqual(a, b) {
|
|
189
|
+
const SCHEMA_KEYS = [
|
|
190
|
+
'type', 'nullable', 'unique', 'default', 'max',
|
|
191
|
+
'unsigned', 'enumValues', 'references', 'precision', 'scale',
|
|
192
|
+
];
|
|
193
|
+
for (const k of SCHEMA_KEYS) {
|
|
194
|
+
if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
walkJs,
|
|
201
|
+
extractClasses,
|
|
202
|
+
isMillasModel,
|
|
203
|
+
resolveTable,
|
|
204
|
+
tableFromClass,
|
|
205
|
+
modelNameToTable,
|
|
206
|
+
isSnakeCase,
|
|
207
|
+
fieldsEqual,
|
|
208
|
+
};
|
package/src/orm/model/Model.js
CHANGED
|
@@ -128,9 +128,70 @@ class Model {
|
|
|
128
128
|
static primaryKey = 'id';
|
|
129
129
|
static timestamps = true;
|
|
130
130
|
static softDeletes = false;
|
|
131
|
-
static fields = {};
|
|
132
131
|
static connection = null;
|
|
133
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Own fields declared directly on this class only.
|
|
135
|
+
* Subclasses override this with just their own additions/overrides —
|
|
136
|
+
* no need to spread parent fields manually (like Django's AbstractUser).
|
|
137
|
+
*
|
|
138
|
+
* class User extends AuthUser {
|
|
139
|
+
* static ownFields = {
|
|
140
|
+
* phone: fields.string({ nullable: true }),
|
|
141
|
+
* role: fields.enum(['tenant', 'landlord'], { default: 'tenant' }),
|
|
142
|
+
* };
|
|
143
|
+
* }
|
|
144
|
+
*
|
|
145
|
+
* You can still use 'static fields = {...}' to completely replace the schema.
|
|
146
|
+
*/
|
|
147
|
+
/**
|
|
148
|
+
* Merged field map — walks the prototype chain and merges fields from:
|
|
149
|
+
* - The class itself (always)
|
|
150
|
+
* - Any ancestor marked 'static abstract = true' (fields flow down)
|
|
151
|
+
* - Concrete ancestors with the same table (single-table inheritance)
|
|
152
|
+
*
|
|
153
|
+
* Child fields win on collision. Result is cached per class.
|
|
154
|
+
*
|
|
155
|
+
* Usage — just declare what's new or overridden, no spread needed:
|
|
156
|
+
*
|
|
157
|
+
* class AuthUser extends Model {
|
|
158
|
+
* static abstract = true;
|
|
159
|
+
* static fields = { id: fields.id(), email: fields.string() };
|
|
160
|
+
* }
|
|
161
|
+
* class User extends AuthUser {
|
|
162
|
+
* static table = 'users';
|
|
163
|
+
* static fields = { phone: fields.string(), role: fields.enum([...]) };
|
|
164
|
+
* // User.getFields() → id, email, phone, role (merged)
|
|
165
|
+
* }
|
|
166
|
+
*/
|
|
167
|
+
static getFields() {
|
|
168
|
+
if (Object.prototype.hasOwnProperty.call(this, '_cachedFields')) return this._cachedFields;
|
|
169
|
+
|
|
170
|
+
const chain = [];
|
|
171
|
+
const myTable = this.table || this.name;
|
|
172
|
+
let cur = this;
|
|
173
|
+
|
|
174
|
+
while (cur && cur !== Function.prototype) {
|
|
175
|
+
if (Object.prototype.hasOwnProperty.call(cur, 'fields')) {
|
|
176
|
+
chain.unshift(cur.fields); // ancestor first → child wins in Object.assign
|
|
177
|
+
}
|
|
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
|
+
if (cur !== this && !cur.abstract && curTable !== myTable) break;
|
|
182
|
+
cur = Object.getPrototypeOf(cur);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const merged = Object.assign({}, ...chain);
|
|
186
|
+
Object.defineProperty(this, '_cachedFields', {
|
|
187
|
+
value: merged, writable: true, configurable: true, enumerable: false,
|
|
188
|
+
});
|
|
189
|
+
return merged;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Clear the fields cache — call if fields are modified at runtime. */
|
|
193
|
+
static _clearFieldCache() { delete this._cachedFields; }
|
|
194
|
+
|
|
134
195
|
/** Define named scopes: static scopes = { published: qb => qb.where('published', true) } */
|
|
135
196
|
static scopes = {};
|
|
136
197
|
|
|
@@ -340,7 +401,7 @@ class Model {
|
|
|
340
401
|
* Post.defer('body', 'metadata').get()
|
|
341
402
|
*/
|
|
342
403
|
static defer(...columns) {
|
|
343
|
-
const all = Object.keys(this.
|
|
404
|
+
const all = Object.keys(this.getFields());
|
|
344
405
|
const exclude = new Set(columns);
|
|
345
406
|
const keep = all.filter(c => !exclude.has(c));
|
|
346
407
|
return new QueryBuilder(this._db(), this).select(...keep.map(c => `${this.table}.${c}`));
|
|
@@ -495,7 +556,7 @@ class Model {
|
|
|
495
556
|
// Start with explicitly declared relations
|
|
496
557
|
const merged = { ...(this.relations || {}) };
|
|
497
558
|
|
|
498
|
-
for (const [fieldName, fieldDef] of Object.entries(this.
|
|
559
|
+
for (const [fieldName, fieldDef] of Object.entries(this.getFields())) {
|
|
499
560
|
|
|
500
561
|
// ── ForeignKey / OneToOne ────────────────────────────────────────────
|
|
501
562
|
if (fieldDef._isForeignKey) {
|
|
@@ -519,11 +580,14 @@ class Model {
|
|
|
519
580
|
return M;
|
|
520
581
|
};
|
|
521
582
|
|
|
583
|
+
// Django convention: declared as 'landlord' → DB column 'landlord_id'.
|
|
584
|
+
// If already ends with _id (e.g. declared as 'landlord_id'), use as-is.
|
|
585
|
+
const colName = fieldName.endsWith('_id') ? fieldName : fieldName + '_id';
|
|
586
|
+
|
|
522
587
|
if (fieldDef._isOneToOne) {
|
|
523
|
-
|
|
524
|
-
merged[accessorName] = new BelongsTo(resolveModel, fieldName.endsWith('_id') ? fieldName : fieldName + '_id', toField);
|
|
588
|
+
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
525
589
|
} else {
|
|
526
|
-
merged[accessorName] = new BelongsTo(resolveModel,
|
|
590
|
+
merged[accessorName] = new BelongsTo(resolveModel, colName, toField);
|
|
527
591
|
}
|
|
528
592
|
}
|
|
529
593
|
}
|
|
@@ -694,7 +758,7 @@ class Model {
|
|
|
694
758
|
|
|
695
759
|
static _applyDefaults(data) {
|
|
696
760
|
const result = { ...data };
|
|
697
|
-
for (const [key, field] of Object.entries(this.
|
|
761
|
+
for (const [key, field] of Object.entries(this.getFields())) {
|
|
698
762
|
if (!(key in result) && field.default !== undefined) {
|
|
699
763
|
result[key] = typeof field.default === 'function'
|
|
700
764
|
? field.default()
|
|
@@ -705,11 +769,15 @@ class Model {
|
|
|
705
769
|
}
|
|
706
770
|
|
|
707
771
|
static _defaultTable() {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
772
|
+
// Convert PascalCase class name to snake_case plural table name.
|
|
773
|
+
// BlogPost → blog_posts, Category → categories, User → users.
|
|
774
|
+
const snake = this.name
|
|
775
|
+
.replace(/([A-Z])/g, (m, c, i) => (i ? '_' : '') + c.toLowerCase())
|
|
776
|
+
.replace(/^_/, '');
|
|
777
|
+
if (snake.endsWith('y') && !['ay','ey','iy','oy','uy'].some(s => snake.endsWith(s)))
|
|
778
|
+
return snake.slice(0, -1) + 'ies';
|
|
779
|
+
if (/(?:s|sh|ch|x|z)$/.test(snake)) return snake + 'es';
|
|
780
|
+
return snake + 's';
|
|
713
781
|
}
|
|
714
782
|
|
|
715
783
|
_getDirty() {
|
|
@@ -723,4 +791,4 @@ class Model {
|
|
|
723
791
|
}
|
|
724
792
|
}
|
|
725
793
|
|
|
726
|
-
module.exports = Model;
|
|
794
|
+
module.exports = Model;
|