gazan-init 0.1.0
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/LICENSE +21 -0
- package/README.md +722 -0
- package/bin/gazan.js +17 -0
- package/package.json +54 -0
- package/src/cli/commands/init.js +126 -0
- package/src/cli/index.js +22 -0
- package/src/config/aliases.js +130 -0
- package/src/config/normalize.js +65 -0
- package/src/generators/aliasResolver.js +68 -0
- package/src/generators/aliasRuntime.js +79 -0
- package/src/generators/database/mongoNative.js +45 -0
- package/src/generators/database/mongoose.js +32 -0
- package/src/generators/database/prisma.js +61 -0
- package/src/generators/engine.js +88 -0
- package/src/generators/entity/sensitiveFields.js +12 -0
- package/src/generators/entity/toApp.js +325 -0
- package/src/generators/entity/toMongoose.js +180 -0
- package/src/generators/entity/toPrisma.js +233 -0
- package/src/generators/entity/toZod.js +70 -0
- package/src/generators/env.js +123 -0
- package/src/generators/errors.js +67 -0
- package/src/generators/features/auth.js +163 -0
- package/src/generators/features/bullmq.js +96 -0
- package/src/generators/features/redis.js +34 -0
- package/src/generators/features/socket.js +45 -0
- package/src/generators/gitignore.js +19 -0
- package/src/generators/index.js +317 -0
- package/src/generators/middlewares.js +167 -0
- package/src/generators/packageJson.js +107 -0
- package/src/generators/paths.js +45 -0
- package/src/generators/project.js +189 -0
- package/src/generators/readme.js +177 -0
- package/src/generators/shutdown.js +113 -0
- package/src/generators/stripSensitiveFieldsHelper.js +50 -0
- package/src/generators/syntax.js +65 -0
- package/src/generators/tsconfig.js +32 -0
- package/src/parser/entity/errors.js +21 -0
- package/src/parser/entity/parse.js +417 -0
- package/src/parser/entity/schema.js +104 -0
- package/src/prompts/index.js +186 -0
- package/src/utils/fsSafety.js +17 -0
- package/src/utils/strings.js +110 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
const { generatePrismaSchema } = require("../entity/toPrisma");
|
|
5
|
+
|
|
6
|
+
function defaultPrismaSchema() {
|
|
7
|
+
return `generator client {
|
|
8
|
+
provider = "prisma-client-js"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
datasource db {
|
|
12
|
+
provider = "postgresql"
|
|
13
|
+
url = env("DATABASE_URL")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Starter model so \`prisma generate\`/\`migrate\` work out of the box.
|
|
17
|
+
// Replace this with your own models, or regenerate with an entity.json.
|
|
18
|
+
model Example {
|
|
19
|
+
id String @id @default(uuid())
|
|
20
|
+
name String
|
|
21
|
+
createdAt DateTime @default(now())
|
|
22
|
+
updatedAt DateTime @updatedAt
|
|
23
|
+
|
|
24
|
+
@@map("examples")
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function generateSchemaPrisma(config, entityModel) {
|
|
30
|
+
if (entityModel && entityModel.models.length > 0) {
|
|
31
|
+
return generatePrismaSchema(entityModel, config);
|
|
32
|
+
}
|
|
33
|
+
return defaultPrismaSchema();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** configs/db/index.{js,ts} — centralized PrismaClient lifecycle. No service instantiates its own client. */
|
|
37
|
+
function generatePrismaDbConfig(config) {
|
|
38
|
+
const esm = isEsm(config);
|
|
39
|
+
const isTs = config.language === "ts";
|
|
40
|
+
const imports = esm
|
|
41
|
+
? `import { PrismaClient } from "@prisma/client";`
|
|
42
|
+
: `const { PrismaClient } = require("@prisma/client");`;
|
|
43
|
+
|
|
44
|
+
return `${imports}
|
|
45
|
+
|
|
46
|
+
const prisma = new PrismaClient();
|
|
47
|
+
|
|
48
|
+
async function connect() {
|
|
49
|
+
await prisma.$connect();
|
|
50
|
+
console.log("[db] prisma connected");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function disconnect() {
|
|
54
|
+
await prisma.$disconnect();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
${esm ? "export { prisma, connect, disconnect };" : "module.exports = { prisma, connect, disconnect };"}
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { generateSchemaPrisma, generatePrismaDbConfig };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs-extra");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Thin, composable filesystem primitives used by every generator.
|
|
8
|
+
* Generators never call `fs` directly — everything funnels through here
|
|
9
|
+
* so behaviour (idempotency, logging, dry-run, etc.) stays centralized.
|
|
10
|
+
*/
|
|
11
|
+
class GeneratorEngine {
|
|
12
|
+
constructor(rootDir) {
|
|
13
|
+
this.rootDir = rootDir;
|
|
14
|
+
this.createdFiles = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
resolve(...segments) {
|
|
18
|
+
return path.join(this.rootDir, ...segments);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
createDirectory(relativePath) {
|
|
22
|
+
const full = this.resolve(relativePath);
|
|
23
|
+
fs.ensureDirSync(full);
|
|
24
|
+
return full;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createFile(relativePath, content) {
|
|
28
|
+
const full = this.resolve(relativePath);
|
|
29
|
+
fs.ensureDirSync(path.dirname(full));
|
|
30
|
+
fs.writeFileSync(full, content, "utf8");
|
|
31
|
+
this.createdFiles.push(relativePath);
|
|
32
|
+
return full;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fileExists(relativePath) {
|
|
36
|
+
return fs.existsSync(this.resolve(relativePath));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
readJson(relativePath) {
|
|
40
|
+
return fs.readJsonSync(this.resolve(relativePath));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
writeJson(relativePath, data) {
|
|
44
|
+
const full = this.resolve(relativePath);
|
|
45
|
+
fs.ensureDirSync(path.dirname(full));
|
|
46
|
+
fs.writeJsonSync(full, data, { spaces: 2 });
|
|
47
|
+
this.createdFiles.push(relativePath);
|
|
48
|
+
return full;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
mergeJson(relativePath, patch) {
|
|
52
|
+
const existing = this.fileExists(relativePath) ? this.readJson(relativePath) : {};
|
|
53
|
+
const merged = deepMerge(existing, patch);
|
|
54
|
+
this.writeJson(relativePath, merged);
|
|
55
|
+
return merged;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merge dependency/script/etc. fragments into package.json, preserving
|
|
60
|
+
* key ordering for the well-known top-level fields.
|
|
61
|
+
*/
|
|
62
|
+
updatePackageJson(patch) {
|
|
63
|
+
const current = this.fileExists("package.json") ? this.readJson("package.json") : {};
|
|
64
|
+
const merged = deepMerge(current, patch);
|
|
65
|
+
this.writeJson("package.json", merged);
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPlainObject(value) {
|
|
71
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function deepMerge(target, source) {
|
|
75
|
+
const result = { ...target };
|
|
76
|
+
for (const key of Object.keys(source)) {
|
|
77
|
+
const sourceVal = source[key];
|
|
78
|
+
const targetVal = target[key];
|
|
79
|
+
if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
|
|
80
|
+
result[key] = deepMerge(targetVal, sourceVal);
|
|
81
|
+
} else {
|
|
82
|
+
result[key] = sourceVal;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { GeneratorEngine, deepMerge };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Field names matching this pattern are treated as sensitive and stripped from every API response
|
|
4
|
+
// generated for a model's CRUD endpoints, regardless of database backend. This is a conservative,
|
|
5
|
+
// name-based heuristic — not a replacement for review — documented in the README's limitations.
|
|
6
|
+
const SENSITIVE_FIELD_PATTERN = /password|secret|hash/i;
|
|
7
|
+
|
|
8
|
+
function getSensitiveFields(model) {
|
|
9
|
+
return model.fields.filter((f) => !f.primaryKey && SENSITIVE_FIELD_PATTERN.test(f.name)).map((f) => f.name);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { getSensitiveFields, SENSITIVE_FIELD_PATTERN };
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { isEsm } = require("../syntax");
|
|
4
|
+
const { sharedDir } = require("../paths");
|
|
5
|
+
const { resolveImportPath } = require("../aliasResolver");
|
|
6
|
+
const { getSensitiveFields } = require("./sensitiveFields");
|
|
7
|
+
|
|
8
|
+
function serviceBody(model, config, sensitiveFields) {
|
|
9
|
+
const orm = config.database.orm;
|
|
10
|
+
const type = config.database.type;
|
|
11
|
+
const isTs = config.language === "ts";
|
|
12
|
+
const wrap = sensitiveFields.length > 0;
|
|
13
|
+
|
|
14
|
+
const dataParam = isTs ? "data: Record<string, unknown>" : "data";
|
|
15
|
+
const idParam = isTs ? "id: string" : "id";
|
|
16
|
+
|
|
17
|
+
// Password/secret/hash-like fields are stripped from every response a generated CRUD service
|
|
18
|
+
// returns, regardless of backend — see helpers/strip-sensitive-fields.
|
|
19
|
+
const one = (expr) => (wrap ? `stripSensitiveFields(${expr}, SENSITIVE_FIELDS)` : expr);
|
|
20
|
+
const many = (expr) => (wrap ? `stripSensitiveFieldsFromList(${expr}, SENSITIVE_FIELDS)` : expr);
|
|
21
|
+
|
|
22
|
+
if (type === "postgresql" && orm === "prisma") {
|
|
23
|
+
const ctor = isTs ? `constructor(private readonly db: PrismaClient = prisma) {}` : `constructor(db = prisma) {\n this.db = db;\n }`;
|
|
24
|
+
// Data is already shaped by the Zod validator at the route boundary; the cast here just
|
|
25
|
+
// bridges our generic service signature to Prisma's per-model input types.
|
|
26
|
+
const createData = isTs ? `data: data as Prisma.${model.pascalName}CreateInput` : "data";
|
|
27
|
+
const updateData = isTs ? `data: data as Prisma.${model.pascalName}UpdateInput` : "data";
|
|
28
|
+
return `class ${model.pascalName}Service {
|
|
29
|
+
${ctor}
|
|
30
|
+
|
|
31
|
+
async create(${dataParam}) {
|
|
32
|
+
const record = await this.db.${model.camelName}.create({ ${createData} });
|
|
33
|
+
return ${one("record")};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async findAll() {
|
|
37
|
+
const records = await this.db.${model.camelName}.findMany();
|
|
38
|
+
return ${many("records")};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async findById(${idParam}) {
|
|
42
|
+
const record = await this.db.${model.camelName}.findUnique({ where: { ${model.primaryKey.camelName}: id } });
|
|
43
|
+
return ${one("record")};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async update(${idParam}, ${dataParam}) {
|
|
47
|
+
const record = await this.db.${model.camelName}.update({ where: { ${model.primaryKey.camelName}: id }, ${updateData} });
|
|
48
|
+
return ${one("record")};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async delete(${idParam}) {
|
|
52
|
+
return this.db.${model.camelName}.delete({ where: { ${model.primaryKey.camelName}: id } });
|
|
53
|
+
}
|
|
54
|
+
}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (type === "mongodb" && orm === "mongoose") {
|
|
58
|
+
const ctor = isTs
|
|
59
|
+
? `constructor(private readonly model: Model<${model.pascalName}Document> = ${model.pascalName}) {}`
|
|
60
|
+
: `constructor(model = ${model.pascalName}) {\n this.model = model;\n }`;
|
|
61
|
+
return `class ${model.pascalName}Service {
|
|
62
|
+
${ctor}
|
|
63
|
+
|
|
64
|
+
async create(${dataParam}) {
|
|
65
|
+
const record = await this.model.create(data);
|
|
66
|
+
return ${one("record")};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async findAll() {
|
|
70
|
+
const records = await this.model.find();
|
|
71
|
+
return ${many("records")};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async findById(${idParam}) {
|
|
75
|
+
const record = await this.model.findById(id);
|
|
76
|
+
return ${one("record")};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async update(${idParam}, ${dataParam}) {
|
|
80
|
+
const record = await this.model.findByIdAndUpdate(id, data, { new: true });
|
|
81
|
+
return ${one("record")};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async delete(${idParam}) {
|
|
85
|
+
return this.model.findByIdAndDelete(id);
|
|
86
|
+
}
|
|
87
|
+
}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (type === "mongodb" && orm === "native") {
|
|
91
|
+
// Mirrors the Mongoose _id strategy: the primary key is always Mongo's native `_id`, generated
|
|
92
|
+
// as a UUID string here (the driver has no schema-level default, so we generate it ourselves).
|
|
93
|
+
// The driver's default Collection<Document> types `_id` as ObjectId, so we type the collection
|
|
94
|
+
// against a document shape with a string `_id` instead of casting at every call site.
|
|
95
|
+
const collectionGeneric = isTs ? `<${model.pascalName}Doc>` : "";
|
|
96
|
+
return `class ${model.pascalName}Service {
|
|
97
|
+
collection() {
|
|
98
|
+
return getDb().collection${collectionGeneric}("${model.tableName}");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async create(${dataParam}) {
|
|
102
|
+
const doc = { _id: randomUUID(), ...data };
|
|
103
|
+
await this.collection().insertOne(doc);
|
|
104
|
+
return ${one("doc")};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async findAll() {
|
|
108
|
+
const records = await this.collection().find().toArray();
|
|
109
|
+
return ${many("records")};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async findById(${idParam}) {
|
|
113
|
+
const record = await this.collection().findOne({ _id: id });
|
|
114
|
+
return ${one("record")};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async update(${idParam}, ${dataParam}) {
|
|
118
|
+
await this.collection().updateOne({ _id: id }, { $set: data });
|
|
119
|
+
return this.findById(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async delete(${idParam}) {
|
|
123
|
+
const result = await this.collection().deleteOne({ _id: id });
|
|
124
|
+
return result.deletedCount > 0;
|
|
125
|
+
}
|
|
126
|
+
}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// No database configured: in-memory store so entity.json can still be scaffolded end-to-end.
|
|
130
|
+
const storeFields = isTs
|
|
131
|
+
? `private store = new Map<string, Record<string, unknown>>();\n private seq = 1;`
|
|
132
|
+
: `constructor() {\n this.store = new Map();\n this.seq = 1;\n }`;
|
|
133
|
+
|
|
134
|
+
return `class ${model.pascalName}Service {
|
|
135
|
+
${storeFields}
|
|
136
|
+
|
|
137
|
+
async create(${dataParam}) {
|
|
138
|
+
const id = String(this.seq++);
|
|
139
|
+
const record = { id, ...data };
|
|
140
|
+
this.store.set(id, record);
|
|
141
|
+
return ${one("record")};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async findAll() {
|
|
145
|
+
return ${many("Array.from(this.store.values())")};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async findById(${idParam}) {
|
|
149
|
+
const record = this.store.get(id) || null;
|
|
150
|
+
return ${one("record")};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async update(${idParam}, ${dataParam}) {
|
|
154
|
+
const existing = this.store.get(id);
|
|
155
|
+
if (!existing) return null;
|
|
156
|
+
const updated = { ...existing, ...data };
|
|
157
|
+
this.store.set(id, updated);
|
|
158
|
+
return ${one("updated")};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async delete(${idParam}) {
|
|
162
|
+
return this.store.delete(id);
|
|
163
|
+
}
|
|
164
|
+
}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function generateService(model, config, aliasConfig, dirs) {
|
|
168
|
+
const esm = isEsm(config);
|
|
169
|
+
const isTs = config.language === "ts";
|
|
170
|
+
const type = config.database.type;
|
|
171
|
+
const orm = config.database.orm;
|
|
172
|
+
const fromDir = dirs.services;
|
|
173
|
+
const r = (target) => resolveImportPath(config, aliasConfig, fromDir, target);
|
|
174
|
+
|
|
175
|
+
const dbFile = `${sharedDir(config, "configs")}/db/index`;
|
|
176
|
+
const dbPath = r(dbFile);
|
|
177
|
+
|
|
178
|
+
const imports = [];
|
|
179
|
+
if (type === "postgresql" && orm === "prisma") {
|
|
180
|
+
imports.push(esm ? `import { prisma } from "${dbPath}";` : `const { prisma } = require("${dbPath}");`);
|
|
181
|
+
if (isTs) imports.push(`import { PrismaClient, Prisma } from "@prisma/client";`);
|
|
182
|
+
} else if (type === "mongodb" && orm === "mongoose") {
|
|
183
|
+
const modelFile = `${sharedDir(config, "models")}/${model.kebabName}.model`;
|
|
184
|
+
const modelPath = r(modelFile);
|
|
185
|
+
const names = isTs ? `${model.pascalName}, ${model.pascalName}Document` : model.pascalName;
|
|
186
|
+
imports.push(esm ? `import { ${names} } from "${modelPath}";` : `const { ${names} } = require("${modelPath}");`);
|
|
187
|
+
if (isTs) imports.push(`import { Model } from "mongoose";`);
|
|
188
|
+
} else if (type === "mongodb" && orm === "native") {
|
|
189
|
+
imports.push(esm ? `import { getDb } from "${dbPath}";` : `const { getDb } = require("${dbPath}");`);
|
|
190
|
+
imports.push(esm ? `import { randomUUID } from "node:crypto";` : `const { randomUUID } = require("node:crypto");`);
|
|
191
|
+
if (isTs) imports.push(`\ninterface ${model.pascalName}Doc {\n _id: string;\n [key: string]: unknown;\n}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sensitiveFields = getSensitiveFields(model);
|
|
195
|
+
if (sensitiveFields.length > 0) {
|
|
196
|
+
const helperPath = r(`${sharedDir(config, "helpers")}/strip-sensitive-fields`);
|
|
197
|
+
imports.push(
|
|
198
|
+
esm
|
|
199
|
+
? `import { stripSensitiveFields, stripSensitiveFieldsFromList } from "${helperPath}";`
|
|
200
|
+
: `const { stripSensitiveFields, stripSensitiveFieldsFromList } = require("${helperPath}");`
|
|
201
|
+
);
|
|
202
|
+
imports.push(`const SENSITIVE_FIELDS = ${JSON.stringify(sensitiveFields)};`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const body = serviceBody(model, config, sensitiveFields);
|
|
206
|
+
const footer = esm ? `\n\nexport { ${model.pascalName}Service };` : `\n\nmodule.exports = ${model.pascalName}Service;`;
|
|
207
|
+
|
|
208
|
+
return `${imports.join("\n")}${imports.length ? "\n\n" : ""}${body}${footer}\n`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function generateController(model, config, aliasConfig, dirs) {
|
|
212
|
+
const esm = isEsm(config);
|
|
213
|
+
const isTs = config.language === "ts";
|
|
214
|
+
const reqType = isTs ? `import { Request, Response, NextFunction } from "express";\n` : "";
|
|
215
|
+
|
|
216
|
+
let serviceImport = "";
|
|
217
|
+
if (isTs) {
|
|
218
|
+
const servicePath = resolveImportPath(config, aliasConfig, dirs.controllers, `${dirs.services}/${model.kebabName}.service`);
|
|
219
|
+
serviceImport = `import { ${model.pascalName}Service } from "${servicePath}";\n`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ctorParam = isTs ? `private readonly service: ${model.pascalName}Service` : "service";
|
|
223
|
+
const handlerSig = isTs ? "async (req: Request, res: Response, next: NextFunction)" : "async (req, res, next)";
|
|
224
|
+
const serviceRef = "this.service";
|
|
225
|
+
|
|
226
|
+
const classKw = esm ? "export class" : "class";
|
|
227
|
+
|
|
228
|
+
const ctorBody = isTs ? "" : `\n this.service = service;`;
|
|
229
|
+
|
|
230
|
+
return `${reqType}${serviceImport}${reqType || serviceImport ? "\n" : ""}${classKw} ${model.pascalName}Controller {
|
|
231
|
+
constructor(${ctorParam}) {${ctorBody}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
create = ${handlerSig} => {
|
|
235
|
+
try {
|
|
236
|
+
const item = await ${serviceRef}.create(req.body);
|
|
237
|
+
return res.status(201).json({ success: true, data: item });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
next(error);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
findAll = ${handlerSig} => {
|
|
244
|
+
try {
|
|
245
|
+
const items = await ${serviceRef}.findAll();
|
|
246
|
+
return res.status(200).json({ success: true, data: items });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
next(error);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
findOne = ${handlerSig} => {
|
|
253
|
+
try {
|
|
254
|
+
const item = await ${serviceRef}.findById(req.params.id);
|
|
255
|
+
return res.status(200).json({ success: true, data: item });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
next(error);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
update = ${handlerSig} => {
|
|
262
|
+
try {
|
|
263
|
+
const item = await ${serviceRef}.update(req.params.id, req.body);
|
|
264
|
+
return res.status(200).json({ success: true, data: item });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
next(error);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
remove = ${handlerSig} => {
|
|
271
|
+
try {
|
|
272
|
+
await ${serviceRef}.delete(req.params.id);
|
|
273
|
+
return res.status(204).send();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
next(error);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
${esm ? "" : `\nmodule.exports = ${model.pascalName}Controller;\n`}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function generateRoutes(model, config, aliasConfig, dirs) {
|
|
283
|
+
const esm = isEsm(config);
|
|
284
|
+
const routesDir = dirs.routes;
|
|
285
|
+
const r = (target) => resolveImportPath(config, aliasConfig, routesDir, target);
|
|
286
|
+
|
|
287
|
+
const controllerPath = r(`${dirs.controllers}/${model.kebabName}.controller`);
|
|
288
|
+
const servicePath = r(`${dirs.services}/${model.kebabName}.service`);
|
|
289
|
+
const validatorPath = r(`${dirs.validators}/${model.kebabName}.validator`);
|
|
290
|
+
const validatePath = r(`${sharedDir(config, "middlewares")}/validate`);
|
|
291
|
+
|
|
292
|
+
const imports = esm
|
|
293
|
+
? [
|
|
294
|
+
`import { Router } from "express";`,
|
|
295
|
+
`import { validate } from "${validatePath}";`,
|
|
296
|
+
`import { ${model.pascalName}Controller } from "${controllerPath}";`,
|
|
297
|
+
`import { ${model.pascalName}Service } from "${servicePath}";`,
|
|
298
|
+
`import { create${model.pascalName}Schema, update${model.pascalName}Schema } from "${validatorPath}";`,
|
|
299
|
+
]
|
|
300
|
+
: [
|
|
301
|
+
`const { Router } = require("express");`,
|
|
302
|
+
`const { validate } = require("${validatePath}");`,
|
|
303
|
+
`const ${model.pascalName}Controller = require("${controllerPath}");`,
|
|
304
|
+
`const ${model.pascalName}Service = require("${servicePath}");`,
|
|
305
|
+
`const { create${model.pascalName}Schema, update${model.pascalName}Schema } = require("${validatorPath}");`,
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const body = `const router = Router();
|
|
309
|
+
|
|
310
|
+
const service = new ${model.pascalName}Service();
|
|
311
|
+
const controller = new ${model.pascalName}Controller(service);
|
|
312
|
+
|
|
313
|
+
router.post("/", validate(create${model.pascalName}Schema), controller.create);
|
|
314
|
+
router.get("/", controller.findAll);
|
|
315
|
+
router.get("/:id", controller.findOne);
|
|
316
|
+
router.patch("/:id", validate(update${model.pascalName}Schema), controller.update);
|
|
317
|
+
router.delete("/:id", controller.remove);
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
const footer = esm ? "\nexport default router;\n" : "\nmodule.exports = router;\n";
|
|
321
|
+
|
|
322
|
+
return `${imports.join("\n")}\n\n${body}${footer}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = { generateService, generateController, generateRoutes };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const MONGOOSE_TYPE_MAP = {
|
|
4
|
+
string: "String",
|
|
5
|
+
text: "String",
|
|
6
|
+
number: "Number",
|
|
7
|
+
integer: "Number",
|
|
8
|
+
float: "Number",
|
|
9
|
+
boolean: "Boolean",
|
|
10
|
+
date: "Date",
|
|
11
|
+
datetime: "Date",
|
|
12
|
+
uuid: "String",
|
|
13
|
+
json: "mongoose.Schema.Types.Mixed",
|
|
14
|
+
enum: "String",
|
|
15
|
+
decimal: "mongoose.Schema.Types.Decimal128",
|
|
16
|
+
bigint: "mongoose.Schema.Types.Mixed",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function buildFieldDefinition(model, field, allModels) {
|
|
20
|
+
const belongsTo = model.relations.find((r) => r.type === "belongsTo" && r.foreignKey === field.name);
|
|
21
|
+
const props = [];
|
|
22
|
+
|
|
23
|
+
if (belongsTo) {
|
|
24
|
+
const target = allModels.find((m) => m.name === belongsTo.model);
|
|
25
|
+
// Every Mongo primary key is a String UUID (see buildIdOverride), so the FK referencing it
|
|
26
|
+
// must be String too — mongoose.Schema.Types.ObjectId would throw a CastError on every write.
|
|
27
|
+
props.push(`type: String`);
|
|
28
|
+
if (target) props.push(`ref: "${target.pascalName}"`);
|
|
29
|
+
} else {
|
|
30
|
+
props.push(`type: ${MONGOOSE_TYPE_MAP[field.type]}`);
|
|
31
|
+
if (field.type === "enum") props.push(`enum: ${JSON.stringify(field.values)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (field.required) props.push(`required: true`);
|
|
35
|
+
if (field.unique) props.push(`unique: true`);
|
|
36
|
+
if (field.index && !field.unique) props.push(`index: true`);
|
|
37
|
+
if (field.default !== undefined && field.default !== null && field.default !== "uuid") {
|
|
38
|
+
props.push(`default: ${JSON.stringify(field.default)}`);
|
|
39
|
+
}
|
|
40
|
+
if (field.min !== undefined) props.push(`min: ${field.min}`);
|
|
41
|
+
if (field.max !== undefined) props.push(`max: ${field.max}`);
|
|
42
|
+
if (field.length !== undefined) props.push(`maxlength: ${field.length}`);
|
|
43
|
+
|
|
44
|
+
return ` ${field.camelName}: { ${props.join(", ")} },`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Entity validation guarantees that a MongoDB model's primary key, if declared explicitly, is
|
|
49
|
+
* always type 'uuid' (anything else — including autoIncrement — is rejected at parse time since
|
|
50
|
+
* Mongo has no native equivalent). So rather than emitting a redundant parallel 'id' field next to
|
|
51
|
+
* Mongo's native `_id`, we always route the primary key through `_id` itself: overriding its type
|
|
52
|
+
* to a generated UUID string. This is the one documented strategy GAZAN uses for Mongo primary
|
|
53
|
+
* keys — see README's MongoDB section.
|
|
54
|
+
*/
|
|
55
|
+
function buildIdOverride() {
|
|
56
|
+
return ` _id: { type: String, default: () => randomUUID() },`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* belongsToMany has no scalar FK column to hang off buildFieldDefinition (it only walks
|
|
61
|
+
* model.fields) — Mongoose represents it as its own array-of-refs field instead.
|
|
62
|
+
*/
|
|
63
|
+
function buildManyToManyFields(model, allModels) {
|
|
64
|
+
const lines = [];
|
|
65
|
+
for (const relation of model.relations) {
|
|
66
|
+
if (relation.type !== "belongsToMany") continue;
|
|
67
|
+
const target = allModels.find((m) => m.name === relation.model);
|
|
68
|
+
if (!target) continue;
|
|
69
|
+
lines.push(` ${relation.camelName}: { type: [String], ref: "${target.pascalName}", default: [] },`);
|
|
70
|
+
}
|
|
71
|
+
return lines;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildVirtuals(model, allModels) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const relation of model.relations) {
|
|
77
|
+
if (relation.type === "hasMany" || relation.type === "hasOne") {
|
|
78
|
+
const target = allModels.find((m) => m.name === relation.model);
|
|
79
|
+
if (!target) continue;
|
|
80
|
+
lines.push(`${model.camelName}Schema.virtual("${relation.name}", {`);
|
|
81
|
+
lines.push(` ref: "${target.pascalName}",`);
|
|
82
|
+
lines.push(` localField: "_id",`);
|
|
83
|
+
lines.push(` foreignField: "${relation.foreignKey}",`);
|
|
84
|
+
lines.push(` justOne: ${relation.type === "hasOne"},`);
|
|
85
|
+
lines.push(`});`);
|
|
86
|
+
lines.push("");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function generateMongooseModel(model, allModels, config) {
|
|
93
|
+
const nonPkFields = model.fields.filter((f) => !f.primaryKey);
|
|
94
|
+
const fieldLines = [
|
|
95
|
+
buildIdOverride(),
|
|
96
|
+
...nonPkFields.map((f) => buildFieldDefinition(model, f, allModels)),
|
|
97
|
+
...buildManyToManyFields(model, allModels),
|
|
98
|
+
].join("\n");
|
|
99
|
+
|
|
100
|
+
const virtuals = buildVirtuals(model, allModels);
|
|
101
|
+
const isTs = config.language === "ts";
|
|
102
|
+
const esm = config.moduleSystem === "mjs" || isTs;
|
|
103
|
+
|
|
104
|
+
const cryptoImport = esm ? `import { randomUUID } from "node:crypto";` : `const { randomUUID } = require("node:crypto");`;
|
|
105
|
+
const mongooseImport = isTs
|
|
106
|
+
? `import mongoose, { Document } from "mongoose";`
|
|
107
|
+
: config.moduleSystem === "mjs"
|
|
108
|
+
? `import mongoose from "mongoose";`
|
|
109
|
+
: `const mongoose = require("mongoose");`;
|
|
110
|
+
|
|
111
|
+
const header = `${mongooseImport}\n${cryptoImport}\n`;
|
|
112
|
+
|
|
113
|
+
const interfaceBlock = isTs ? buildTsInterface(model) : "";
|
|
114
|
+
|
|
115
|
+
const schemaBody = [
|
|
116
|
+
`const ${model.camelName}Schema = new mongoose.Schema(`,
|
|
117
|
+
` {`,
|
|
118
|
+
fieldLines,
|
|
119
|
+
` },`,
|
|
120
|
+
` {`,
|
|
121
|
+
` timestamps: ${model.timestamps},`,
|
|
122
|
+
` collection: "${model.tableName}",`,
|
|
123
|
+
` }`,
|
|
124
|
+
`);`,
|
|
125
|
+
``,
|
|
126
|
+
virtuals ? virtuals : "",
|
|
127
|
+
`${model.camelName}Schema.set("toJSON", { virtuals: true });`,
|
|
128
|
+
``,
|
|
129
|
+
]
|
|
130
|
+
.filter((l) => l !== "")
|
|
131
|
+
.join("\n");
|
|
132
|
+
|
|
133
|
+
const exportBlock = isTs
|
|
134
|
+
? `export const ${model.pascalName} = mongoose.model<${model.pascalName}Document>("${model.pascalName}", ${model.camelName}Schema);\n`
|
|
135
|
+
: config.moduleSystem === "mjs"
|
|
136
|
+
? `export const ${model.pascalName} = mongoose.model("${model.pascalName}", ${model.camelName}Schema);\n`
|
|
137
|
+
: `const ${model.pascalName} = mongoose.model("${model.pascalName}", ${model.camelName}Schema);\n\nmodule.exports = ${model.pascalName};\n`;
|
|
138
|
+
|
|
139
|
+
return [header, interfaceBlock, schemaBody, exportBlock].filter(Boolean).join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const TS_TYPE_MAP = {
|
|
143
|
+
string: "string",
|
|
144
|
+
text: "string",
|
|
145
|
+
number: "number",
|
|
146
|
+
integer: "number",
|
|
147
|
+
float: "number",
|
|
148
|
+
boolean: "boolean",
|
|
149
|
+
date: "Date",
|
|
150
|
+
datetime: "Date",
|
|
151
|
+
uuid: "string",
|
|
152
|
+
json: "Record<string, unknown>",
|
|
153
|
+
enum: "string",
|
|
154
|
+
decimal: "number",
|
|
155
|
+
bigint: "number",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
function buildTsInterface(model) {
|
|
159
|
+
// Document<string, ...> types this model's `_id` (and the `.id` virtual) as a string, matching
|
|
160
|
+
// the UUID primary key strategy above — Mongoose's default `Document` types `_id` as an ObjectId.
|
|
161
|
+
const lines = [`export interface ${model.pascalName}Document extends Document<string> {`];
|
|
162
|
+
for (const field of model.fields) {
|
|
163
|
+
if (field.primaryKey) continue;
|
|
164
|
+
const optional = field.required ? "" : "?";
|
|
165
|
+
lines.push(` ${field.camelName}${optional}: ${TS_TYPE_MAP[field.type]};`);
|
|
166
|
+
}
|
|
167
|
+
for (const relation of model.relations) {
|
|
168
|
+
if (relation.type === "belongsToMany") {
|
|
169
|
+
lines.push(` ${relation.camelName}: string[];`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (model.timestamps) {
|
|
173
|
+
lines.push(` createdAt: Date;`);
|
|
174
|
+
lines.push(` updatedAt: Date;`);
|
|
175
|
+
}
|
|
176
|
+
lines.push(`}`);
|
|
177
|
+
return lines.join("\n") + "\n";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { generateMongooseModel };
|