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,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralizes CJS/MJS/JS/TS syntax decisions so every template stays
|
|
5
|
+
* consistent instead of re-deriving require()/import rules ad-hoc.
|
|
6
|
+
*/
|
|
7
|
+
function isEsm(config) {
|
|
8
|
+
return config.moduleSystem === "mjs" || config.language === "ts";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Default import: `import X from "y"` / `const X = require("y")` */
|
|
12
|
+
function importDefault(config, name, from) {
|
|
13
|
+
return isEsm(config) ? `import ${name} from "${from}";` : `const ${name} = require("${from}");`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Named import: `import { a, b } from "y"` / `const { a, b } = require("y")` */
|
|
17
|
+
function importNamed(config, names, from) {
|
|
18
|
+
const list = Array.isArray(names) ? names.join(", ") : names;
|
|
19
|
+
return isEsm(config) ? `import { ${list} } from "${from}";` : `const { ${list} } = require("${from}");`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Namespace/star import for CommonJS-only interop packages (e.g. express) */
|
|
23
|
+
function importCjsInterop(config, name, from) {
|
|
24
|
+
if (isEsm(config)) return `import ${name} from "${from}";`;
|
|
25
|
+
return `const ${name} = require("${from}");`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function exportDefault(config, name) {
|
|
29
|
+
return isEsm(config) ? `export default ${name};` : `module.exports = ${name};`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function exportNamed(config, name) {
|
|
33
|
+
return isEsm(config) ? `export { ${name} };` : `module.exports.${name} = ${name};`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exportAssign(config, expr) {
|
|
37
|
+
return isEsm(config) ? `export default ${expr};` : `module.exports = ${expr};`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fileExt(config) {
|
|
41
|
+
return config.language === "ts" ? "ts" : "js";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Relative import specifier. Native ESM resolution (moduleSystem: "mjs")
|
|
46
|
+
* requires an explicit extension on relative imports — this holds even for
|
|
47
|
+
* TypeScript compiled under NodeNext, where source files import the future
|
|
48
|
+
* ".js" output extension rather than ".ts". CJS resolution (for both JS and
|
|
49
|
+
* TS via ts-node/tsc with commonjs module resolution) needs no extension.
|
|
50
|
+
*/
|
|
51
|
+
function specifier(config, relativePathNoExt) {
|
|
52
|
+
return config.moduleSystem === "mjs" ? `${relativePathNoExt}.js` : relativePathNoExt;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
isEsm,
|
|
57
|
+
importDefault,
|
|
58
|
+
importNamed,
|
|
59
|
+
importCjsInterop,
|
|
60
|
+
exportDefault,
|
|
61
|
+
exportNamed,
|
|
62
|
+
exportAssign,
|
|
63
|
+
fileExt,
|
|
64
|
+
specifier,
|
|
65
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { baseDir } = require("./paths");
|
|
4
|
+
|
|
5
|
+
function buildTsconfig(config) {
|
|
6
|
+
const rootDir = baseDir(config);
|
|
7
|
+
const isMjs = config.moduleSystem === "mjs";
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
compilerOptions: {
|
|
11
|
+
target: "ES2022",
|
|
12
|
+
lib: ["ES2022"],
|
|
13
|
+
module: isMjs ? "NodeNext" : "CommonJS",
|
|
14
|
+
moduleResolution: isMjs ? "NodeNext" : "Node",
|
|
15
|
+
rootDir,
|
|
16
|
+
outDir: "dist",
|
|
17
|
+
strict: true,
|
|
18
|
+
noImplicitAny: true,
|
|
19
|
+
esModuleInterop: true,
|
|
20
|
+
allowSyntheticDefaultImports: true,
|
|
21
|
+
skipLibCheck: true,
|
|
22
|
+
forceConsistentCasingInFileNames: true,
|
|
23
|
+
resolveJsonModule: true,
|
|
24
|
+
declaration: false,
|
|
25
|
+
sourceMap: true,
|
|
26
|
+
},
|
|
27
|
+
include: [rootDir === "." ? "**/*.ts" : `${rootDir}/**/*.ts`],
|
|
28
|
+
exclude: ["node_modules", "dist"],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { buildTsconfig };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
class EntityValidationError extends Error {
|
|
4
|
+
constructor(message, issues = []) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "EntityValidationError";
|
|
7
|
+
this.issues = issues; // array of { path, message }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
format() {
|
|
11
|
+
const lines = [`Invalid entity.json`, ""];
|
|
12
|
+
for (const issue of this.issues) {
|
|
13
|
+
lines.push(`${issue.path}:`);
|
|
14
|
+
lines.push(` ${issue.message}`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
}
|
|
17
|
+
return lines.join("\n").trimEnd();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { EntityValidationError };
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs-extra");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { EntityFileSchema, FIELD_TYPES } = require("./schema");
|
|
6
|
+
const { EntityValidationError } = require("./errors");
|
|
7
|
+
const { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, pluralize, didYouMean } = require("../../utils/strings");
|
|
8
|
+
|
|
9
|
+
// Words that collide with JS/class semantics regardless of the selected database.
|
|
10
|
+
const JS_RESERVED_WORDS = new Set([
|
|
11
|
+
"class",
|
|
12
|
+
"constructor",
|
|
13
|
+
"prototype",
|
|
14
|
+
"__proto__",
|
|
15
|
+
"function",
|
|
16
|
+
"return",
|
|
17
|
+
"new",
|
|
18
|
+
"delete",
|
|
19
|
+
"typeof",
|
|
20
|
+
"instanceof",
|
|
21
|
+
"this",
|
|
22
|
+
"super",
|
|
23
|
+
"extends",
|
|
24
|
+
"import",
|
|
25
|
+
"export",
|
|
26
|
+
"default",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Prisma schema block keywords — using one as a model name breaks `model X { ... }` parsing.
|
|
30
|
+
const PRISMA_RESERVED_MODEL_WORDS = new Set(["datasource", "generator", "model", "enum", "type", "view"]);
|
|
31
|
+
|
|
32
|
+
// Fields that collide with Mongoose's own Document machinery if used as a schema field name.
|
|
33
|
+
const MONGO_RESERVED_FIELD_WORDS = new Set([
|
|
34
|
+
"_id",
|
|
35
|
+
"__v",
|
|
36
|
+
"id",
|
|
37
|
+
"save",
|
|
38
|
+
"validate",
|
|
39
|
+
"remove",
|
|
40
|
+
"populate",
|
|
41
|
+
"depopulate",
|
|
42
|
+
"toobject",
|
|
43
|
+
"tojson",
|
|
44
|
+
"schema",
|
|
45
|
+
"collection",
|
|
46
|
+
"model",
|
|
47
|
+
"db",
|
|
48
|
+
"errors",
|
|
49
|
+
"isnew",
|
|
50
|
+
"prototype",
|
|
51
|
+
"constructor",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const NUMERIC_TYPES = new Set(["number", "integer", "float", "decimal", "bigint"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads and validates entity.json from disk.
|
|
58
|
+
* Throws EntityValidationError with detailed, field-level messages.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} entityPath
|
|
61
|
+
* @param {{ databaseType?: "postgresql" | "mongodb" | "none" }} [context]
|
|
62
|
+
*/
|
|
63
|
+
function readEntityFile(entityPath, context = {}) {
|
|
64
|
+
const absolute = path.resolve(entityPath);
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(absolute)) {
|
|
67
|
+
throw new EntityValidationError(`entity.json not found`, [
|
|
68
|
+
{ path: "file", message: `No file exists at: ${absolute}` },
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const raw = fs.readFileSync(absolute, "utf8");
|
|
73
|
+
|
|
74
|
+
let json;
|
|
75
|
+
try {
|
|
76
|
+
json = JSON.parse(raw);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new EntityValidationError(`entity.json is not valid JSON`, [
|
|
79
|
+
{ path: "file", message: error.message },
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parseEntityJson(json, context);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validates a raw parsed entity.json object against the schema and
|
|
88
|
+
* semantic rules, then returns the normalized internal entity model.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} json
|
|
91
|
+
* @param {{ databaseType?: "postgresql" | "mongodb" | "none" }} [context]
|
|
92
|
+
*/
|
|
93
|
+
function parseEntityJson(json, context = {}) {
|
|
94
|
+
const result = EntityFileSchema.safeParse(json);
|
|
95
|
+
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
const issues = result.error.issues.map((issue) => ({
|
|
98
|
+
path: formatIssuePath(issue.path),
|
|
99
|
+
message: issue.message,
|
|
100
|
+
}));
|
|
101
|
+
throw new EntityValidationError("entity.json failed schema validation", issues);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const semanticIssues = runSemanticValidation(result.data, context);
|
|
105
|
+
if (semanticIssues.length > 0) {
|
|
106
|
+
throw new EntityValidationError("entity.json failed semantic validation", semanticIssues);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return normalizeEntityModel(result.data);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatIssuePath(pathParts) {
|
|
113
|
+
let out = "";
|
|
114
|
+
for (const part of pathParts) {
|
|
115
|
+
if (typeof part === "number") {
|
|
116
|
+
out += `[${part}]`;
|
|
117
|
+
} else if (out.length === 0) {
|
|
118
|
+
out = String(part);
|
|
119
|
+
} else {
|
|
120
|
+
out += `.${part}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function runSemanticValidation(data, context) {
|
|
127
|
+
const issues = [];
|
|
128
|
+
const modelNames = data.models.map((m) => m.name);
|
|
129
|
+
const modelNameSet = new Set(modelNames);
|
|
130
|
+
const seenNames = new Set();
|
|
131
|
+
const databaseType = context.databaseType;
|
|
132
|
+
|
|
133
|
+
data.models.forEach((model, mi) => {
|
|
134
|
+
validateModelIdentity(model, mi, seenNames, databaseType, issues);
|
|
135
|
+
validatePrimaryKeys(model, mi, databaseType, issues);
|
|
136
|
+
validateFields(model, mi, databaseType, issues);
|
|
137
|
+
validateRelations(model, mi, data.models, modelNames, modelNameSet, issues);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
validateManyToManyPairing(data.models, issues);
|
|
141
|
+
|
|
142
|
+
return issues;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateModelIdentity(model, mi, seenNames, databaseType, issues) {
|
|
146
|
+
if (seenNames.has(model.name)) {
|
|
147
|
+
issues.push({ path: `models[${mi}].name`, message: `duplicate model name '${model.name}'` });
|
|
148
|
+
}
|
|
149
|
+
seenNames.add(model.name);
|
|
150
|
+
|
|
151
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(model.name)) {
|
|
152
|
+
issues.push({
|
|
153
|
+
path: `models[${mi}].name`,
|
|
154
|
+
message: `model name must be a valid identifier (letters/digits, starting with a letter): '${model.name}'`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (JS_RESERVED_WORDS.has(model.name.toLowerCase())) {
|
|
158
|
+
issues.push({ path: `models[${mi}].name`, message: `'${model.name}' is a reserved word and cannot be used as a model name` });
|
|
159
|
+
}
|
|
160
|
+
if (databaseType === "postgresql" && PRISMA_RESERVED_MODEL_WORDS.has(model.name.toLowerCase())) {
|
|
161
|
+
issues.push({
|
|
162
|
+
path: `models[${mi}].name`,
|
|
163
|
+
message: `'${model.name}' is a reserved Prisma schema keyword and cannot be used as a model name`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function validatePrimaryKeys(model, mi, databaseType, issues) {
|
|
169
|
+
const primaryKeys = Object.entries(model.fields).filter(([, f]) => f.primaryKey);
|
|
170
|
+
if (primaryKeys.length > 1) {
|
|
171
|
+
issues.push({
|
|
172
|
+
path: `models[${mi}].fields`,
|
|
173
|
+
message: `model '${model.name}' declares more than one primaryKey field`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const [fieldName, field] of primaryKeys) {
|
|
177
|
+
if (field.nullable) {
|
|
178
|
+
issues.push({
|
|
179
|
+
path: `models[${mi}].fields.${fieldName}.nullable`,
|
|
180
|
+
message: `primary key field '${fieldName}' cannot be nullable`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (databaseType === "mongodb") {
|
|
185
|
+
// MongoDB primary keys are always represented as the native `_id`. Only 'uuid' can be
|
|
186
|
+
// mapped onto it (as a String _id with a generated default); anything else — most
|
|
187
|
+
// importantly autoIncrement, which Mongo has no native equivalent for — would silently
|
|
188
|
+
// produce different semantics than requested, so we reject it instead.
|
|
189
|
+
if (field.autoIncrement) {
|
|
190
|
+
issues.push({
|
|
191
|
+
path: `models[${mi}].fields.${fieldName}.autoIncrement`,
|
|
192
|
+
message: `autoIncrement is not supported for MongoDB — it has no native auto-increment primary key`,
|
|
193
|
+
});
|
|
194
|
+
} else if (field.type !== "uuid") {
|
|
195
|
+
issues.push({
|
|
196
|
+
path: `models[${mi}].fields.${fieldName}.type`,
|
|
197
|
+
message: `MongoDB primary keys must be type 'uuid' (mapped to a String _id) or omitted entirely (falls back to ObjectId _id) — type '${field.type}' is not supported`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateFields(model, mi, databaseType, issues) {
|
|
205
|
+
const fieldNames = new Set(Object.keys(model.fields));
|
|
206
|
+
|
|
207
|
+
for (const [fieldName, field] of Object.entries(model.fields)) {
|
|
208
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(fieldName)) {
|
|
209
|
+
issues.push({
|
|
210
|
+
path: `models[${mi}].fields.${fieldName}`,
|
|
211
|
+
message: `field name must be a valid identifier: '${fieldName}'`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (JS_RESERVED_WORDS.has(fieldName.toLowerCase())) {
|
|
215
|
+
issues.push({
|
|
216
|
+
path: `models[${mi}].fields.${fieldName}`,
|
|
217
|
+
message: `'${fieldName}' is a reserved word and cannot be used as a field name`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (databaseType === "mongodb" && MONGO_RESERVED_FIELD_WORDS.has(fieldName.toLowerCase()) && fieldName.toLowerCase() !== "id") {
|
|
221
|
+
issues.push({
|
|
222
|
+
path: `models[${mi}].fields.${fieldName}`,
|
|
223
|
+
message: `'${fieldName}' collides with a built-in Mongoose Document property and cannot be used as a field name`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (field.min !== undefined && field.max !== undefined && field.min > field.max) {
|
|
228
|
+
issues.push({
|
|
229
|
+
path: `models[${mi}].fields.${fieldName}`,
|
|
230
|
+
message: `min (${field.min}) cannot be greater than max (${field.max})`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (field.required && field.nullable) {
|
|
234
|
+
issues.push({
|
|
235
|
+
path: `models[${mi}].fields.${fieldName}`,
|
|
236
|
+
message: `field cannot be both 'required: true' and 'nullable: true' — required implies non-null`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
validateFieldDefault(model, mi, fieldName, field, issues);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Relation names must not collide with a real field of the same name — both become
|
|
244
|
+
// properties on the generated model/service and would otherwise silently shadow each other.
|
|
245
|
+
for (const relationName of Object.keys(model.relations)) {
|
|
246
|
+
if (fieldNames.has(relationName)) {
|
|
247
|
+
issues.push({
|
|
248
|
+
path: `models[${mi}].relations.${relationName}`,
|
|
249
|
+
message: `relation name '${relationName}' collides with a field of the same name on model '${model.name}'`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateFieldDefault(model, mi, fieldName, field, issues) {
|
|
256
|
+
if (field.default === undefined || field.default === null) return;
|
|
257
|
+
const path_ = `models[${mi}].fields.${fieldName}.default`;
|
|
258
|
+
|
|
259
|
+
if (field.type === "enum") {
|
|
260
|
+
if (Array.isArray(field.values) && !field.values.includes(field.default)) {
|
|
261
|
+
issues.push({ path: path_, message: `default '${field.default}' is not one of the declared enum values [${field.values.join(", ")}]` });
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (field.type === "uuid" && field.default === "uuid") return; // sentinel meaning "generate one"
|
|
267
|
+
if ((field.type === "date" || field.type === "datetime") && field.default === "now") return; // sentinel
|
|
268
|
+
|
|
269
|
+
if (field.type === "boolean" && typeof field.default !== "boolean") {
|
|
270
|
+
issues.push({ path: path_, message: `default must be a boolean for type 'boolean', got ${JSON.stringify(field.default)}` });
|
|
271
|
+
} else if (NUMERIC_TYPES.has(field.type) && typeof field.default !== "number") {
|
|
272
|
+
issues.push({ path: path_, message: `default must be a number for type '${field.type}', got ${JSON.stringify(field.default)}` });
|
|
273
|
+
} else if (["string", "text", "uuid"].includes(field.type) && typeof field.default !== "string") {
|
|
274
|
+
issues.push({ path: path_, message: `default must be a string for type '${field.type}', got ${JSON.stringify(field.default)}` });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateRelations(model, mi, allModels, modelNames, modelNameSet, issues) {
|
|
279
|
+
for (const [relationName, relation] of Object.entries(model.relations)) {
|
|
280
|
+
if (!modelNameSet.has(relation.model)) {
|
|
281
|
+
const suggestion = didYouMean(relation.model, modelNames);
|
|
282
|
+
issues.push({
|
|
283
|
+
path: `models[${mi}].relations.${relationName}.model`,
|
|
284
|
+
message: suggestion
|
|
285
|
+
? `unknown model '${relation.model}'. Did you mean '${suggestion}'?`
|
|
286
|
+
: `unknown model '${relation.model}'`,
|
|
287
|
+
});
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const target = allModels.find((m) => m.name === relation.model);
|
|
292
|
+
|
|
293
|
+
if (relation.type === "belongsTo" && relation.foreignKey) {
|
|
294
|
+
const fkField = model.fields[relation.foreignKey];
|
|
295
|
+
if (!fkField) {
|
|
296
|
+
issues.push({
|
|
297
|
+
path: `models[${mi}].relations.${relationName}.foreignKey`,
|
|
298
|
+
message: `foreignKey '${relation.foreignKey}' does not exist on model '${model.name}'`,
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
const targetPk = Object.entries(target.fields).find(([, f]) => f.primaryKey);
|
|
302
|
+
if (targetPk && targetPk[1].type !== fkField.type) {
|
|
303
|
+
issues.push({
|
|
304
|
+
path: `models[${mi}].relations.${relationName}.foreignKey`,
|
|
305
|
+
message: `foreignKey '${relation.foreignKey}' has type '${fkField.type}' but '${relation.model}.${targetPk[0]}' (its primary key) has type '${targetPk[1].type}'`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if ((relation.type === "hasMany" || relation.type === "hasOne") && relation.foreignKey) {
|
|
312
|
+
if (!target.fields[relation.foreignKey]) {
|
|
313
|
+
issues.push({
|
|
314
|
+
path: `models[${mi}].relations.${relationName}.foreignKey`,
|
|
315
|
+
message: `foreignKey '${relation.foreignKey}' does not exist on related model '${relation.model}'`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Prisma's implicit many-to-many requires both sides to declare the array relation with a
|
|
324
|
+
* matching join name. A one-sided belongsToMany silently breaks `prisma generate`, so we
|
|
325
|
+
* reject it here instead of letting an invalid schema reach the database generator.
|
|
326
|
+
*/
|
|
327
|
+
function validateManyToManyPairing(models, issues) {
|
|
328
|
+
models.forEach((model, mi) => {
|
|
329
|
+
for (const [relationName, relation] of Object.entries(model.relations)) {
|
|
330
|
+
if (relation.type !== "belongsToMany") continue;
|
|
331
|
+
const target = models.find((m) => m.name === relation.model);
|
|
332
|
+
if (!target) continue; // already reported as an unknown-model error
|
|
333
|
+
|
|
334
|
+
const reciprocal = Object.entries(target.relations).find(
|
|
335
|
+
([, r]) => r.type === "belongsToMany" && r.model === model.name
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
if (!reciprocal) {
|
|
339
|
+
issues.push({
|
|
340
|
+
path: `models[${mi}].relations.${relationName}`,
|
|
341
|
+
message: `many-to-many relation '${relationName}' has no matching belongsToMany declared on '${relation.model}' back to '${model.name}'`,
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const [reciprocalName, reciprocalRelation] = reciprocal;
|
|
347
|
+
if (relation.through !== reciprocalRelation.through) {
|
|
348
|
+
issues.push({
|
|
349
|
+
path: `models[${mi}].relations.${relationName}.through`,
|
|
350
|
+
message: `through '${relation.through}' does not match '${relation.model}.relations.${reciprocalName}.through' ('${reciprocalRelation.through}') — both sides of a many-to-many must agree`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function normalizeEntityModel(data) {
|
|
358
|
+
const models = data.models.map((model) => normalizeModel(model));
|
|
359
|
+
return { models };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeModel(model) {
|
|
363
|
+
const pascalName = toPascalCase(model.name);
|
|
364
|
+
const camelName = toCamelCase(model.name);
|
|
365
|
+
const kebabName = toKebabCase(model.name);
|
|
366
|
+
const snakeName = toSnakeCase(model.name);
|
|
367
|
+
const tableName = model.tableName || pluralize(snakeName);
|
|
368
|
+
const routePath = pluralize(kebabName);
|
|
369
|
+
|
|
370
|
+
const fields = Object.entries(model.fields).map(([name, field]) => ({
|
|
371
|
+
name,
|
|
372
|
+
camelName: toCamelCase(name),
|
|
373
|
+
...field,
|
|
374
|
+
}));
|
|
375
|
+
|
|
376
|
+
let primaryKey = fields.find((f) => f.primaryKey) || null;
|
|
377
|
+
if (!primaryKey) {
|
|
378
|
+
primaryKey = {
|
|
379
|
+
name: "id",
|
|
380
|
+
camelName: "id",
|
|
381
|
+
type: "uuid",
|
|
382
|
+
required: true,
|
|
383
|
+
nullable: false,
|
|
384
|
+
unique: false,
|
|
385
|
+
index: false,
|
|
386
|
+
default: "uuid",
|
|
387
|
+
primaryKey: true,
|
|
388
|
+
autoIncrement: false,
|
|
389
|
+
synthetic: true,
|
|
390
|
+
};
|
|
391
|
+
fields.unshift(primaryKey);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const relations = Object.entries(model.relations).map(([name, relation]) => ({
|
|
395
|
+
name,
|
|
396
|
+
camelName: toCamelCase(name),
|
|
397
|
+
pascalName: toPascalCase(name),
|
|
398
|
+
...relation,
|
|
399
|
+
}));
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
name: model.name,
|
|
403
|
+
pascalName,
|
|
404
|
+
camelName,
|
|
405
|
+
kebabName,
|
|
406
|
+
snakeName,
|
|
407
|
+
tableName,
|
|
408
|
+
routePath,
|
|
409
|
+
timestamps: model.timestamps,
|
|
410
|
+
crud: model.crud,
|
|
411
|
+
primaryKey,
|
|
412
|
+
fields,
|
|
413
|
+
relations,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = { readEntityFile, parseEntityJson, FIELD_TYPES };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { z } = require("zod");
|
|
4
|
+
|
|
5
|
+
const FIELD_TYPES = [
|
|
6
|
+
"string",
|
|
7
|
+
"text",
|
|
8
|
+
"number",
|
|
9
|
+
"integer",
|
|
10
|
+
"float",
|
|
11
|
+
"boolean",
|
|
12
|
+
"date",
|
|
13
|
+
"datetime",
|
|
14
|
+
"uuid",
|
|
15
|
+
"json",
|
|
16
|
+
"enum",
|
|
17
|
+
"decimal",
|
|
18
|
+
"bigint",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const RELATION_TYPES = ["belongsTo", "hasMany", "hasOne", "belongsToMany"];
|
|
22
|
+
|
|
23
|
+
const FieldSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
type: z.enum(FIELD_TYPES, {
|
|
26
|
+
errorMap: () => ({ message: `type must be one of: ${FIELD_TYPES.join(", ")}` }),
|
|
27
|
+
}),
|
|
28
|
+
required: z.boolean().optional().default(false),
|
|
29
|
+
nullable: z.boolean().optional().default(false),
|
|
30
|
+
unique: z.boolean().optional().default(false),
|
|
31
|
+
index: z.boolean().optional().default(false),
|
|
32
|
+
default: z.any().optional(),
|
|
33
|
+
primaryKey: z.boolean().optional().default(false),
|
|
34
|
+
autoIncrement: z.boolean().optional().default(false),
|
|
35
|
+
length: z.number().int().positive().optional(),
|
|
36
|
+
min: z.number().optional(),
|
|
37
|
+
max: z.number().optional(),
|
|
38
|
+
values: z.array(z.string()).optional(),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
.superRefine((field, ctx) => {
|
|
42
|
+
if (field.type === "enum" && (!field.values || field.values.length === 0)) {
|
|
43
|
+
ctx.addIssue({
|
|
44
|
+
code: z.ZodIssueCode.custom,
|
|
45
|
+
message: "enum fields require a non-empty 'values' array",
|
|
46
|
+
path: ["values"],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (field.autoIncrement && field.type !== "integer" && field.type !== "bigint") {
|
|
50
|
+
ctx.addIssue({
|
|
51
|
+
code: z.ZodIssueCode.custom,
|
|
52
|
+
message: "autoIncrement is only valid for 'integer' or 'bigint' fields",
|
|
53
|
+
path: ["autoIncrement"],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const RelationSchema = z
|
|
59
|
+
.object({
|
|
60
|
+
type: z.enum(RELATION_TYPES, {
|
|
61
|
+
errorMap: () => ({ message: `relation type must be one of: ${RELATION_TYPES.join(", ")}` }),
|
|
62
|
+
}),
|
|
63
|
+
model: z.string().min(1),
|
|
64
|
+
foreignKey: z.string().min(1).optional(),
|
|
65
|
+
through: z.string().min(1).optional(),
|
|
66
|
+
})
|
|
67
|
+
.strict()
|
|
68
|
+
.superRefine((relation, ctx) => {
|
|
69
|
+
if ((relation.type === "belongsTo" || relation.type === "hasOne" || relation.type === "hasMany") && !relation.foreignKey) {
|
|
70
|
+
ctx.addIssue({
|
|
71
|
+
code: z.ZodIssueCode.custom,
|
|
72
|
+
message: `relations of type '${relation.type}' require a 'foreignKey'`,
|
|
73
|
+
path: ["foreignKey"],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (relation.type === "belongsToMany" && !relation.through) {
|
|
77
|
+
ctx.addIssue({
|
|
78
|
+
code: z.ZodIssueCode.custom,
|
|
79
|
+
message: "relations of type 'belongsToMany' require a 'through' join model/table name",
|
|
80
|
+
path: ["through"],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const ModelSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
name: z.string().min(1),
|
|
88
|
+
tableName: z.string().min(1).optional(),
|
|
89
|
+
timestamps: z.boolean().optional().default(true),
|
|
90
|
+
crud: z.boolean().optional().default(true),
|
|
91
|
+
fields: z.record(z.string(), FieldSchema).refine((fields) => Object.keys(fields).length > 0, {
|
|
92
|
+
message: "model must declare at least one field",
|
|
93
|
+
}),
|
|
94
|
+
relations: z.record(z.string(), RelationSchema).optional().default({}),
|
|
95
|
+
})
|
|
96
|
+
.strict();
|
|
97
|
+
|
|
98
|
+
const EntityFileSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
models: z.array(ModelSchema).min(1, "entity.json must declare at least one model"),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
|
|
104
|
+
module.exports = { EntityFileSchema, ModelSchema, FieldSchema, RelationSchema, FIELD_TYPES, RELATION_TYPES };
|