millas 0.2.12-beta → 0.2.12-beta-2
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 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- 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 +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -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 +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -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 +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -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/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- 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 +412 -344
- 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/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { ProjectState, normaliseField } = require('./ProjectState');
|
|
6
|
+
const { walkJs, extractClasses, isMillasModel, resolveTable } = require('./utils');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ModelScanner
|
|
10
|
+
*
|
|
11
|
+
* Scans model files from disk and builds a ProjectState representing
|
|
12
|
+
* the CURRENT state of the schema as defined in source code.
|
|
13
|
+
*
|
|
14
|
+
* This is the "current state" that makemigrations diffs against the
|
|
15
|
+
* "reconstructed state" from migration files.
|
|
16
|
+
*
|
|
17
|
+
* ── Model loading convention ──────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* Models are loaded from a single entry point — app/models/index.js —
|
|
20
|
+
* which must export all models as named exports:
|
|
21
|
+
*
|
|
22
|
+
* // app/models/index.js
|
|
23
|
+
* module.exports = { User, Post, Comment, Order };
|
|
24
|
+
*
|
|
25
|
+
* This mirrors Django's pattern where each app's models/__init__.py
|
|
26
|
+
* explicitly imports and exposes every model class. The developer
|
|
27
|
+
* decides what is registered — ModelScanner never auto-discovers files.
|
|
28
|
+
*
|
|
29
|
+
* Sub-folders are supported as long as index.js re-exports everything:
|
|
30
|
+
*
|
|
31
|
+
* // app/models/index.js
|
|
32
|
+
* const { User } = require('./auth/User');
|
|
33
|
+
* const { Post } = require('./content/Post');
|
|
34
|
+
* module.exports = { User, Post };
|
|
35
|
+
*
|
|
36
|
+
* ── Inheritance handling ──────────────────────────────────────────────────────
|
|
37
|
+
*
|
|
38
|
+
* Abstract Base Class (static abstract = true):
|
|
39
|
+
* - No table created
|
|
40
|
+
* - Fields merged into every concrete subclass
|
|
41
|
+
*
|
|
42
|
+
* Multi-table Inheritance (different static table from parent):
|
|
43
|
+
* - Parent gets its own table (already in migration history)
|
|
44
|
+
* - Child gets its own table
|
|
45
|
+
* - A OneToOne FK from child → parent is auto-injected
|
|
46
|
+
*
|
|
47
|
+
* Same-table inheritance (child overrides fields, same static table):
|
|
48
|
+
* - Treated as one model — most-derived fields win
|
|
49
|
+
*
|
|
50
|
+
* Proxy model (static proxy = true):
|
|
51
|
+
* - No table created, no migration generated
|
|
52
|
+
*/
|
|
53
|
+
class ModelScanner {
|
|
54
|
+
constructor(modelsPath) {
|
|
55
|
+
this._modelsPath = modelsPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scan all model files and return a ProjectState.
|
|
60
|
+
* Never touches the database.
|
|
61
|
+
*/
|
|
62
|
+
scan() {
|
|
63
|
+
const state = new ProjectState();
|
|
64
|
+
const classes = this._loadAllClasses();
|
|
65
|
+
|
|
66
|
+
// Two passes: first register concrete tables, then detect relationships
|
|
67
|
+
const tableToClass = new Map(); // table → most-derived class
|
|
68
|
+
|
|
69
|
+
for (const cls of classes) {
|
|
70
|
+
// Skip abstract and proxy models — no table
|
|
71
|
+
// Only skip if the class itself declares abstract/proxy (not inherited)
|
|
72
|
+
if (cls.hasOwnProperty('abstract') && cls.abstract) continue;
|
|
73
|
+
if (cls.hasOwnProperty('proxy') && cls.proxy) continue;
|
|
74
|
+
|
|
75
|
+
const table = resolveTable(cls);
|
|
76
|
+
if (!table) continue;
|
|
77
|
+
|
|
78
|
+
// Keep the most-derived class for each table
|
|
79
|
+
const existing = tableToClass.get(table);
|
|
80
|
+
if (!existing || this._fieldCount(cls) >= this._fieldCount(existing)) {
|
|
81
|
+
tableToClass.set(table, cls);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build state from the canonical (most-derived) class per table
|
|
86
|
+
for (const [table, cls] of tableToClass) {
|
|
87
|
+
const fields = this._resolveFields(cls, classes, tableToClass);
|
|
88
|
+
state.createModel(table, fields);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load all model classes from app/models/index.js.
|
|
98
|
+
*
|
|
99
|
+
* The index must export all models as named (or default) exports.
|
|
100
|
+
* ModelScanner never auto-discovers individual files — the developer
|
|
101
|
+
* controls what is registered, exactly like Django's models/__init__.py.
|
|
102
|
+
*
|
|
103
|
+
* Falls back to scanning individual .js files in the models directory
|
|
104
|
+
* only if index.js does not exist, so existing projects without an
|
|
105
|
+
* index.js keep working. A warning is printed to encourage migration.
|
|
106
|
+
*/
|
|
107
|
+
_loadAllClasses() {
|
|
108
|
+
if (!fs.existsSync(this._modelsPath)) return [];
|
|
109
|
+
|
|
110
|
+
const indexPath = path.join(this._modelsPath, 'index.js');
|
|
111
|
+
|
|
112
|
+
// ── Primary path: load from index.js ────────────────────────────────
|
|
113
|
+
if (fs.existsSync(indexPath)) {
|
|
114
|
+
try { delete require.cache[require.resolve(indexPath)]; } catch {}
|
|
115
|
+
let exported;
|
|
116
|
+
try {
|
|
117
|
+
exported = require(indexPath);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
` ✖ [makemigrations] Failed to load app/models/index.js: ${err.message}\n`
|
|
121
|
+
);
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const classes = [];
|
|
125
|
+
const candidates = extractClasses(exported);
|
|
126
|
+
for (const cls of candidates) {
|
|
127
|
+
if (isMillasModel(cls)) classes.push(cls);
|
|
128
|
+
}
|
|
129
|
+
return classes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Fallback: walk individual files (legacy — no index.js yet) ───────
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
` ⚠ [makemigrations] No app/models/index.js found. ` +
|
|
135
|
+
`Create one that exports all your models to follow the recommended pattern.\n` +
|
|
136
|
+
` Falling back to file scanning (slower, may miss models in subfolders).\n`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const classes = [];
|
|
140
|
+
const files = walkJs(this._modelsPath);
|
|
141
|
+
|
|
142
|
+
for (const fullPath of files) {
|
|
143
|
+
try { delete require.cache[require.resolve(fullPath)]; } catch {}
|
|
144
|
+
let exported;
|
|
145
|
+
try {
|
|
146
|
+
exported = require(fullPath);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
process.stderr.write(
|
|
149
|
+
` ⚠ [makemigrations] Could not load ${path.relative(this._modelsPath, fullPath)}: ${err.message}\n`
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const candidates = extractClasses(exported);
|
|
154
|
+
for (const cls of candidates) {
|
|
155
|
+
if (isMillasModel(cls)) classes.push(cls);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return classes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
_resolveFields(cls, allClasses, tableToClass) {
|
|
165
|
+
const fields = {};
|
|
166
|
+
|
|
167
|
+
// Walk the prototype chain collecting fields, child overrides parent
|
|
168
|
+
const chain = this._inheritanceChain(cls);
|
|
169
|
+
|
|
170
|
+
for (const ancestor of chain.reverse()) {
|
|
171
|
+
// Skip ancestors that have their own table (multi-table inheritance)
|
|
172
|
+
// unless they are the starting class itself
|
|
173
|
+
if (ancestor !== cls) {
|
|
174
|
+
const ancestorTable = resolveTable(ancestor);
|
|
175
|
+
if (ancestorTable && ancestorTable !== resolveTable(cls)) {
|
|
176
|
+
// Multi-table: inject OneToOne FK instead of merging fields
|
|
177
|
+
// The FK column name is `${ancestorTable.replace(/s$/, '')}_ptr`
|
|
178
|
+
const ptrCol = `${ancestorTable.replace(/s$/, '')}_ptr`;
|
|
179
|
+
fields[ptrCol] = normaliseField({
|
|
180
|
+
type: 'integer',
|
|
181
|
+
unsigned: true,
|
|
182
|
+
nullable: false,
|
|
183
|
+
unique: true,
|
|
184
|
+
references: { table: ancestorTable, column: 'id', onDelete: 'CASCADE' },
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Merge fields from this ancestor (skip abstract flag, just take fields)
|
|
191
|
+
if (ancestor.fields && typeof ancestor.fields === 'object') {
|
|
192
|
+
for (const [name, def] of Object.entries(ancestor.fields)) {
|
|
193
|
+
if (def && typeof def === 'object' && def.type === 'm2m') continue; // skip M2M
|
|
194
|
+
// null = explicit removal, like Django's title = None — remove from merged set
|
|
195
|
+
if (def === null) { delete fields[name]; continue; }
|
|
196
|
+
// Django convention: ForeignKey declared as 'landlord' creates column 'landlord_id'.
|
|
197
|
+
// If already ends with _id, use as-is to avoid double-appending.
|
|
198
|
+
const colName = (def && def._isForeignKey && !name.endsWith('_id'))
|
|
199
|
+
? name + '_id'
|
|
200
|
+
: name;
|
|
201
|
+
fields[colName] = normaliseField(def);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return fields;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_inheritanceChain(cls) {
|
|
210
|
+
const chain = [];
|
|
211
|
+
let current = cls;
|
|
212
|
+
while (current && current.fields !== undefined) {
|
|
213
|
+
chain.push(current);
|
|
214
|
+
current = Object.getPrototypeOf(current);
|
|
215
|
+
}
|
|
216
|
+
return chain; // child first, ancestors last
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
_fieldCount(cls) {
|
|
220
|
+
return cls.fields ? Object.keys(cls.fields).length : 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = ModelScanner;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { tableFromClass, modelNameToTable, isSnakeCase } = require('./utils');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ProjectState
|
|
7
|
+
*
|
|
8
|
+
* An in-memory representation of the full database schema at a given point
|
|
9
|
+
* in migration history. Built by replaying migration operations in order.
|
|
10
|
+
*
|
|
11
|
+
* This is the Django-equivalent of ProjectState / ModelState.
|
|
12
|
+
* It is NEVER derived from the live database — only from migration files.
|
|
13
|
+
*
|
|
14
|
+
* Shape:
|
|
15
|
+
* state.models = Map<tableName, ModelState>
|
|
16
|
+
*
|
|
17
|
+
* ModelState:
|
|
18
|
+
* { table, fields: Map<columnName, FieldState>, meta: {} }
|
|
19
|
+
*
|
|
20
|
+
* FieldState (plain object, serialisable):
|
|
21
|
+
* { type, nullable, unique, default, max, unsigned, enumValues,
|
|
22
|
+
* references, precision, scale }
|
|
23
|
+
*/
|
|
24
|
+
class ProjectState {
|
|
25
|
+
constructor() {
|
|
26
|
+
// Map<table, { table, fields: Map<name, fieldState> }>
|
|
27
|
+
this.models = new Map();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Mutation (called by operations during replay) ────────────────────────
|
|
31
|
+
|
|
32
|
+
createModel(table, fields) {
|
|
33
|
+
if (this.models.has(table)) {
|
|
34
|
+
throw new Error(`ProjectState: table "${table}" already exists`);
|
|
35
|
+
}
|
|
36
|
+
const fieldMap = new Map();
|
|
37
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
38
|
+
fieldMap.set(name, normaliseField(def));
|
|
39
|
+
}
|
|
40
|
+
this.models.set(table, { table, fields: fieldMap });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
deleteModel(table) {
|
|
44
|
+
this.models.delete(table);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addField(table, column, fieldDef) {
|
|
48
|
+
const model = this._requireModel(table);
|
|
49
|
+
if (model.fields.has(column)) {
|
|
50
|
+
throw new Error(`ProjectState: column "${column}" already exists on "${table}"`);
|
|
51
|
+
}
|
|
52
|
+
model.fields.set(column, normaliseField(fieldDef));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
removeField(table, column) {
|
|
56
|
+
const model = this._requireModel(table);
|
|
57
|
+
model.fields.delete(column);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
alterField(table, column, fieldDef) {
|
|
61
|
+
const model = this._requireModel(table);
|
|
62
|
+
model.fields.set(column, normaliseField(fieldDef));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
renameField(table, oldColumn, newColumn) {
|
|
66
|
+
const model = this._requireModel(table);
|
|
67
|
+
const def = model.fields.get(oldColumn);
|
|
68
|
+
if (!def) throw new Error(`ProjectState: column "${oldColumn}" not found on "${table}"`);
|
|
69
|
+
model.fields.delete(oldColumn);
|
|
70
|
+
model.fields.set(newColumn, def);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
renameModel(oldTable, newTable) {
|
|
74
|
+
const model = this._requireModel(oldTable);
|
|
75
|
+
this.models.delete(oldTable);
|
|
76
|
+
model.table = newTable;
|
|
77
|
+
this.models.set(newTable, model);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Queries ──────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
hasTable(table) {
|
|
83
|
+
return this.models.has(table);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getFields(table) {
|
|
87
|
+
return this._requireModel(table).fields;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Return a plain-object snapshot of the full state (for diffing). */
|
|
91
|
+
toSchema() {
|
|
92
|
+
const schema = {};
|
|
93
|
+
for (const [table, model] of this.models) {
|
|
94
|
+
schema[table] = {};
|
|
95
|
+
for (const [col, def] of model.fields) {
|
|
96
|
+
schema[table][col] = { ...def };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return schema;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Deep clone — used to capture state at a point in time. */
|
|
103
|
+
clone() {
|
|
104
|
+
const copy = new ProjectState();
|
|
105
|
+
for (const [table, model] of this.models) {
|
|
106
|
+
const fieldMap = new Map();
|
|
107
|
+
for (const [col, def] of model.fields) {
|
|
108
|
+
fieldMap.set(col, { ...def });
|
|
109
|
+
}
|
|
110
|
+
copy.models.set(table, { table, fields: fieldMap });
|
|
111
|
+
}
|
|
112
|
+
return copy;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Internal ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
_requireModel(table) {
|
|
118
|
+
const m = this.models.get(table);
|
|
119
|
+
if (!m) throw new Error(`ProjectState: table "${table}" not found`);
|
|
120
|
+
return m;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalise a field definition (FieldDefinition instance or plain object)
|
|
126
|
+
* into a stable plain object for storage in ProjectState.
|
|
127
|
+
*
|
|
128
|
+
* Handles both:
|
|
129
|
+
* - Legacy foreignId() / raw references object → references: { table, column, onDelete }
|
|
130
|
+
* - Modern ForeignKey() / OneToOne() → _isForeignKey + _fkModel* resolved here
|
|
131
|
+
*
|
|
132
|
+
* For ForeignKey fields, the target table is resolved eagerly from _fkModel so that
|
|
133
|
+
* the migration system can diff and generate FK constraints correctly.
|
|
134
|
+
*/
|
|
135
|
+
function normaliseField(def) {
|
|
136
|
+
if (!def) return { type: 'string', nullable: false, unique: false, default: null, max: null, unsigned: false, enumValues: null, references: null, precision: null, scale: null };
|
|
137
|
+
const type = def.type ?? 'string';
|
|
138
|
+
const precision = def.precision ?? (type === 'decimal' ? 8 : null);
|
|
139
|
+
const scale = def.scale ?? (type === 'decimal' ? 2 : null);
|
|
140
|
+
|
|
141
|
+
// ── Resolve modern ForeignKey / OneToOne references ───────────────────────
|
|
142
|
+
// fields.ForeignKey() stores metadata in _isForeignKey + _fkModel* rather
|
|
143
|
+
// than the legacy `references` plain object. Resolve that here so all
|
|
144
|
+
// downstream code (Operations, SchemaBuilder, MigrationWriter) sees a
|
|
145
|
+
// uniform `references: { table, column, onDelete }` shape.
|
|
146
|
+
let references = def.references ?? null;
|
|
147
|
+
if (def._isForeignKey && !references) {
|
|
148
|
+
const targetTable = _resolveTargetTable(def._fkModel, def._fkModelRef);
|
|
149
|
+
if (targetTable) {
|
|
150
|
+
references = {
|
|
151
|
+
table: targetTable,
|
|
152
|
+
column: def._fkToField ?? 'id',
|
|
153
|
+
onDelete: def._fkOnDelete ?? 'CASCADE',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
type,
|
|
160
|
+
nullable: def.nullable ?? false,
|
|
161
|
+
unique: def.unique ?? false,
|
|
162
|
+
default: def.default !== undefined ? def.default : null,
|
|
163
|
+
max: def.max ?? null,
|
|
164
|
+
unsigned: def.unsigned ?? false,
|
|
165
|
+
enumValues: def.enumValues ?? null,
|
|
166
|
+
references,
|
|
167
|
+
precision,
|
|
168
|
+
scale,
|
|
169
|
+
// Preserve FK metadata so MigrationWriter can render fields.ForeignKey(...)
|
|
170
|
+
// instead of a bare fields.integer(...). Stripped from plain objects (migration files).
|
|
171
|
+
_isForeignKey: def._isForeignKey ?? false,
|
|
172
|
+
_isOneToOne: def._isOneToOne ?? false,
|
|
173
|
+
_fkOnDelete: def._fkOnDelete ?? null,
|
|
174
|
+
_fkRelatedName: def._fkRelatedName ?? null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a _fkModel / _fkModelRef pair to a table name string.
|
|
180
|
+
*
|
|
181
|
+
* _fkModel may be:
|
|
182
|
+
* - A Model class (has static .table)
|
|
183
|
+
* - A string model name like 'User' or 'self'
|
|
184
|
+
* - null / undefined
|
|
185
|
+
*
|
|
186
|
+
* _fkModelRef is a lazy () => ModelClass resolver generated by _makeModelRef().
|
|
187
|
+
*/
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a _fkModel / _fkModelRef pair to a table name string.
|
|
190
|
+
* Delegates to tableFromClass / modelNameToTable from utils.js.
|
|
191
|
+
*/
|
|
192
|
+
function _resolveTargetTable(fkModel, fkModelRef) {
|
|
193
|
+
if (fkModel && typeof fkModel === 'function') {
|
|
194
|
+
const table = tableFromClass(fkModel);
|
|
195
|
+
if (table) return table;
|
|
196
|
+
}
|
|
197
|
+
if (typeof fkModelRef === 'function') {
|
|
198
|
+
try {
|
|
199
|
+
const resolved = fkModelRef();
|
|
200
|
+
if (resolved) {
|
|
201
|
+
const table = tableFromClass(resolved);
|
|
202
|
+
if (table) return table;
|
|
203
|
+
}
|
|
204
|
+
} catch { /* unresolvable at scan time */ }
|
|
205
|
+
}
|
|
206
|
+
if (typeof fkModel === 'string' && fkModel !== 'self') {
|
|
207
|
+
return isSnakeCase(fkModel) ? fkModel : modelNameToTable(fkModel);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
module.exports = { ProjectState, normaliseField };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RenameDetector
|
|
7
|
+
*
|
|
8
|
+
* Detects likely field renames during makemigrations and prompts the developer
|
|
9
|
+
* to confirm, exactly as Django does:
|
|
10
|
+
*
|
|
11
|
+
* Was student.age4 renamed to student.age7 (a IntegerField)? [y/N]
|
|
12
|
+
*
|
|
13
|
+
* ── Algorithm ─────────────────────────────────────────────────────────────────
|
|
14
|
+
*
|
|
15
|
+
* For each table with both RemoveField and AddField ops:
|
|
16
|
+
* 1. Find pairs where types match
|
|
17
|
+
* 2. Score similarity (type match required; attribute similarity is a bonus)
|
|
18
|
+
* 3. Prompt for each candidate, highest-score first
|
|
19
|
+
* 4. On confirm → replace the Remove+Add pair with a single RenameField
|
|
20
|
+
* 5. On deny → keep Remove + Add as separate ops
|
|
21
|
+
*
|
|
22
|
+
* Multiple renames in a single run: each candidate is prompted independently.
|
|
23
|
+
* Chained renames across migrations: handled automatically because the history
|
|
24
|
+
* state is replayed from all existing migrations, so the "old name" is always
|
|
25
|
+
* whatever the field is currently called in the DB.
|
|
26
|
+
*
|
|
27
|
+
* ── Matching rules ────────────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Required: same table, same type
|
|
30
|
+
* Bonus: same nullable, same default, same max/precision/enumValues
|
|
31
|
+
* No match: different types (an integer cannot rename to a string)
|
|
32
|
+
*
|
|
33
|
+
* ── Non-interactive mode ──────────────────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* Never prompts. All Remove+Add pairs are kept as-is.
|
|
36
|
+
* This matches Django's --no-input behaviour.
|
|
37
|
+
*/
|
|
38
|
+
class RenameDetector {
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this._nonInteractive = options.nonInteractive || !process.stdin.isTTY;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Given a flat ops list from MigrationWriter.diff(), detect and resolve renames.
|
|
45
|
+
* Returns a new ops list with confirmed renames replaced by RenameField ops.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<object>} ops
|
|
48
|
+
* @returns {Promise<Array<object>>}
|
|
49
|
+
*/
|
|
50
|
+
async detect(ops) {
|
|
51
|
+
if (this._nonInteractive) return ops;
|
|
52
|
+
|
|
53
|
+
// Group RemoveField and AddField by table
|
|
54
|
+
const removes = ops.filter(op => op.type === 'RemoveField');
|
|
55
|
+
const adds = ops.filter(op => op.type === 'AddField');
|
|
56
|
+
|
|
57
|
+
if (removes.length === 0 || adds.length === 0) return ops;
|
|
58
|
+
|
|
59
|
+
// Build rename candidates: (remove, add) pairs on the same table with same type
|
|
60
|
+
const candidates = [];
|
|
61
|
+
for (const rem of removes) {
|
|
62
|
+
for (const add of adds) {
|
|
63
|
+
if (rem.table !== add.table) continue;
|
|
64
|
+
if (rem.field.type !== add.field.type) continue;
|
|
65
|
+
|
|
66
|
+
const score = this._similarity(rem.field, add.field);
|
|
67
|
+
candidates.push({ rem, add, score });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (candidates.length === 0) return ops;
|
|
72
|
+
|
|
73
|
+
// Sort by score descending — highest confidence first
|
|
74
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
75
|
+
|
|
76
|
+
// Track which ops have been consumed by a confirmed rename
|
|
77
|
+
const consumed = new Set(); // op references
|
|
78
|
+
|
|
79
|
+
for (const { rem, add } of candidates) {
|
|
80
|
+
// Skip if either op was already consumed by a previous rename
|
|
81
|
+
if (consumed.has(rem) || consumed.has(add)) continue;
|
|
82
|
+
|
|
83
|
+
const fieldTypeLabel = this._fieldTypeLabel(rem.field);
|
|
84
|
+
const confirmed = await this._ask(rem.table, rem.column, add.column, fieldTypeLabel);
|
|
85
|
+
|
|
86
|
+
if (confirmed) {
|
|
87
|
+
consumed.add(rem);
|
|
88
|
+
consumed.add(add);
|
|
89
|
+
// Mark for replacement — attach the rename info to the RemoveField op
|
|
90
|
+
rem._renameToColumn = add.column;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Rebuild ops list: replace consumed pairs with RenameField, preserve order
|
|
95
|
+
const result = [];
|
|
96
|
+
for (const op of ops) {
|
|
97
|
+
if (consumed.has(op)) {
|
|
98
|
+
if (op.type === 'RemoveField' && op._renameToColumn) {
|
|
99
|
+
// Replace Remove with RenameField
|
|
100
|
+
result.push({
|
|
101
|
+
type: 'RenameField',
|
|
102
|
+
table: op.table,
|
|
103
|
+
oldColumn: op.column,
|
|
104
|
+
newColumn: op._renameToColumn,
|
|
105
|
+
});
|
|
106
|
+
delete op._renameToColumn;
|
|
107
|
+
}
|
|
108
|
+
// Skip the AddField that was consumed — it's been folded into RenameField
|
|
109
|
+
} else {
|
|
110
|
+
result.push(op);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Internals ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Score how similar two field definitions are.
|
|
121
|
+
* Type match is a prerequisite (checked before calling this).
|
|
122
|
+
* Returns a score 0–5: higher = more likely a rename.
|
|
123
|
+
*/
|
|
124
|
+
_similarity(a, b) {
|
|
125
|
+
let score = 1; // base: types match
|
|
126
|
+
if (a.nullable === b.nullable) score++;
|
|
127
|
+
if (JSON.stringify(a.default) === JSON.stringify(b.default)) score++;
|
|
128
|
+
if (a.max === b.max) score++;
|
|
129
|
+
if (JSON.stringify(a.enumValues) === JSON.stringify(b.enumValues)) score++;
|
|
130
|
+
if (a.unique === b.unique) score++;
|
|
131
|
+
return score;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Human-readable field type label — matches Django's "a IntegerField" style.
|
|
136
|
+
*/
|
|
137
|
+
_fieldTypeLabel(field) {
|
|
138
|
+
const map = {
|
|
139
|
+
id: 'AutoField',
|
|
140
|
+
string: 'CharField',
|
|
141
|
+
text: 'TextField',
|
|
142
|
+
integer: 'IntegerField',
|
|
143
|
+
bigInteger: 'BigIntegerField',
|
|
144
|
+
float: 'FloatField',
|
|
145
|
+
decimal: 'DecimalField',
|
|
146
|
+
boolean: 'BooleanField',
|
|
147
|
+
json: 'JSONField',
|
|
148
|
+
date: 'DateField',
|
|
149
|
+
timestamp: 'DateTimeField',
|
|
150
|
+
enum: 'CharField',
|
|
151
|
+
uuid: 'UUIDField',
|
|
152
|
+
};
|
|
153
|
+
return map[field.type] || `${field.type}Field`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Prompt: Was <table>.<oldCol> renamed to <table>.<newCol> (a <Type>)? [y/N]
|
|
158
|
+
* Returns true if confirmed.
|
|
159
|
+
*/
|
|
160
|
+
async _ask(table, oldCol, newCol, typeLabel) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const question = `Was ${table}.${oldCol} renamed to ${table}.${newCol} (a ${typeLabel})? [y/N] `;
|
|
163
|
+
const rl = readline.createInterface({
|
|
164
|
+
input: process.stdin,
|
|
165
|
+
output: process.stdout,
|
|
166
|
+
});
|
|
167
|
+
rl.question(question, (answer) => {
|
|
168
|
+
rl.close();
|
|
169
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = RenameDetector;
|