db-model-router 1.0.11 → 1.0.13
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 +6 -9
- package/dbmr.postgres.schema.json +269 -0
- package/dbmr.postgres.test.schema.json +705 -0
- package/docs/dbmr-schema-spec.md +26 -38
- package/package.json +3 -2
- package/scripts/demo-create.js +63 -23
- package/skill/SKILL.md +4 -4
- package/src/cli/commands/diff.js +17 -2
- package/src/cli/commands/generate.js +96 -52
- package/src/cli/diff-engine.js +65 -44
- package/src/cli/generate-migration.js +25 -13
- package/src/cli/generate-route.js +11 -4
- package/src/cli/init/generators.js +0 -125
- package/src/cli/init.js +0 -13
- package/src/schema/schema-validator.js +27 -10
package/docs/dbmr-schema-spec.md
CHANGED
|
@@ -17,18 +17,16 @@ This document is the single source of truth for writing a `dbmr.schema.json` fil
|
|
|
17
17
|
"logger": true,
|
|
18
18
|
"loki": false
|
|
19
19
|
},
|
|
20
|
-
"tables": { ... }
|
|
21
|
-
"relationships": [ ... ]
|
|
20
|
+
"tables": { ... }
|
|
22
21
|
}
|
|
23
22
|
```
|
|
24
23
|
|
|
25
|
-
| Field
|
|
26
|
-
|
|
|
27
|
-
| `adapter`
|
|
28
|
-
| `framework`
|
|
29
|
-
| `options`
|
|
30
|
-
| `tables`
|
|
31
|
-
| `relationships` | No | Array of parent-child route nesting descriptors |
|
|
24
|
+
| Field | Required | Description |
|
|
25
|
+
| ----------- | -------- | ------------------------------------------------------------------------------ |
|
|
26
|
+
| `adapter` | Yes | Database adapter. One of: `mysql`, `mariadb`, `postgres`, `sqlite3`, `mongodb`, `mssql`, `cockroachdb`, `oracle`, `redis`, `dynamodb` |
|
|
27
|
+
| `framework` | Yes | Express variant. One of: `express`, `ultimate-express` |
|
|
28
|
+
| `options` | No | Project scaffolding options (session store, rate limiting, helmet, logger, loki) |
|
|
29
|
+
| `tables` | Yes | Object where each key is a table name and the value is a **Table Definition** |
|
|
32
30
|
|
|
33
31
|
---
|
|
34
32
|
|
|
@@ -54,7 +52,7 @@ Each entry in `tables` is a Table Definition object:
|
|
|
54
52
|
| ------------ | -------- | --------------------------------------------------------------------------------------------- |
|
|
55
53
|
| `columns` | Yes | **All** columns in the table, including PK, timestamps, and soft-delete. Each key is a column name; the value is a **Column Rule** string. |
|
|
56
54
|
| `pk` | Yes | Primary key column name. Convention: `<table>_id` (e.g. `user_id`, `order_id`). |
|
|
57
|
-
| `unique` | No |
|
|
55
|
+
| `unique` | No | Unique constraint columns. A flat array creates one composite unique group; an array-of-arrays creates multiple independent constraints. Defaults to `[[pk]]`. |
|
|
58
56
|
| `softDelete` | No | Column name used for soft-delete. When set, `remove()` updates this column to `1`/`true` instead of hard-deleting. |
|
|
59
57
|
| `timestamps` | No | Object mapping `{ created_at: "col_name", modified_at: "col_name" }`. These columns are auto-excluded from insert/update payloads. |
|
|
60
58
|
| `parent` | No | Parent table name for route nesting, or `null` for a top-level route. |
|
|
@@ -367,31 +365,28 @@ Tables that must **never** appear in your schema (because SaaS generates them):
|
|
|
367
365
|
|
|
368
366
|
---
|
|
369
367
|
|
|
370
|
-
##
|
|
368
|
+
## Route Nesting via the `parent` Field
|
|
371
369
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
```json
|
|
375
|
-
"relationships": [
|
|
376
|
-
{
|
|
377
|
-
"parent": "posts",
|
|
378
|
-
"child": "comments",
|
|
379
|
-
"foreignKey": "post_id"
|
|
380
|
-
}
|
|
381
|
-
]
|
|
382
|
-
```
|
|
383
|
-
|
|
384
|
-
| Field | Description |
|
|
385
|
-
| ------------ | -------------------------------------------------------------- |
|
|
386
|
-
| `parent` | Parent table name. Must exist in `tables`. |
|
|
387
|
-
| `child` | Child table name. Must exist in `tables`. |
|
|
388
|
-
| `foreignKey` | Column in the child table that references the parent's PK. |
|
|
370
|
+
Route nesting is driven **exclusively** by the `parent` field on each table. Set `parent` to the name of the table this module belongs under. Use `null` (or omit) for top-level routes.
|
|
389
371
|
|
|
390
372
|
### Routing Behavior
|
|
391
373
|
|
|
392
374
|
- `parent: null` → Top-level routes: `GET /products/`, `GET /products/:product_id`
|
|
393
375
|
- `parent: "posts"` → Nested routes: `GET /posts/:post_id/comments/`, `GET /posts/:post_id/comments/:comment_id`
|
|
394
|
-
- Child routes are **
|
|
376
|
+
- Child routes are **only** available under their parent path. There is no duplicate top-level route for child tables.
|
|
377
|
+
|
|
378
|
+
### Multi-Level Nesting (Deep Hierarchies)
|
|
379
|
+
|
|
380
|
+
You can nest more than two levels. Intermediate tables that are both a child and a parent (e.g. `tasks` under `projects`, with `subtasks` under `tasks`) get a **hybrid route file** that:
|
|
381
|
+
|
|
382
|
+
1. Scopes their own CRUD by the ancestor parameter (e.g. `project_id`).
|
|
383
|
+
2. Mounts their own children under their path.
|
|
384
|
+
|
|
385
|
+
Example: `projects → tasks → subtasks` produces:
|
|
386
|
+
|
|
387
|
+
- `GET /projects/` — top-level project CRUD
|
|
388
|
+
- `GET /projects/:project_id/tasks/` — task CRUD scoped by project
|
|
389
|
+
- `GET /projects/:project_id/tasks/:task_id/subtasks/` — subtask CRUD scoped by task
|
|
395
390
|
|
|
396
391
|
### Best Practice: Don't Nest System Tables
|
|
397
392
|
|
|
@@ -399,7 +394,7 @@ Tables like `users`, `tenants`, `roles`, `permissions`, `sessions`, `accounts`,
|
|
|
399
394
|
|
|
400
395
|
- `posts → comments`
|
|
401
396
|
- `orders → order_items`
|
|
402
|
-
- `projects → tasks`
|
|
397
|
+
- `projects → tasks → subtasks`
|
|
403
398
|
- `invoices → invoice_items`
|
|
404
399
|
|
|
405
400
|
---
|
|
@@ -559,14 +554,7 @@ Validators map to OpenAPI schema properties:
|
|
|
559
554
|
},
|
|
560
555
|
"parent": "orders"
|
|
561
556
|
}
|
|
562
|
-
}
|
|
563
|
-
"relationships": [
|
|
564
|
-
{
|
|
565
|
-
"parent": "orders",
|
|
566
|
-
"child": "order_items",
|
|
567
|
-
"foreignKey": "order_id"
|
|
568
|
-
}
|
|
569
|
-
]
|
|
557
|
+
}
|
|
570
558
|
}
|
|
571
559
|
```
|
|
572
560
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "db-model-router",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Generative API Creation using mysql2 and express libraries in node js",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"test:all": "mocha test/adapters/*.test.js test/properties/*.property.test.js test/function.test.js test/commands/*.test.js --timeout 300000 --exit",
|
|
24
24
|
"test:command": "mocha test/commands/*.test.js --timeout 30000 --exit",
|
|
25
25
|
"demo:clear": "node -e \"var fs=require('fs'),p=require('path'),d=p.join(__dirname,'demo');fs.existsSync(d)&&fs.rmSync(d,{recursive:true,force:true});fs.mkdirSync(d,{recursive:true})\"",
|
|
26
|
-
"demo:create": "node scripts/demo-create.js"
|
|
26
|
+
"demo:create": "node scripts/demo-create.js",
|
|
27
|
+
"demo:create-postgres": "node scripts/demo-create.js postgres"
|
|
27
28
|
},
|
|
28
29
|
"repository": {
|
|
29
30
|
"type": "git",
|
package/scripts/demo-create.js
CHANGED
|
@@ -8,6 +8,15 @@ const { execSync } = require("child_process");
|
|
|
8
8
|
const ROOT = path.resolve(__dirname, "..");
|
|
9
9
|
const DEMO = path.join(ROOT, "demo");
|
|
10
10
|
|
|
11
|
+
const ADAPTER = process.argv[2] || "sqlite3";
|
|
12
|
+
const SUPPORTED_ADAPTERS = ["sqlite3", "postgres", "mysql", "mariadb"];
|
|
13
|
+
|
|
14
|
+
if (!SUPPORTED_ADAPTERS.includes(ADAPTER)) {
|
|
15
|
+
console.error(`Error: Unsupported adapter "${ADAPTER}".`);
|
|
16
|
+
console.error(`Supported: ${SUPPORTED_ADAPTERS.join(", ")}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
// 1. Clear demo folder
|
|
12
21
|
if (fs.existsSync(DEMO)) {
|
|
13
22
|
fs.rmSync(DEMO, { recursive: true, force: true });
|
|
@@ -19,29 +28,60 @@ const run = (cmd, cwd) => {
|
|
|
19
28
|
execSync(cmd, { cwd, stdio: "inherit" });
|
|
20
29
|
};
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
fs.readFileSync(
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
);
|
|
38
|
-
console.log("\n> Copied and patched dbmr.schema.json (adapter → sqlite3)");
|
|
31
|
+
if (ADAPTER === "postgres") {
|
|
32
|
+
// --- Postgres demo flow ---
|
|
33
|
+
const schemaPath = path.join(ROOT, "dbmr.postgres.test.schema.json");
|
|
34
|
+
|
|
35
|
+
// 2. Copy Postgres schema into the demo folder
|
|
36
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
|
37
|
+
const schemaDest = path.join(DEMO, "dbmr.schema.json");
|
|
38
|
+
fs.writeFileSync(schemaDest, JSON.stringify(schema, null, 2) + "\n");
|
|
39
|
+
console.log(`\n> Copied ${schemaPath} → ${schemaDest}`);
|
|
40
|
+
|
|
41
|
+
// 3. Scaffold project with postgres
|
|
42
|
+
run(
|
|
43
|
+
"node ../src/cli/main.js init --database postgres --yes --no-install",
|
|
44
|
+
DEMO,
|
|
45
|
+
);
|
|
39
46
|
|
|
40
|
-
// 4. Generate
|
|
41
|
-
run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
|
|
47
|
+
// 4. Generate from schema (already copied)
|
|
48
|
+
run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
|
|
42
49
|
|
|
43
|
-
// 5. Install dependencies
|
|
44
|
-
run("npm install", DEMO);
|
|
50
|
+
// 5. Install dependencies
|
|
51
|
+
run("npm install", DEMO);
|
|
45
52
|
|
|
46
|
-
console.log("\n✔
|
|
47
|
-
console.log("cd demo && npm run migrate && npm run dev\n");
|
|
53
|
+
console.log("\n✔ Postgres demo project ready in ./demo");
|
|
54
|
+
console.log("cd demo && npm run migrate && npm run dev\n");
|
|
55
|
+
} else {
|
|
56
|
+
// --- SQLite3 demo flow (default) ---
|
|
57
|
+
|
|
58
|
+
// 2. Scaffold project with sqlite3
|
|
59
|
+
run(
|
|
60
|
+
"node ../src/cli/main.js init --database sqlite3 --yes --no-install",
|
|
61
|
+
DEMO,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// 3. Copy schema and patch adapter to sqlite3
|
|
65
|
+
const schema = JSON.parse(
|
|
66
|
+
fs.readFileSync(path.join(ROOT, "dbmr.schema.json"), "utf8"),
|
|
67
|
+
);
|
|
68
|
+
schema.adapter = "sqlite3";
|
|
69
|
+
if (schema.options) {
|
|
70
|
+
delete schema.options.session;
|
|
71
|
+
delete schema.options.loki;
|
|
72
|
+
}
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(DEMO, "dbmr.schema.json"),
|
|
75
|
+
JSON.stringify(schema, null, 2) + "\n",
|
|
76
|
+
);
|
|
77
|
+
console.log("\n> Copied and patched dbmr.schema.json (adapter → sqlite3)");
|
|
78
|
+
|
|
79
|
+
// 4. Generate models, routes, tests, openapi from schema
|
|
80
|
+
run("node ../src/cli/main.js generate --from dbmr.schema.json", DEMO);
|
|
81
|
+
|
|
82
|
+
// 5. Install dependencies
|
|
83
|
+
run("npm install", DEMO);
|
|
84
|
+
|
|
85
|
+
console.log("\n✔ Demo project ready in ./demo");
|
|
86
|
+
console.log("cd demo && npm run migrate && npm run dev\n");
|
|
87
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -117,7 +117,7 @@ For adapter-specific connect options (ports, env vars, upsert behavior), read th
|
|
|
117
117
|
| ----------- | --------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
118
118
|
| `structure` | `{col: "rule"}` | Types: `string\|integer\|numeric\|boolean\|object\|datetime\|auto_increment`. Prefix `required\|` for NOT NULL. |
|
|
119
119
|
| `pk` | string | Primary key column. Convention: `<table>_id` |
|
|
120
|
-
| `unique` | string[]
|
|
120
|
+
| `unique` | string[] \| string[][] | Flat array = one composite unique group; array-of-arrays = multiple independent constraints |
|
|
121
121
|
| `option` | object | `{ safeDelete, created_at, modified_at }` — column names or null |
|
|
122
122
|
|
|
123
123
|
> PK, timestamp, soft-delete, and `auto_increment` cols are auto-excluded from insert/update payloads.
|
|
@@ -249,7 +249,7 @@ Generated structure (ESM, `"type":"module"`):
|
|
|
249
249
|
```
|
|
250
250
|
app.js Express entry point
|
|
251
251
|
.env / .env.example Env config (random passwords)
|
|
252
|
-
docker-compose.yml DB +
|
|
252
|
+
docker-compose.yml DB + optional Loki/Grafana
|
|
253
253
|
<output>/commons/db.js Database init + global.db
|
|
254
254
|
<output>/commons/migrate.js Migration runner
|
|
255
255
|
<output>/route/index.js Central route mounting
|
|
@@ -257,7 +257,7 @@ docker-compose.yml DB + CloudBeaver + optional Loki/Grafana
|
|
|
257
257
|
<output>/migrations/ Initial migration files
|
|
258
258
|
```
|
|
259
259
|
|
|
260
|
-
Docker services auto-generated: database, Redis (if session=redis),
|
|
260
|
+
Docker services auto-generated: database, Redis (if session=redis), Loki + Grafana (if --loki).
|
|
261
261
|
|
|
262
262
|
Scripts: `start`, `dev`, `test`, `migrate`, `add_migration`, `docker:build`, `docker:up`, `docker:down`.
|
|
263
263
|
|
|
@@ -383,7 +383,7 @@ Requires a `.env` file with `DB_TYPE` and connection variables.
|
|
|
383
383
|
### `parent` field rules
|
|
384
384
|
|
|
385
385
|
- `"parent": null` → top-level route: `/comments/`
|
|
386
|
-
- `"parent": "posts"` → nested route: `/posts/:post_id/comments/` (
|
|
386
|
+
- `"parent": "posts"` → nested route: `/posts/:post_id/comments/` (only available under parent path)
|
|
387
387
|
- **Do NOT use system tables as parents** (`users`, `tenants`, `roles`, `permissions`, `sessions`, `accounts`, `auth_tokens`). They are cross-cutting and referenced via FK columns — not route hierarchies. Only use `parent` for true domain hierarchies: `posts → comments`, `orders → order_items`, `projects → tasks`.
|
|
388
388
|
|
|
389
389
|
### Column Rules
|
package/src/cli/commands/diff.js
CHANGED
|
@@ -61,8 +61,23 @@ async function diff(args, flags, ctx) {
|
|
|
61
61
|
|
|
62
62
|
// --- 2. Compute diff ---
|
|
63
63
|
const meta = schemaToModelMeta(schema);
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
|
|
65
|
+
// For diff, match generate by using only parent-derived relationships
|
|
66
|
+
const routeRelationships = [];
|
|
67
|
+
for (const [tableName, tableDef] of Object.entries(schema.tables)) {
|
|
68
|
+
if (tableDef.parent) {
|
|
69
|
+
const parentTable = schema.tables[tableDef.parent];
|
|
70
|
+
if (parentTable) {
|
|
71
|
+
routeRelationships.push({
|
|
72
|
+
parent: tableDef.parent,
|
|
73
|
+
child: tableName,
|
|
74
|
+
foreignKey: parentTable.pk,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = computeDiff(baseDir, meta, routeRelationships);
|
|
66
81
|
|
|
67
82
|
// --- 3. Output results ---
|
|
68
83
|
if (flags.json) {
|
|
@@ -97,6 +97,35 @@ async function generate(args, flags, ctx) {
|
|
|
97
97
|
const relationships = schema.relationships || [];
|
|
98
98
|
const tableNames = meta.map((m) => m.table).sort();
|
|
99
99
|
|
|
100
|
+
// For route generation, only use parent-derived relationships.
|
|
101
|
+
// Explicit relationships may define multiple foreign keys per table,
|
|
102
|
+
// but nested routes should follow the canonical parent declared on each table.
|
|
103
|
+
const routeRelationships = [];
|
|
104
|
+
for (const [tableName, tableDef] of Object.entries(schema.tables)) {
|
|
105
|
+
if (tableDef.parent) {
|
|
106
|
+
const parentTable = schema.tables[tableDef.parent];
|
|
107
|
+
if (parentTable) {
|
|
108
|
+
routeRelationships.push({
|
|
109
|
+
parent: tableDef.parent,
|
|
110
|
+
child: tableName,
|
|
111
|
+
foreignKey: parentTable.pk,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Build ancestry chains for correct multi-level nested file placement.
|
|
118
|
+
const ancestors = {};
|
|
119
|
+
for (const m of meta) {
|
|
120
|
+
const chain = [];
|
|
121
|
+
let current = m.table;
|
|
122
|
+
while (schema.tables[current]?.parent) {
|
|
123
|
+
chain.unshift(schema.tables[current].parent);
|
|
124
|
+
current = schema.tables[current].parent;
|
|
125
|
+
}
|
|
126
|
+
ancestors[m.table] = chain;
|
|
127
|
+
}
|
|
128
|
+
|
|
100
129
|
// Determine which artifact types to generate.
|
|
101
130
|
// If any specific artifact flag is explicitly set to true, only generate those.
|
|
102
131
|
// Otherwise, all artifact types are generated (unless explicitly set to false).
|
|
@@ -144,47 +173,62 @@ async function generate(args, flags, ctx) {
|
|
|
144
173
|
|
|
145
174
|
// --- Route files ---
|
|
146
175
|
if (genRoutes) {
|
|
147
|
-
// Collect child tables and group by parent
|
|
148
|
-
const nestedChildren = new Set();
|
|
149
176
|
const childrenByParent = {};
|
|
150
|
-
for (const rel of
|
|
151
|
-
nestedChildren.add(rel.child);
|
|
177
|
+
for (const rel of routeRelationships) {
|
|
152
178
|
if (!childrenByParent[rel.parent]) childrenByParent[rel.parent] = [];
|
|
153
179
|
childrenByParent[rel.parent].push(rel);
|
|
154
180
|
}
|
|
155
181
|
|
|
156
|
-
// Generate route
|
|
182
|
+
// Generate exactly ONE route file per table at its correct nested path.
|
|
183
|
+
// Intermediate tables (both child and parent) get a hybrid route file
|
|
184
|
+
// that scopes their own CRUD by an ancestor FK and mounts their children.
|
|
157
185
|
for (const m of meta) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
const tableName = m.table;
|
|
187
|
+
const chain = ancestors[tableName];
|
|
188
|
+
const hasChildren = (childrenByParent[tableName] || []).length > 0;
|
|
189
|
+
const hasParent = chain.length > 0;
|
|
190
|
+
|
|
191
|
+
const pathParts = [...chain, tableName];
|
|
192
|
+
const relPath = `routes/${pathParts.join("/")}/index.js`;
|
|
193
|
+
|
|
194
|
+
if (hasChildren) {
|
|
195
|
+
const children = childrenByParent[tableName];
|
|
196
|
+
if (hasParent) {
|
|
197
|
+
// Intermediate node: own CRUD scoped by parent PK + mounts children
|
|
198
|
+
const immediateParent = chain[chain.length - 1];
|
|
199
|
+
const parentFk = schema.tables[immediateParent].pk;
|
|
200
|
+
planned.push({
|
|
201
|
+
relPath,
|
|
202
|
+
content: generateParentRouteFile(tableName, children, parentFk),
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
// Root parent: own CRUD unscoped + mounts children
|
|
206
|
+
planned.push({
|
|
207
|
+
relPath,
|
|
208
|
+
content: generateParentRouteFile(tableName, children),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
} else if (hasParent) {
|
|
212
|
+
// Leaf child
|
|
213
|
+
const immediateParent = chain[chain.length - 1];
|
|
214
|
+
const parentFk = schema.tables[immediateParent].pk;
|
|
163
215
|
planned.push({
|
|
164
|
-
relPath
|
|
165
|
-
content:
|
|
216
|
+
relPath,
|
|
217
|
+
content: generateChildRouteFile(tableName, immediateParent, parentFk),
|
|
166
218
|
});
|
|
167
219
|
} else {
|
|
168
|
-
//
|
|
220
|
+
// Root leaf
|
|
169
221
|
planned.push({
|
|
170
|
-
relPath
|
|
171
|
-
content: generateRouteFile(
|
|
222
|
+
relPath,
|
|
223
|
+
content: generateRouteFile(tableName),
|
|
172
224
|
});
|
|
173
225
|
}
|
|
174
226
|
}
|
|
175
227
|
|
|
176
|
-
// Child route files inside parent folders: routes/<parent>/<child>/index.js
|
|
177
|
-
for (const rel of relationships) {
|
|
178
|
-
planned.push({
|
|
179
|
-
relPath: `routes/${rel.parent}/${rel.child}/index.js`,
|
|
180
|
-
content: generateChildRouteFile(rel.child, rel.parent, rel.foreignKey),
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
228
|
// Routes index file (include docs route when openapi is being generated)
|
|
185
229
|
planned.push({
|
|
186
230
|
relPath: "routes/index.js",
|
|
187
|
-
content: generateRoutesIndexFile(tableNames,
|
|
231
|
+
content: generateRoutesIndexFile(tableNames, routeRelationships, {
|
|
188
232
|
includeDocs: genOpenapi,
|
|
189
233
|
}),
|
|
190
234
|
});
|
|
@@ -192,7 +236,7 @@ async function generate(args, flags, ctx) {
|
|
|
192
236
|
|
|
193
237
|
// --- OpenAPI spec + docs route ---
|
|
194
238
|
if (genOpenapi) {
|
|
195
|
-
const spec = generateOpenAPISpec(meta, { relationships });
|
|
239
|
+
const spec = generateOpenAPISpec(meta, { relationships: routeRelationships });
|
|
196
240
|
|
|
197
241
|
// Merge SaaS routes into the OpenAPI spec when saas-structure is active
|
|
198
242
|
// SaaS routes appear BEFORE product routes in the docs
|
|
@@ -239,33 +283,33 @@ async function generate(args, flags, ctx) {
|
|
|
239
283
|
|
|
240
284
|
// --- Test files ---
|
|
241
285
|
if (genTests) {
|
|
242
|
-
// Collect child tables to skip generating top-level test files for them
|
|
243
|
-
const nestedChildrenForTests = new Set();
|
|
244
|
-
for (const rel of relationships) {
|
|
245
|
-
nestedChildrenForTests.add(rel.child);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
286
|
for (const m of meta) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
const tableName = m.table;
|
|
288
|
+
const chain = ancestors[tableName];
|
|
289
|
+
const hasParent = chain.length > 0;
|
|
290
|
+
|
|
291
|
+
if (hasParent) {
|
|
292
|
+
const immediateParent = chain[chain.length - 1];
|
|
293
|
+
const parentFk = schema.tables[immediateParent].pk;
|
|
294
|
+
const pathParts = [...chain, tableName];
|
|
295
|
+
const depth = pathParts.length;
|
|
296
|
+
const modelsRelPath = "../".repeat(depth) + "models/";
|
|
297
|
+
planned.push({
|
|
298
|
+
relPath: `test/${pathParts.join("/")}.test.js`,
|
|
299
|
+
content: generateChildTestFile(
|
|
300
|
+
tableName,
|
|
301
|
+
immediateParent,
|
|
302
|
+
parentFk,
|
|
303
|
+
m.primary_key,
|
|
304
|
+
modelsRelPath,
|
|
305
|
+
),
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
planned.push({
|
|
309
|
+
relPath: `test/${tableName}.test.js`,
|
|
310
|
+
content: generateTestFile(tableName, m.primary_key, m.structure),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
269
313
|
}
|
|
270
314
|
}
|
|
271
315
|
|
|
@@ -292,7 +336,7 @@ async function generate(args, flags, ctx) {
|
|
|
292
336
|
json: flags.json,
|
|
293
337
|
timestamp: new Date(),
|
|
294
338
|
tableNames,
|
|
295
|
-
relationships,
|
|
339
|
+
relationships: routeRelationships,
|
|
296
340
|
routeOptions: { includeDocs: genOpenapi },
|
|
297
341
|
});
|
|
298
342
|
|
package/src/cli/diff-engine.js
CHANGED
|
@@ -51,53 +51,69 @@ function lineDiff(expected, actual) {
|
|
|
51
51
|
*/
|
|
52
52
|
function buildExpectedFiles(meta, relationships) {
|
|
53
53
|
const expected = new Map();
|
|
54
|
-
const modelsRelPath = "../models";
|
|
55
54
|
const tableNames = meta.map((m) => m.table).sort();
|
|
56
55
|
|
|
57
|
-
//
|
|
58
|
-
const nestedChildren = new Set();
|
|
56
|
+
// Build children map
|
|
59
57
|
const childrenByParent = {};
|
|
60
58
|
for (const rel of relationships) {
|
|
61
|
-
nestedChildren.add(rel.child);
|
|
62
59
|
if (!childrenByParent[rel.parent]) childrenByParent[rel.parent] = [];
|
|
63
60
|
childrenByParent[rel.parent].push(rel);
|
|
64
61
|
}
|
|
65
62
|
|
|
63
|
+
// Build ancestry chains from relationships
|
|
64
|
+
const parentMap = {};
|
|
65
|
+
for (const rel of relationships) {
|
|
66
|
+
parentMap[rel.child] = rel.parent;
|
|
67
|
+
}
|
|
68
|
+
const ancestors = {};
|
|
69
|
+
for (const m of meta) {
|
|
70
|
+
const chain = [];
|
|
71
|
+
let current = m.table;
|
|
72
|
+
while (parentMap[current]) {
|
|
73
|
+
chain.unshift(parentMap[current]);
|
|
74
|
+
current = parentMap[current];
|
|
75
|
+
}
|
|
76
|
+
ancestors[m.table] = chain;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getPk = (table) => {
|
|
80
|
+
const m = meta.find((x) => x.table === table);
|
|
81
|
+
return m ? m.primary_key : "id";
|
|
82
|
+
};
|
|
83
|
+
|
|
66
84
|
// Model files
|
|
67
85
|
for (const m of meta) {
|
|
68
86
|
expected.set(`models/${m.table}.js`, generateModelFile(m));
|
|
69
87
|
}
|
|
70
88
|
|
|
71
|
-
// Route files
|
|
89
|
+
// Route files: exactly one per table at its correct nested path
|
|
72
90
|
for (const m of meta) {
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
const tableName = m.table;
|
|
92
|
+
const chain = ancestors[tableName];
|
|
93
|
+
const hasChildren = (childrenByParent[tableName] || []).length > 0;
|
|
94
|
+
const hasParent = chain.length > 0;
|
|
95
|
+
|
|
96
|
+
const pathParts = [...chain, tableName];
|
|
97
|
+
const relPath = `routes/${pathParts.join("/")}/index.js`;
|
|
98
|
+
|
|
99
|
+
if (hasChildren) {
|
|
100
|
+
const children = childrenByParent[tableName];
|
|
101
|
+
if (hasParent) {
|
|
102
|
+
const immediateParent = chain[chain.length - 1];
|
|
103
|
+
const parentFk = getPk(immediateParent);
|
|
104
|
+
expected.set(relPath, generateParentRouteFile(tableName, children, parentFk));
|
|
105
|
+
} else {
|
|
106
|
+
expected.set(relPath, generateParentRouteFile(tableName, children));
|
|
107
|
+
}
|
|
108
|
+
} else if (hasParent) {
|
|
109
|
+
const immediateParent = chain[chain.length - 1];
|
|
110
|
+
const parentFk = getPk(immediateParent);
|
|
111
|
+
expected.set(relPath, generateChildRouteFile(tableName, immediateParent, parentFk));
|
|
80
112
|
} else {
|
|
81
|
-
expected.set(
|
|
82
|
-
`routes/${m.table}/index.js`,
|
|
83
|
-
generateRouteFile(m.table, modelsRelPath),
|
|
84
|
-
);
|
|
113
|
+
expected.set(relPath, generateRouteFile(tableName));
|
|
85
114
|
}
|
|
86
115
|
}
|
|
87
116
|
|
|
88
|
-
// Child route files inside parent folders: routes/<parent>/<child>/index.js
|
|
89
|
-
for (const rel of relationships) {
|
|
90
|
-
expected.set(
|
|
91
|
-
`routes/${rel.parent}/${rel.child}/index.js`,
|
|
92
|
-
generateChildRouteFile(
|
|
93
|
-
rel.child,
|
|
94
|
-
rel.parent,
|
|
95
|
-
rel.foreignKey,
|
|
96
|
-
`../../models`,
|
|
97
|
-
),
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
117
|
// Routes index file (with docs route)
|
|
102
118
|
expected.set(
|
|
103
119
|
"routes/index.js",
|
|
@@ -107,23 +123,28 @@ function buildExpectedFiles(meta, relationships) {
|
|
|
107
123
|
// Docs route (Swagger UI)
|
|
108
124
|
expected.set("routes/docs.js", generateDocsRoute());
|
|
109
125
|
|
|
110
|
-
// Test files
|
|
126
|
+
// Test files at correct nested paths
|
|
111
127
|
for (const m of meta) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
generateTestFile(m.table, m.primary_key, m.structure),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
128
|
+
const tableName = m.table;
|
|
129
|
+
const chain = ancestors[tableName];
|
|
130
|
+
const hasParent = chain.length > 0;
|
|
118
131
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
if (hasParent) {
|
|
133
|
+
const immediateParent = chain[chain.length - 1];
|
|
134
|
+
const parentFk = getPk(immediateParent);
|
|
135
|
+
const pathParts = [...chain, tableName];
|
|
136
|
+
const depth = pathParts.length;
|
|
137
|
+
const modelsRelPath = "../".repeat(depth) + "models/";
|
|
138
|
+
expected.set(
|
|
139
|
+
`test/${pathParts.join("/")}.test.js`,
|
|
140
|
+
generateChildTestFile(tableName, immediateParent, parentFk, m.primary_key, modelsRelPath),
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
expected.set(
|
|
144
|
+
`test/${tableName}.test.js`,
|
|
145
|
+
generateTestFile(tableName, m.primary_key, m.structure),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
127
148
|
}
|
|
128
149
|
|
|
129
150
|
// OpenAPI spec
|