UNPKG

heyjarvis

Version:

J.A.R.V.I.S. - Advanced Node.js MVC Framework with ORM, built-in validation, soft delete, and query builder

918 lines (771 loc) 32.9 kB
const fs = require('fs'); const path = require('path'); const chalk = require('chalk'); const inquirer = require('inquirer'); const { getText } = require('../config'); // Helper functions const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); const pluralize = (str) => str.endsWith('y') ? str.slice(0, -1) + 'ies' : str.endsWith('s') ? str : str + 's'; const writeFile = (filePath, content) => { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(filePath, content); }; // Main generate function that handles all types const generateModel = async (type, name, options = {}) => { try { switch (type.toLowerCase()) { case 'model': return await generateModelFile(name, options); case 'controller': return await generateControllerFile(name, options); case 'route': return await generateRouteFile(name, options); case 'migration': return await generateMigrationFile(name, options); case 'all': return await generateAllFiles(name, options); default: throw new Error(getText('errors.unknownGenerator', { type })); } } catch (error) { console.error(chalk.red(getText('errors.failedToGenerate', { type })), error.message); throw error; } }; // Enhanced Model Generator const generateModelFile = async (name, options) => { const modelName = capitalize(name); const tableName = pluralize(name.toLowerCase()); let fields; if (!options.fields) { console.log(chalk.cyan(getText('model.noFields', { model: modelName }) + '\n')); fields = await createFieldsInteractively(); } else { fields = parseFields(options.fields); } const hasRelationships = await askForRelationships(modelName); let relationships = ''; if (hasRelationships.length > 0) { relationships = generateRelationships(hasRelationships); } const modelContent = `const { DataTypes } = require('sequelize'); const { v4: uuidv4 } = require('uuid'); const sequelize = require('../config/database'); const ${modelName} = sequelize.define('${modelName}', { id: { type: DataTypes.UUID, defaultValue: () => uuidv4(), primaryKey: true, allowNull: false }${fields}, visible: { type: DataTypes.BOOLEAN, defaultValue: true } }, { tableName: '${tableName}', paranoid: true, timestamps: true, underscored: true, indexes: [ { name: '${tableName}_visible_idx', fields: ['visible'] }, { name: '${tableName}_created_at_idx', fields: ['created_at'] } ], hooks: { beforeCreate: (instance) => { if (!instance.id) { instance.id = uuidv4(); } } } }); ${relationships} module.exports = ${modelName};`; const filePath = path.join('src/models', `${modelName}.js`); writeFile(filePath, modelContent); console.log(chalk.green(getText('model.success.generated', { path: filePath }))); console.log(chalk.blue(getText('model.success.table', { table: tableName }))); console.log(chalk.blue(getText('model.success.primaryKey'))); console.log(chalk.blue(getText('model.success.features'))); const fieldCount = fields.split('type: DataTypes.').length - 1; console.log(chalk.cyan(getText('model.success.fieldCount', { count: fieldCount }))); return filePath; }; // Interactive field creation const createFieldsInteractively = async () => { const fields = []; let addMore = true; while (addMore) { console.log(chalk.cyan(`\n${getText('model.addingField', { count: fields.length + 1 })}:`)); const fieldData = await inquirer.prompt([ { type: 'input', name: 'name', message: getText('model.fieldName'), validate: (input) => { if (!input) return getText('validation.required', { field: 'Field name' }); if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(input)) return getText('validation.invalidFormat', { field: 'Field name' }); return true; } }, { type: 'list', name: 'type', message: getText('model.fieldType'), choices: [ { name: getText('model.fieldTypes.string'), value: 'string' }, { name: getText('model.fieldTypes.text'), value: 'text' }, { name: getText('model.fieldTypes.integer'), value: 'integer' }, { name: getText('model.fieldTypes.decimal'), value: 'decimal' }, { name: getText('model.fieldTypes.boolean'), value: 'boolean' }, { name: getText('model.fieldTypes.date'), value: 'date' }, { name: getText('model.fieldTypes.datetime'), value: 'datetime' }, { name: getText('model.fieldTypes.email'), value: 'email' }, { name: getText('model.fieldTypes.uuid'), value: 'uuid' }, { name: getText('model.fieldTypes.json'), value: 'json' } ] }, { type: 'checkbox', name: 'options', message: getText('model.fieldOptions'), choices: [ { name: getText('model.fieldOptions.required'), value: 'required' }, { name: getText('model.fieldOptions.unique'), value: 'unique' }, { name: getText('model.fieldOptions.indexed'), value: 'indexed' } ] }, { type: 'input', name: 'defaultValue', message: getText('model.defaultValue'), when: (answers) => !answers.options.includes('required') } ]); let fieldDef = `, ${fieldData.name}: { type: DataTypes.${getDataType(fieldData.type)}`; if (fieldData.options.includes('required')) { fieldDef += ',\n allowNull: false'; } if (fieldData.options.includes('unique')) { fieldDef += ',\n unique: true'; } if (fieldData.options.includes('indexed')) { fieldDef += ',\n indexes: [{ fields: [\'' + fieldData.name + '\'] }]'; } if (fieldData.defaultValue) { if (['true', 'false'].includes(fieldData.defaultValue.toLowerCase())) { fieldDef += `,\n defaultValue: ${fieldData.defaultValue.toLowerCase()}`; } else if (!isNaN(fieldData.defaultValue)) { fieldDef += `,\n defaultValue: ${fieldData.defaultValue}`; } else { fieldDef += `,\n defaultValue: '${fieldData.defaultValue}'`; } } if (fieldData.type === 'email') { fieldDef += `,\n validate: { isEmail: { msg: 'Must be a valid email address' } }`; } fieldDef += '\n }'; fields.push(fieldDef); const continueAdding = await inquirer.prompt([ { type: 'confirm', name: 'addMore', message: getText('model.addMoreFields'), default: false } ]); addMore = continueAdding.addMore; } return fields.join(''); }; // Ask for relationships const askForRelationships = async (modelName) => { const hasRelations = await inquirer.prompt([ { type: 'confirm', name: 'addRelationships', message: getText('model.addRelationships', { model: modelName }), default: false } ]); if (!hasRelations.addRelationships) return []; const relationships = []; let addMore = true; while (addMore) { const relationData = await inquirer.prompt([ { type: 'list', name: 'type', message: getText('model.relationshipType'), choices: [ { name: getText('model.relationships.belongsTo'), value: 'belongsTo' }, { name: getText('model.relationships.hasMany'), value: 'hasMany' }, { name: getText('model.relationships.hasOne'), value: 'hasOne' }, { name: getText('model.relationships.belongsToMany'), value: 'belongsToMany' } ] }, { type: 'input', name: 'model', message: getText('model.targetModel'), validate: (input) => input.length > 0 || getText('validation.required', { field: 'Model name' }) }, { type: 'input', name: 'foreignKey', message: getText('model.foreignKey'), when: (answers) => ['belongsTo', 'hasMany', 'hasOne'].includes(answers.type) } ]); relationships.push(relationData); const continueAdding = await inquirer.prompt([ { type: 'confirm', name: 'addMore', message: getText('model.addMoreRelations'), default: false } ]); addMore = continueAdding.addMore; } return relationships; }; // Generate relationship code const generateRelationships = (relationships) => { if (relationships.length === 0) return ''; let code = '// Model Relationships\n'; relationships.forEach(rel => { const targetModel = capitalize(rel.model); const options = rel.foreignKey ? `{ foreignKey: '${rel.foreignKey}' }` : ''; code += `${rel.type === 'belongsToMany' ? `// ` : ''}// ${rel.type} relationship with ${targetModel}\n`; code += `// ${targetModel}.${rel.type}(require('./${targetModel}')${options ? `, ${options}` : ''});\n\n`; }); return code; }; // Enhanced field parser const parseFields = (fieldsString) => { if (!fieldsString) { return `, name: { type: DataTypes.STRING, allowNull: false, validate: { notEmpty: { msg: 'Name cannot be empty' } } }`; } const cleanInput = fieldsString.replace(/\s+/g, ' ').trim(); const fields = cleanInput.split(',').map(field => field.trim()).filter(field => field.length > 0); if (fields.length === 0) { return `, name: { type: DataTypes.STRING, allowNull: false }`; } const parsedFields = fields.map((field) => { const parts = field.split(':'); const name = parts[0] ? parts[0].trim() : ''; const type = parts[1] ? parts[1].trim() : 'string'; const options = parts.slice(2).map(opt => opt.trim()).filter(opt => opt.length > 0); if (!name) return null; const dataType = getDataType(type); let fieldDef = `, ${name}: { type: DataTypes.${dataType}`; if (options.includes('required')) { fieldDef += ',\n allowNull: false'; } if (options.includes('unique')) { fieldDef += ',\n unique: true'; } if (options.includes('nullable')) { fieldDef += ',\n allowNull: true'; } const defaultOption = options.find(opt => opt.startsWith('default:')); if (defaultOption) { const defaultValue = defaultOption.split('default:')[1]; if (['true', 'false'].includes(defaultValue)) { fieldDef += `,\n defaultValue: ${defaultValue}`; } else if (!isNaN(defaultValue)) { fieldDef += `,\n defaultValue: ${defaultValue}`; } else { fieldDef += `,\n defaultValue: '${defaultValue}'`; } } if (type === 'email') { fieldDef += `,\n validate: { isEmail: { msg: 'Must be a valid email address' } }`; } fieldDef += '\n }'; return fieldDef; }).filter(field => field !== null); return parsedFields.join(''); }; // Helper function to map field types const getDataType = (type) => { const typeMap = { 'string': 'STRING', 'text': 'TEXT', 'integer': 'INTEGER', 'int': 'INTEGER', 'decimal': 'DECIMAL(10,2)', 'float': 'FLOAT', 'double': 'DOUBLE', 'boolean': 'BOOLEAN', 'bool': 'BOOLEAN', 'date': 'DATEONLY', 'datetime': 'DATE', 'timestamp': 'DATE', 'time': 'TIME', 'json': 'JSON', 'uuid': 'UUID', 'email': 'STRING' }; return typeMap[type.toLowerCase()] || 'STRING'; }; // Generate Migration File const generateMigrationFile = async (name, options) => { const modelName = capitalize(name); const tableName = pluralize(name.toLowerCase()); const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', ''); const fields = parseFields(options.fields); const migrationContent = `'use strict'; module.exports = { async up(queryInterface, Sequelize) { await queryInterface.createTable('${tableName}', { id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true, allowNull: false }${fields.replace(/DataTypes\./g, 'Sequelize.')}, visible: { type: Sequelize.BOOLEAN, defaultValue: true }, created_at: { allowNull: false, type: Sequelize.DATE }, updated_at: { allowNull: false, type: Sequelize.DATE }, deleted_at: { type: Sequelize.DATE } }); await queryInterface.addIndex('${tableName}', ['visible']); await queryInterface.addIndex('${tableName}', ['created_at']); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable('${tableName}'); } };`; const filePath = path.join('src/migrations', `${timestamp}-create-${tableName}.js`); writeFile(filePath, migrationContent); console.log(chalk.green(getText('migration.generated', { path: filePath }))); return filePath; }; // Generate all files at once const generateAllFiles = async (name, options) => { console.log(chalk.cyan(getText('all.generating', { name }) + '\n')); try { const modelPath = await generateModelFile(name, options); const controllerPath = await generateControllerFile(name, options); const routePath = await generateRouteFile(name, options); const migrationPath = await generateMigrationFile(name, options); console.log(chalk.green('\n' + getText('all.success', { name }))); console.log(chalk.cyan(getText('all.filesCreated'))); console.log(chalk.white(` ${modelPath}`)); console.log(chalk.white(` ${controllerPath}`)); console.log(chalk.white(` ${routePath}`)); console.log(chalk.white(` ${migrationPath}`)); return { modelPath, controllerPath, routePath, migrationPath }; } catch (error) { console.error(chalk.red('Failed to generate complete resource:'), error.message); throw error; } }; // Controller Generator const generateControllerFile = async (name, options) => { const modelName = capitalize(name); const controllerName = `${modelName}Controller`; const routeName = name.toLowerCase(); const controllerContent = `const ${modelName} = require('../models/${modelName}'); const { sendSuccess, sendError, sendNotFound } = require('../helpers/response'); const { paginate } = require('../helpers/pagination'); const { Op } = require('sequelize'); const { validate: isValidUUID } = require('uuid'); const ${controllerName} = { // GET /api/${pluralize(routeName)} index: async (req, res) => { try { const { page = 1, limit = 10, search, sortBy = 'created_at', sortOrder = 'DESC' } = req.query; let whereClause = { visible: true }; if (search) { whereClause[Op.or] = [ { name: { [Op.like]: \`%\${search}%\` } } ]; } const result = await paginate(${modelName}, { page, limit, where: whereClause, order: [[sortBy, sortOrder.toUpperCase()]] }); sendSuccess(res, '${modelName}s retrieved successfully', result); } catch (error) { console.error('${modelName} Index Error:', error); sendError(res, 'Failed to retrieve ${pluralize(routeName)}', 500); } }, // GET /api/${pluralize(routeName)}/:id show: async (req, res) => { try { const { id } = req.params; if (!isValidUUID(id)) { return sendError(res, 'Invalid ${routeName} ID format', 400); } const ${routeName} = await ${modelName}.findOne({ where: { id, visible: true } }); if (!${routeName}) { return sendNotFound(res, '${modelName} not found'); } sendSuccess(res, '${modelName} retrieved successfully', ${routeName}); } catch (error) { console.error('${modelName} Show Error:', error); sendError(res, 'Failed to retrieve ${routeName}', 500); } }, // POST /api/${pluralize(routeName)} store: async (req, res) => { try { const ${routeName} = await ${modelName}.create(req.body); sendSuccess(res, '${modelName} created successfully', ${routeName}, 201); } catch (error) { console.error('${modelName} Store Error:', error); if (error.name === 'SequelizeValidationError') { const errors = error.errors.map(err => ({ field: err.path, message: err.message })); return sendError(res, 'Validation failed', 400, errors); } sendError(res, 'Failed to create ${routeName}', 400); } }, // PUT /api/${pluralize(routeName)}/:id update: async (req, res) => { try { const { id } = req.params; if (!isValidUUID(id)) { return sendError(res, 'Invalid ${routeName} ID format', 400); } const [updated] = await ${modelName}.update(req.body, { where: { id, visible: true } }); if (!updated) { return sendNotFound(res, '${modelName} not found'); } const ${routeName} = await ${modelName}.findByPk(id); sendSuccess(res, '${modelName} updated successfully', ${routeName}); } catch (error) { console.error('${modelName} Update Error:', error); sendError(res, 'Failed to update ${routeName}', 400); } }, // DELETE /api/${pluralize(routeName)}/:id destroy: async (req, res) => { try { const { id } = req.params; if (!isValidUUID(id)) { return sendError(res, 'Invalid ${routeName} ID format', 400); } const deleted = await ${modelName}.destroy({ where: { id } }); if (!deleted) { return sendNotFound(res, '${modelName} not found'); } sendSuccess(res, '${modelName} deleted successfully', { id }); } catch (error) { console.error('${modelName} Destroy Error:', error); sendError(res, 'Failed to delete ${routeName}', 500); } } }; module.exports = ${controllerName};`; const filePath = path.join('src/controllers', `${controllerName}.js`); writeFile(filePath, controllerContent); console.log(chalk.green(getText('controller.generated', { path: filePath }))); return filePath; }; // Route Generator - Smart & Simple const generateRouteFile = async (name, options) => { const routeName = name.toLowerCase(); const routePlural = pluralize(routeName); const modelName = capitalize(name); const controllerName = `${modelName}Controller`; // Ask for validation middleware if not specified let withValidation = options.withValidation || false; if (!options.withValidation && !options.noValidation) { const validationChoice = await inquirer.prompt([ { type: 'confirm', name: 'addValidation', message: getText('route.askValidation', { model: modelName }) || `Add validation middleware for ${modelName}?`, default: false } ]); withValidation = validationChoice.addValidation; } // Create route content let routeContent = `const express = require('express'); const router = express.Router(); const ${controllerName} = require('../controllers/${controllerName}');`; if (withValidation) { // Generate validation middleware first await generateValidationMiddleware(name); routeContent += ` const { validate${modelName}Create, validate${modelName}Update } = require('../middleware/validation/${routeName}Validation'); const { validateUUID } = require('../middleware/validation/common');`; } routeContent += ` // Routes for ${modelName} router.get('/', ${controllerName}.index); // GET /api/${routePlural} router.get('/:id', ${withValidation ? 'validateUUID(\'id\'), ' : ''}${controllerName}.show); // GET /api/${routePlural}/:id router.post('/', ${withValidation ? `validate${modelName}Create, ` : ''}${controllerName}.store); // POST /api/${routePlural} router.put('/:id', ${withValidation ? `validateUUID(\'id\'), validate${modelName}Update, ` : ''}${controllerName}.update); // PUT /api/${routePlural}/:id router.delete('/:id', ${withValidation ? 'validateUUID(\'id\'), ' : ''}${controllerName}.destroy); // DELETE /api/${routePlural}/:id module.exports = router;`; const filePath = path.join('src/routes', `${routePlural}.js`); writeFile(filePath, routeContent); // Auto register route in index.js await registerRouteAutomatically(routePlural); // Create common validation if needed if (withValidation) { await ensureCommonValidationExists(); } console.log(chalk.green(getText('route.generated', { path: filePath }))); if (withValidation) { console.log(chalk.blue(`🛡️ Validation middleware: src/middleware/validation/${routeName}Validation.js`)); } console.log(chalk.green(`🔄 Route automatically registered in src/routes/index.js`)); return filePath; }; // Generate validation middleware (only when needed) const generateValidationMiddleware = async (name) => { const modelName = capitalize(name); const routeName = name.toLowerCase(); // Try to read existing model to get field information let fields = { createRules: [], updateRules: [] }; try { const modelPath = path.join('src/models', `${modelName}.js`); if (fs.existsSync(modelPath)) { const modelContent = fs.readFileSync(modelPath, 'utf8'); fields = parseModelForValidation(modelContent); } else { fields = getDefaultValidationFields(); } } catch (error) { fields = getDefaultValidationFields(); } const validationContent = `const { body, validationResult } = require('express-validator'); const { sendError } = require('../../helpers/response'); // Validation rules for ${modelName} creation const validate${modelName}Create = [ ${fields.createRules.map(rule => ` ${rule}`).join(',\n')}, // Check validation result (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { const formattedErrors = errors.array().map(error => ({ field: error.path, message: error.msg, value: error.value })); return sendError(res, 'Validation failed', 400, formattedErrors); } next(); } ]; // Validation rules for ${modelName} update const validate${modelName}Update = [ ${fields.updateRules.map(rule => ` ${rule}`).join(',\n')}, // Check validation result (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { const formattedErrors = errors.array().map(error => ({ field: error.path, message: error.msg, value: error.value })); return sendError(res, 'Validation failed', 400, formattedErrors); } next(); } ]; module.exports = { validate${modelName}Create, validate${modelName}Update };`; const validationDir = path.join('src/middleware/validation'); const validationFilePath = path.join(validationDir, `${routeName}Validation.js`); if (!fs.existsSync(validationDir)) { fs.mkdirSync(validationDir, { recursive: true }); } writeFile(validationFilePath, validationContent); return validationFilePath; }; // Parse existing model file for validation rules const parseModelForValidation = (modelContent) => { const createRules = []; const updateRules = []; // Extract field definitions from model const fieldMatches = modelContent.match(/(\w+):\s*\{[\s\S]*?type:\s*DataTypes\.(\w+)[\s\S]*?\}/g); if (fieldMatches) { fieldMatches.forEach(fieldMatch => { const nameMatch = fieldMatch.match(/(\w+):/); const typeMatch = fieldMatch.match(/DataTypes\.(\w+)/); const allowNullMatch = fieldMatch.match(/allowNull:\s*false/); const isEmailMatch = fieldMatch.match(/isEmail/); if (nameMatch && typeMatch) { const fieldName = nameMatch[1]; const fieldType = typeMatch[1].toLowerCase(); // Skip system fields if (['id', 'visible', 'created_at', 'updated_at', 'deleted_at'].includes(fieldName)) { return; } let createRule = ''; let updateRule = ''; if (isEmailMatch) { createRule = `body('${fieldName}').isEmail().withMessage('${fieldName} must be a valid email')`; updateRule = `body('${fieldName}').optional().isEmail().withMessage('${fieldName} must be a valid email')`; } else { switch (fieldType) { case 'string': createRule = `body('${fieldName}').isString().withMessage('${fieldName} must be a string')`; updateRule = `body('${fieldName}').optional().isString().withMessage('${fieldName} must be a string')`; break; case 'integer': createRule = `body('${fieldName}').isInt().withMessage('${fieldName} must be an integer')`; updateRule = `body('${fieldName}').optional().isInt().withMessage('${fieldName} must be an integer')`; break; case 'boolean': createRule = `body('${fieldName}').isBoolean().withMessage('${fieldName} must be true or false')`; updateRule = `body('${fieldName}').optional().isBoolean().withMessage('${fieldName} must be true or false')`; break; case 'uuid': createRule = `body('${fieldName}').isUUID().withMessage('${fieldName} must be a valid UUID')`; updateRule = `body('${fieldName}').optional().isUUID().withMessage('${fieldName} must be a valid UUID')`; break; default: createRule = `body('${fieldName}').notEmpty().withMessage('${fieldName} is required')`; updateRule = `body('${fieldName}').optional()`; } } // Add required validation if (allowNullMatch) { createRule += `.notEmpty().withMessage('${fieldName} is required')`; } createRules.push(createRule); updateRules.push(updateRule); } }); } return { createRules, updateRules }; }; // Default validation fields const getDefaultValidationFields = () => ({ createRules: [`body('name').isString().notEmpty().withMessage('Name is required')`], updateRules: [`body('name').optional().isString().withMessage('Name must be a string')`] }); // Auto register route in index.js const registerRouteAutomatically = async (routePlural) => { const indexPath = path.join('src/routes/index.js'); const routeRegistration = `router.use('/${routePlural}', require('./${routePlural}'));`; try { let indexContent = ''; if (fs.existsSync(indexPath)) { indexContent = fs.readFileSync(indexPath, 'utf8'); } else { indexContent = `const express = require('express'); const router = express.Router(); // API Routes router.get('/', (req, res) => { res.json({ message: 'J.A.R.V.I.S. API is running', version: '1.0.0', timestamp: new Date().toISOString() }); }); module.exports = router;`; } // Check if route already registered if (indexContent.includes(`'/${routePlural}'`)) { return; } // Add route registration before module.exports const lines = indexContent.split('\n'); const exportIndex = lines.findIndex(line => line.includes('module.exports')); if (exportIndex > -1) { lines.splice(exportIndex, 0, routeRegistration, ''); } else { lines.push('', routeRegistration); } const updatedContent = lines.join('\n'); fs.writeFileSync(indexPath, updatedContent); } catch (error) { console.warn(chalk.yellow(`⚠️ Could not auto-register route: ${error.message}`)); } }; // Ensure common validation middleware exists const ensureCommonValidationExists = async () => { const commonValidationPath = path.join('src/middleware/validation/common.js'); if (fs.existsSync(commonValidationPath)) { return; } const commonValidationContent = `const { param } = require('express-validator'); const { validate: isValidUUID } = require('uuid'); // UUID validation middleware const validateUUID = (paramName = 'id') => { return param(paramName) .custom((value) => { if (!isValidUUID(value)) { throw new Error(\`\${paramName} must be a valid UUID\`); } return true; }); }; module.exports = { validateUUID };`; writeFile(commonValidationPath, commonValidationContent); }; module.exports = { generateModel };