UNPKG

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
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;