db-model-router 1.0.8 → 1.0.10
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 +25 -4
- package/db-manager/.dbmanager.sqlite-shm +0 -0
- package/db-manager/.dbmanager.sqlite-wal +0 -0
- package/demo/package-lock.json +1255 -93
- package/demo/package.json +5 -1
- package/demo/routes/index.js +0 -14
- package/demo/seeds/saas-seed.js +1 -1
- package/docs/dbmr-schema-spec.md +393 -0
- package/package.json +4 -2
- package/skill/SKILL.md +47 -4
- package/src/cli/commands/generate.js +39 -9
- package/src/cli/diff-engine.js +19 -7
- package/src/cli/generate-migration.js +207 -19
- package/src/cli/generate-route.js +152 -54
- package/src/cli/init/dependencies.js +4 -0
- package/src/cli/saas/generate-saas-routes.js +3 -13
- /package/demo/migrations/{20260510092158_create_migrations_table.sql → 20260518204325_create_migrations_table.sql} +0 -0
- /package/demo/migrations/{20260510092159_create_saas_tables.sql → 20260518204325_create_saas_tables.sql} +0 -0
- /package/demo/migrations/{20260510092159_create_tables.sql → 20260518204325_create_tables.sql} +0 -0
package/src/cli/diff-engine.js
CHANGED
|
@@ -5,6 +5,7 @@ const path = require("path");
|
|
|
5
5
|
const { generateModelFile } = require("./generate-model.js");
|
|
6
6
|
const {
|
|
7
7
|
generateRouteFile,
|
|
8
|
+
generateParentRouteFile,
|
|
8
9
|
generateChildRouteFile,
|
|
9
10
|
generateRoutesIndexFile,
|
|
10
11
|
generateTestFile,
|
|
@@ -55,8 +56,11 @@ function buildExpectedFiles(meta, relationships) {
|
|
|
55
56
|
|
|
56
57
|
// Collect child tables
|
|
57
58
|
const nestedChildren = new Set();
|
|
59
|
+
const childrenByParent = {};
|
|
58
60
|
for (const rel of relationships) {
|
|
59
61
|
nestedChildren.add(rel.child);
|
|
62
|
+
if (!childrenByParent[rel.parent]) childrenByParent[rel.parent] = [];
|
|
63
|
+
childrenByParent[rel.parent].push(rel);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
// Model files
|
|
@@ -67,16 +71,24 @@ function buildExpectedFiles(meta, relationships) {
|
|
|
67
71
|
// Route files (top-level only, skip children)
|
|
68
72
|
for (const m of meta) {
|
|
69
73
|
if (nestedChildren.has(m.table)) continue;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
const children = childrenByParent[m.table] || [];
|
|
75
|
+
if (children.length > 0) {
|
|
76
|
+
expected.set(
|
|
77
|
+
`routes/${m.table}/index.js`,
|
|
78
|
+
generateParentRouteFile(m.table, children),
|
|
79
|
+
);
|
|
80
|
+
} else {
|
|
81
|
+
expected.set(
|
|
82
|
+
`routes/${m.table}/index.js`,
|
|
83
|
+
generateRouteFile(m.table, modelsRelPath),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
// Child route files
|
|
88
|
+
// Child route files inside parent folders: routes/<parent>/<child>/index.js
|
|
77
89
|
for (const rel of relationships) {
|
|
78
90
|
expected.set(
|
|
79
|
-
`routes/${rel.parent}/${rel.child}.js`,
|
|
91
|
+
`routes/${rel.parent}/${rel.child}/index.js`,
|
|
80
92
|
generateChildRouteFile(
|
|
81
93
|
rel.child,
|
|
82
94
|
rel.parent,
|
|
@@ -100,7 +112,7 @@ function buildExpectedFiles(meta, relationships) {
|
|
|
100
112
|
if (nestedChildren.has(m.table)) continue;
|
|
101
113
|
expected.set(
|
|
102
114
|
`test/${m.table}.test.js`,
|
|
103
|
-
generateTestFile(m.table, m.primary_key),
|
|
115
|
+
generateTestFile(m.table, m.primary_key, m.structure),
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
|
|
@@ -11,17 +11,83 @@
|
|
|
11
11
|
// Column type mapping per adapter
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Known base types — anything not in this set is treated as a validator.
|
|
16
|
+
*/
|
|
17
|
+
const BASE_TYPES = new Set([
|
|
18
|
+
"auto_increment",
|
|
19
|
+
"string",
|
|
20
|
+
"integer",
|
|
21
|
+
"numeric",
|
|
22
|
+
"boolean",
|
|
23
|
+
"datetime",
|
|
24
|
+
"object",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a dbmr column rule string into its components.
|
|
29
|
+
*
|
|
30
|
+
* Format: [required|]<type>[:<subtype>][|<validator>...]
|
|
31
|
+
*
|
|
32
|
+
* @param {string} rule - e.g. "required|string:text|minLength:10|maxLength:5000"
|
|
33
|
+
* @returns {{ isRequired: boolean, baseType: string, subType: string|null, validators: string[] }}
|
|
34
|
+
*/
|
|
35
|
+
function parseColumnRule(rule) {
|
|
36
|
+
const parts = rule.split("|");
|
|
37
|
+
const isRequired = parts.includes("required");
|
|
38
|
+
|
|
39
|
+
let baseType = "string";
|
|
40
|
+
let subType = null;
|
|
41
|
+
const validators = [];
|
|
42
|
+
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
if (part === "required") continue;
|
|
45
|
+
|
|
46
|
+
// Check if this part is a base type (possibly with :subtype)
|
|
47
|
+
const colonIdx = part.indexOf(":");
|
|
48
|
+
const token = colonIdx > -1 ? part.slice(0, colonIdx) : part;
|
|
49
|
+
|
|
50
|
+
if (!baseType || baseType === "string") {
|
|
51
|
+
if (BASE_TYPES.has(token)) {
|
|
52
|
+
baseType = token;
|
|
53
|
+
subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If we already found the base type, or this isn't a known type, it's a validator
|
|
59
|
+
if (BASE_TYPES.has(token) && baseType === "string" && token !== "string") {
|
|
60
|
+
baseType = token;
|
|
61
|
+
subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
|
|
62
|
+
} else if (BASE_TYPES.has(part)) {
|
|
63
|
+
// Bare base type without colon, and we haven't set one yet
|
|
64
|
+
if (baseType === "string" && part !== "string") {
|
|
65
|
+
baseType = part;
|
|
66
|
+
} else if (baseType === "string" && part === "string") {
|
|
67
|
+
// already default, skip
|
|
68
|
+
} else {
|
|
69
|
+
validators.push(part);
|
|
70
|
+
}
|
|
71
|
+
} else if (BASE_TYPES.has(token)) {
|
|
72
|
+
baseType = token;
|
|
73
|
+
subType = colonIdx > -1 ? part.slice(colonIdx + 1) : null;
|
|
74
|
+
} else {
|
|
75
|
+
validators.push(part);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { isRequired, baseType, subType, validators };
|
|
80
|
+
}
|
|
81
|
+
|
|
14
82
|
/**
|
|
15
83
|
* Map a dbmr column rule to a SQL column type for the given adapter.
|
|
16
84
|
*
|
|
17
|
-
* @param {string} rule - e.g. "required|string", "auto_increment", "integer"
|
|
85
|
+
* @param {string} rule - e.g. "required|string:text|minLength:10", "auto_increment", "integer:bigint"
|
|
18
86
|
* @param {string} adapter - e.g. "postgres", "mysql", "sqlite3"
|
|
19
|
-
* @returns {{ sqlType: string, nullable: boolean, isAutoIncrement: boolean }}
|
|
87
|
+
* @returns {{ sqlType: string, nullable: boolean, isAutoIncrement: boolean, validators: string[] }}
|
|
20
88
|
*/
|
|
21
89
|
function mapColumnType(rule, adapter) {
|
|
22
|
-
const
|
|
23
|
-
const isRequired = parts.includes("required");
|
|
24
|
-
const baseType = parts.filter((p) => p !== "required")[0] || "string";
|
|
90
|
+
const { isRequired, baseType, subType, validators } = parseColumnRule(rule);
|
|
25
91
|
|
|
26
92
|
let sqlType;
|
|
27
93
|
let isAutoIncrement = false;
|
|
@@ -32,13 +98,13 @@ function mapColumnType(rule, adapter) {
|
|
|
32
98
|
sqlType = autoIncrementType(adapter);
|
|
33
99
|
break;
|
|
34
100
|
case "string":
|
|
35
|
-
sqlType = stringType(adapter);
|
|
101
|
+
sqlType = stringType(adapter, subType);
|
|
36
102
|
break;
|
|
37
103
|
case "integer":
|
|
38
|
-
sqlType = integerType(adapter);
|
|
104
|
+
sqlType = integerType(adapter, subType);
|
|
39
105
|
break;
|
|
40
106
|
case "numeric":
|
|
41
|
-
sqlType = numericType(adapter);
|
|
107
|
+
sqlType = numericType(adapter, subType);
|
|
42
108
|
break;
|
|
43
109
|
case "boolean":
|
|
44
110
|
sqlType = booleanType(adapter);
|
|
@@ -50,13 +116,14 @@ function mapColumnType(rule, adapter) {
|
|
|
50
116
|
sqlType = jsonType(adapter);
|
|
51
117
|
break;
|
|
52
118
|
default:
|
|
53
|
-
sqlType = stringType(adapter);
|
|
119
|
+
sqlType = stringType(adapter, subType);
|
|
54
120
|
}
|
|
55
121
|
|
|
56
122
|
return {
|
|
57
123
|
sqlType,
|
|
58
124
|
nullable: !isRequired && !isAutoIncrement,
|
|
59
125
|
isAutoIncrement,
|
|
126
|
+
validators,
|
|
60
127
|
};
|
|
61
128
|
}
|
|
62
129
|
|
|
@@ -77,20 +144,140 @@ function autoIncrementType(adapter) {
|
|
|
77
144
|
}
|
|
78
145
|
}
|
|
79
146
|
|
|
80
|
-
function stringType(adapter) {
|
|
81
|
-
if (
|
|
82
|
-
|
|
147
|
+
function stringType(adapter, subType) {
|
|
148
|
+
if (!subType) {
|
|
149
|
+
if (adapter === "oracle") return "VARCHAR2(255)";
|
|
150
|
+
if (adapter === "mssql") return "NVARCHAR(255)";
|
|
151
|
+
return "VARCHAR(255)";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// varchar(N) or char(N) with explicit length
|
|
155
|
+
const varcharMatch = subType.match(/^varchar\((\d+)\)$/i);
|
|
156
|
+
if (varcharMatch) {
|
|
157
|
+
const n = varcharMatch[1];
|
|
158
|
+
if (adapter === "oracle") return `VARCHAR2(${n})`;
|
|
159
|
+
if (adapter === "mssql") return `NVARCHAR(${n})`;
|
|
160
|
+
return `VARCHAR(${n})`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const charMatch = subType.match(/^char\((\d+)\)$/i);
|
|
164
|
+
if (charMatch) {
|
|
165
|
+
const n = charMatch[1];
|
|
166
|
+
if (adapter === "oracle") return `CHAR(${n})`;
|
|
167
|
+
if (adapter === "mssql") return `NCHAR(${n})`;
|
|
168
|
+
if (adapter === "sqlite3") return "TEXT";
|
|
169
|
+
return `CHAR(${n})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
switch (subType.toLowerCase()) {
|
|
173
|
+
case "text":
|
|
174
|
+
if (adapter === "oracle") return "CLOB";
|
|
175
|
+
if (adapter === "mssql") return "NVARCHAR(MAX)";
|
|
176
|
+
return "TEXT";
|
|
177
|
+
case "mediumtext":
|
|
178
|
+
if (adapter === "mysql" || adapter === "mariadb") return "MEDIUMTEXT";
|
|
179
|
+
if (adapter === "oracle") return "CLOB";
|
|
180
|
+
if (adapter === "mssql") return "NVARCHAR(MAX)";
|
|
181
|
+
return "TEXT";
|
|
182
|
+
case "longtext":
|
|
183
|
+
if (adapter === "mysql" || adapter === "mariadb") return "LONGTEXT";
|
|
184
|
+
if (adapter === "oracle") return "CLOB";
|
|
185
|
+
if (adapter === "mssql") return "NVARCHAR(MAX)";
|
|
186
|
+
return "TEXT";
|
|
187
|
+
case "char":
|
|
188
|
+
if (adapter === "oracle") return "CHAR(255)";
|
|
189
|
+
if (adapter === "mssql") return "NCHAR(255)";
|
|
190
|
+
if (adapter === "sqlite3") return "TEXT";
|
|
191
|
+
return "CHAR(255)";
|
|
192
|
+
case "uuid":
|
|
193
|
+
if (adapter === "postgres" || adapter === "cockroachdb") return "UUID";
|
|
194
|
+
if (adapter === "mssql") return "UNIQUEIDENTIFIER";
|
|
195
|
+
if (adapter === "oracle") return "CHAR(36)";
|
|
196
|
+
return "CHAR(36)";
|
|
197
|
+
default:
|
|
198
|
+
if (adapter === "oracle") return "VARCHAR2(255)";
|
|
199
|
+
if (adapter === "mssql") return "NVARCHAR(255)";
|
|
200
|
+
return "VARCHAR(255)";
|
|
201
|
+
}
|
|
83
202
|
}
|
|
84
203
|
|
|
85
|
-
function integerType(adapter) {
|
|
86
|
-
if (
|
|
87
|
-
|
|
204
|
+
function integerType(adapter, subType) {
|
|
205
|
+
if (!subType) {
|
|
206
|
+
if (adapter === "oracle") return "NUMBER(10)";
|
|
207
|
+
return "INTEGER";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
switch (subType.toLowerCase()) {
|
|
211
|
+
case "tinyint":
|
|
212
|
+
if (adapter === "oracle") return "NUMBER(3)";
|
|
213
|
+
if (adapter === "mssql") return "TINYINT";
|
|
214
|
+
if (adapter === "postgres" || adapter === "cockroachdb")
|
|
215
|
+
return "SMALLINT";
|
|
216
|
+
if (adapter === "sqlite3") return "INTEGER";
|
|
217
|
+
return "TINYINT";
|
|
218
|
+
case "smallint":
|
|
219
|
+
if (adapter === "oracle") return "NUMBER(5)";
|
|
220
|
+
if (adapter === "sqlite3") return "INTEGER";
|
|
221
|
+
return "SMALLINT";
|
|
222
|
+
case "bigint":
|
|
223
|
+
if (adapter === "oracle") return "NUMBER(19)";
|
|
224
|
+
if (adapter === "sqlite3") return "INTEGER";
|
|
225
|
+
return "BIGINT";
|
|
226
|
+
case "unsigned":
|
|
227
|
+
if (adapter === "mysql" || adapter === "mariadb") return "INT UNSIGNED";
|
|
228
|
+
if (adapter === "oracle") return "NUMBER(10)";
|
|
229
|
+
// PostgreSQL, SQLite, MSSQL don't support UNSIGNED — use plain INT
|
|
230
|
+
return "INTEGER";
|
|
231
|
+
case "bigint_unsigned":
|
|
232
|
+
if (adapter === "mysql" || adapter === "mariadb")
|
|
233
|
+
return "BIGINT UNSIGNED";
|
|
234
|
+
if (adapter === "oracle") return "NUMBER(19)";
|
|
235
|
+
if (adapter === "sqlite3") return "INTEGER";
|
|
236
|
+
return "BIGINT";
|
|
237
|
+
default:
|
|
238
|
+
if (adapter === "oracle") return "NUMBER(10)";
|
|
239
|
+
return "INTEGER";
|
|
240
|
+
}
|
|
88
241
|
}
|
|
89
242
|
|
|
90
|
-
function numericType(adapter) {
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
243
|
+
function numericType(adapter, subType) {
|
|
244
|
+
if (!subType) {
|
|
245
|
+
if (adapter === "oracle") return "NUMBER(12,2)";
|
|
246
|
+
if (adapter === "mssql") return "DECIMAL(12,2)";
|
|
247
|
+
return "DECIMAL(12,2)";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// decimal(P,S) with explicit precision/scale
|
|
251
|
+
const decimalMatch = subType.match(/^decimal\((\d+),(\d+)\)$/i);
|
|
252
|
+
if (decimalMatch) {
|
|
253
|
+
const p = decimalMatch[1];
|
|
254
|
+
const s = decimalMatch[2];
|
|
255
|
+
if (adapter === "oracle") return `NUMBER(${p},${s})`;
|
|
256
|
+
return `DECIMAL(${p},${s})`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
switch (subType.toLowerCase()) {
|
|
260
|
+
case "float":
|
|
261
|
+
if (adapter === "oracle") return "FLOAT";
|
|
262
|
+
if (adapter === "postgres" || adapter === "cockroachdb") return "REAL";
|
|
263
|
+
if (adapter === "sqlite3") return "REAL";
|
|
264
|
+
return "FLOAT";
|
|
265
|
+
case "double":
|
|
266
|
+
if (adapter === "oracle") return "BINARY_DOUBLE";
|
|
267
|
+
if (adapter === "postgres" || adapter === "cockroachdb")
|
|
268
|
+
return "DOUBLE PRECISION";
|
|
269
|
+
if (adapter === "sqlite3") return "REAL";
|
|
270
|
+
if (adapter === "mssql") return "FLOAT";
|
|
271
|
+
return "DOUBLE";
|
|
272
|
+
case "money":
|
|
273
|
+
if (adapter === "postgres" || adapter === "cockroachdb") return "MONEY";
|
|
274
|
+
if (adapter === "mssql") return "MONEY";
|
|
275
|
+
if (adapter === "oracle") return "NUMBER(19,4)";
|
|
276
|
+
return "DECIMAL(19,4)";
|
|
277
|
+
default:
|
|
278
|
+
if (adapter === "oracle") return "NUMBER(12,2)";
|
|
279
|
+
return "DECIMAL(12,2)";
|
|
280
|
+
}
|
|
94
281
|
}
|
|
95
282
|
|
|
96
283
|
function booleanType(adapter) {
|
|
@@ -353,4 +540,5 @@ module.exports = {
|
|
|
353
540
|
generateMigrationFiles,
|
|
354
541
|
generateCreateTableSQL,
|
|
355
542
|
mapColumnType,
|
|
543
|
+
parseColumnRule,
|
|
356
544
|
};
|
|
@@ -166,15 +166,19 @@ function generateSimpleRoutesIndexFile(tableNames) {
|
|
|
166
166
|
* Generate a test file for a route covering all CRUD methods.
|
|
167
167
|
* Uses supertest + the app's express setup.
|
|
168
168
|
*/
|
|
169
|
-
function generateTestFile(tableName, pk) {
|
|
169
|
+
function generateTestFile(tableName, pk, structure) {
|
|
170
170
|
const varName = safeVarName(tableName);
|
|
171
|
+
const fakerFields = generateFakerFields(structure || {});
|
|
172
|
+
const fakerImport = `import { faker } from "@faker-js/faker";`;
|
|
173
|
+
|
|
171
174
|
return `import assert from "assert";
|
|
172
175
|
import express from "express";
|
|
173
176
|
import request from "supertest";
|
|
174
177
|
import "dotenv/config";
|
|
175
|
-
import "
|
|
178
|
+
import "../commons/db.js";
|
|
176
179
|
import dbModelRouter from "db-model-router";
|
|
177
|
-
import { ${varName} } from "
|
|
180
|
+
import { ${varName} } from "../models/index.js";
|
|
181
|
+
${fakerImport}
|
|
178
182
|
|
|
179
183
|
const { route } = dbModelRouter;
|
|
180
184
|
|
|
@@ -185,92 +189,186 @@ function createApp() {
|
|
|
185
189
|
return app;
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
${fakerFields.helperFn}
|
|
193
|
+
|
|
188
194
|
describe("${tableName} routes", function () {
|
|
189
195
|
let app;
|
|
196
|
+
let createdId;
|
|
190
197
|
|
|
191
198
|
before(function () {
|
|
192
199
|
app = createApp();
|
|
193
200
|
});
|
|
194
201
|
|
|
195
|
-
describe("
|
|
196
|
-
it("should
|
|
202
|
+
describe("CRUD lifecycle", function () {
|
|
203
|
+
it("POST /${tableName}/add — should insert a record", async function () {
|
|
204
|
+
const data = generateFakeData();
|
|
205
|
+
const res = await request(app)
|
|
206
|
+
.post("/${tableName}/add")
|
|
207
|
+
.send(data);
|
|
208
|
+
assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
|
|
209
|
+
createdId = res.body.${pk} || res.body.id;
|
|
210
|
+
assert.ok(createdId, "Response should contain the created record ID");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("GET /${tableName}/ — should list records including created one", async function () {
|
|
197
214
|
const res = await request(app).get("/${tableName}/");
|
|
198
215
|
assert.strictEqual(res.status, 200);
|
|
199
216
|
assert.ok(Array.isArray(res.body.data));
|
|
217
|
+
assert.ok(res.body.data.length > 0, "Should have at least one record");
|
|
200
218
|
});
|
|
201
|
-
});
|
|
202
219
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.send({});
|
|
208
|
-
assert.ok([200, 201, 400, 422, 500].includes(res.status));
|
|
220
|
+
it("GET /${tableName}/:${pk} — should get the created record", async function () {
|
|
221
|
+
const res = await request(app).get(\`/${tableName}/\${createdId}\`);
|
|
222
|
+
assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}\`);
|
|
223
|
+
assert.strictEqual(String(res.body.${pk} || res.body.id), String(createdId));
|
|
209
224
|
});
|
|
210
|
-
});
|
|
211
225
|
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
it("PUT /${tableName}/:${pk} — should update the record", async function () {
|
|
227
|
+
const data = { ...generateFakeData(), ${pk}: createdId };
|
|
214
228
|
const res = await request(app)
|
|
215
|
-
.
|
|
216
|
-
.send(
|
|
217
|
-
assert.
|
|
229
|
+
.put(\`/${tableName}/\${createdId}\`)
|
|
230
|
+
.send(data);
|
|
231
|
+
assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
|
|
218
232
|
});
|
|
219
|
-
});
|
|
220
233
|
|
|
221
|
-
|
|
222
|
-
it("should get a record by ID", async function () {
|
|
223
|
-
const res = await request(app).get("/${tableName}/1");
|
|
224
|
-
assert.ok([200, 404].includes(res.status));
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe("PUT /${tableName}/:${pk}", function () {
|
|
229
|
-
it("should update a record", async function () {
|
|
234
|
+
it("PATCH /${tableName}/:${pk} — should partially update the record", async function () {
|
|
230
235
|
const res = await request(app)
|
|
231
|
-
.
|
|
232
|
-
.send({});
|
|
233
|
-
assert.
|
|
236
|
+
.patch(\`/${tableName}/\${createdId}\`)
|
|
237
|
+
.send(${fakerFields.patchPayload});
|
|
238
|
+
assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
|
|
234
239
|
});
|
|
235
|
-
});
|
|
236
240
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
.patch("/${tableName}/1")
|
|
241
|
-
.send({});
|
|
242
|
-
assert.ok([200, 400, 404].includes(res.status));
|
|
241
|
+
it("DELETE /${tableName}/:${pk} — should delete the record", async function () {
|
|
242
|
+
const res = await request(app).delete(\`/${tableName}/\${createdId}\`);
|
|
243
|
+
assert.ok([200, 204].includes(res.status), \`Expected 200/204, got \${res.status}\`);
|
|
243
244
|
});
|
|
244
|
-
});
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
assert.ok([200, 204, 404].includes(res.status));
|
|
246
|
+
it("GET /${tableName}/:${pk} — should return 404 after deletion", async function () {
|
|
247
|
+
const res = await request(app).get(\`/${tableName}/\${createdId}\`);
|
|
248
|
+
assert.strictEqual(res.status, 404);
|
|
250
249
|
});
|
|
251
250
|
});
|
|
252
251
|
|
|
253
|
-
describe("
|
|
254
|
-
it("should bulk
|
|
252
|
+
describe("Bulk operations", function () {
|
|
253
|
+
it("POST /${tableName}/ — should bulk insert records", async function () {
|
|
254
|
+
const data = [generateFakeData(), generateFakeData()];
|
|
255
255
|
const res = await request(app)
|
|
256
|
-
.
|
|
257
|
-
.send({ data
|
|
258
|
-
assert.
|
|
256
|
+
.post("/${tableName}/")
|
|
257
|
+
.send({ data });
|
|
258
|
+
assert.strictEqual(res.status, 200, \`Expected 200, got \${res.status}: \${JSON.stringify(res.body)}\`);
|
|
259
259
|
});
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
describe("
|
|
263
|
-
it("should
|
|
264
|
-
const res = await request(app)
|
|
265
|
-
|
|
266
|
-
.send({});
|
|
267
|
-
assert.ok([200, 204, 400].includes(res.status));
|
|
262
|
+
describe("Error handling", function () {
|
|
263
|
+
it("GET /${tableName}/:${pk} — should return 404 for non-existent ID", async function () {
|
|
264
|
+
const res = await request(app).get("/${tableName}/999999");
|
|
265
|
+
assert.strictEqual(res.status, 404);
|
|
268
266
|
});
|
|
269
267
|
});
|
|
270
268
|
});
|
|
271
269
|
`;
|
|
272
270
|
}
|
|
273
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Generate a faker helper function string and patch payload based on model structure.
|
|
274
|
+
*
|
|
275
|
+
* @param {object} structure - Model structure { colName: "rule" }
|
|
276
|
+
* @returns {{ helperFn: string, patchPayload: string }}
|
|
277
|
+
*/
|
|
278
|
+
function generateFakerFields(structure) {
|
|
279
|
+
const lines = [];
|
|
280
|
+
let firstStringCol = null;
|
|
281
|
+
|
|
282
|
+
for (const [col, rule] of Object.entries(structure)) {
|
|
283
|
+
const fakerCall = columnToFaker(col, rule);
|
|
284
|
+
lines.push(` ${col}: ${fakerCall},`);
|
|
285
|
+
if (!firstStringCol && rule.includes("string")) {
|
|
286
|
+
firstStringCol = col;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const helperFn = `function generateFakeData() {
|
|
291
|
+
return {
|
|
292
|
+
${lines.join("\n")}
|
|
293
|
+
};
|
|
294
|
+
}`;
|
|
295
|
+
|
|
296
|
+
const patchPayload = firstStringCol
|
|
297
|
+
? `{ ${firstStringCol}: faker.lorem.word() }`
|
|
298
|
+
: `{ ${Object.keys(structure)[0] || "name"}: faker.lorem.word() }`;
|
|
299
|
+
|
|
300
|
+
return { helperFn, patchPayload };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Map a column name + rule to an appropriate faker call.
|
|
305
|
+
*/
|
|
306
|
+
function columnToFaker(col, rule) {
|
|
307
|
+
const lowerCol = col.toLowerCase();
|
|
308
|
+
|
|
309
|
+
// Name-based heuristics first
|
|
310
|
+
if (lowerCol === "email") return "faker.internet.email()";
|
|
311
|
+
if (lowerCol === "phone") return "faker.phone.number()";
|
|
312
|
+
if (lowerCol === "name" || lowerCol.endsWith("_name"))
|
|
313
|
+
return "faker.person.fullName()";
|
|
314
|
+
if (lowerCol === "url" || lowerCol.endsWith("_url"))
|
|
315
|
+
return "faker.internet.url()";
|
|
316
|
+
if (lowerCol === "slug") return "faker.helpers.slugify(faker.lorem.words(2))";
|
|
317
|
+
if (lowerCol === "password" || lowerCol === "password_hash")
|
|
318
|
+
return "faker.internet.password()";
|
|
319
|
+
if (lowerCol === "secret" || lowerCol === "key" || lowerCol === "token")
|
|
320
|
+
return "faker.string.alphanumeric(32)";
|
|
321
|
+
if (lowerCol === "title") return "faker.lorem.sentence()";
|
|
322
|
+
if (
|
|
323
|
+
lowerCol === "description" ||
|
|
324
|
+
lowerCol === "body" ||
|
|
325
|
+
lowerCol === "content"
|
|
326
|
+
)
|
|
327
|
+
return "faker.lorem.paragraph()";
|
|
328
|
+
if (lowerCol === "status")
|
|
329
|
+
return "faker.helpers.arrayElement(['active', 'inactive', 'pending'])";
|
|
330
|
+
if (lowerCol === "event_type")
|
|
331
|
+
return "faker.helpers.arrayElement(['user.created', 'order.placed', 'payment.received'])";
|
|
332
|
+
if (lowerCol === "currency") return "faker.finance.currencyCode()";
|
|
333
|
+
if (
|
|
334
|
+
lowerCol.includes("amount") ||
|
|
335
|
+
lowerCol.includes("price") ||
|
|
336
|
+
lowerCol.includes("total") ||
|
|
337
|
+
lowerCol.includes("subtotal")
|
|
338
|
+
)
|
|
339
|
+
return "parseFloat(faker.finance.amount())";
|
|
340
|
+
if (lowerCol.includes("quantity") || lowerCol.includes("count"))
|
|
341
|
+
return "faker.number.int({ min: 1, max: 100 })";
|
|
342
|
+
if (lowerCol === "unique_attribute") return "faker.string.uuid()";
|
|
343
|
+
if (lowerCol === "attributes") return "{ custom: faker.lorem.word() }";
|
|
344
|
+
if (lowerCol === "permission")
|
|
345
|
+
return "{ module: 'users', action: 'read', scope: 'tenant' }";
|
|
346
|
+
if (lowerCol === "response_body") return "faker.lorem.sentence()";
|
|
347
|
+
if (lowerCol === "response_status_code")
|
|
348
|
+
return "faker.helpers.arrayElement([200, 201, 400, 500])";
|
|
349
|
+
|
|
350
|
+
// Type-based fallback
|
|
351
|
+
const parts = rule.split("|");
|
|
352
|
+
const baseType = parts.filter((p) => p !== "required")[0] || "string";
|
|
353
|
+
|
|
354
|
+
switch (baseType) {
|
|
355
|
+
case "integer":
|
|
356
|
+
if (lowerCol.endsWith("_id"))
|
|
357
|
+
return "faker.number.int({ min: 1, max: 100 })";
|
|
358
|
+
return "faker.number.int({ min: 1, max: 1000 })";
|
|
359
|
+
case "numeric":
|
|
360
|
+
return "parseFloat(faker.finance.amount())";
|
|
361
|
+
case "boolean":
|
|
362
|
+
return "faker.datatype.boolean()";
|
|
363
|
+
case "object":
|
|
364
|
+
return "{ key: faker.lorem.word() }";
|
|
365
|
+
case "datetime":
|
|
366
|
+
return "faker.date.recent().toISOString()";
|
|
367
|
+
default:
|
|
368
|
+
return "faker.lorem.word()";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
274
372
|
/**
|
|
275
373
|
* Generate a child route test file that tests the nested parent/:fk/child endpoints.
|
|
276
374
|
*/
|
|
@@ -62,6 +62,10 @@ function collectDependencies(answers) {
|
|
|
62
62
|
|
|
63
63
|
// Dev dependencies
|
|
64
64
|
devDependencies["nodemon"] = "latest";
|
|
65
|
+
devDependencies["mocha"] = "latest";
|
|
66
|
+
devDependencies["supertest"] = "latest";
|
|
67
|
+
devDependencies["dotenv-cli"] = "latest";
|
|
68
|
+
devDependencies["@faker-js/faker"] = "latest";
|
|
65
69
|
|
|
66
70
|
// Swagger UI for API documentation
|
|
67
71
|
dependencies["swagger-ui-express"] = "latest";
|
|
@@ -370,19 +370,16 @@ function generateRoutesIndex(tableNames, relationships, options) {
|
|
|
370
370
|
code += `import saasPermissionsRoute from "#routes/roles/permissions/index.js";\n\n`;
|
|
371
371
|
|
|
372
372
|
// --- dbmr schema-generated route imports (folder-based, skip SaaS-owned tables) ---
|
|
373
|
+
// Child routes are mounted inside their parent's index.js, not here
|
|
373
374
|
const dbmrTables = tableNames.filter(
|
|
374
375
|
(t) => !saasRouteModules.has(t) && !nestedChildren.has(t),
|
|
375
376
|
);
|
|
376
|
-
if (dbmrTables.length > 0
|
|
377
|
+
if (dbmrTables.length > 0) {
|
|
377
378
|
code += `// Schema-generated routes\n`;
|
|
378
379
|
}
|
|
379
380
|
for (const table of dbmrTables) {
|
|
380
381
|
code += `import ${safeVarName(table)}Route from "#routes/${table}/index.js";\n`;
|
|
381
382
|
}
|
|
382
|
-
for (const rel of relationships) {
|
|
383
|
-
if (saasRouteModules.has(rel.child)) continue;
|
|
384
|
-
code += `import ${safeVarName(rel.child)}ChildRoute from "#routes/${rel.parent}/${rel.child}/index.js";\n`;
|
|
385
|
-
}
|
|
386
383
|
|
|
387
384
|
if (options.includeDocs) {
|
|
388
385
|
code += `import docsRoute from "#routes/docs.js";\n`;
|
|
@@ -403,14 +400,7 @@ function generateRoutesIndex(tableNames, relationships, options) {
|
|
|
403
400
|
code += `router.use("/docs", docsRoute);\n`;
|
|
404
401
|
}
|
|
405
402
|
|
|
406
|
-
// --- Mount dbmr
|
|
407
|
-
for (const rel of relationships) {
|
|
408
|
-
if (saasRouteModules.has(rel.child)) continue;
|
|
409
|
-
const childVar = safeVarName(rel.child);
|
|
410
|
-
code += `router.use("/${rel.parent}/:${rel.foreignKey}/${rel.child}", ${childVar}ChildRoute);\n`;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// --- Mount dbmr top-level routes ---
|
|
403
|
+
// --- Mount dbmr top-level routes (children are inside parent's index.js) ---
|
|
414
404
|
if (dbmrTables.length > 0) {
|
|
415
405
|
code += `\n// Schema-generated routes\n`;
|
|
416
406
|
}
|
|
File without changes
|
|
File without changes
|
/package/demo/migrations/{20260510092159_create_tables.sql → 20260518204325_create_tables.sql}
RENAMED
|
File without changes
|