outlet-orm 4.2.1 → 5.5.1
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/README.md +166 -53
- package/bin/init.js +18 -0
- package/bin/migrate.js +109 -7
- package/bin/reverse.js +602 -0
- package/package.json +22 -13
- package/src/Database/DatabaseConnection.js +4 -0
- package/src/DatabaseConnection.js +98 -46
- package/{lib → src}/Migrations/Migration.js +48 -48
- package/{lib → src}/Migrations/MigrationManager.js +22 -19
- package/src/Model.js +30 -7
- package/src/QueryBuilder.js +134 -35
- package/src/RawExpression.js +11 -0
- package/src/Relations/BelongsToManyRelation.js +466 -466
- package/{lib → src}/Schema/Schema.js +157 -117
- package/src/Seeders/Seeder.js +60 -0
- package/src/Seeders/SeederManager.js +105 -0
- package/src/index.js +25 -1
- package/types/index.d.ts +14 -0
- package/lib/Database/DatabaseConnection.js +0 -4
package/bin/reverse.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outlet ORM — Database Reverse Engineering Tool
|
|
6
|
+
*
|
|
7
|
+
* Introspects an existing database (or SQL dump file) and generates:
|
|
8
|
+
* • Migration files (up/down using Schema Blueprint methods)
|
|
9
|
+
* • Seeder files (INSERT rows fetched from each table)
|
|
10
|
+
*
|
|
11
|
+
* Usage (CLI): node bin/reverse.js
|
|
12
|
+
* Usage (API): const { parseCreateTable, generateMigration, generateSeeder } = require('./bin/reverse');
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// readline is only initialised when running as a CLI (not when required as a lib)
|
|
19
|
+
let rl;
|
|
20
|
+
function getRL() {
|
|
21
|
+
if (!rl) {
|
|
22
|
+
const readline = require('readline');
|
|
23
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
}
|
|
25
|
+
return rl;
|
|
26
|
+
}
|
|
27
|
+
function question(q) { return new Promise(resolve => getRL().question(q, resolve)); }
|
|
28
|
+
|
|
29
|
+
// ─── SQL Parser ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Split the body of a CREATE TABLE statement into individual definition lines,
|
|
33
|
+
* respecting nested parentheses (ENUM values, CHECK expressions, etc.).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} body Text between the outermost ( … )
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function splitDefinitions(body) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
let depth = 0;
|
|
41
|
+
let current = '';
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < body.length; i++) {
|
|
44
|
+
const ch = body[i];
|
|
45
|
+
if (ch === '(') { depth++; current += ch; }
|
|
46
|
+
else if (ch === ')') { depth--; current += ch; }
|
|
47
|
+
else if (ch === ',' && depth === 0) { lines.push(current.trim()); current = ''; }
|
|
48
|
+
else { current += ch; }
|
|
49
|
+
}
|
|
50
|
+
if (current.trim()) lines.push(current.trim());
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a CREATE TABLE statement into a structured object.
|
|
56
|
+
* Handles MySQL, PostgreSQL, and SQLite dialects.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} sql Raw CREATE TABLE SQL (single statement)
|
|
59
|
+
* @returns {{ tableName: string, columns: object[], foreignKeys: object[] } | null}
|
|
60
|
+
*/
|
|
61
|
+
function parseCreateTable(sql) {
|
|
62
|
+
if (!sql || typeof sql !== 'string') return null;
|
|
63
|
+
|
|
64
|
+
// Strip single-line and block comments
|
|
65
|
+
sql = sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
66
|
+
|
|
67
|
+
const tableMatch = sql.match(
|
|
68
|
+
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"[]?(\w+)[`"\]]?\s*\(/i
|
|
69
|
+
);
|
|
70
|
+
if (!tableMatch) return null;
|
|
71
|
+
|
|
72
|
+
const tableName = tableMatch[1];
|
|
73
|
+
const columns = [];
|
|
74
|
+
const foreignKeys = [];
|
|
75
|
+
|
|
76
|
+
// Extract body between outermost parens
|
|
77
|
+
const bodyStart = sql.indexOf('(', tableMatch.index) + 1;
|
|
78
|
+
const bodyEnd = sql.lastIndexOf(')');
|
|
79
|
+
if (bodyStart <= 0 || bodyEnd < 0) return null;
|
|
80
|
+
const body = sql.slice(bodyStart, bodyEnd);
|
|
81
|
+
|
|
82
|
+
for (const line of splitDefinitions(body)) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (!trimmed) continue;
|
|
85
|
+
const upperTrimmed = trimmed.toUpperCase();
|
|
86
|
+
|
|
87
|
+
// ── Table-level FOREIGN KEY constraint ───────────────────────────────────
|
|
88
|
+
if (/^FOREIGN\s+KEY/i.test(trimmed) || /^CONSTRAINT\s+\S+\s+FOREIGN\s+KEY/i.test(trimmed)) {
|
|
89
|
+
const fkMatch = trimmed.match(
|
|
90
|
+
/FOREIGN\s+KEY\s*\(`?(\w+)`?\)\s*REFERENCES\s+`?(\w+)`?\s*\(`?(\w+)`?\)/i
|
|
91
|
+
);
|
|
92
|
+
if (fkMatch) {
|
|
93
|
+
foreignKeys.push({
|
|
94
|
+
column: fkMatch[1],
|
|
95
|
+
referencedTable: fkMatch[2],
|
|
96
|
+
referencedColumn: fkMatch[3]
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Skip other table-level constraints and index declarations ─────────────
|
|
103
|
+
if (/^(PRIMARY\s+KEY|UNIQUE\s+(KEY|INDEX)?|KEY\s|INDEX\s|CHECK\s|CONSTRAINT\s)/i.test(trimmed)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Column definition ─────────────────────────────────────────────────────
|
|
108
|
+
// Pattern: [`"]?colname[`"]? TYPE(params) ...rest...
|
|
109
|
+
const colMatch = trimmed.match(/^[`"[]?(\w+)[`"\]]?\s+(\w+(?:\s*\([^)]+\))?)\s*([\s\S]*)/i);
|
|
110
|
+
if (!colMatch) continue;
|
|
111
|
+
|
|
112
|
+
const [, name, rawType, rest] = colMatch;
|
|
113
|
+
const restUpper = rest.toUpperCase();
|
|
114
|
+
|
|
115
|
+
// Safety: skip if "name" is a reserved SQL keyword used at table level
|
|
116
|
+
if (['PRIMARY', 'UNIQUE', 'KEY', 'INDEX', 'CONSTRAINT', 'FOREIGN',
|
|
117
|
+
'CHECK', 'FULLTEXT', 'SPATIAL'].includes(name.toUpperCase())) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const col = {
|
|
122
|
+
name,
|
|
123
|
+
type: rawType.trim(),
|
|
124
|
+
nullable: !restUpper.includes('NOT NULL'),
|
|
125
|
+
autoIncrement: restUpper.includes('AUTO_INCREMENT') || restUpper.includes('AUTOINCREMENT'),
|
|
126
|
+
primary: restUpper.includes('PRIMARY KEY'),
|
|
127
|
+
unique: /\bUNIQUE\b/.test(restUpper) && !/\bUNIQUE\s+KEY\b/.test(restUpper),
|
|
128
|
+
unsigned: restUpper.includes('UNSIGNED'),
|
|
129
|
+
default: null
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Extract DEFAULT value (handles quoted strings and bare values)
|
|
133
|
+
const defMatch = rest.match(/DEFAULT\s+('(?:[^'\\]|\\.)*'|\S+)/i);
|
|
134
|
+
if (defMatch) {
|
|
135
|
+
col.default = defMatch[1].replace(/^'|'$/g, '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
columns.push(col);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { tableName, columns, foreignKeys };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Blueprint Mapper ─────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Map a parsed column object → a Schema Blueprint call descriptor.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} col Column object from parseCreateTable
|
|
150
|
+
* @returns {{ method: string, args: any[], modifiers: string[] }}
|
|
151
|
+
*/
|
|
152
|
+
function columnToBlueprint(col) {
|
|
153
|
+
const rawType = col.type || '';
|
|
154
|
+
const typeLower = rawType.toLowerCase();
|
|
155
|
+
|
|
156
|
+
// Parse base type and optional numeric parameters
|
|
157
|
+
const paramMatch = rawType.match(/\(([^)]+)\)/);
|
|
158
|
+
const params = paramMatch ? paramMatch[1].split(',').map(s => s.trim()) : [];
|
|
159
|
+
const baseType = typeLower.replace(/\s*\([^)]+\)/, '').replace(/\s+unsigned$/i, '').trim();
|
|
160
|
+
|
|
161
|
+
// Build modifier chain
|
|
162
|
+
const modifiers = [];
|
|
163
|
+
const isKey = col.primary || col.autoIncrement;
|
|
164
|
+
if (!isKey && col.nullable) modifiers.push('nullable()');
|
|
165
|
+
if (!isKey && col.unique) modifiers.push('unique()');
|
|
166
|
+
if (!isKey && col.default !== null && col.default !== undefined) {
|
|
167
|
+
const defVal = /^-?\d+(\.\d+)?$/.test(String(col.default))
|
|
168
|
+
? col.default
|
|
169
|
+
: `'${col.default}'`;
|
|
170
|
+
modifiers.push(`default(${defVal})`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Auto-increment primary keys ───────────────────────────────────────────
|
|
174
|
+
if (col.autoIncrement || (col.primary && col.name === 'id')) {
|
|
175
|
+
if (baseType === 'bigint' || baseType === 'bigserial') {
|
|
176
|
+
return { method: 'bigIncrements', args: [`'${col.name}'`], modifiers: [] };
|
|
177
|
+
}
|
|
178
|
+
return { method: 'increments', args: [`'${col.name}'`], modifiers: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Numeric types ────────────────────────────────────────────────────────
|
|
182
|
+
if (baseType === 'tinyint') {
|
|
183
|
+
if (params[0] === '1') return { method: 'boolean', args: [`'${col.name}'`], modifiers };
|
|
184
|
+
return { method: 'tinyInteger', args: [`'${col.name}'`], modifiers };
|
|
185
|
+
}
|
|
186
|
+
if (baseType === 'smallint')
|
|
187
|
+
return { method: 'smallInteger', args: [`'${col.name}'`], modifiers };
|
|
188
|
+
if (baseType === 'mediumint')
|
|
189
|
+
return { method: 'integer', args: [`'${col.name}'`], modifiers };
|
|
190
|
+
if (baseType === 'int' || baseType === 'integer')
|
|
191
|
+
return { method: 'integer', args: [`'${col.name}'`], modifiers };
|
|
192
|
+
if (baseType === 'bigint')
|
|
193
|
+
return { method: 'bigInteger', args: [`'${col.name}'`], modifiers };
|
|
194
|
+
if (baseType === 'serial')
|
|
195
|
+
return { method: 'increments', args: [`'${col.name}'`], modifiers: [] };
|
|
196
|
+
if (baseType === 'bigserial')
|
|
197
|
+
return { method: 'bigIncrements',args: [`'${col.name}'`], modifiers: [] };
|
|
198
|
+
if (baseType === 'float' || baseType === 'double' || baseType === 'real' ||
|
|
199
|
+
baseType === 'double precision')
|
|
200
|
+
return { method: 'float', args: [`'${col.name}'`], modifiers };
|
|
201
|
+
if (baseType === 'decimal' || baseType === 'numeric') {
|
|
202
|
+
const bpArgs = [`'${col.name}'`];
|
|
203
|
+
if (params.length >= 1) bpArgs.push(parseInt(params[0], 10));
|
|
204
|
+
if (params.length >= 2) bpArgs.push(parseInt(params[1], 10));
|
|
205
|
+
return { method: 'decimal', args: bpArgs, modifiers };
|
|
206
|
+
}
|
|
207
|
+
if (baseType === 'boolean' || baseType === 'bool')
|
|
208
|
+
return { method: 'boolean', args: [`'${col.name}'`], modifiers };
|
|
209
|
+
|
|
210
|
+
// ── String types ──────────────────────────────────────────────────────────
|
|
211
|
+
if (baseType === 'varchar' || baseType === 'character varying' || baseType === 'nvarchar') {
|
|
212
|
+
const len = params[0] ? parseInt(params[0], 10) : 255;
|
|
213
|
+
return { method: 'string', args: [`'${col.name}'`, len], modifiers };
|
|
214
|
+
}
|
|
215
|
+
if (baseType === 'char' || baseType === 'character') {
|
|
216
|
+
const len = params[0] ? parseInt(params[0], 10) : 1;
|
|
217
|
+
return { method: 'char', args: [`'${col.name}'`, len], modifiers };
|
|
218
|
+
}
|
|
219
|
+
if (baseType === 'text' || baseType === 'mediumtext' ||
|
|
220
|
+
baseType === 'longtext' || baseType === 'tinytext' || baseType === 'clob')
|
|
221
|
+
return { method: 'text', args: [`'${col.name}'`], modifiers };
|
|
222
|
+
if (baseType === 'blob' || baseType === 'mediumblob' || baseType === 'longblob' ||
|
|
223
|
+
baseType === 'tinyblob' || baseType === 'bytea' || baseType === 'binary' ||
|
|
224
|
+
baseType === 'varbinary')
|
|
225
|
+
return { method: 'binary', args: [`'${col.name}'`], modifiers };
|
|
226
|
+
|
|
227
|
+
// ── Date / time types ─────────────────────────────────────────────────────
|
|
228
|
+
if (baseType === 'date')
|
|
229
|
+
return { method: 'date', args: [`'${col.name}'`], modifiers };
|
|
230
|
+
if (baseType === 'datetime')
|
|
231
|
+
return { method: 'dateTime', args: [`'${col.name}'`], modifiers };
|
|
232
|
+
if (baseType === 'timestamp' || baseType === 'timestamptz')
|
|
233
|
+
return { method: 'timestamp',args: [`'${col.name}'`], modifiers };
|
|
234
|
+
if (baseType === 'time' || baseType === 'timetz')
|
|
235
|
+
return { method: 'time', args: [`'${col.name}'`], modifiers };
|
|
236
|
+
if (baseType === 'year')
|
|
237
|
+
return { method: 'year', args: [`'${col.name}'`], modifiers };
|
|
238
|
+
|
|
239
|
+
// ── JSON / UUID / Enum ────────────────────────────────────────────────────
|
|
240
|
+
if (baseType === 'json' || baseType === 'jsonb')
|
|
241
|
+
return { method: 'json', args: [`'${col.name}'`], modifiers };
|
|
242
|
+
if (baseType === 'uuid')
|
|
243
|
+
return { method: 'uuid', args: [`'${col.name}'`], modifiers };
|
|
244
|
+
if (baseType === 'enum') {
|
|
245
|
+
// Fallback: store as string, pick length from longest enum value
|
|
246
|
+
const maxLen = params.reduce((acc, p) => Math.max(acc, p.replace(/['"]/g, '').length), 50);
|
|
247
|
+
return { method: 'string', args: [`'${col.name}'`, maxLen], modifiers };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Fallback
|
|
251
|
+
return { method: 'string', args: [`'${col.name}'`], modifiers };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Migration Generator ──────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate a complete Migration class from a parsed table definition.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} tableInfo Output of parseCreateTable
|
|
260
|
+
* @returns {{ filename: string, className: string, code: string }}
|
|
261
|
+
*/
|
|
262
|
+
function generateMigration(tableInfo) {
|
|
263
|
+
const { tableName, columns = [], foreignKeys = [] } = tableInfo;
|
|
264
|
+
|
|
265
|
+
// Timestamp prefix YYYYMMDD_HHmmss
|
|
266
|
+
const now = new Date();
|
|
267
|
+
const pad = n => String(n).padStart(2, '0');
|
|
268
|
+
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
269
|
+
+ `_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
270
|
+
|
|
271
|
+
// PascalCase class name: blog_posts → CreateBlogPostsTable
|
|
272
|
+
const className = 'Create'
|
|
273
|
+
+ tableName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
|
|
274
|
+
+ 'Table';
|
|
275
|
+
|
|
276
|
+
// Detect timestamps shorthand
|
|
277
|
+
const useTimestamps = columns.some(c => c.name === 'created_at') &&
|
|
278
|
+
columns.some(c => c.name === 'updated_at');
|
|
279
|
+
|
|
280
|
+
// Build up() body lines
|
|
281
|
+
const upLines = [];
|
|
282
|
+
for (const col of columns) {
|
|
283
|
+
if (useTimestamps && (col.name === 'created_at' || col.name === 'updated_at')) continue;
|
|
284
|
+
|
|
285
|
+
const bp = columnToBlueprint(col);
|
|
286
|
+
const argsStr = bp.args.join(', ');
|
|
287
|
+
let line = `table.${bp.method}(${argsStr})`;
|
|
288
|
+
|
|
289
|
+
for (const mod of bp.modifiers) {
|
|
290
|
+
line += `.${mod}`;
|
|
291
|
+
}
|
|
292
|
+
upLines.push(` ${line};`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Explicit foreign key constraints
|
|
296
|
+
for (const fk of foreignKeys) {
|
|
297
|
+
upLines.push(
|
|
298
|
+
` table.foreign('${fk.column}')`
|
|
299
|
+
+ `.references('${fk.referencedColumn}')`
|
|
300
|
+
+ `.on('${fk.referencedTable}');`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (useTimestamps) {
|
|
305
|
+
upLines.push(` table.timestamps();`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const code = [
|
|
309
|
+
`const { Schema } = require('outlet-orm');`,
|
|
310
|
+
``,
|
|
311
|
+
`class ${className} {`,
|
|
312
|
+
` async up(schema) {`,
|
|
313
|
+
` await schema.create('${tableName}', (table) => {`,
|
|
314
|
+
...upLines,
|
|
315
|
+
` });`,
|
|
316
|
+
` }`,
|
|
317
|
+
``,
|
|
318
|
+
` async down(schema) {`,
|
|
319
|
+
` await schema.dropIfExists('${tableName}');`,
|
|
320
|
+
` }`,
|
|
321
|
+
`}`,
|
|
322
|
+
``,
|
|
323
|
+
`module.exports = new ${className}();`,
|
|
324
|
+
``
|
|
325
|
+
].join('\n');
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
filename: `${ts}_create_${tableName}_table.js`,
|
|
329
|
+
className,
|
|
330
|
+
code
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Seeder Generator ─────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Generate a Seeder class from a table name and an array of row objects.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} tableName
|
|
340
|
+
* @param {object[]} rows
|
|
341
|
+
* @returns {{ filename: string, className: string, code: string }}
|
|
342
|
+
*/
|
|
343
|
+
function generateSeeder(tableName, rows) {
|
|
344
|
+
const className = tableName
|
|
345
|
+
.split('_')
|
|
346
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
347
|
+
.join('') + 'Seeder';
|
|
348
|
+
|
|
349
|
+
const rowsJson = JSON.stringify(rows, null, 4);
|
|
350
|
+
|
|
351
|
+
const code = [
|
|
352
|
+
`class ${className} {`,
|
|
353
|
+
` async run(db) {`,
|
|
354
|
+
` const rows = ${rowsJson};`,
|
|
355
|
+
` for (const row of rows) {`,
|
|
356
|
+
` await db.table('${tableName}').insert(row);`,
|
|
357
|
+
` }`,
|
|
358
|
+
` }`,
|
|
359
|
+
`}`,
|
|
360
|
+
``,
|
|
361
|
+
`module.exports = new ${className}();`,
|
|
362
|
+
``
|
|
363
|
+
].join('\n');
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
filename: `${tableName}_seeder.js`,
|
|
367
|
+
className,
|
|
368
|
+
code
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Batch helpers ────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Parse a full SQL dump (multiple CREATE TABLE statements) and return
|
|
376
|
+
* migration objects for every table found.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} sql
|
|
379
|
+
* @returns {Array<{ filename: string, className: string, code: string }>}
|
|
380
|
+
*/
|
|
381
|
+
function reverseFromSql(sql) {
|
|
382
|
+
if (!sql || typeof sql !== 'string') return [];
|
|
383
|
+
const regex = /CREATE\s+TABLE\s+[\s\S]*?;/gi;
|
|
384
|
+
const stmts = sql.match(regex) || [];
|
|
385
|
+
return stmts
|
|
386
|
+
.map(stmt => parseCreateTable(stmt))
|
|
387
|
+
.filter(Boolean)
|
|
388
|
+
.map(info => generateMigration(info));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ─── Database connection helpers ──────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
async function getDatabaseConfig() {
|
|
394
|
+
const driver = (await question('Driver (mysql/postgres/sqlite): ')).trim().toLowerCase();
|
|
395
|
+
const config = { driver };
|
|
396
|
+
|
|
397
|
+
if (driver === 'mysql') {
|
|
398
|
+
config.host = (await question('Host (default: localhost): ')) || 'localhost';
|
|
399
|
+
config.port = parseInt((await question('Port (default: 3306): ')) || '3306', 10);
|
|
400
|
+
config.database = await question('Database: ');
|
|
401
|
+
config.user = await question('User: ');
|
|
402
|
+
config.password = await question('Password: ');
|
|
403
|
+
} else if (driver === 'postgres' || driver === 'postgresql') {
|
|
404
|
+
config.host = (await question('Host (default: localhost): ')) || 'localhost';
|
|
405
|
+
config.port = parseInt((await question('Port (default: 5432): ')) || '5432', 10);
|
|
406
|
+
config.database = await question('Database: ');
|
|
407
|
+
config.user = await question('User: ');
|
|
408
|
+
config.password = await question('Password: ');
|
|
409
|
+
} else if (driver === 'sqlite') {
|
|
410
|
+
config.database = await question('SQLite file path: ');
|
|
411
|
+
} else {
|
|
412
|
+
throw new Error(`Unsupported driver: ${driver}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return config;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function fetchTablesList(connection, driver) {
|
|
419
|
+
if (driver === 'mysql') {
|
|
420
|
+
const rows = await connection.query('SHOW TABLES');
|
|
421
|
+
const key = Object.keys(rows[0])[0];
|
|
422
|
+
return rows.map(r => r[key]);
|
|
423
|
+
}
|
|
424
|
+
if (driver === 'postgres' || driver === 'postgresql') {
|
|
425
|
+
const rows = await connection.query(
|
|
426
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
|
427
|
+
);
|
|
428
|
+
return rows.map(r => r.table_name);
|
|
429
|
+
}
|
|
430
|
+
if (driver === 'sqlite') {
|
|
431
|
+
const rows = await connection.query(
|
|
432
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
433
|
+
);
|
|
434
|
+
return rows.map(r => r.name);
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`Unsupported driver: ${driver}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function fetchCreateTableSql(connection, driver, tableName) {
|
|
440
|
+
if (driver === 'mysql') {
|
|
441
|
+
const rows = await connection.query(`SHOW CREATE TABLE \`${tableName}\``);
|
|
442
|
+
return rows[0]['Create Table'];
|
|
443
|
+
}
|
|
444
|
+
if (driver === 'postgres' || driver === 'postgresql') {
|
|
445
|
+
// Reconstruct from information_schema
|
|
446
|
+
const cols = await connection.query(`
|
|
447
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
448
|
+
FROM information_schema.columns
|
|
449
|
+
WHERE table_name = '${tableName}'
|
|
450
|
+
ORDER BY ordinal_position
|
|
451
|
+
`);
|
|
452
|
+
let sql = `CREATE TABLE ${tableName} (\n`;
|
|
453
|
+
cols.forEach((c, i) => {
|
|
454
|
+
sql += ` ${c.column_name} ${c.data_type}`;
|
|
455
|
+
if (c.is_nullable === 'NO') sql += ' NOT NULL';
|
|
456
|
+
if (c.column_default) sql += ` DEFAULT ${c.column_default}`;
|
|
457
|
+
if (i < cols.length - 1) sql += ',';
|
|
458
|
+
sql += '\n';
|
|
459
|
+
});
|
|
460
|
+
sql += ');';
|
|
461
|
+
return sql;
|
|
462
|
+
}
|
|
463
|
+
if (driver === 'sqlite') {
|
|
464
|
+
const rows = await connection.query(
|
|
465
|
+
`SELECT sql FROM sqlite_master WHERE type='table' AND name='${tableName}'`
|
|
466
|
+
);
|
|
467
|
+
return rows[0].sql;
|
|
468
|
+
}
|
|
469
|
+
throw new Error(`Unsupported driver: ${driver}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function fetchTableRows(connection, driver, tableName) {
|
|
473
|
+
const query = driver === 'mysql'
|
|
474
|
+
? `SELECT * FROM \`${tableName}\``
|
|
475
|
+
: `SELECT * FROM "${tableName}"`;
|
|
476
|
+
return connection.query(query);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ─── Interactive reverse from database ────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
async function reverseFromDatabase() {
|
|
482
|
+
console.log('\n🔄 Reverse-engineering a live database\n');
|
|
483
|
+
|
|
484
|
+
const dbConfig = await getDatabaseConfig();
|
|
485
|
+
|
|
486
|
+
const { DatabaseConnection } = require('../src/index.js');
|
|
487
|
+
const connection = new DatabaseConnection(dbConfig);
|
|
488
|
+
|
|
489
|
+
console.log('\n⏳ Connecting…');
|
|
490
|
+
await connection.connect();
|
|
491
|
+
console.log('✅ Connected!\n');
|
|
492
|
+
|
|
493
|
+
const tables = await fetchTablesList(connection, dbConfig.driver);
|
|
494
|
+
if (!tables.length) {
|
|
495
|
+
console.error('❌ No tables found.');
|
|
496
|
+
await connection.close();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
console.log(`📋 ${tables.length} table(s) found:\n`);
|
|
501
|
+
tables.forEach((t, i) => console.log(` ${i + 1}. ${t}`));
|
|
502
|
+
console.log('');
|
|
503
|
+
|
|
504
|
+
const migDir = (await question('Migration output dir (default: ./database/migrations): ')) || './database/migrations';
|
|
505
|
+
const seedDir = (await question('Seeder output dir (default: ./database/seeds): ')) || './database/seeds';
|
|
506
|
+
const seedRows = (await question('Generate seeders with actual row data? (y/N): ')).trim().toLowerCase() === 'y';
|
|
507
|
+
|
|
508
|
+
fs.mkdirSync(migDir, { recursive: true });
|
|
509
|
+
fs.mkdirSync(seedDir, { recursive: true });
|
|
510
|
+
|
|
511
|
+
console.log('\n🔍 Generating migrations…\n');
|
|
512
|
+
for (const tableName of tables) {
|
|
513
|
+
const sql = await fetchCreateTableSql(connection, dbConfig.driver, tableName);
|
|
514
|
+
const tableInfo = parseCreateTable(sql);
|
|
515
|
+
if (!tableInfo) { console.warn(` ⚠️ Could not parse schema for: ${tableName}`); continue; }
|
|
516
|
+
|
|
517
|
+
const mig = generateMigration(tableInfo);
|
|
518
|
+
fs.writeFileSync(path.join(migDir, mig.filename), mig.code, 'utf8');
|
|
519
|
+
console.log(` ✅ ${mig.filename}`);
|
|
520
|
+
|
|
521
|
+
if (seedRows) {
|
|
522
|
+
const rows = await fetchTableRows(connection, dbConfig.driver, tableName);
|
|
523
|
+
const seeder = generateSeeder(tableName, rows);
|
|
524
|
+
fs.writeFileSync(path.join(seedDir, seeder.filename), seeder.code, 'utf8');
|
|
525
|
+
console.log(` 🌱 ${seeder.filename} (${rows.length} row${rows.length !== 1 ? 's' : ''})`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log(`\n✨ Done! Migrations → ${migDir}${seedRows ? ` Seeders → ${seedDir}` : ''}\n`);
|
|
530
|
+
await connection.close();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ─── Interactive reverse from SQL file ────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
async function reverseFromFile() {
|
|
536
|
+
console.log('\n📄 Reverse-engineering from a SQL file\n');
|
|
537
|
+
|
|
538
|
+
const sqlFile = (await question('Path to SQL file: ')).trim();
|
|
539
|
+
if (!fs.existsSync(sqlFile)) {
|
|
540
|
+
console.error(`❌ File not found: ${sqlFile}`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const sql = fs.readFileSync(sqlFile, 'utf8');
|
|
545
|
+
const migDir = (await question('Migration output dir (default: ./database/migrations): ')) || './database/migrations';
|
|
546
|
+
fs.mkdirSync(migDir, { recursive: true });
|
|
547
|
+
|
|
548
|
+
const migrations = reverseFromSql(sql);
|
|
549
|
+
if (!migrations.length) {
|
|
550
|
+
console.error('❌ No CREATE TABLE statements found.');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log(`\n✅ ${migrations.length} migration(s) generated:\n`);
|
|
555
|
+
for (const mig of migrations) {
|
|
556
|
+
fs.writeFileSync(path.join(migDir, mig.filename), mig.code, 'utf8');
|
|
557
|
+
console.log(` ✅ ${mig.filename}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
console.log(`\n✨ Done! Migrations → ${migDir}\n`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ─── Main CLI ─────────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
async function main() {
|
|
566
|
+
console.log('\n╔══════════════════════════════════════════╗');
|
|
567
|
+
console.log('║ Outlet ORM — Database Reverse Tool ║');
|
|
568
|
+
console.log('╚══════════════════════════════════════════╝\n');
|
|
569
|
+
console.log(' 1. Reverse from SQL file → Migrations');
|
|
570
|
+
console.log(' 2. Reverse from database → Migrations + Seeders');
|
|
571
|
+
console.log(' 3. Quit\n');
|
|
572
|
+
|
|
573
|
+
const choice = (await question('Your choice: ')).trim();
|
|
574
|
+
|
|
575
|
+
switch (choice) {
|
|
576
|
+
case '1': await reverseFromFile(); break;
|
|
577
|
+
case '2': await reverseFromDatabase(); break;
|
|
578
|
+
case '3': console.log('Goodbye! 👋\n'); break;
|
|
579
|
+
default: console.log('❌ Invalid choice.\n');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (rl) rl.close();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ─── Exports (for testing) ────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
if (require.main === module) {
|
|
588
|
+
main().catch(err => {
|
|
589
|
+
console.error('❌ Fatal:', err.message);
|
|
590
|
+
if (rl) rl.close();
|
|
591
|
+
process.exit(1);
|
|
592
|
+
});
|
|
593
|
+
} else {
|
|
594
|
+
module.exports = {
|
|
595
|
+
parseCreateTable,
|
|
596
|
+
splitDefinitions,
|
|
597
|
+
columnToBlueprint,
|
|
598
|
+
generateMigration,
|
|
599
|
+
generateSeeder,
|
|
600
|
+
reverseFromSql,
|
|
601
|
+
};
|
|
602
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "outlet-orm",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.5.1",
|
|
4
4
|
"description": "A Laravel Eloquent-inspired ORM for Node.js with support for MySQL, PostgreSQL, and SQLite",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"outlet-init": "
|
|
9
|
-
"outlet-convert": "
|
|
10
|
-
"outlet-migrate": "
|
|
8
|
+
"outlet-init": "bin/init.js",
|
|
9
|
+
"outlet-convert": "bin/convert.js",
|
|
10
|
+
"outlet-migrate": "bin/migrate.js",
|
|
11
|
+
"outlet-reverse": "bin/reverse.js"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"src/**",
|
|
14
|
-
"lib/**",
|
|
15
15
|
"bin/**",
|
|
16
16
|
"types/**",
|
|
17
17
|
"README.md",
|
|
@@ -21,15 +21,17 @@
|
|
|
21
21
|
"test": "jest",
|
|
22
22
|
"test:watch": "jest --watch",
|
|
23
23
|
"test:coverage": "jest --coverage",
|
|
24
|
-
"lint": "eslint \"{src,bin
|
|
25
|
-
"lint:fix": "eslint \"{src,bin
|
|
24
|
+
"lint": "eslint \"{src,bin}/**/*.js\"",
|
|
25
|
+
"lint:fix": "eslint \"{src,bin}/**/*.js\" --fix",
|
|
26
26
|
"migrate": "node bin/migrate.js migrate",
|
|
27
27
|
"migrate:make": "node bin/migrate.js make",
|
|
28
28
|
"migrate:rollback": "node bin/migrate.js rollback --steps 1",
|
|
29
29
|
"migrate:status": "node bin/migrate.js status",
|
|
30
30
|
"migrate:reset": "node bin/migrate.js reset --yes",
|
|
31
31
|
"migrate:refresh": "node bin/migrate.js refresh --yes",
|
|
32
|
-
"migrate:fresh": "node bin/migrate.js fresh --yes"
|
|
32
|
+
"migrate:fresh": "node bin/migrate.js fresh --yes",
|
|
33
|
+
"seed": "node bin/migrate.js seed",
|
|
34
|
+
"seed:make": "node bin/migrate.js make:seed"
|
|
33
35
|
},
|
|
34
36
|
"keywords": [
|
|
35
37
|
"orm",
|
|
@@ -46,14 +48,15 @@
|
|
|
46
48
|
"license": "MIT",
|
|
47
49
|
"repository": {
|
|
48
50
|
"type": "git",
|
|
49
|
-
"url": "https://github.com/omgbwa-yasse/outlet-orm.git"
|
|
51
|
+
"url": "git+https://github.com/omgbwa-yasse/outlet-orm.git"
|
|
50
52
|
},
|
|
51
53
|
"bugs": {
|
|
52
54
|
"url": "https://github.com/omgbwa-yasse/outlet-orm/issues"
|
|
53
55
|
},
|
|
54
56
|
"homepage": "https://github.com/omgbwa-yasse/outlet-orm#readme",
|
|
55
57
|
"dependencies": {
|
|
56
|
-
"dotenv": "^16.4.5"
|
|
58
|
+
"dotenv": "^16.4.5",
|
|
59
|
+
"pluralize": "^8.0.0"
|
|
57
60
|
},
|
|
58
61
|
"peerDependencies": {
|
|
59
62
|
"mysql2": "^3.15.2",
|
|
@@ -61,9 +64,15 @@
|
|
|
61
64
|
"sqlite3": "^5.1.6"
|
|
62
65
|
},
|
|
63
66
|
"peerDependenciesMeta": {
|
|
64
|
-
"mysql2": {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
"mysql2": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"pg": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"sqlite3": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
67
76
|
},
|
|
68
77
|
"devDependencies": {
|
|
69
78
|
"@types/node": "^20.10.0",
|