UNPKG

@codehance/rapid-stack

Version:

A modern full-stack development toolkit for rapid application development

814 lines (703 loc) 28.7 kB
const Generator = require('yeoman-generator'); const path = require('path'); const fs = require('fs'); const yaml = require('js-yaml'); const utils = require('../../lib/utils'); const { handlePrompt } = require('../../lib/utils'); module.exports = class extends Generator { constructor(args, opts) { super(args, opts); this.backendPath = './backend'; this.schemaPath = path.join(this.backendPath, '_schema'); this.modelsPath = path.join(this.backendPath, 'app', 'models'); this.graphqlTypesPath = path.join(this.backendPath, 'app', 'graphql', 'types'); // Add debug option this.option('debug', { desc: 'Enable debug mode', type: Boolean, default: false }); // Map of YAML types to Ruby types this.typeMapping = { 'String': 'String', 'Integer': 'Integer', 'Float': 'Float', 'Boolean': 'Boolean', 'Time': 'Time', 'Date': 'Date', 'DateTime': 'DateTime', 'Array': 'Array', 'Hash': 'Hash', 'Object': 'Object', 'Enum': 'String', // Changed to String for better compatibility with enum values 'Polymorphic': 'String' // For polymorphic relationships }; } async prompting() { // Get list of available schema files const schemaFiles = this._getSchemaFiles(); if (schemaFiles.length === 0) { this.log('No schema files found in the _schema directory.'); return; } // Ask which schema file to run this.answers = await handlePrompt(this, [ { type: 'list', name: 'schemaFile', message: 'Which schema file would you like to use?', choices: schemaFiles }, { type: 'confirm', name: 'confirmRun', message: 'This will generate model files based on the schema. Continue?', default: true } ]); if (!this.answers.confirmRun) { this.log('Schema generation cancelled.'); return; } // Load and parse the selected schema file this.schemaData = this._loadSchemaFile(this.answers.schemaFile); // Debug the schema data if (this.schemaData) { this._debugSchema(); } else { this.log('Failed to parse schema file. Please check the format.'); } } writing() { if (!this.schemaData || !this.schemaData.models) { return; } this.log(`Running schema: ${this.answers.schemaFile}`); // Process each model in the schema Object.keys(this.schemaData.models).forEach(modelName => { const modelData = this.schemaData.models[modelName]; if (!modelData || !modelData.attributes) { return; } this.log(`Generating model: ${modelName}`); // Process attributes into fields const fields = this._processAttributes(modelData.attributes, modelName); // Process relationships const relationships = this._extractRelationships(modelData.attributes, modelName); // Generate the model file this._generateModelFile(modelName, fields, relationships); // Generate the GraphQL type file this._generateGraphQLTypeFile(modelName, fields, relationships); }); this.log('Schema generation completed successfully!'); } // Helper method to get list of schema files _getSchemaFiles() { if (!fs.existsSync(this.schemaPath)) { return []; } return fs.readdirSync(this.schemaPath) .filter(file => file.endsWith('.yml')) .sort((a, b) => b.localeCompare(a)); // Sort in reverse order (newest first) } // Helper method to load and parse a schema file _loadSchemaFile(filename) { const filePath = path.join(this.schemaPath, filename); try { // Read the file content const fileContent = fs.readFileSync(filePath, 'utf8'); if (this.options.debug) { this.log('Original file content:'); this.log(fileContent); } // Try to parse as YAML directly first try { const parsedYaml = yaml.load(fileContent); if (parsedYaml && parsedYaml.models) { return parsedYaml; } } catch (yamlError) { this.log('YAML parsing failed, trying manual parsing...'); } // Parse the schema manually if YAML parsing fails const models = {}; let currentModel = null; let inAttributes = false; let currentAttribute = null; let indentLevel = 0; fileContent.split('\n').forEach(line => { // Skip comments and empty lines if (line.trim().startsWith('#') || !line.trim()) { return; } // Calculate indent level indentLevel = line.search(/\S/); // Check for model definition (2 spaces) const modelMatch = line.match(/^\s{2}([A-Za-z0-9]+):/); if (modelMatch) { currentModel = modelMatch[1]; models[currentModel] = { attributes: {} }; inAttributes = false; currentAttribute = null; return; } // Check for attributes section (4 spaces) if (line.match(/^\s{4}attributes:/) && currentModel) { inAttributes = true; return; } // Parse attribute (6 spaces) if (inAttributes && currentModel) { const attrMatch = line.match(/^\s{6}([a-zA-Z0-9_]+):/); if (attrMatch) { currentAttribute = attrMatch[1]; models[currentModel].attributes[currentAttribute] = {}; return; } // Parse attribute properties (8 spaces) const propMatch = line.match(/^\s{8}([a-zA-Z0-9_]+):\s*(.*)/); if (propMatch && currentAttribute) { const [, propName, propValue] = propMatch; if (propValue.trim() === '') { // This is a nested property models[currentModel].attributes[currentAttribute][propName] = {}; } else { // This is a value property models[currentModel].attributes[currentAttribute][propName] = propValue.trim(); } return; } // Parse nested validation properties (10 spaces) const validationMatch = line.match(/^\s{10}([a-zA-Z0-9_]+):\s*(.*)/); if (validationMatch && currentAttribute) { const [, validationType, validationValue] = validationMatch; if (!models[currentModel].attributes[currentAttribute].validates) { models[currentModel].attributes[currentAttribute].validates = {}; } models[currentModel].attributes[currentAttribute].validates[validationType] = validationValue ? { message: validationValue.replace(/^"(.*)"$/, '$1') } : true; } } }); if (Object.keys(models).length > 0) { if (this.options.debug) { this.log('Parsed models:'); this.log(JSON.stringify(models, null, 2)); } return { models }; } this.log('Failed to parse schema file'); return null; } catch (error) { this.log(`Error loading schema file: ${error.message}`); return null; } } // Helper method to process attributes from schema format to fields format _processAttributes(attributes, modelName) { const fields = []; Object.keys(attributes).forEach(attrName => { let attrValue = attributes[attrName]; // Skip polymorphic fields - they're handled in relationships if (typeof attrValue === 'string' && attrValue.includes('Polymorphic')) { fields.push({ name: `${attrName}_id`, type: 'BSON::ObjectId', required: false, is_polymorphic_id: true }); return; } let field = { name: attrName }; // Handle different attribute formats if (typeof attrValue === 'object') { // New format with nested type and validations field.type = attrValue.type || 'String'; // Handle enum type with values if (attrValue.values) { field.type = 'Enum'; field.enum_values = attrValue.values; field.default = attrValue.default || field.enum_values[0]; } // Handle default value if (attrValue.default !== undefined) { field.default = attrValue.default; if (field.type === 'Boolean') { field.default = field.default === true || field.default === 'true'; } } // Handle validations if (attrValue.validates) { field.validations = attrValue.validates; // Check for presence validation to set required flag if (attrValue.validates.presence) { field.required = true; } } // Handle relationship fields if (field.type.includes('has_many') || field.type.includes('belongs_to')) { // Skip relationship fields as they're handled by _extractRelationships return; } } else if (typeof attrValue === 'string') { // Legacy format support field.required = attrValue.includes('required: true'); // Parse the attribute value - get the type part let type = attrValue.split(' ')[0]; // Check if it's an enum type with values if (type.startsWith('Enum[')) { field.type = 'Enum'; // Extract enum values and default const enumMatch = attrValue.match(/Enum\[(.*?)\](?:\(default: (.*?)\))?/); if (enumMatch) { field.enum_values = enumMatch[1].split(',').map(v => v.trim()); field.default = enumMatch[2] || field.enum_values[0]; } } else if (attrValue.includes('(default:')) { // Handle type with default value const defaultMatch = attrValue.match(/(.*?)\(default: (.*?)\)/); if (defaultMatch) { field.type = defaultMatch[1]; field.default = defaultMatch[2]; // Handle boolean defaults if (field.type === 'Boolean') { field.default = field.default === 'true'; } } else { field.type = type; } } else { field.type = type; } // Skip relationship fields if (field.type.includes('has_many') || field.type.includes('belongs_to')) { return; } } else { // Default handling if not a string or object field.type = 'String'; } // Map to Ruby type field.type = this.typeMapping[field.type] || 'String'; fields.push(field); }); return fields; } // Helper method to extract relationships from attributes _extractRelationships(attributes, modelName) { const relationships = []; const bidirectionalRelationships = {}; Object.keys(attributes).forEach(attrName => { let attrValue = attributes[attrName]; // Check if this is a relationship field if (typeof attrValue === 'object') { // Handle polymorphic relationships if (attrValue.type && attrValue.type.includes('Polymorphic')) { // Extract referenced models if specified let referencedModels = []; const polymorphicMatch = attrValue.type.match(/Polymorphic\[(.*?)\]/); if (polymorphicMatch && polymorphicMatch[1]) { referencedModels = polymorphicMatch[1].split(',').map(model => model.trim()); } relationships.push({ type: 'belongs_to', model: attrName, polymorphic: true, modelSnakeCase: utils.camelToSnake(attrName), referencedModels: referencedModels }); return; } // Handle fields ending with _id that define relationships if (attrName.endsWith('_id')) { // Extract the base model name (e.g., company_id -> Company) const relatedModel = utils.toPascalCase(attrName.slice(0, -3)); // Check if there's a relationship type defined const relType = this._getRelationshipType(attrValue.type); if (relType) { // This is a bidirectional relationship // Current model belongs_to the related model relationships.push({ type: 'belongs_to', model: relatedModel, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(relatedModel) }); // Store the inverse relationship to be added to the related model if (!bidirectionalRelationships[relatedModel]) { bidirectionalRelationships[relatedModel] = []; } // Add the inverse relationship bidirectionalRelationships[relatedModel].push({ type: relType, model: modelName, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(modelName), // Add pluralized model name for has_many relationships pluralizedModel: relType === 'has_many' ? utils.pluralize(utils.camelToSnake(modelName)) : utils.camelToSnake(modelName) }); } else { // Default to belongs_to if no relationship type is specified relationships.push({ type: 'belongs_to', model: relatedModel, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(relatedModel) }); } } } else if (typeof attrValue === 'string') { // Legacy format support // Handle polymorphic relationships if (attrValue.includes('Polymorphic')) { // Extract referenced models if specified let referencedModels = []; const polymorphicMatch = attrValue.match(/Polymorphic\[(.*?)\]/); if (polymorphicMatch && polymorphicMatch[1]) { referencedModels = polymorphicMatch[1].split(',').map(model => model.trim()); } relationships.push({ type: 'belongs_to', model: attrName, polymorphic: true, modelSnakeCase: utils.camelToSnake(attrName), referencedModels: referencedModels }); return; } // Handle fields ending with _id that define relationships if (attrName.endsWith('_id')) { // Extract the base model name (e.g., company_id -> Company) const relatedModel = utils.toPascalCase(attrName.slice(0, -3)); // Check if there's a relationship type defined const relType = this._getRelationshipType(attrValue); if (relType) { // This is a bidirectional relationship // Current model belongs_to the related model relationships.push({ type: 'belongs_to', model: relatedModel, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(relatedModel) }); // Store the inverse relationship to be added to the related model if (!bidirectionalRelationships[relatedModel]) { bidirectionalRelationships[relatedModel] = []; } // Add the inverse relationship bidirectionalRelationships[relatedModel].push({ type: relType, model: modelName, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(modelName), // Add pluralized model name for has_many relationships pluralizedModel: relType === 'has_many' ? utils.pluralize(utils.camelToSnake(modelName)) : utils.camelToSnake(modelName) }); } else { // Default to belongs_to if no relationship type is specified relationships.push({ type: 'belongs_to', model: relatedModel, foreign_key: attrName, // Add snake_case model name for proper naming modelSnakeCase: utils.camelToSnake(relatedModel) }); } } } }); // Store the bidirectional relationships for later use when generating the related models if (!this.bidirectionalRelationships) { this.bidirectionalRelationships = {}; } Object.keys(bidirectionalRelationships).forEach(relatedModel => { if (!this.bidirectionalRelationships[relatedModel]) { this.bidirectionalRelationships[relatedModel] = []; } this.bidirectionalRelationships[relatedModel] = [ ...this.bidirectionalRelationships[relatedModel], ...bidirectionalRelationships[relatedModel] ]; }); return relationships; } // Helper method to extract relationship type from attribute value _getRelationshipType(value) { const relationshipTypes = [ 'has_many', 'has_one', 'has_and_belongs_to_many', 'embeds_one', 'embeds_many' ]; // Check if the value starts with any of the relationship types for (const type of relationshipTypes) { if (value.startsWith(type)) { return type; } } return null; } // Helper method to generate a model file _generateModelFile(model, fields, relationships) { this.log(`Generating model file for ${model}...`); // Process validations for each field fields.forEach(field => { if (field.validations) { field.validationStatements = []; Object.entries(field.validations).forEach(([validationType, validationConfig]) => { let validationStatement = `validates :${field.name}, ${validationType}: true`; // Handle validation options if (typeof validationConfig === 'object') { const options = []; // Handle message option if (validationConfig.message) { options.push(`message: "${validationConfig.message}"`); } // Handle other validation-specific options if (validationType === 'numericality') { if (validationConfig.greater_than !== undefined) { options.push(`greater_than: ${validationConfig.greater_than}`); } if (validationConfig.less_than !== undefined) { options.push(`less_than: ${validationConfig.less_than}`); } if (validationConfig.only_integer !== undefined) { options.push(`only_integer: ${validationConfig.only_integer}`); } } else if (validationType === 'length') { if (validationConfig.minimum !== undefined) { options.push(`minimum: ${validationConfig.minimum}`); } if (validationConfig.maximum !== undefined) { options.push(`maximum: ${validationConfig.maximum}`); } } else if (validationType === 'format') { if (validationConfig.with) { options.push(`with: ${validationConfig.with}`); } } if (options.length > 0) { validationStatement = `validates :${field.name}, ${validationType}: { ${options.join(', ')} }`; } } field.validationStatements.push(validationStatement); }); } }); // Check for enum fields const enumFields = fields.filter(field => field.type === 'Enum' || (field.enum_values && field.enum_values.length > 0)); if (enumFields.length > 0) { this.log(`Found ${enumFields.length} enum fields for ${model}: ${enumFields.map(f => f.name).join(', ')}`); } // Add bidirectional relationships if they exist if (this.bidirectionalRelationships && this.bidirectionalRelationships[model]) { this.log(`Adding ${this.bidirectionalRelationships[model].length} bidirectional relationships to ${model}`); relationships = [...relationships, ...this.bidirectionalRelationships[model]]; } // Add inverse polymorphic relationships if this model is referenced by polymorphic fields if (this.polymorphicRelationships && this.polymorphicRelationships[model]) { this.log(`Adding ${this.polymorphicRelationships[model].length} polymorphic inverse relationships to ${model}`); relationships = [...relationships, ...this.polymorphicRelationships[model]]; } let outputPath = ''; // Special handling for User and Company models if (model === 'User') { // Find role field and prepare role enum const roleField = fields.find(f => f.name === 'role'); let roleEnum = "admin: 'admin', manager: 'manager', user: 'user'"; // Default roles if (roleField && roleField.enum_values && roleField.enum_values.length > 0) { // Format role enum values as key-value pairs roleEnum = roleField.enum_values .map(value => { const key = this._snakeToCamel(value); return `${key}: '${value}'`; }) .join(', '); } // Generate User model with special template outputPath = `${this.backendPath}/app/models/user.rb`; this.fs.copyTpl( this.templatePath('user.rb.template'), this.destinationPath(outputPath), { fields, relationships, roleEnum, utils } ); } else if (model === 'Company') { // Add code field if not present for Company model if (!fields.some(f => f.name === 'code')) { fields.push({ name: 'code', type: 'String', required: false }); } // Generate Company model with special template outputPath = `${this.backendPath}/app/models/company.rb`; this.fs.copyTpl( this.templatePath('company.rb.template'), this.destinationPath(outputPath), { fields, relationships, utils } ); } else { // Generate generic model // Use utils.camelToSnake to ensure proper snake_case conversion const fileName = utils.camelToSnake(model); this.log(`Model file name: ${fileName}.rb`); outputPath = `${this.backendPath}/app/models/${fileName}.rb`; this.fs.copyTpl( this.templatePath('generic_model.rb.template'), this.destinationPath(outputPath), { modelName: model, fields, relationships, utils } ); } // Post-process the file to remove multiple consecutive blank lines this.on('end', () => { try { if (fs.existsSync(outputPath)) { let content = fs.readFileSync(outputPath, 'utf8'); // Replace multiple consecutive blank lines with a single blank line content = content.replace(/\n{3,}/g, '\n\n'); fs.writeFileSync(outputPath, content); this.log(`Post-processed ${outputPath} to remove extra blank lines`); } } catch (error) { this.log(`Error post-processing file: ${error.message}`); } }); } // Helper method to generate a GraphQL type file _generateGraphQLTypeFile(modelName, fields, relationships) { this.log(`Generating GraphQL type file for ${modelName}...`); // Create GraphQL types directory if it doesn't exist if (!fs.existsSync(this.graphqlTypesPath)) { fs.mkdirSync(this.graphqlTypesPath, { recursive: true }); } // Add bidirectional relationships if they exist if (this.bidirectionalRelationships && this.bidirectionalRelationships[modelName]) { this.log(`Adding ${this.bidirectionalRelationships[modelName].length} bidirectional relationships to ${modelName} GraphQL type`); relationships = [...relationships, ...this.bidirectionalRelationships[modelName]]; } // Add inverse polymorphic relationships if this model is referenced by polymorphic fields if (this.polymorphicRelationships && this.polymorphicRelationships[modelName]) { this.log(`Adding ${this.polymorphicRelationships[modelName].length} polymorphic inverse relationships to ${modelName} GraphQL type`); relationships = [...relationships, ...this.polymorphicRelationships[modelName]]; } // Generate the main type file // Use utils.camelToSnake to ensure proper snake_case conversion const fileName = utils.camelToSnake(modelName); this.log(`GraphQL type file name: ${fileName}_type.rb`); const typeFilePath = path.join(this.graphqlTypesPath, `${fileName}_type.rb`); this.fs.copyTpl( this.templatePath('generic_type.rb.template'), this.destinationPath(typeFilePath), { modelName, fields, relationships, utils } ); this.log(`Created GraphQL type file at ${typeFilePath}`); // Generate enum type files if needed this._generateEnumTypeFiles(modelName, fields); } // Helper method to generate GraphQL enum type files _generateEnumTypeFiles(modelName, fields) { // Find enum fields const enumFields = fields.filter(field => field.type === 'Enum' && field.enum_values && field.enum_values.length > 0 ); if (enumFields.length === 0) { return; } this.log(`Found ${enumFields.length} enum fields for ${modelName}`); // Generate an enum type file for each enum field enumFields.forEach(enumField => { // Use utils.camelToSnake to ensure proper snake_case conversion const modelFileName = utils.camelToSnake(modelName); const enumTypeFilePath = path.join( this.graphqlTypesPath, `${modelFileName}_${enumField.name}_enum_type.rb` ); this.fs.copyTpl( this.templatePath('enum_type.rb.template'), this.destinationPath(enumTypeFilePath), { modelName, enumField } ); this.log(`Created GraphQL enum type file at ${enumTypeFilePath}`); }); } // Helper method to convert snake_case to PascalCase for class names _toClassName(str) { return utils.toPascalCase(str); } // Helper method to convert snake_case to camelCase for class names _snakeToCamel(str) { return utils.toCamelCase(str); } // Helper method to convert camelCase to snake_case for filenames _camelToSnake(str) { return utils.camelToSnake(str); } // Helper method to debug the schema data _debugSchema() { this.log('\nSchema parsed successfully. Structure:'); if (!this.schemaData.models) { this.log('No models found in schema.'); return; } Object.keys(this.schemaData.models).forEach(modelName => { this.log(`\nModel: ${modelName}`); const modelData = this.schemaData.models[modelName]; if (!modelData.attributes) { this.log(' No attributes found.'); return; } this.log(' Attributes:'); Object.keys(modelData.attributes).forEach(attrName => { this.log(` ${attrName}: ${modelData.attributes[attrName]}`); }); // Process and show fields const fields = this._processAttributes(modelData.attributes, modelName); this.log(' Processed Fields:'); fields.forEach(field => { let fieldInfo = ` ${field.name}: ${field.type}`; if (field.required) fieldInfo += ' (required)'; if (field.default !== undefined) fieldInfo += ` (default: ${field.default})`; if (field.enum_values) fieldInfo += ` (enum values: ${field.enum_values.join(', ')})`; this.log(fieldInfo); }); // Process and show relationships const relationships = this._extractRelationships(modelData.attributes, modelName); if (relationships.length > 0) { this.log(' Relationships:'); relationships.forEach(rel => { this.log(` ${rel.type} ${rel.model} (${rel.foreign_key})`); }); } }); } };