db-model-router 1.0.7 → 1.0.9
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/.env.example +1 -0
- package/demo/app.js +2 -2
- package/demo/commons/db.js +0 -11
- package/demo/middleware/tenantIsolation.js +2 -0
- package/demo/package-lock.json +1224 -62
- package/demo/package.json +6 -3
- package/demo/routes/addresses/index.js +5 -1
- package/demo/routes/auth/index.js +1 -1
- package/demo/routes/carts/cart_items/index.js +5 -1
- package/demo/routes/carts/index.js +9 -1
- package/demo/routes/categories/index.js +5 -1
- package/demo/routes/coupons/index.js +5 -1
- package/demo/routes/index.js +1 -15
- package/demo/routes/orders/index.js +13 -1
- package/demo/routes/orders/order_items/index.js +5 -1
- package/demo/routes/orders/payments/index.js +5 -1
- package/demo/routes/orders/shipments/index.js +5 -1
- package/demo/routes/products/index.js +13 -1
- package/demo/routes/products/product_images/index.js +5 -1
- package/demo/routes/products/product_reviews/index.js +5 -1
- package/demo/routes/products/product_variants/index.js +5 -1
- package/demo/routes/roles/index.js +1 -1
- package/demo/routes/tenants/index.js +1 -1
- package/demo/routes/users/index.js +1 -1
- package/demo/routes/wishlists/index.js +5 -1
- 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 +45 -15
- package/src/cli/diff-engine.js +17 -5
- package/src/cli/generate-migration.js +207 -19
- package/src/cli/generate-route.js +156 -58
- package/src/cli/generate-saas-structure.js +8 -1
- package/src/cli/init/dependencies.js +5 -1
- package/src/cli/init/generators.js +4 -81
- package/src/cli/init.js +1 -2
- package/src/cli/saas/generate-saas-middleware.js +2 -0
- package/src/cli/saas/generate-saas-routes.js +3 -13
- package/src/cli/saas/generate-saas-tests.js +473 -0
- package/src/commons/route.js +6 -6
- /package/demo/migrations/{20260509170349_create_migrations_table.sql → 20260510193736_create_migrations_table.sql} +0 -0
- /package/demo/migrations/{20260509170349_create_saas_tables.sql → 20260510193737_create_saas_tables.sql} +0 -0
- /package/demo/migrations/{20260509170349_create_tables.sql → 20260510193737_create_tables.sql} +0 -0
|
@@ -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
|
};
|
|
@@ -40,7 +40,7 @@ export default router;
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Generate a parent route file that includes its own CRUD and mounts child routes.
|
|
43
|
-
* e.g., routes/
|
|
43
|
+
* e.g., routes/users.js mounts posts under /:user_id/posts
|
|
44
44
|
*
|
|
45
45
|
* @param {string} tableName - Parent table name
|
|
46
46
|
* @param {Array<{child, foreignKey}>} children - Child relationships for this parent
|
|
@@ -56,7 +56,7 @@ import { ${varName} } from "#models";
|
|
|
56
56
|
// Import child routes
|
|
57
57
|
for (const child of children) {
|
|
58
58
|
const childVar = safeVarName(child.child);
|
|
59
|
-
code += `import ${childVar}Route from "./${child.child}
|
|
59
|
+
code += `import ${childVar}Route from "./${tableName}/${child.child}.js";\n`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
code += `
|
|
@@ -129,7 +129,7 @@ function generateRoutesIndexFile(tableNames, relationships = [], options = {}) {
|
|
|
129
129
|
for (const table of tableNames) {
|
|
130
130
|
if (nestedChildren.has(table)) continue;
|
|
131
131
|
const varName = safeVarName(table);
|
|
132
|
-
imports += `import ${varName}Route from "./${table}
|
|
132
|
+
imports += `import ${varName}Route from "./${table}.js";\n`;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// Import docs route if openapi is generated
|
|
@@ -166,18 +166,22 @@ 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";
|
|
177
|
+
import "dotenv/config";
|
|
178
|
+
import "../commons/db.js";
|
|
174
179
|
import dbModelRouter from "db-model-router";
|
|
180
|
+
import { ${varName} } from "../models/index.js";
|
|
181
|
+
${fakerImport}
|
|
175
182
|
|
|
176
183
|
const { route } = dbModelRouter;
|
|
177
184
|
|
|
178
|
-
// Adjust the path to your model file as needed
|
|
179
|
-
import ${varName} from "../models/${tableName}.js";
|
|
180
|
-
|
|
181
185
|
function createApp() {
|
|
182
186
|
const app = express();
|
|
183
187
|
app.use(express.json());
|
|
@@ -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].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
|
*/
|
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
generateModulesUtil,
|
|
22
22
|
generateWebhookUtil,
|
|
23
23
|
} = require("./saas/generate-saas-utils");
|
|
24
|
+
const { generateSaasTests } = require("./saas/generate-saas-tests");
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Read the existing .gitignore file and append `credentials.md` if not already present.
|
|
@@ -113,7 +114,13 @@ function generateSaasStructure(adapter, options) {
|
|
|
113
114
|
content: generateWebhookUtil(),
|
|
114
115
|
});
|
|
115
116
|
|
|
116
|
-
// 7.
|
|
117
|
+
// 7. Tests
|
|
118
|
+
const tests = generateSaasTests();
|
|
119
|
+
for (const entry of tests) {
|
|
120
|
+
planned.push(entry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 8. .gitignore update (add credentials.md)
|
|
117
124
|
planned.push({ relPath: ".gitignore", content: getGitignoreContent() });
|
|
118
125
|
|
|
119
126
|
return planned;
|
|
@@ -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";
|
|
@@ -79,7 +83,7 @@ function getScripts(outputDir) {
|
|
|
79
83
|
return {
|
|
80
84
|
start: "node app.js",
|
|
81
85
|
dev: "nodemon app.js",
|
|
82
|
-
test:
|
|
86
|
+
test: "dotenv -- mocha --exit",
|
|
83
87
|
migrate: `node ${prefix}commons/migrate.js`,
|
|
84
88
|
add_migration: `node ${prefix}commons/add_migration.js`,
|
|
85
89
|
"docker:build": "docker build -t app .",
|