ucg
Version:
Universal CRUD Generator - Express.js plugin and CLI tool for generating complete Node.js REST APIs with database models, controllers, routes, validators, and admin interface. Supports PostgreSQL, MySQL, SQLite with Sequelize, TypeORM, and Knex.js. Develo
512 lines (421 loc) • 15 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const { pascalCase } = require('pascal-case');
const camelCase = require('camelcase');
const pluralize = require('pluralize');
class ModelGenerator {
constructor(databaseManager, configManager) {
this.databaseManager = databaseManager;
this.configManager = configManager;
}
async preview(tableName, modelName) {
const schema = await this.databaseManager.getTableSchema(tableName);
const relations = await this.databaseManager.getRelations(tableName);
const dbConfig = await this.configManager.getDatabase();
const finalModelName = modelName || pascalCase(tableName);
const code = await this.generateModelCode(schema, relations, finalModelName, dbConfig.orm);
return {
modelName: finalModelName,
fileName: `${finalModelName}.js`,
code,
schema,
relations
};
}
async generate(tableName, modelName, outputPath) {
const previewData = await this.preview(tableName, modelName);
// Default output path
if (!outputPath) {
outputPath = path.join(process.cwd(), 'src', 'models');
}
// Ensure output directory exists
await fs.ensureDir(outputPath);
const filePath = path.join(outputPath, previewData.fileName);
await fs.writeFile(filePath, previewData.code, 'utf8');
return {
type: 'model',
tableName,
modelName: previewData.modelName,
filePath,
files: [filePath]
};
}
async generateModelCode(schema, relations, modelName, orm) {
switch (orm) {
case 'sequelize':
return this.generateSequelizeModel(schema, relations, modelName);
case 'typeorm':
return this.generateTypeOrmModel(schema, relations, modelName);
case 'knex':
return this.generateKnexModel(schema, relations, modelName);
default:
throw new Error(`Unsupported ORM: ${orm}`);
}
}
generateSequelizeModel(schema, relations, modelName) {
const { columns, tableName } = schema;
let code = `const { DataTypes } = require('sequelize');
const { sequelize } = require('../config/database');
const ${modelName} = sequelize.define('${modelName}', {
`;
// Generate columns
columns.forEach(column => {
const dataType = this.mapPostgreSQLToSequelize(column.data_type, column);
const nullable = column.is_nullable === 'YES';
const isPrimary = schema.primaryKeys.includes(column.column_name);
code += ` ${column.column_name}: {\n`;
code += ` type: DataTypes.${dataType}`;
if (isPrimary) {
code += ',\n primaryKey: true';
if (column.column_default && column.column_default.includes('nextval')) {
code += ',\n autoIncrement: true';
}
}
if (!nullable) {
code += ',\n allowNull: false';
}
if (column.column_default && !column.column_default.includes('nextval')) {
const defaultValue = this.parseDefaultValue(column.column_default);
code += `,\n defaultValue: ${defaultValue}`;
}
code += '\n },\n';
});
code = code.slice(0, -2) + '\n'; // Remove last comma and newline
const hasCreatedAt = columns.some(col => col.column_name === 'created_at');
const hasUpdatedAt = columns.some(col => col.column_name === 'updated_at');
const hasTimestamps = hasCreatedAt || hasUpdatedAt;
code += `}, {
tableName: '${tableName}',
timestamps: ${hasTimestamps}`;
if (hasTimestamps) {
if (hasCreatedAt) {
code += ',\n createdAt: \'created_at\'';
} else {
code += ',\n createdAt: false';
}
if (hasUpdatedAt) {
code += ',\n updatedAt: \'updated_at\'';
} else {
code += ',\n updatedAt: false';
}
}
code += `\n});
`;
// Add relations
if (relations.belongsTo.length > 0 || relations.hasMany.length > 0) {
code += `${modelName}.associate = (models) => {\n`;
// Track used aliases to avoid conflicts
const usedAliases = new Set();
relations.belongsTo.forEach(rel => {
const relatedModel = pascalCase(rel.referenced_table);
let alias = camelCase(rel.referenced_table);
// Handle multiple foreign keys to the same table
if (usedAliases.has(alias)) {
// Create unique alias based on column name
const columnBaseName = rel.column_name.replace(/_id$/, '').replace(/id$/, '');
alias = camelCase(columnBaseName) || `${alias}_${rel.column_name}`;
// If still conflicts, add numeric suffix
let counter = 1;
const baseAlias = alias;
while (usedAliases.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
}
usedAliases.add(alias);
code += ` ${modelName}.belongsTo(models.${relatedModel}, {\n`;
code += ` foreignKey: '${rel.column_name}',\n`;
code += ` as: '${alias}'\n`;
code += ' });\n';
});
relations.hasMany.forEach(rel => {
const relatedModel = pascalCase(rel.referencing_table);
let alias = camelCase(pluralize(rel.referencing_table));
// Handle multiple foreign keys to the same table
if (usedAliases.has(alias)) {
// Create unique alias based on column name
const columnBaseName = rel.referencing_column.replace(/_id$/, '').replace(/id$/, '');
alias = camelCase(pluralize(columnBaseName)) || `${alias}_${rel.referencing_column}`;
// If still conflicts, add numeric suffix
let counter = 1;
const baseAlias = alias;
while (usedAliases.has(alias)) {
alias = `${baseAlias}${counter}`;
counter++;
}
}
usedAliases.add(alias);
code += ` ${modelName}.hasMany(models.${relatedModel}, {\n`;
code += ` foreignKey: '${rel.referencing_column}',\n`;
code += ` as: '${alias}'\n`;
code += ' });\n';
});
code += '};\n\n';
}
code += `module.exports = ${modelName};\n`;
return code;
}
generateTypeOrmModel(schema, relations, modelName) {
const { columns, tableName } = schema;
let code = 'import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn';
// Add relation imports if needed
if (relations.belongsTo.length > 0 || relations.hasMany.length > 0) {
code += ', ManyToOne, OneToMany, JoinColumn';
}
code += ' } from \'typeorm\';\n\n';
// Add related model imports
const relatedModels = new Set();
relations.belongsTo.forEach(rel => relatedModels.add(pascalCase(rel.referenced_table)));
relations.hasMany.forEach(rel => relatedModels.add(pascalCase(rel.referencing_table)));
relatedModels.forEach(model => {
if (model !== modelName) {
code += `import { ${model} } from './${model}';\n`;
}
});
if (relatedModels.size > 0) code += '\n';
code += `@Entity('${tableName}')\n`;
code += `export class ${modelName} {\n`;
// Generate columns
columns.forEach(column => {
const dataType = this.mapPostgreSQLToTypeORM(column.data_type, column);
const nullable = column.is_nullable === 'YES';
const isPrimary = schema.primaryKeys.includes(column.column_name);
if (isPrimary && column.column_default && column.column_default.includes('nextval')) {
code += ' @PrimaryGeneratedColumn()\n';
} else if (isPrimary) {
code += ' @PrimaryColumn()\n';
} else if (['created_at', 'createdAt'].includes(column.column_name)) {
code += ' @CreateDateColumn()\n';
} else if (['updated_at', 'updatedAt'].includes(column.column_name)) {
code += ' @UpdateDateColumn()\n';
} else {
code += ' @Column({\n';
code += ` type: '${dataType}'`;
if (!nullable) code += ',\n nullable: false';
if (column.column_default && !column.column_default.includes('nextval')) {
const defaultValue = this.parseDefaultValue(column.column_default);
code += `,\n default: ${defaultValue}`;
}
code += '\n })\n';
}
code += ` ${column.column_name}: ${this.mapPostgreSQLToTSType(column.data_type)};\n\n`;
});
// Add relations
relations.belongsTo.forEach(rel => {
const relatedModel = pascalCase(rel.referenced_table);
code += ` @ManyToOne(() => ${relatedModel})\n`;
code += ` @JoinColumn({ name: '${rel.column_name}' })\n`;
code += ` ${camelCase(rel.referenced_table)}: ${relatedModel};\n\n`;
});
relations.hasMany.forEach(rel => {
const relatedModel = pascalCase(rel.referencing_table);
code += ` @OneToMany(() => ${relatedModel}, ${camelCase(rel.referencing_table)} => ${camelCase(rel.referencing_table)}.${camelCase(tableName)})\n`;
code += ` ${camelCase(pluralize(rel.referencing_table))}: ${relatedModel}[];\n\n`;
});
code += '}\n';
return code;
}
generateKnexModel(schema, relations, modelName) {
const { columns, tableName } = schema;
let code = `const { Model } = require('objection');
const knex = require('../config/database');
class ${modelName} extends Model {
static get tableName() {
return '${tableName}';
}
static get idColumn() {
return [${schema.primaryKeys.map(pk => `'${pk}'`).join(', ')}];
}
`;
// JSON schema
code += ` static get jsonSchema() {
return {
type: 'object',
required: [${columns.filter(col => col.is_nullable === 'NO' && !schema.primaryKeys.includes(col.column_name)).map(col => `'${col.column_name}'`).join(', ')}],
properties: {
`;
columns.forEach(column => {
const jsonType = this.mapPostgreSQLToJSONSchema(column.data_type);
code += ` ${column.column_name}: { type: '${jsonType}' },\n`;
});
code += ` }
};
}
`;
// Relations
if (relations.belongsTo.length > 0 || relations.hasMany.length > 0) {
code += ` static get relationMappings() {
return {
`;
relations.belongsTo.forEach(rel => {
const relatedModel = pascalCase(rel.referenced_table);
code += ` ${camelCase(rel.referenced_table)}: {
relation: Model.BelongsToOneRelation,
modelClass: require('./${relatedModel}'),
join: {
from: '${tableName}.${rel.column_name}',
to: '${rel.referenced_table}.${rel.referenced_column}'
}
},
`;
});
relations.hasMany.forEach(rel => {
const relatedModel = pascalCase(rel.referencing_table);
code += ` ${camelCase(pluralize(rel.referencing_table))}: {
relation: Model.HasManyRelation,
modelClass: require('./${relatedModel}'),
join: {
from: '${tableName}.${rel.referenced_column}',
to: '${rel.referencing_table}.${rel.referencing_column}'
}
},
`;
});
code = code.slice(0, -2) + '\n'; // Remove last comma
code += ` };
}
`;
}
code += `}
module.exports = ${modelName};
`;
return code;
}
mapPostgreSQLToSequelize(pgType, column) {
const typeMap = {
'integer': 'INTEGER',
'bigint': 'BIGINT',
'smallint': 'INTEGER',
'decimal': 'DECIMAL',
'numeric': 'DECIMAL',
'real': 'REAL',
'double precision': 'DOUBLE',
'character varying': 'STRING',
'varchar': 'STRING',
'character': 'STRING',
'char': 'STRING',
'text': 'TEXT',
'boolean': 'BOOLEAN',
'date': 'DATEONLY',
'timestamp': 'DATE',
'timestamp with time zone': 'DATE',
'time': 'TIME',
'json': 'JSON',
'jsonb': 'JSONB',
'uuid': 'UUID'
};
return typeMap[pgType] || 'STRING';
}
mapPostgreSQLToTypeORM(pgType, column) {
const typeMap = {
'integer': 'int',
'bigint': 'bigint',
'smallint': 'smallint',
'decimal': 'decimal',
'numeric': 'numeric',
'real': 'real',
'double precision': 'double precision',
'character varying': 'varchar',
'varchar': 'varchar',
'character': 'char',
'char': 'char',
'text': 'text',
'boolean': 'boolean',
'date': 'date',
'timestamp': 'timestamp',
'timestamp with time zone': 'timestamptz',
'time': 'time',
'json': 'json',
'jsonb': 'jsonb',
'uuid': 'uuid'
};
return typeMap[pgType] || 'varchar';
}
mapPostgreSQLToTSType(pgType) {
const typeMap = {
'integer': 'number',
'bigint': 'number',
'smallint': 'number',
'decimal': 'number',
'numeric': 'number',
'real': 'number',
'double precision': 'number',
'character varying': 'string',
'varchar': 'string',
'character': 'string',
'char': 'string',
'text': 'string',
'boolean': 'boolean',
'date': 'Date',
'timestamp': 'Date',
'timestamp with time zone': 'Date',
'time': 'string',
'json': 'any',
'jsonb': 'any',
'uuid': 'string'
};
return typeMap[pgType] || 'string';
}
mapPostgreSQLToJSONSchema(pgType) {
const typeMap = {
'integer': 'integer',
'bigint': 'integer',
'smallint': 'integer',
'decimal': 'number',
'numeric': 'number',
'real': 'number',
'double precision': 'number',
'character varying': 'string',
'varchar': 'string',
'character': 'string',
'char': 'string',
'text': 'string',
'boolean': 'boolean',
'date': 'string',
'timestamp': 'string',
'timestamp with time zone': 'string',
'time': 'string',
'json': 'object',
'jsonb': 'object',
'uuid': 'string'
};
return typeMap[pgType] || 'string';
}
parseDefaultValue(defaultValue) {
if (!defaultValue) return null;
// Handle PostgreSQL function calls
if (defaultValue.includes('gen_random_uuid()')) {
return 'DataTypes.UUIDV4';
}
if (defaultValue.includes('now()')) {
return 'DataTypes.NOW';
}
// Handle JSONB defaults
if (defaultValue.includes('\'{}\'::jsonb')) {
return '{}';
}
if (defaultValue.includes('::jsonb')) {
// Extract the JSON part before ::jsonb
const jsonPart = defaultValue.split('::')[0];
if (jsonPart.startsWith('\'') && jsonPart.endsWith('\'')) {
return jsonPart.slice(1, -1); // Remove surrounding quotes
}
return jsonPart;
}
// Handle quoted strings
if (defaultValue.startsWith('\'') && defaultValue.endsWith('\'')) {
return defaultValue;
}
// Handle booleans
if (['true', 'false'].includes(defaultValue.toLowerCase())) {
return defaultValue.toLowerCase();
}
// Handle numbers
if (!isNaN(defaultValue)) {
return defaultValue;
}
// Default case - wrap in quotes
return `'${defaultValue}'`;
}
}
module.exports = ModelGenerator;