millas 0.2.19 → 0.2.20
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/commands/migrate.js +34 -2
- package/src/container/AppInitializer.js +43 -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 +4 -0
- 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 +32 -6
- package/src/orm/migration/operations/registry.js +24 -3
- package/src/orm/model/Model.js +26 -14
- package/src/orm/query/QueryBuilder.js +1 -1
package/package.json
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/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
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { tableFromClass, modelNameToTable, isSnakeCase } = require('./utils');
|
|
4
|
+
const { indexName } = require('./operations/indexes');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ProjectState
|
|
@@ -29,7 +30,7 @@ class ProjectState {
|
|
|
29
30
|
|
|
30
31
|
// ─── Mutation (called by operations during replay) ────────────────────────
|
|
31
32
|
|
|
32
|
-
createModel(table, fields) {
|
|
33
|
+
createModel(table, fields, indexes = [], uniqueTogether = []) {
|
|
33
34
|
if (this.models.has(table)) {
|
|
34
35
|
throw new Error(`ProjectState: table "${table}" already exists`);
|
|
35
36
|
}
|
|
@@ -37,13 +38,39 @@ class ProjectState {
|
|
|
37
38
|
for (const [name, def] of Object.entries(fields)) {
|
|
38
39
|
fieldMap.set(name, normaliseField(def));
|
|
39
40
|
}
|
|
40
|
-
this.models.set(table, { table, fields: fieldMap });
|
|
41
|
+
this.models.set(table, { table, fields: fieldMap, indexes: [...indexes], uniqueTogether: [...uniqueTogether] });
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
deleteModel(table) {
|
|
44
45
|
this.models.delete(table);
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
addIndex(table, index) {
|
|
49
|
+
const model = this._requireModel(table);
|
|
50
|
+
model.indexes = model.indexes || [];
|
|
51
|
+
model.indexes.push(index);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
removeIndex(table, index) {
|
|
55
|
+
const model = this._requireModel(table);
|
|
56
|
+
model.indexes = (model.indexes || []).filter(
|
|
57
|
+
i => JSON.stringify(i) !== JSON.stringify(index)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
renameIndex(table, oldName, newName) {
|
|
62
|
+
const model = this._requireModel(table);
|
|
63
|
+
model.indexes = (model.indexes || []).map(i => {
|
|
64
|
+
const iName = i.name || indexName(model.table, i.fields, i.unique);
|
|
65
|
+
return iName === oldName ? { ...i, name: newName } : i;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
alterUniqueTogether(table, uniqueTogether) {
|
|
70
|
+
const model = this._requireModel(table);
|
|
71
|
+
model.uniqueTogether = uniqueTogether;
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
addField(table, column, fieldDef) {
|
|
48
75
|
const model = this._requireModel(table);
|
|
49
76
|
if (model.fields.has(column)) {
|
|
@@ -91,9 +118,13 @@ class ProjectState {
|
|
|
91
118
|
toSchema() {
|
|
92
119
|
const schema = {};
|
|
93
120
|
for (const [table, model] of this.models) {
|
|
94
|
-
schema[table] = {
|
|
121
|
+
schema[table] = {
|
|
122
|
+
fields: {},
|
|
123
|
+
indexes: model.indexes || [],
|
|
124
|
+
uniqueTogether: model.uniqueTogether || [],
|
|
125
|
+
};
|
|
95
126
|
for (const [col, def] of model.fields) {
|
|
96
|
-
schema[table][col] = { ...def };
|
|
127
|
+
schema[table].fields[col] = { ...def };
|
|
97
128
|
}
|
|
98
129
|
}
|
|
99
130
|
return schema;
|
|
@@ -107,7 +138,12 @@ class ProjectState {
|
|
|
107
138
|
for (const [col, def] of model.fields) {
|
|
108
139
|
fieldMap.set(col, { ...def });
|
|
109
140
|
}
|
|
110
|
-
copy.models.set(table, {
|
|
141
|
+
copy.models.set(table, {
|
|
142
|
+
table,
|
|
143
|
+
fields: fieldMap,
|
|
144
|
+
indexes: JSON.parse(JSON.stringify(model.indexes || [])),
|
|
145
|
+
uniqueTogether: JSON.parse(JSON.stringify(model.uniqueTogether || [])),
|
|
146
|
+
});
|
|
111
147
|
}
|
|
112
148
|
return copy;
|
|
113
149
|
}
|
|
@@ -109,6 +109,10 @@ function _buildColumn(t, name, def, opts = {}) {
|
|
|
109
109
|
return null; // increments() doesn't return a chainable column builder
|
|
110
110
|
|
|
111
111
|
case 'string':
|
|
112
|
+
case 'email':
|
|
113
|
+
case 'url':
|
|
114
|
+
case 'slug':
|
|
115
|
+
case 'ipAddress':
|
|
112
116
|
return t.string(name, def.max || 255);
|
|
113
117
|
|
|
114
118
|
case 'text':
|
|
@@ -22,34 +22,17 @@ const { applyColumn, alterColumn,
|
|
|
22
22
|
const { CreateModel, DeleteModel, RenameModel } = require('./models');
|
|
23
23
|
const { AddField, RemoveField,
|
|
24
24
|
AlterField, RenameField } = require('./fields');
|
|
25
|
+
const { AddIndex, RemoveIndex,
|
|
26
|
+
AlterUniqueTogether, RenameIndex } = require('./indexes');
|
|
25
27
|
const { RunSQL } = require('./special');
|
|
26
28
|
const { deserialise, migrations, _tableFromName } = require('./registry');
|
|
27
29
|
|
|
28
30
|
module.exports = {
|
|
29
|
-
// Base
|
|
30
31
|
BaseOperation,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
attachFKConstraints,
|
|
36
|
-
|
|
37
|
-
// Table-level ops
|
|
38
|
-
CreateModel,
|
|
39
|
-
DeleteModel,
|
|
40
|
-
RenameModel,
|
|
41
|
-
|
|
42
|
-
// Field-level ops
|
|
43
|
-
AddField,
|
|
44
|
-
RemoveField,
|
|
45
|
-
AlterField,
|
|
46
|
-
RenameField,
|
|
47
|
-
|
|
48
|
-
// Escape hatch
|
|
32
|
+
applyColumn, alterColumn, attachFKConstraints,
|
|
33
|
+
CreateModel, DeleteModel, RenameModel,
|
|
34
|
+
AddField, RemoveField, AlterField, RenameField,
|
|
35
|
+
AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex,
|
|
49
36
|
RunSQL,
|
|
50
|
-
|
|
51
|
-
// Registry
|
|
52
|
-
deserialise,
|
|
53
|
-
migrations,
|
|
54
|
-
_tableFromName,
|
|
37
|
+
deserialise, migrations, _tableFromName,
|
|
55
38
|
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BaseOperation } = require('./base');
|
|
4
|
+
|
|
5
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function indexName(table, fields, unique = false) {
|
|
8
|
+
const fieldPart = fields.map(f => f.replace(/^-/, '')).join('_');
|
|
9
|
+
return `${table}_${fieldPart}_${unique ? 'unique' : 'index'}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _applyIndex(t, table, idx) {
|
|
13
|
+
const { fields, name, unique } = idx;
|
|
14
|
+
const idxName = name || indexName(table, fields, unique);
|
|
15
|
+
// Separate plain fields from descending fields (prefixed with '-')
|
|
16
|
+
const columns = fields.map(f => f.replace(/^-/, ''));
|
|
17
|
+
const hasDesc = fields.some(f => f.startsWith('-'));
|
|
18
|
+
|
|
19
|
+
if (unique) {
|
|
20
|
+
t.unique(columns, { indexName: idxName });
|
|
21
|
+
} else if (hasDesc) {
|
|
22
|
+
// knex doesn't support per-column sort direction in t.index() —
|
|
23
|
+
// use raw for descending indexes
|
|
24
|
+
const colsSql = fields.map(f =>
|
|
25
|
+
f.startsWith('-') ? `\`${f.slice(1)}\` DESC` : `\`${f}\``
|
|
26
|
+
).join(', ');
|
|
27
|
+
t.index(columns, idxName); // fallback — knex limitation
|
|
28
|
+
// Note: true DESC index requires raw SQL; knex wraps it as regular index
|
|
29
|
+
} else {
|
|
30
|
+
t.index(columns, idxName);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── AddIndex ─────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
class AddIndex extends BaseOperation {
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} table
|
|
39
|
+
* @param {object} index — { fields, name?, unique? }
|
|
40
|
+
*/
|
|
41
|
+
constructor(table, index) {
|
|
42
|
+
super();
|
|
43
|
+
this.type = 'AddIndex';
|
|
44
|
+
this.table = table;
|
|
45
|
+
this.index = index;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
applyState(state) {
|
|
49
|
+
state.addIndex(this.table, this.index);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async up(db) {
|
|
53
|
+
const { fields, name, unique } = this.index;
|
|
54
|
+
const idxName = name || indexName(this.table, fields, unique);
|
|
55
|
+
await db.schema.table(this.table, (t) => _applyIndex(t, this.table, this.index));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async down(db) {
|
|
59
|
+
const { fields, name, unique } = this.index;
|
|
60
|
+
const idxName = name || indexName(this.table, fields, unique);
|
|
61
|
+
const columns = fields.map(f => f.replace(/^-/, ''));
|
|
62
|
+
await db.schema.table(this.table, (t) => {
|
|
63
|
+
if (unique) t.dropUnique(columns, idxName);
|
|
64
|
+
else t.dropIndex(columns, idxName);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toJSON() {
|
|
69
|
+
return { type: 'AddIndex', table: this.table, index: this.index };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── RemoveIndex ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
class RemoveIndex extends BaseOperation {
|
|
76
|
+
constructor(table, index) {
|
|
77
|
+
super();
|
|
78
|
+
this.type = 'RemoveIndex';
|
|
79
|
+
this.table = table;
|
|
80
|
+
this.index = index;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
applyState(state) {
|
|
84
|
+
state.removeIndex(this.table, this.index);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async up(db) {
|
|
88
|
+
const { fields, name, unique } = this.index;
|
|
89
|
+
const idxName = name || indexName(this.table, fields, unique);
|
|
90
|
+
const columns = fields.map(f => f.replace(/^-/, ''));
|
|
91
|
+
await db.schema.table(this.table, (t) => {
|
|
92
|
+
if (unique) t.dropUnique(columns, idxName);
|
|
93
|
+
else t.dropIndex(columns, idxName);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async down(db) {
|
|
98
|
+
await db.schema.table(this.table, (t) => _applyIndex(t, this.table, this.index));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
toJSON() {
|
|
102
|
+
return { type: 'RemoveIndex', table: this.table, index: this.index };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── AlterUniqueTogether ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
class AlterUniqueTogether extends BaseOperation {
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} table
|
|
111
|
+
* @param {string[][]} newUnique — new uniqueTogether sets
|
|
112
|
+
* @param {string[][]} oldUnique — previous uniqueTogether sets (for down())
|
|
113
|
+
*/
|
|
114
|
+
constructor(table, newUnique, oldUnique = []) {
|
|
115
|
+
super();
|
|
116
|
+
this.type = 'AlterUniqueTogether';
|
|
117
|
+
this.table = table;
|
|
118
|
+
this.newUnique = newUnique;
|
|
119
|
+
this.oldUnique = oldUnique;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
applyState(state) {
|
|
123
|
+
state.alterUniqueTogether(this.table, this.newUnique);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async up(db) {
|
|
127
|
+
await this._apply(db, this.oldUnique, this.newUnique);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async down(db) {
|
|
131
|
+
await this._apply(db, this.newUnique, this.oldUnique);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async _apply(db, remove, add) {
|
|
135
|
+
await db.schema.table(this.table, (t) => {
|
|
136
|
+
for (const fields of remove) {
|
|
137
|
+
const name = `${this.table}_${fields.join('_')}_unique`;
|
|
138
|
+
try { t.dropUnique(fields, name); } catch { /* already gone */ }
|
|
139
|
+
}
|
|
140
|
+
for (const fields of add) {
|
|
141
|
+
const name = `${this.table}_${fields.join('_')}_unique`;
|
|
142
|
+
t.unique(fields, { indexName: name });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
toJSON() {
|
|
148
|
+
return {
|
|
149
|
+
type: 'AlterUniqueTogether',
|
|
150
|
+
table: this.table,
|
|
151
|
+
newUnique: this.newUnique,
|
|
152
|
+
oldUnique: this.oldUnique,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── RenameIndex ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
class RenameIndex extends BaseOperation {
|
|
160
|
+
constructor(table, oldName, newName) {
|
|
161
|
+
super();
|
|
162
|
+
this.type = 'RenameIndex';
|
|
163
|
+
this.table = table;
|
|
164
|
+
this.oldName = oldName;
|
|
165
|
+
this.newName = newName;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
applyState(state) {
|
|
169
|
+
state.renameIndex(this.table, this.oldName, this.newName);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async up(db) {
|
|
173
|
+
// knex doesn't have renameIndex — drop and recreate
|
|
174
|
+
// The index definition is stored on the op for reconstruction
|
|
175
|
+
await db.schema.table(this.table, (t) => {
|
|
176
|
+
t.dropIndex([], this.oldName);
|
|
177
|
+
});
|
|
178
|
+
await db.schema.table(this.table, (t) => {
|
|
179
|
+
t.index(this.fields || [], this.newName);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async down(db) {
|
|
184
|
+
await db.schema.table(this.table, (t) => {
|
|
185
|
+
t.dropIndex([], this.newName);
|
|
186
|
+
});
|
|
187
|
+
await db.schema.table(this.table, (t) => {
|
|
188
|
+
t.index(this.fields || [], this.oldName);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
toJSON() {
|
|
193
|
+
return { type: 'RenameIndex', table: this.table, oldName: this.oldName, newName: this.newName, fields: this.fields };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex, indexName, _applyIndex };
|
|
@@ -28,16 +28,20 @@ class CreateModel extends BaseOperation {
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {string} table
|
|
30
30
|
* @param {object} fields — { columnName: normalisedFieldDef }
|
|
31
|
+
* @param {Array} [indexes]
|
|
32
|
+
* @param {Array} [uniqueTogether]
|
|
31
33
|
*/
|
|
32
|
-
constructor(table, fields) {
|
|
34
|
+
constructor(table, fields, indexes = [], uniqueTogether = []) {
|
|
33
35
|
super();
|
|
34
|
-
this.type
|
|
35
|
-
this.table
|
|
36
|
-
this.fields
|
|
36
|
+
this.type = 'CreateModel';
|
|
37
|
+
this.table = table;
|
|
38
|
+
this.fields = fields;
|
|
39
|
+
this.indexes = indexes;
|
|
40
|
+
this.uniqueTogether = uniqueTogether;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
applyState(state) {
|
|
40
|
-
state.createModel(this.table, this.fields);
|
|
44
|
+
state.createModel(this.table, this.fields, this.indexes, this.uniqueTogether);
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
// Standard up() — inline FKs. Safe when only one CreateModel in a migration.
|
|
@@ -47,6 +51,7 @@ class CreateModel extends BaseOperation {
|
|
|
47
51
|
applyColumn(t, name, normaliseField(def));
|
|
48
52
|
}
|
|
49
53
|
});
|
|
54
|
+
await this._applyIndexes(db);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -59,6 +64,7 @@ class CreateModel extends BaseOperation {
|
|
|
59
64
|
applyColumn(t, name, { ...normaliseField(def), references: null });
|
|
60
65
|
}
|
|
61
66
|
});
|
|
67
|
+
await this._applyIndexes(db);
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/**
|
|
@@ -76,8 +82,28 @@ class CreateModel extends BaseOperation {
|
|
|
76
82
|
await db.schema.dropTableIfExists(this.table);
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
async _applyIndexes(db) {
|
|
86
|
+
const { indexName } = require('./indexes');
|
|
87
|
+
const all = [
|
|
88
|
+
...(this.indexes || []),
|
|
89
|
+
];
|
|
90
|
+
const ut = this.uniqueTogether || [];
|
|
91
|
+
if (!all.length && !ut.length) return;
|
|
92
|
+
await db.schema.table(this.table, (t) => {
|
|
93
|
+
for (const idx of all) {
|
|
94
|
+
const name = idx.name || indexName(this.table, idx.fields, idx.unique);
|
|
95
|
+
if (idx.unique) t.unique(idx.fields, { indexName: name });
|
|
96
|
+
else t.index(idx.fields, name);
|
|
97
|
+
}
|
|
98
|
+
for (const fields of ut) {
|
|
99
|
+
const name = `${this.table}_${fields.join('_')}_unique`;
|
|
100
|
+
t.unique(fields, { indexName: name });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
toJSON() {
|
|
80
|
-
return { type: 'CreateModel', table: this.table, fields: this.fields };
|
|
106
|
+
return { type: 'CreateModel', table: this.table, fields: this.fields, indexes: this.indexes, uniqueTogether: this.uniqueTogether };
|
|
81
107
|
}
|
|
82
108
|
}
|
|
83
109
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { CreateModel, DeleteModel, RenameModel } = require('./models');
|
|
4
4
|
const { AddField, RemoveField, AlterField, RenameField } = require('./fields');
|
|
5
|
+
const { AddIndex, RemoveIndex, AlterUniqueTogether, RenameIndex } = require('./indexes');
|
|
5
6
|
const { RunSQL } = require('./special');
|
|
6
7
|
const { modelNameToTable, isSnakeCase } = require('../utils');
|
|
7
8
|
|
|
@@ -42,13 +43,17 @@ const { modelNameToTable, isSnakeCase } = require('../utils');
|
|
|
42
43
|
*/
|
|
43
44
|
function deserialise(op) {
|
|
44
45
|
switch (op.type) {
|
|
45
|
-
case 'CreateModel': return new CreateModel(op.table, op.fields);
|
|
46
|
+
case 'CreateModel': return new CreateModel(op.table, op.fields, op.indexes || [], op.uniqueTogether || []);
|
|
46
47
|
case 'DeleteModel': return new DeleteModel(op.table, op.fields);
|
|
47
48
|
case 'RenameModel': return new RenameModel(op.oldTable, op.newTable);
|
|
48
49
|
case 'AddField': return new AddField(op.table, op.column, op.field, op.oneOffDefault);
|
|
49
50
|
case 'RemoveField': return new RemoveField(op.table, op.column, op.field);
|
|
50
51
|
case 'AlterField': return new AlterField(op.table, op.column, op.field, op.previousField);
|
|
51
52
|
case 'RenameField': return new RenameField(op.table, op.oldColumn, op.newColumn);
|
|
53
|
+
case 'AddIndex': return new AddIndex(op.table, op.index);
|
|
54
|
+
case 'RemoveIndex': return new RemoveIndex(op.table, op.index);
|
|
55
|
+
case 'RenameIndex': return new RenameIndex(op.table, op.oldName, op.newName);
|
|
56
|
+
case 'AlterUniqueTogether': return new AlterUniqueTogether(op.table, op.newUnique, op.oldUnique);
|
|
52
57
|
case 'RunSQL': return new RunSQL(op.sql, op.reverseSql);
|
|
53
58
|
default:
|
|
54
59
|
throw new Error(`Unknown migration operation type: "${op.type}"`);
|
|
@@ -63,10 +68,10 @@ function deserialise(op) {
|
|
|
63
68
|
*/
|
|
64
69
|
const migrations = {
|
|
65
70
|
|
|
66
|
-
CreateModel({ name, fields: fieldList = [] }) {
|
|
71
|
+
CreateModel({ name, fields: fieldList = [], indexes = [], uniqueTogether = [] }) {
|
|
67
72
|
const fields = {};
|
|
68
73
|
for (const [col, def] of fieldList) fields[col] = def;
|
|
69
|
-
return { type: 'CreateModel', table: _tableFromName(name), fields };
|
|
74
|
+
return { type: 'CreateModel', table: _tableFromName(name), fields, indexes, uniqueTogether };
|
|
70
75
|
},
|
|
71
76
|
|
|
72
77
|
DeleteModel({ name, fields: fieldList = [] }) {
|
|
@@ -101,6 +106,22 @@ const migrations = {
|
|
|
101
106
|
return { type: 'RenameField', table: modelName, oldColumn: oldName, newColumn: newName };
|
|
102
107
|
},
|
|
103
108
|
|
|
109
|
+
AddIndex({ modelName, index }) {
|
|
110
|
+
return { type: 'AddIndex', table: modelName, index };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
RemoveIndex({ modelName, index }) {
|
|
114
|
+
return { type: 'RemoveIndex', table: modelName, index };
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
RenameIndex({ modelName, oldName, newName }) {
|
|
118
|
+
return { type: 'RenameIndex', table: modelName, oldName, newName };
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
AlterUniqueTogether({ modelName, newUnique, oldUnique = [] }) {
|
|
122
|
+
return { type: 'AlterUniqueTogether', table: modelName, newUnique, oldUnique };
|
|
123
|
+
},
|
|
124
|
+
|
|
104
125
|
RunSQL({ sql, reverseSql = null }) {
|
|
105
126
|
return { type: 'RunSQL', sql, reverseSql };
|
|
106
127
|
},
|
package/src/orm/model/Model.js
CHANGED
|
@@ -173,16 +173,22 @@ class Model {
|
|
|
173
173
|
|
|
174
174
|
while (cur && cur !== Function.prototype) {
|
|
175
175
|
if (Object.prototype.hasOwnProperty.call(cur, 'fields')) {
|
|
176
|
-
chain.unshift(cur.fields);
|
|
176
|
+
chain.unshift(cur.fields);
|
|
177
177
|
}
|
|
178
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
179
|
if (cur !== this && !cur.abstract && curTable !== myTable) break;
|
|
182
180
|
cur = Object.getPrototypeOf(cur);
|
|
183
181
|
}
|
|
184
182
|
|
|
185
|
-
|
|
183
|
+
let merged = Object.assign({}, ...chain);
|
|
184
|
+
|
|
185
|
+
// Auto-inject id if no primary key is declared — same as Django
|
|
186
|
+
const hasPk = Object.values(merged).some(f => f?.primary === true || f?.type === 'id');
|
|
187
|
+
if (!hasPk) {
|
|
188
|
+
const { fields } = require('../fields/index');
|
|
189
|
+
merged = { id: fields.id(), ...merged };
|
|
190
|
+
}
|
|
191
|
+
|
|
186
192
|
Object.defineProperty(this, '_cachedFields', {
|
|
187
193
|
value: merged, writable: true, configurable: true, enumerable: false,
|
|
188
194
|
});
|
|
@@ -659,7 +665,7 @@ class Model {
|
|
|
659
665
|
};
|
|
660
666
|
|
|
661
667
|
payload = await this.constructor.beforeUpdate(payload) ?? payload;
|
|
662
|
-
|
|
668
|
+
const dbPayload = this.constructor._serializeForDb(payload);
|
|
663
669
|
|
|
664
670
|
const q = trx
|
|
665
671
|
? trx(this.constructor.table)
|
|
@@ -667,9 +673,9 @@ class Model {
|
|
|
667
673
|
|
|
668
674
|
await q
|
|
669
675
|
.where(this.constructor.primaryKey, this[this.constructor.primaryKey])
|
|
670
|
-
.update(
|
|
676
|
+
.update(dbPayload);
|
|
671
677
|
|
|
672
|
-
Object.assign(this, payload);
|
|
678
|
+
Object.assign(this, payload); // keep JS types on the instance, not serialized values
|
|
673
679
|
await this.constructor.afterUpdate(this);
|
|
674
680
|
return this;
|
|
675
681
|
}
|
|
@@ -774,15 +780,21 @@ class Model {
|
|
|
774
780
|
static _castValue(val, type) {
|
|
775
781
|
if (val == null) return val;
|
|
776
782
|
switch (type) {
|
|
777
|
-
case 'boolean':
|
|
778
|
-
case 'integer':
|
|
779
|
-
case 'bigInteger':return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
783
|
+
case 'boolean': return Boolean(val);
|
|
784
|
+
case 'integer': return Number.isInteger(val) ? val : parseInt(val, 10);
|
|
785
|
+
case 'bigInteger': return typeof val === 'bigint' ? val : parseInt(val, 10);
|
|
780
786
|
case 'float':
|
|
781
|
-
case 'decimal':
|
|
782
|
-
case 'json':
|
|
787
|
+
case 'decimal': return typeof val === 'number' ? val : parseFloat(val);
|
|
788
|
+
case 'json': return typeof val === 'string' ? JSON.parse(val) : val;
|
|
783
789
|
case 'date':
|
|
784
|
-
case 'timestamp':
|
|
785
|
-
|
|
790
|
+
case 'timestamp': return val instanceof Date ? val : new Date(val);
|
|
791
|
+
// string-backed types — no casting needed
|
|
792
|
+
case 'string':
|
|
793
|
+
case 'email':
|
|
794
|
+
case 'url':
|
|
795
|
+
case 'slug':
|
|
796
|
+
case 'ipAddress': return String(val);
|
|
797
|
+
default: return val;
|
|
786
798
|
}
|
|
787
799
|
}
|
|
788
800
|
|
|
@@ -381,7 +381,7 @@ class QueryBuilder {
|
|
|
381
381
|
async _eagerLoad(instances) {
|
|
382
382
|
if (!instances.length) return;
|
|
383
383
|
|
|
384
|
-
const relations = this._model.
|
|
384
|
+
const relations = this._model._effectiveRelations();
|
|
385
385
|
|
|
386
386
|
for (const { name, constraint, aggregate, aggregateColumn } of this._withs) {
|
|
387
387
|
|