db-model-router 1.0.2 → 1.0.4

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.
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { printSchema } = require("../../schema/schema-printer");
6
+ const {
7
+ introspectMySQL,
8
+ introspectPostgres,
9
+ introspectSQLite3,
10
+ introspectMSSQL,
11
+ introspectOracle,
12
+ introspectCockroachDB,
13
+ } = require("../generate-model");
14
+
15
+ /**
16
+ * Map of adapter names to their introspection functions.
17
+ * Each value is an async function(db) => ModelMeta[].
18
+ */
19
+ const INTROSPECT_MAP = {
20
+ mysql: introspectMySQL,
21
+ postgres: introspectPostgres,
22
+ sqlite3: introspectSQLite3,
23
+ mssql: introspectMSSQL,
24
+ oracle: introspectOracle,
25
+ cockroachdb: introspectCockroachDB,
26
+ };
27
+
28
+ /**
29
+ * Convert a ModelMeta array (from introspection) into a ParsedSchema object.
30
+ * This is the reverse of schemaToModelMeta.
31
+ *
32
+ * @param {string} adapter - The database adapter name
33
+ * @param {string} framework - The framework name (default: "express")
34
+ * @param {Array<{table, structure, primary_key, unique, option}>} models
35
+ * @returns {object} ParsedSchema
36
+ */
37
+ function modelMetaToSchema(adapter, framework, models) {
38
+ const tables = {};
39
+
40
+ for (const m of models) {
41
+ const columns = {};
42
+
43
+ const pk = m.primary_key || "id";
44
+ const opt = m.option || {};
45
+
46
+ // Add PK column as auto_increment
47
+ columns[pk] = "auto_increment";
48
+
49
+ // Add timestamp columns as datetime
50
+ if (opt.created_at) {
51
+ columns[opt.created_at] = "datetime";
52
+ }
53
+ if (opt.modified_at) {
54
+ columns[opt.modified_at] = "datetime";
55
+ }
56
+
57
+ // Add softDelete column
58
+ if (opt.safeDelete) {
59
+ columns[opt.safeDelete] = "boolean";
60
+ }
61
+
62
+ // Add remaining columns from structure
63
+ for (const [col, rule] of Object.entries(m.structure)) {
64
+ columns[col] = rule;
65
+ }
66
+
67
+ const unique = m.unique && m.unique.length > 0 ? [...m.unique] : [pk];
68
+
69
+ const softDelete = opt.safeDelete || null;
70
+ const timestamps = {
71
+ created_at: opt.created_at || null,
72
+ modified_at: opt.modified_at || null,
73
+ };
74
+
75
+ tables[m.table] = {
76
+ name: m.table,
77
+ columns,
78
+ pk,
79
+ unique,
80
+ softDelete,
81
+ timestamps,
82
+ };
83
+ }
84
+
85
+ return {
86
+ adapter,
87
+ framework: framework || "express",
88
+ tables,
89
+ relationships: [],
90
+ options: {},
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Inspect command handler for the unified CLI.
96
+ *
97
+ * Connects to a live database, introspects its structure, converts to
98
+ * ParsedSchema, prints via schema-printer, and writes to file.
99
+ *
100
+ * Supported flags:
101
+ * --type Database adapter type (required)
102
+ * --env Path to .env file for connection params
103
+ * --out Output file path (default: dbmr.schema.json)
104
+ * --tables Comma-separated list of tables to include
105
+ * --json Output schema to stdout as JSON (no file write)
106
+ * --dry-run Output schema to stdout without writing file
107
+ *
108
+ * @param {object} args - Parsed key-value args
109
+ * @param {object} flags - Universal flags: { yes, json, dryRun, noInstall, help }
110
+ * @param {import('../flags').OutputContext} ctx - Output context
111
+ */
112
+ async function inspect(args, flags, ctx) {
113
+ const adapterType = args.type;
114
+ if (!adapterType || !INTROSPECT_MAP[adapterType]) {
115
+ const supported = Object.keys(INTROSPECT_MAP).join(", ");
116
+ const msg = adapterType
117
+ ? `Unsupported --type "${adapterType}". Supported: ${supported}`
118
+ : `Missing required --type flag. Supported: ${supported}`;
119
+ if (flags.json) {
120
+ ctx.result({ error: true, code: "INVALID_TYPE", message: msg });
121
+ } else {
122
+ ctx.log(`Error: ${msg}`);
123
+ }
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+
128
+ // Load .env file if --env provided
129
+ if (args.env) {
130
+ require("dotenv").config({ path: path.resolve(args.env) });
131
+ }
132
+
133
+ // Connect to database
134
+ let db;
135
+ try {
136
+ const restRouter = require("../../index.js");
137
+ restRouter.init(adapterType);
138
+ db = restRouter.db;
139
+
140
+ const config = {
141
+ host: process.env.DB_HOST || "localhost",
142
+ port: process.env.DB_PORT,
143
+ database: process.env.DB_NAME,
144
+ user: process.env.DB_USER,
145
+ password: process.env.DB_PASS,
146
+ filename: process.env.DB_NAME,
147
+ server: process.env.DB_HOST || "localhost",
148
+ options: { encrypt: false, trustServerCertificate: true },
149
+ };
150
+
151
+ db.connect(config);
152
+ } catch (err) {
153
+ const msg = `Database connection failed: ${err.message}`;
154
+ if (flags.json) {
155
+ ctx.result({ error: true, code: "CONNECTION_FAILED", message: msg });
156
+ } else {
157
+ ctx.log(`Error: ${msg}`);
158
+ }
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+
163
+ // Introspect
164
+ let models;
165
+ try {
166
+ const introspectFn = INTROSPECT_MAP[adapterType];
167
+ models = await introspectFn(db);
168
+ } catch (err) {
169
+ const msg = `Introspection failed: ${err.message}`;
170
+ if (flags.json) {
171
+ ctx.result({ error: true, code: "INTROSPECTION_FAILED", message: msg });
172
+ } else {
173
+ ctx.log(`Error: ${msg}`);
174
+ }
175
+ process.exitCode = 1;
176
+ // Disconnect
177
+ if (db.disconnect) await db.disconnect();
178
+ else if (db.close) db.close();
179
+ return;
180
+ }
181
+
182
+ // Disconnect
183
+ try {
184
+ if (db.disconnect) await db.disconnect();
185
+ else if (db.close) db.close();
186
+ } catch (_) {
187
+ // ignore disconnect errors
188
+ }
189
+
190
+ // Filter by --tables if provided
191
+ if (args.tables) {
192
+ const allowed = new Set(args.tables.split(",").map((s) => s.trim()));
193
+ models = models.filter((m) => allowed.has(m.table));
194
+ }
195
+
196
+ // Convert ModelMeta[] → ParsedSchema
197
+ const schema = modelMetaToSchema(adapterType, "express", models);
198
+
199
+ // Print via schema-printer
200
+ const output = printSchema(schema);
201
+
202
+ // Determine output path
203
+ const outPath = args.out || "dbmr.schema.json";
204
+
205
+ if (flags.json) {
206
+ // --json: output schema to stdout, no file write
207
+ ctx.result({ schema: JSON.parse(output), writtenTo: null });
208
+ } else if (flags.dryRun) {
209
+ // --dry-run: output schema to stdout, no file write
210
+ ctx.log(output);
211
+ ctx.log(`Would write to: ${outPath}`);
212
+ } else {
213
+ // Write to file
214
+ const resolvedPath = path.resolve(outPath);
215
+ fs.writeFileSync(resolvedPath, output, "utf8");
216
+ ctx.log(`Schema written to ${outPath}`);
217
+ ctx.log(output);
218
+ }
219
+ }
220
+
221
+ module.exports = inspect;
222
+ module.exports.modelMetaToSchema = modelMetaToSchema;
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { generateModelFile } = require("./generate-model.js");
6
+ const {
7
+ generateRouteFile,
8
+ generateChildRouteFile,
9
+ generateRoutesIndexFile,
10
+ generateTestFile,
11
+ generateChildTestFile,
12
+ } = require("./generate-route.js");
13
+ const { generateOpenAPISpec } = require("./generate-openapi.js");
14
+
15
+ /**
16
+ * Simple line-by-line diff between two strings.
17
+ * Returns a human-readable unified-style diff string.
18
+ */
19
+ function lineDiff(expected, actual) {
20
+ const expectedLines = expected.split("\n");
21
+ const actualLines = actual.split("\n");
22
+ const lines = [];
23
+ const maxLen = Math.max(expectedLines.length, actualLines.length);
24
+
25
+ for (let i = 0; i < maxLen; i++) {
26
+ const exp = i < expectedLines.length ? expectedLines[i] : undefined;
27
+ const act = i < actualLines.length ? actualLines[i] : undefined;
28
+
29
+ if (exp === act) continue;
30
+ if (act !== undefined && exp === undefined) {
31
+ lines.push(`+${i + 1}: ${act}`);
32
+ } else if (exp !== undefined && act === undefined) {
33
+ lines.push(`-${i + 1}: ${exp}`);
34
+ } else {
35
+ lines.push(`-${i + 1}: ${act}`);
36
+ lines.push(`+${i + 1}: ${exp}`);
37
+ }
38
+ }
39
+ return lines.join("\n");
40
+ }
41
+
42
+ /**
43
+ * Build a map of relative file path → expected content for all artifacts
44
+ * that the schema would generate.
45
+ *
46
+ * @param {Array<{table, structure, primary_key, unique, option}>} meta
47
+ * @param {Array<{parent, child, foreignKey}>} relationships
48
+ * @returns {Map<string, string>}
49
+ */
50
+ function buildExpectedFiles(meta, relationships) {
51
+ const expected = new Map();
52
+ const modelsRelPath = "../models";
53
+ const tableNames = meta.map((m) => m.table).sort();
54
+
55
+ // Model files
56
+ for (const m of meta) {
57
+ expected.set(`models/${m.table}.js`, generateModelFile(m));
58
+ }
59
+
60
+ // Route files (one per table)
61
+ for (const m of meta) {
62
+ expected.set(
63
+ `routes/${m.table}.js`,
64
+ generateRouteFile(m.table, modelsRelPath),
65
+ );
66
+ }
67
+
68
+ // Child route files (one per relationship)
69
+ for (const rel of relationships) {
70
+ const childMeta = meta.find((m) => m.table === rel.child);
71
+ const pk = childMeta ? childMeta.primary_key : "id";
72
+ expected.set(
73
+ `routes/${rel.child}_child_of_${rel.parent}.js`,
74
+ generateChildRouteFile(
75
+ rel.child,
76
+ rel.parent,
77
+ rel.foreignKey,
78
+ modelsRelPath,
79
+ ),
80
+ );
81
+ }
82
+
83
+ // Routes index file
84
+ expected.set(
85
+ "routes/index.js",
86
+ generateRoutesIndexFile(tableNames, relationships),
87
+ );
88
+
89
+ // Test files (one per table)
90
+ for (const m of meta) {
91
+ expected.set(
92
+ `test/${m.table}.test.js`,
93
+ generateTestFile(m.table, m.primary_key),
94
+ );
95
+ }
96
+
97
+ // Child test files (one per relationship)
98
+ for (const rel of relationships) {
99
+ const childMeta = meta.find((m) => m.table === rel.child);
100
+ const pk = childMeta ? childMeta.primary_key : "id";
101
+ expected.set(
102
+ `test/${rel.child}_child_of_${rel.parent}.test.js`,
103
+ generateChildTestFile(rel.child, rel.parent, rel.foreignKey, pk),
104
+ );
105
+ }
106
+
107
+ // OpenAPI spec
108
+ expected.set(
109
+ "openapi.json",
110
+ JSON.stringify(generateOpenAPISpec(meta), null, 2) + "\n",
111
+ );
112
+
113
+ return expected;
114
+ }
115
+
116
+ /**
117
+ * Scan known artifact directories on disk and return a set of relative paths
118
+ * that exist.
119
+ *
120
+ * @param {string} baseDir
121
+ * @returns {Set<string>}
122
+ */
123
+ function scanDiskFiles(baseDir) {
124
+ const files = new Set();
125
+
126
+ const dirs = [
127
+ { dir: "models", ext: ".js" },
128
+ { dir: "routes", ext: ".js" },
129
+ { dir: "test", ext: ".test.js" },
130
+ ];
131
+
132
+ for (const { dir, ext } of dirs) {
133
+ const fullDir = path.join(baseDir, dir);
134
+ if (!fs.existsSync(fullDir)) continue;
135
+ for (const file of fs.readdirSync(fullDir)) {
136
+ if (file.endsWith(ext)) {
137
+ files.add(`${dir}/${file}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ // Check for openapi.json at root
143
+ const openapiPath = path.join(baseDir, "openapi.json");
144
+ if (fs.existsSync(openapiPath)) {
145
+ files.add("openapi.json");
146
+ }
147
+
148
+ return files;
149
+ }
150
+
151
+ /**
152
+ * Compare expected generated content against actual files on disk.
153
+ *
154
+ * @param {string} baseDir — project root
155
+ * @param {Array<{table, structure, primary_key, unique, option}>} meta — from schema
156
+ * @param {Array<{parent, child, foreignKey}>} relationships
157
+ * @returns {{ added: string[], modified: Array<{file: string, diff: string}>, deleted: string[] }}
158
+ */
159
+ function computeDiff(baseDir, meta, relationships) {
160
+ const expected = buildExpectedFiles(meta, relationships);
161
+ const diskFiles = scanDiskFiles(baseDir);
162
+
163
+ const added = [];
164
+ const modified = [];
165
+ const deleted = [];
166
+
167
+ // Check expected files against disk
168
+ for (const [relPath, expectedContent] of expected) {
169
+ const fullPath = path.join(baseDir, relPath);
170
+ if (!fs.existsSync(fullPath)) {
171
+ added.push(relPath);
172
+ } else {
173
+ const actualContent = fs.readFileSync(fullPath, "utf8");
174
+ if (actualContent !== expectedContent) {
175
+ modified.push({
176
+ file: relPath,
177
+ diff: lineDiff(expectedContent, actualContent),
178
+ });
179
+ }
180
+ // unchanged — not reported
181
+ }
182
+ }
183
+
184
+ // Check disk files not in expected set → deleted
185
+ for (const diskFile of diskFiles) {
186
+ if (!expected.has(diskFile)) {
187
+ deleted.push(diskFile);
188
+ }
189
+ }
190
+
191
+ return {
192
+ added: added.sort(),
193
+ modified: modified.sort((a, b) => a.file.localeCompare(b.file)),
194
+ deleted: deleted.sort(),
195
+ };
196
+ }
197
+
198
+ module.exports = { computeDiff, buildExpectedFiles, lineDiff };
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Universal flag parser and OutputContext for the db-model-router CLI.
5
+ *
6
+ * Parses --yes, --json, --dry-run, --no-install, --help from argv.
7
+ * Extracts the subcommand (first non-flag argument).
8
+ * Collects remaining key-value flags into an args object.
9
+ *
10
+ * @module cli/flags
11
+ */
12
+
13
+ /**
14
+ * Parse CLI argv into subcommand, flags, and args.
15
+ *
16
+ * @param {string[]} argv - process.argv.slice(2) style array
17
+ * @returns {{ subcommand: string|null, flags: Flags, args: object }}
18
+ */
19
+ function parseFlags(argv) {
20
+ const flags = {
21
+ yes: false,
22
+ json: false,
23
+ dryRun: false,
24
+ noInstall: false,
25
+ help: false,
26
+ };
27
+
28
+ const args = {};
29
+ let subcommand = null;
30
+
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+
34
+ if (arg === "--yes") {
35
+ flags.yes = true;
36
+ } else if (arg === "--json") {
37
+ flags.json = true;
38
+ } else if (arg === "--dry-run") {
39
+ flags.dryRun = true;
40
+ } else if (arg === "--no-install") {
41
+ flags.noInstall = true;
42
+ } else if (arg === "--help") {
43
+ flags.help = true;
44
+ } else if (arg.startsWith("--")) {
45
+ // Key-value flag: --from schema.json → { from: "schema.json" }
46
+ const key = arg.slice(2);
47
+ const next = argv[i + 1];
48
+ if (next !== undefined && !next.startsWith("--")) {
49
+ args[key] = next;
50
+ i++; // skip the value
51
+ } else {
52
+ args[key] = true;
53
+ }
54
+ } else if (subcommand === null) {
55
+ subcommand = arg;
56
+ }
57
+ }
58
+
59
+ return { subcommand, flags, args };
60
+ }
61
+
62
+ /**
63
+ * OutputContext controls CLI output behavior based on flags.
64
+ *
65
+ * When --json is active:
66
+ * - log() is a no-op (suppresses human-readable output)
67
+ * - result() accumulates data
68
+ * - flush() prints the accumulated JSON to stdout
69
+ *
70
+ * When --json is NOT active:
71
+ * - log() prints to stdout
72
+ * - result() is a no-op
73
+ * - flush() is a no-op
74
+ */
75
+ class OutputContext {
76
+ constructor(flags) {
77
+ this._json = !!(flags && flags.json);
78
+ this._results = [];
79
+ }
80
+
81
+ /**
82
+ * Log a human-readable message. No-op when --json is active.
83
+ * @param {string} msg
84
+ */
85
+ log(msg) {
86
+ if (!this._json) {
87
+ console.log(msg);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Accumulate a result object for JSON output.
93
+ * @param {*} data
94
+ */
95
+ result(data) {
96
+ this._results.push(data);
97
+ }
98
+
99
+ /**
100
+ * Flush accumulated JSON results to stdout if --json is active.
101
+ * Prints a single JSON object (or the last result if only one was accumulated).
102
+ */
103
+ flush() {
104
+ if (this._json && this._results.length > 0) {
105
+ const output =
106
+ this._results.length === 1 ? this._results[0] : this._results;
107
+ console.log(JSON.stringify(output));
108
+ }
109
+ }
110
+ }
111
+
112
+ module.exports = { parseFlags, OutputContext };
@@ -475,7 +475,7 @@ function mysqlTypeToValidator(t) {
475
475
  if (/json/.test(t)) return "object";
476
476
  if (/text|char|varchar|enum|set/.test(t)) return "string";
477
477
  if (/blob|binary/.test(t)) return "string";
478
- if (/date|time|year/.test(t)) return "string";
478
+ if (/date|time|year/.test(t)) return "datetime";
479
479
  if (/bool/.test(t)) return "integer";
480
480
  return "string";
481
481
  }
@@ -487,7 +487,7 @@ function pgTypeToValidator(t) {
487
487
  if (/json/.test(t)) return "object";
488
488
  if (/bool/.test(t)) return "integer";
489
489
  if (/char|text|varchar|uuid/.test(t)) return "string";
490
- if (/date|time|interval/.test(t)) return "string";
490
+ if (/date|time|interval/.test(t)) return "datetime";
491
491
  return "string";
492
492
  }
493
493
 
@@ -496,6 +496,7 @@ function sqliteTypeToValidator(t) {
496
496
  if (/int/.test(t)) return "integer";
497
497
  if (/real|float|double|numeric|decimal/.test(t)) return "numeric";
498
498
  if (/json/.test(t)) return "object";
499
+ if (/date|time/.test(t)) return "datetime";
499
500
  if (/blob/.test(t)) return "string";
500
501
  return "string";
501
502
  }
@@ -506,7 +507,7 @@ function mssqlTypeToValidator(t) {
506
507
  if (/decimal|numeric|float|real|money/.test(t)) return "numeric";
507
508
  if (/bit/.test(t)) return "integer";
508
509
  if (/char|text|varchar|nchar|nvarchar|ntext/.test(t)) return "string";
509
- if (/date|time|datetime/.test(t)) return "string";
510
+ if (/date|time|datetime/.test(t)) return "datetime";
510
511
  if (/uniqueidentifier/.test(t)) return "string";
511
512
  return "string";
512
513
  }
@@ -515,7 +516,7 @@ function oracleTypeToValidator(t) {
515
516
  if (/NUMBER|INTEGER|FLOAT|BINARY_FLOAT|BINARY_DOUBLE/.test(t))
516
517
  return "numeric";
517
518
  if (/CLOB|BLOB|RAW|LONG/.test(t)) return "string";
518
- if (/DATE|TIMESTAMP/.test(t)) return "string";
519
+ if (/DATE|TIMESTAMP/.test(t)) return "datetime";
519
520
  if (/CHAR|VARCHAR|NCHAR|NVARCHAR/.test(t)) return "string";
520
521
  return "string";
521
522
  }