millas 0.2.19 → 0.2.21
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/admin/QueryEngine.js +17 -13
- package/src/cli.js +3 -0
- package/src/commands/migrate.js +34 -2
- package/src/container/AppInitializer.js +43 -0
- package/src/core/db.js +9 -8
- package/src/orm/drivers/DatabaseManager.js +12 -0
- package/src/orm/fields/index.js +18 -11
- package/src/orm/migration/Makemigrations.js +34 -29
- package/src/orm/migration/MigrationWriter.js +117 -13
- package/src/orm/migration/ModelInspector.js +4 -0
- package/src/orm/migration/ModelScanner.js +12 -6
- package/src/orm/migration/ProjectState.js +41 -5
- package/src/orm/migration/operations/column.js +45 -95
- package/src/orm/migration/operations/fields.js +6 -6
- package/src/orm/migration/operations/index.js +7 -24
- package/src/orm/migration/operations/indexes.js +197 -0
- package/src/orm/migration/operations/models.js +35 -9
- package/src/orm/migration/operations/registry.js +24 -3
- package/src/orm/model/Model.js +315 -72
- package/src/orm/query/F.js +98 -0
- package/src/orm/query/LookupParser.js +316 -157
- package/src/orm/query/QueryBuilder.js +178 -8
- package/src/providers/DatabaseServiceProvider.js +2 -2
package/package.json
CHANGED
package/src/admin/QueryEngine.js
CHANGED
|
@@ -145,7 +145,7 @@ class QueryEngine {
|
|
|
145
145
|
// ── Execute data + count in parallel ──────────────────────────────────
|
|
146
146
|
const [rows, countResult] = await Promise.all([
|
|
147
147
|
q.clone().limit(limit).offset(offset),
|
|
148
|
-
q.clone().count('* as count').first(),
|
|
148
|
+
q.clone().clearOrder().clearSelect().count('* as count').first(),
|
|
149
149
|
]);
|
|
150
150
|
|
|
151
151
|
const total = Number(countResult?.count ?? 0);
|
|
@@ -231,12 +231,12 @@ class QueryEngine {
|
|
|
231
231
|
case 'in': return q.whereIn(col, Array.isArray(value) ? value : [value]);
|
|
232
232
|
case 'notin': return q.whereNotIn(col, Array.isArray(value) ? value : [value]);
|
|
233
233
|
case 'between': return q.whereBetween(col, Array.isArray(value) ? value : [value, value]);
|
|
234
|
-
case 'contains':
|
|
235
|
-
case 'icontains': return q.where(col, 'like', `%${value}%`);
|
|
236
|
-
case 'startswith':
|
|
237
|
-
case 'istartswith': return q.where(col, 'like', `${value}%`);
|
|
238
|
-
case 'endswith':
|
|
239
|
-
case 'iendswith': return q.where(col, 'like', `%${value}`);
|
|
234
|
+
case 'contains': return q.where(col, 'like', `%${value}%`);
|
|
235
|
+
case 'icontains': return q.where(q.client?.config?.client?.includes('pg') ? col : col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `%${value}%`);
|
|
236
|
+
case 'startswith': return q.where(col, 'like', `${value}%`);
|
|
237
|
+
case 'istartswith': return q.where(col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `${value}%`);
|
|
238
|
+
case 'endswith': return q.where(col, 'like', `%${value}`);
|
|
239
|
+
case 'iendswith': return q.where(col, q.client?.config?.client?.includes('pg') ? 'ilike' : 'like', `%${value}`);
|
|
240
240
|
default: return q.where(key, value);
|
|
241
241
|
}
|
|
242
242
|
}
|
|
@@ -246,15 +246,19 @@ class QueryEngine {
|
|
|
246
246
|
* Uses strftime for SQLite/MySQL; falls back gracefully on PostgreSQL.
|
|
247
247
|
*/
|
|
248
248
|
_applyDateHierarchy(q, col, year, month) {
|
|
249
|
+
const client = q.client?.config?.client || 'sqlite3';
|
|
250
|
+
const isPg = client.includes('pg') || client.includes('postgres');
|
|
251
|
+
const isMy = client.includes('mysql') || client.includes('maria');
|
|
252
|
+
|
|
249
253
|
if (year) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
if (isPg) q = q.whereRaw(`EXTRACT(YEAR FROM "${col}") = ?`, [Number(year)]);
|
|
255
|
+
else if (isMy) q = q.whereRaw(`YEAR(\`${col}\`) = ?`, [Number(year)]);
|
|
256
|
+
else q = q.whereRaw(`strftime('%Y', \`${col}\`) = ?`, [String(year)]);
|
|
253
257
|
}
|
|
254
258
|
if (month) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
259
|
+
if (isPg) q = q.whereRaw(`EXTRACT(MONTH FROM "${col}") = ?`, [Number(month)]);
|
|
260
|
+
else if (isMy) q = q.whereRaw(`MONTH(\`${col}\`) = ?`, [Number(month)]);
|
|
261
|
+
else q = q.whereRaw(`strftime('%m', \`${col}\`) = ?`, [String(month).padStart(2, '0')]);
|
|
258
262
|
}
|
|
259
263
|
return q;
|
|
260
264
|
}
|
package/src/cli.js
CHANGED
package/src/commands/migrate.js
CHANGED
|
@@ -34,8 +34,22 @@ module.exports = function (program) {
|
|
|
34
34
|
// Match Django's +/-/~ prefix style exactly
|
|
35
35
|
let prefix, label;
|
|
36
36
|
switch (op.type) {
|
|
37
|
-
case 'CreateModel':
|
|
38
|
-
|
|
37
|
+
case 'CreateModel': {
|
|
38
|
+
const idxLines = [];
|
|
39
|
+
for (const idx of (op.indexes || [])) {
|
|
40
|
+
const l = idx.name
|
|
41
|
+
? `${idx.name} on ${op.table}`
|
|
42
|
+
: `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
|
|
43
|
+
idxLines.push(` ${chalk.green('+')} Create index ${l}`);
|
|
44
|
+
}
|
|
45
|
+
for (const ut of (op.uniqueTogether || [])) {
|
|
46
|
+
idxLines.push(` ${chalk.green('+')} Create constraint on ${op.table} (${ut.join(', ')})`);
|
|
47
|
+
}
|
|
48
|
+
prefix = chalk.green('+'); label = `Create model ${op.table}`;
|
|
49
|
+
console.log(chalk.gray(` ${prefix} ${label}`));
|
|
50
|
+
idxLines.forEach(l => console.log(chalk.gray(l)));
|
|
51
|
+
return; // return from forEach callback
|
|
52
|
+
}
|
|
39
53
|
case 'DeleteModel':
|
|
40
54
|
prefix = chalk.red('-'); label = `Delete model ${op.table}`; break;
|
|
41
55
|
case 'AddField':
|
|
@@ -48,6 +62,24 @@ module.exports = function (program) {
|
|
|
48
62
|
prefix = chalk.yellow('~'); label = `Rename field ${op.oldColumn} on ${op.table} to ${op.newColumn}`; break;
|
|
49
63
|
case 'RenameModel':
|
|
50
64
|
prefix = chalk.yellow('~'); label = `Rename model ${op.oldTable} to ${op.newTable}`; break;
|
|
65
|
+
case 'AddIndex': {
|
|
66
|
+
const idx = op.index;
|
|
67
|
+
const idxLabel = idx.name
|
|
68
|
+
? `${idx.name} on ${op.table}`
|
|
69
|
+
: `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
|
|
70
|
+
prefix = chalk.green('+'); label = `Create index ${idxLabel}`; break;
|
|
71
|
+
}
|
|
72
|
+
case 'RemoveIndex': {
|
|
73
|
+
const idx = op.index;
|
|
74
|
+
const idxLabel = idx.name
|
|
75
|
+
? `${idx.name} from ${op.table}`
|
|
76
|
+
: `on field(s) ${idx.fields.join(', ')} of model ${op.table}`;
|
|
77
|
+
prefix = chalk.red('-'); label = `Remove index ${idxLabel}`; break;
|
|
78
|
+
}
|
|
79
|
+
case 'RenameIndex':
|
|
80
|
+
prefix = chalk.yellow('~'); label = `Rename index ${op.oldName} on ${op.table} to ${op.newName}`; break;
|
|
81
|
+
case 'AlterUniqueTogether':
|
|
82
|
+
prefix = chalk.yellow('~'); label = `Alter unique_together on ${op.table}`; break;
|
|
51
83
|
default:
|
|
52
84
|
prefix = chalk.gray(' '); label = op.type;
|
|
53
85
|
}
|
|
@@ -107,6 +107,13 @@ class AppInitializer {
|
|
|
107
107
|
|
|
108
108
|
this._kernel._container.instance('basePath', basePath);
|
|
109
109
|
|
|
110
|
+
// ── Static file serving ───────────────────────────────────────────────
|
|
111
|
+
// Auto-serve each storage disk that has a baseUrl configured.
|
|
112
|
+
// Mirrors Laravel's public disk serving — zero config required.
|
|
113
|
+
// e.g. LocalDriver root=storage/uploads baseUrl=/storage
|
|
114
|
+
// → GET /storage/avatars/photo.jpg serves the file directly.
|
|
115
|
+
this._serveStorageDisks(expressApp, basePath);
|
|
116
|
+
|
|
110
117
|
const coreProviders = this._buildCoreProviders(cfg);
|
|
111
118
|
this._kernel.providers([...coreProviders, ...cfg.providers]);
|
|
112
119
|
|
|
@@ -175,6 +182,42 @@ class AppInitializer {
|
|
|
175
182
|
|
|
176
183
|
// ── Internal ───────────────────────────────────────────────────────────────
|
|
177
184
|
|
|
185
|
+
_serveStorageDisks(expressApp, basePath) {
|
|
186
|
+
const express = require('express');
|
|
187
|
+
const path = require('path');
|
|
188
|
+
const fs = require('fs');
|
|
189
|
+
|
|
190
|
+
let storageConfig;
|
|
191
|
+
try {
|
|
192
|
+
storageConfig = require(basePath + '/config/storage');
|
|
193
|
+
} catch {
|
|
194
|
+
// Fall back to the same defaults StorageServiceProvider uses
|
|
195
|
+
storageConfig = {
|
|
196
|
+
default: 'local',
|
|
197
|
+
disks: {
|
|
198
|
+
local: { driver: 'local', root: 'storage/uploads', baseUrl: '/storage' },
|
|
199
|
+
public: { driver: 'local', root: 'public/storage', baseUrl: '/storage' },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const seen = new Set(); // avoid mounting the same baseUrl twice
|
|
205
|
+
for (const [, disk] of Object.entries(storageConfig.disks || {})) {
|
|
206
|
+
if (!disk.baseUrl || !disk.root) continue;
|
|
207
|
+
if (seen.has(disk.baseUrl)) continue;
|
|
208
|
+
seen.add(disk.baseUrl);
|
|
209
|
+
|
|
210
|
+
const absRoot = path.isAbsolute(disk.root)
|
|
211
|
+
? disk.root
|
|
212
|
+
: path.join(basePath, disk.root);
|
|
213
|
+
|
|
214
|
+
// Ensure the directory exists so express.static doesn't warn
|
|
215
|
+
fs.mkdirSync(absRoot, { recursive: true });
|
|
216
|
+
|
|
217
|
+
expressApp.use(disk.baseUrl, express.static(absRoot));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
178
221
|
_buildCoreProviders(cfg) {
|
|
179
222
|
const providers = [];
|
|
180
223
|
const load = (p) => {
|
package/src/core/db.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
const {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
1
|
+
const { Model, fields } = require('../orm');
|
|
2
|
+
const { migrations } = require('../orm/migration/operations');
|
|
3
|
+
const F = require('../orm/query/F');
|
|
4
|
+
const Q = require('../orm/query/Q');
|
|
5
|
+
const HasMany = require('../orm/relations/HasMany');
|
|
6
|
+
const BelongsTo = require('../orm/relations/BelongsTo');
|
|
7
|
+
const HasOne = require('../orm/relations/HasOne');
|
|
8
|
+
const BelongsToMany = require('../orm/relations/BelongsToMany');
|
|
6
9
|
|
|
7
|
-
module.exports = {
|
|
8
|
-
Model, fields,migrations
|
|
9
|
-
}
|
|
10
|
+
module.exports = { Model, fields, migrations, F, Q, HasMany, BelongsTo, HasOne, BelongsToMany };
|
|
@@ -99,6 +99,12 @@ class DatabaseManager {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
case 'mysql':
|
|
102
|
+
try { require('mysql2'); } catch {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'MySQL driver not installed.\n' +
|
|
105
|
+
'Run: npm install mysql2'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
102
108
|
return knex({
|
|
103
109
|
client: 'mysql2',
|
|
104
110
|
connection: {
|
|
@@ -112,6 +118,12 @@ class DatabaseManager {
|
|
|
112
118
|
});
|
|
113
119
|
|
|
114
120
|
case 'postgres':
|
|
121
|
+
try { require('pg'); } catch {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'PostgreSQL driver not installed.\n' +
|
|
124
|
+
'Run: npm install pg'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
115
127
|
return knex({
|
|
116
128
|
client: 'pg',
|
|
117
129
|
connection: {
|
package/src/orm/fields/index.js
CHANGED
|
@@ -54,19 +54,10 @@ function _makeModelRef(model) {
|
|
|
54
54
|
if (model === 'self') return null;
|
|
55
55
|
return () => {
|
|
56
56
|
const path = require('path');
|
|
57
|
-
const modelsDir = path.join(process.cwd(), 'app', 'models');
|
|
58
57
|
try {
|
|
59
|
-
|
|
58
|
+
const all = require(path.join(process.cwd(), 'app', 'models', 'index.js'));
|
|
59
|
+
return all[model] ?? null;
|
|
60
60
|
} catch {
|
|
61
|
-
try {
|
|
62
|
-
const fs = require('fs');
|
|
63
|
-
const files = fs.readdirSync(modelsDir);
|
|
64
|
-
const match = files.find(f =>
|
|
65
|
-
f.replace(/\.js$/, '').toLowerCase() === model.toLowerCase()
|
|
66
|
-
);
|
|
67
|
-
if (match) return require(path.join(modelsDir, match));
|
|
68
|
-
} catch {
|
|
69
|
-
}
|
|
70
61
|
return null;
|
|
71
62
|
}
|
|
72
63
|
};
|
|
@@ -126,6 +117,22 @@ const fields = {
|
|
|
126
117
|
return new FieldDefinition('uuid', options);
|
|
127
118
|
},
|
|
128
119
|
|
|
120
|
+
email(options = {}) {
|
|
121
|
+
return new FieldDefinition('email', { max: 254, ...options });
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
url(options = {}) {
|
|
125
|
+
return new FieldDefinition('url', { max: 2048, ...options });
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
slug(options = {}) {
|
|
129
|
+
return new FieldDefinition('slug', { max: 255, ...options });
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
ipAddress(options = {}) {
|
|
133
|
+
return new FieldDefinition('ipAddress', { max: 45, ...options });
|
|
134
|
+
},
|
|
135
|
+
|
|
129
136
|
/**
|
|
130
137
|
* ForeignKey — Django-style.
|
|
131
138
|
*
|
|
@@ -93,7 +93,14 @@ class Makemigrations {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// Strip internal flags before writing
|
|
96
|
-
ops.forEach(op => {
|
|
96
|
+
ops.forEach(op => {
|
|
97
|
+
if (op._destructiveWarning) {
|
|
98
|
+
process.stderr.write(`\n ⚠ ${op._destructiveWarning}\n`);
|
|
99
|
+
}
|
|
100
|
+
delete op._needsDefault;
|
|
101
|
+
delete op._madeNullable;
|
|
102
|
+
delete op._destructiveWarning;
|
|
103
|
+
});
|
|
97
104
|
|
|
98
105
|
// ── Step 7: Compute file name and content ────────────────────────────────
|
|
99
106
|
// Detect initial FIRST — isInitial must be known before naming the file
|
|
@@ -245,50 +252,48 @@ class Makemigrations {
|
|
|
245
252
|
_opsToName(ops) {
|
|
246
253
|
if (ops.length === 0) return 'auto';
|
|
247
254
|
|
|
248
|
-
// For initial migrations → 'initial'
|
|
249
|
-
// For single op → descriptive name
|
|
250
|
-
// For multiple ops → Django style: table_field1_field2 (up to 3 segments)
|
|
251
|
-
|
|
252
255
|
if (ops.length === 1) {
|
|
253
256
|
const op = ops[0];
|
|
254
257
|
switch (op.type) {
|
|
255
|
-
// 0001_students (initial already handled above)
|
|
256
258
|
case 'CreateModel': return op.table;
|
|
257
|
-
// 0009_delete_course
|
|
258
259
|
case 'DeleteModel': return `delete_${op.table}`;
|
|
259
|
-
// 0004_student_age4
|
|
260
260
|
case 'AddField': return `${op.table}_${op.column}`;
|
|
261
|
-
// 0008_remove_student_age8
|
|
262
261
|
case 'RemoveField': return `remove_${op.table}_${op.column}`;
|
|
263
|
-
// 0007_alter_student_age8
|
|
264
262
|
case 'AlterField': return `alter_${op.table}_${op.column}`;
|
|
265
|
-
// 0006_rename_age7_student_age8
|
|
266
263
|
case 'RenameField': return `rename_${op.oldColumn}_${op.table}_${op.newColumn}`;
|
|
267
264
|
case 'RenameModel': return `rename_${op.oldTable}`;
|
|
265
|
+
case 'AddIndex': return `${op.table}_${(op.index.name || op.index.fields.join('_'))}_idx`;
|
|
266
|
+
case 'RemoveIndex': return `remove_${op.table}_${(op.index.name || op.index.fields.join('_'))}_idx`;
|
|
267
|
+
case 'AlterUniqueTogether': return `${op.table}_unique_together`;
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
// Multiple ops — build
|
|
272
|
-
//
|
|
273
|
-
const
|
|
271
|
+
// Multiple ops — deduplicate tables, build a compact name
|
|
272
|
+
// Group by type prefix to avoid repetition
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const parts = [];
|
|
275
|
+
for (const op of ops.slice(0, 3)) {
|
|
276
|
+
const table = op.table || op.oldTable || 'change';
|
|
277
|
+
let segment;
|
|
274
278
|
switch (op.type) {
|
|
275
|
-
case 'CreateModel':
|
|
276
|
-
case 'DeleteModel':
|
|
277
|
-
case 'AddField':
|
|
278
|
-
case 'RemoveField':
|
|
279
|
-
case 'AlterField':
|
|
280
|
-
case 'RenameField':
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
279
|
+
case 'CreateModel': segment = table; break;
|
|
280
|
+
case 'DeleteModel': segment = `delete_${table}`; break;
|
|
281
|
+
case 'AddField': segment = `${table}_${op.column}`; break;
|
|
282
|
+
case 'RemoveField': segment = `${table}_${op.column}`; break;
|
|
283
|
+
case 'AlterField': segment = `alter_${table}_${op.column}`; break;
|
|
284
|
+
case 'RenameField': segment = `rename_${op.oldColumn}_${table}_${op.newColumn}`; break;
|
|
285
|
+
case 'AddIndex': segment = `${table}_idx`; break;
|
|
286
|
+
case 'RemoveIndex': segment = `${table}_idx`; break;
|
|
287
|
+
case 'AlterUniqueTogether': segment = `${table}_unique`; break;
|
|
288
|
+
default: segment = table;
|
|
286
289
|
}
|
|
287
|
-
|
|
288
|
-
|
|
290
|
+
if (!seen.has(segment)) {
|
|
291
|
+
seen.add(segment);
|
|
292
|
+
parts.push(segment);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
289
295
|
const prefix = ops[0]?.type === 'RemoveField' ? 'remove_' : '';
|
|
290
|
-
|
|
291
|
-
return joined || 'auto';
|
|
296
|
+
return (prefix + parts.join('_')) || 'auto';
|
|
292
297
|
}
|
|
293
298
|
|
|
294
299
|
/**
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
const { fieldsEqual } = require('./utils');
|
|
39
|
+
const { indexName } = require('./operations/indexes');
|
|
39
40
|
|
|
40
41
|
const MILLAS_VERSION = (() => {
|
|
41
42
|
try { return require('../../..').version || require('../../../package.json').version; } catch {}
|
|
@@ -65,37 +66,44 @@ class MigrationWriter {
|
|
|
65
66
|
const histSch = historyState.toSchema();
|
|
66
67
|
const currSch = currentState.toSchema();
|
|
67
68
|
|
|
69
|
+
// ── helpers to get fields/indexes from new schema shape ──────────────────
|
|
70
|
+
const getFields = (s, t) => s[t]?.fields ?? s[t] ?? {};
|
|
71
|
+
const getIndexes = (s, t) => s[t]?.indexes ?? [];
|
|
72
|
+
const getUniqueTogether = (s, t) => s[t]?.uniqueTogether ?? [];
|
|
73
|
+
|
|
68
74
|
// New tables
|
|
69
75
|
const newTableOps = [];
|
|
70
76
|
for (const table of Object.keys(currSch)) {
|
|
71
77
|
if (SYSTEM_TABLES.has(table)) continue;
|
|
72
78
|
if (!histSch[table]) {
|
|
73
|
-
newTableOps.push({
|
|
79
|
+
newTableOps.push({
|
|
80
|
+
type: 'CreateModel',
|
|
81
|
+
table,
|
|
82
|
+
fields: getFields(currSch, table),
|
|
83
|
+
indexes: getIndexes(currSch, table),
|
|
84
|
+
uniqueTogether: getUniqueTogether(currSch, table),
|
|
85
|
+
});
|
|
74
86
|
}
|
|
75
87
|
}
|
|
76
|
-
|
|
77
|
-
// Topologically sort new CreateModel ops so that if Post has a FK → users,
|
|
78
|
-
// CreateModel(users) appears before CreateModel(posts) in the migration file.
|
|
79
|
-
// Cycles are detected and noted — the two-pass FK creation in Operations.js
|
|
80
|
-
// handles them safely at migrate time.
|
|
81
88
|
ops.push(...this._sortCreateModels(newTableOps));
|
|
82
89
|
|
|
83
90
|
// Dropped tables
|
|
84
91
|
for (const table of Object.keys(histSch)) {
|
|
85
92
|
if (SYSTEM_TABLES.has(table)) continue;
|
|
86
93
|
if (!currSch[table]) {
|
|
87
|
-
ops.push({ type: 'DeleteModel', table, fields: histSch
|
|
94
|
+
ops.push({ type: 'DeleteModel', table, fields: getFields(histSch, table) });
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
// Column-level changes
|
|
98
|
+
// Column-level + index-level changes
|
|
92
99
|
for (const table of Object.keys(currSch)) {
|
|
93
100
|
if (SYSTEM_TABLES.has(table)) continue;
|
|
94
101
|
if (!histSch[table]) continue;
|
|
95
102
|
|
|
96
|
-
const curr = currSch
|
|
97
|
-
const prev = histSch
|
|
103
|
+
const curr = getFields(currSch, table);
|
|
104
|
+
const prev = getFields(histSch, table);
|
|
98
105
|
|
|
106
|
+
// Added columns
|
|
99
107
|
for (const col of Object.keys(curr)) {
|
|
100
108
|
if (!prev[col]) {
|
|
101
109
|
const field = curr[col];
|
|
@@ -105,15 +113,62 @@ class MigrationWriter {
|
|
|
105
113
|
!!histSch[table];
|
|
106
114
|
ops.push({ type: 'AddField', table, column: col, field, _needsDefault: isDangerous });
|
|
107
115
|
} else if (!this._fieldsEqual(curr[col], prev[col])) {
|
|
108
|
-
|
|
116
|
+
const op = { type: 'AlterField', table, column: col, field: curr[col], previousField: prev[col] };
|
|
117
|
+
// Warn on destructive type changes — same as Django's warning
|
|
118
|
+
if (this._isDestructiveTypeChange(prev[col].type, curr[col].type)) {
|
|
119
|
+
op._destructiveWarning =
|
|
120
|
+
`Warning: changing '${table}.${col}' from ${prev[col].type} to ${curr[col].type} ` +
|
|
121
|
+
`may cause data loss. Existing data cannot be automatically converted.`;
|
|
122
|
+
}
|
|
123
|
+
ops.push(op);
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
|
127
|
+
// Removed columns
|
|
112
128
|
for (const col of Object.keys(prev)) {
|
|
113
129
|
if (!curr[col]) {
|
|
114
130
|
ops.push({ type: 'RemoveField', table, column: col, field: prev[col] });
|
|
115
131
|
}
|
|
116
132
|
}
|
|
133
|
+
|
|
134
|
+
// ── Index diffs ────────────────────────────────────────────────────────
|
|
135
|
+
const currIndexes = getIndexes(currSch, table);
|
|
136
|
+
const prevIndexes = getIndexes(histSch, table);
|
|
137
|
+
|
|
138
|
+
const prevIdxKeys = new Set(prevIndexes.map(i => JSON.stringify(i)));
|
|
139
|
+
const currIdxKeys = new Set(currIndexes.map(i => JSON.stringify(i)));
|
|
140
|
+
|
|
141
|
+
// Detect renames: same fields, different name
|
|
142
|
+
const addedIdxs = currIndexes.filter(i => !prevIdxKeys.has(JSON.stringify(i)));
|
|
143
|
+
const removedIdxs = prevIndexes.filter(i => !currIdxKeys.has(JSON.stringify(i)));
|
|
144
|
+
|
|
145
|
+
for (const added of addedIdxs) {
|
|
146
|
+
const matchingRemoved = removedIdxs.find(
|
|
147
|
+
r => JSON.stringify(r.fields) === JSON.stringify(added.fields) &&
|
|
148
|
+
r.unique === added.unique &&
|
|
149
|
+
r.name !== added.name
|
|
150
|
+
);
|
|
151
|
+
if (matchingRemoved) {
|
|
152
|
+
// It's a rename
|
|
153
|
+
const oldName = matchingRemoved.name || indexName(table, matchingRemoved.fields, matchingRemoved.unique);
|
|
154
|
+
const newName = added.name || indexName(table, added.fields, added.unique);
|
|
155
|
+
ops.push({ type: 'RenameIndex', table, oldName, newName, fields: added.fields });
|
|
156
|
+
removedIdxs.splice(removedIdxs.indexOf(matchingRemoved), 1);
|
|
157
|
+
} else {
|
|
158
|
+
ops.push({ type: 'AddIndex', table, index: added });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const idx of removedIdxs) {
|
|
162
|
+
ops.push({ type: 'RemoveIndex', table, index: idx });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── uniqueTogether diffs ───────────────────────────────────────────────
|
|
166
|
+
const currUT = getUniqueTogether(currSch, table);
|
|
167
|
+
const prevUT = getUniqueTogether(histSch, table);
|
|
168
|
+
if (JSON.stringify(currUT.map(s => [...s].sort()).sort()) !==
|
|
169
|
+
JSON.stringify(prevUT.map(s => [...s].sort()).sort())) {
|
|
170
|
+
ops.push({ type: 'AlterUniqueTogether', table, newUnique: currUT, oldUnique: prevUT });
|
|
171
|
+
}
|
|
117
172
|
}
|
|
118
173
|
|
|
119
174
|
return ops;
|
|
@@ -166,15 +221,22 @@ ${opsCode},
|
|
|
166
221
|
_renderOp(op) {
|
|
167
222
|
switch (op.type) {
|
|
168
223
|
|
|
169
|
-
case 'CreateModel':
|
|
224
|
+
case 'CreateModel': {
|
|
225
|
+
const idxCode = op.indexes?.length
|
|
226
|
+
? `,\n indexes: ${JSON.stringify(op.indexes)}`
|
|
227
|
+
: '';
|
|
228
|
+
const utCode = op.uniqueTogether?.length
|
|
229
|
+
? `,\n uniqueTogether: ${JSON.stringify(op.uniqueTogether)}`
|
|
230
|
+
: '';
|
|
170
231
|
return `migrations.CreateModel({\n` +
|
|
171
232
|
` name: '${this._modelName(op.table)}',\n` +
|
|
172
233
|
` fields: [\n` +
|
|
173
234
|
Object.entries(op.fields)
|
|
174
235
|
.map(([col, def]) => ` ['${col}', ${this._renderField(def)}],`)
|
|
175
236
|
.join('\n') + '\n' +
|
|
176
|
-
` ],\n` +
|
|
237
|
+
` ]${idxCode}${utCode},\n` +
|
|
177
238
|
` })`;
|
|
239
|
+
}
|
|
178
240
|
|
|
179
241
|
case 'DeleteModel':
|
|
180
242
|
// Django omits fields= — not needed in the migration file
|
|
@@ -222,6 +284,32 @@ ${opsCode},
|
|
|
222
284
|
` newName: '${op.newTable}',\n` +
|
|
223
285
|
` })`;
|
|
224
286
|
|
|
287
|
+
case 'AddIndex':
|
|
288
|
+
return `migrations.AddIndex({\n` +
|
|
289
|
+
` modelName: '${op.table}',\n` +
|
|
290
|
+
` index: ${JSON.stringify(op.index)},\n` +
|
|
291
|
+
` })`;
|
|
292
|
+
|
|
293
|
+
case 'RemoveIndex':
|
|
294
|
+
return `migrations.RemoveIndex({\n` +
|
|
295
|
+
` modelName: '${op.table}',\n` +
|
|
296
|
+
` index: ${JSON.stringify(op.index)},\n` +
|
|
297
|
+
` })`;
|
|
298
|
+
|
|
299
|
+
case 'RenameIndex':
|
|
300
|
+
return `migrations.RenameIndex({\n` +
|
|
301
|
+
` modelName: '${op.table}',\n` +
|
|
302
|
+
` oldName: '${op.oldName}',\n` +
|
|
303
|
+
` newName: '${op.newName}',\n` +
|
|
304
|
+
` })`;
|
|
305
|
+
|
|
306
|
+
case 'AlterUniqueTogether':
|
|
307
|
+
return `migrations.AlterUniqueTogether({\n` +
|
|
308
|
+
` modelName: '${op.table}',\n` +
|
|
309
|
+
` newUnique: ${JSON.stringify(op.newUnique)},\n` +
|
|
310
|
+
` oldUnique: ${JSON.stringify(op.oldUnique)},\n` +
|
|
311
|
+
` })`;
|
|
312
|
+
|
|
225
313
|
case 'RunSQL':
|
|
226
314
|
return `migrations.RunSQL({\n` +
|
|
227
315
|
` sql: ${JSON.stringify(op.sql)},\n` +
|
|
@@ -458,6 +546,22 @@ ${opsCode},
|
|
|
458
546
|
}
|
|
459
547
|
|
|
460
548
|
_fieldsEqual(a, b) { return fieldsEqual(a, b); }
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Returns true when changing from oldType to newType risks data loss.
|
|
552
|
+
* Mirrors Django's check_for_system_checks / check_for_data_loss logic.
|
|
553
|
+
*/
|
|
554
|
+
_isDestructiveTypeChange(oldType, newType) {
|
|
555
|
+
if (oldType === newType) return false;
|
|
556
|
+
// String-family — safe between each other
|
|
557
|
+
const stringFamily = new Set(['string', 'text', 'email', 'url', 'slug', 'ipAddress']);
|
|
558
|
+
if (stringFamily.has(oldType) && stringFamily.has(newType)) return false;
|
|
559
|
+
// Number-family — safe between each other (widening)
|
|
560
|
+
const numberFamily = new Set(['integer', 'bigInteger', 'float', 'decimal']);
|
|
561
|
+
if (numberFamily.has(oldType) && numberFamily.has(newType)) return false;
|
|
562
|
+
// Everything else is potentially destructive
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
461
565
|
}
|
|
462
566
|
|
|
463
567
|
module.exports = MigrationWriter;
|
|
@@ -484,6 +484,10 @@ ${this._renderColumn(' ', diff.column, diff.previous, '.alter()')}
|
|
|
484
484
|
return `${indent}t.increments('${name}')${suffix};`;
|
|
485
485
|
|
|
486
486
|
case 'string':
|
|
487
|
+
case 'email':
|
|
488
|
+
case 'url':
|
|
489
|
+
case 'slug':
|
|
490
|
+
case 'ipAddress':
|
|
487
491
|
line = `t.string('${name}', ${field.max || 255})`;
|
|
488
492
|
break;
|
|
489
493
|
|
|
@@ -84,8 +84,10 @@ class ModelScanner {
|
|
|
84
84
|
|
|
85
85
|
// Build state from the canonical (most-derived) class per table
|
|
86
86
|
for (const [table, cls] of tableToClass) {
|
|
87
|
-
const fields
|
|
88
|
-
|
|
87
|
+
const fields = this._resolveFields(cls, classes, tableToClass);
|
|
88
|
+
const indexes = cls.indexes || [];
|
|
89
|
+
const uniqueTogether = cls.uniqueTogether || [];
|
|
90
|
+
state.createModel(table, fields, indexes, uniqueTogether);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
return state;
|
|
@@ -162,7 +164,7 @@ class ModelScanner {
|
|
|
162
164
|
|
|
163
165
|
|
|
164
166
|
_resolveFields(cls, allClasses, tableToClass) {
|
|
165
|
-
|
|
167
|
+
let fields = {};
|
|
166
168
|
|
|
167
169
|
// Walk the prototype chain collecting fields, child overrides parent
|
|
168
170
|
const chain = this._inheritanceChain(cls);
|
|
@@ -191,10 +193,7 @@ class ModelScanner {
|
|
|
191
193
|
if (ancestor.fields && typeof ancestor.fields === 'object') {
|
|
192
194
|
for (const [name, def] of Object.entries(ancestor.fields)) {
|
|
193
195
|
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
196
|
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
197
|
const colName = (def && def._isForeignKey && !name.endsWith('_id'))
|
|
199
198
|
? name + '_id'
|
|
200
199
|
: name;
|
|
@@ -203,6 +202,13 @@ class ModelScanner {
|
|
|
203
202
|
}
|
|
204
203
|
}
|
|
205
204
|
|
|
205
|
+
// Auto-inject id if no primary key declared — same as Django
|
|
206
|
+
const hasPk = Object.values(fields).some(f => f?.type === 'id' || f?.primary === true);
|
|
207
|
+
if (!hasPk) {
|
|
208
|
+
const { fields: fieldDefs } = require('../fields/index');
|
|
209
|
+
fields = { id: normaliseField(fieldDefs.id()), ...fields };
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
return fields;
|
|
207
213
|
}
|
|
208
214
|
|