UNPKG

@codehance/rapid-stack

Version:

A modern full-stack development toolkit for rapid application development

826 lines (714 loc) 24.9 kB
const Generator = require('yeoman-generator'); const path = require('path'); const fs = require('fs'); const { handlePrompt } = require('../../lib/utils'); module.exports = class extends Generator { constructor(args, opts) { super(args, opts); this.models = []; this.reservedModels = ['User', 'Company']; this.modelFields = {}; this.modelFieldTypes = {}; this.modelFieldEnums = {}; this.modelFieldBooleans = {}; // Store boolean default values this.modelFieldPolymorphics = {}; this.modelFieldValidations = {}; // Store field validations this.userRoleConfig = null; this._done = false; // Flag to indicate generator is done this._promptingCompleted = false; // Flag to track prompting completion this._writingCompleted = false; // Flag to track writing completion this.standardTypes = [ 'String', 'Integer', 'Float', 'Boolean', 'Time', 'Date', 'DateTime', 'Array', 'Hash', 'Object', 'Enum', 'Polymorphic' ]; this.relationshipTypes = [ 'has_many', 'has_one', 'has_and_belongs_to_many', 'embeds_one', 'embeds_many' ]; this.validationTypes = { String: ['presence', 'uniqueness', 'email', 'strongPassword'], Integer: ['presence', 'uniqueness', 'numericality', 'range'], Float: ['presence', 'uniqueness', 'numericality', 'range'], Boolean: ['presence'], Time: ['presence'], Date: ['presence'], DateTime: ['presence'], Array: ['presence'], Hash: ['presence'], Object: ['presence'], Enum: ['presence'], Polymorphic: ['presence'] }; this.defaultMessages = { presence: "field_name can't be blank", uniqueness: 'field_name has already been taken', email: 'field_name must be a valid email address', strongPassword: 'field_name must include at least one uppercase letter, one lowercase letter, one digit, and one special character', numericality: 'field_name must be a number', range: 'field_name must be within the specified range' }; } // Format model name to proper capitalization formatModelName(name) { if (!name) return ''; // Remove any leading/trailing spaces name = name.trim(); // Split by spaces, hyphens, or underscores const words = name.split(/[\s\-_]+/); // Capitalize each word and join them return words.map(word => { return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }).join(''); } // Convert to snake_case toSnakeCase(str) { if (!str) return ''; return str // Handle camelCase .replace(/([a-z])([A-Z])/g, '$1_$2') // Replace spaces and hyphens with underscores .replace(/[\s-]+/g, '_') // Convert to lowercase .toLowerCase() // Remove any leading/trailing underscores .replace(/^_+|_+$/g, ''); } // Handle enum type fields async handleEnumField(fieldName) { // Safety check - skip if already done if (this._done) { return { values: ['default'], default: 'default' }; } // Safety check - ensure fieldName is valid if (!fieldName || typeof fieldName !== 'string') { return { values: ['default'], default: 'default' }; } const enumPrompt = await this.prompt([ { type: 'input', name: 'enumValues', message: `Enter comma-separated enum values for ${fieldName}:`, default: 'public,guest,admin', validate: (input) => { if (!input || input.trim() === '') { return 'At least one enum value is required'; } return true; } } ]); // Convert enum values to snake_case const enumValues = enumPrompt.enumValues .split(',') .map(v => v.trim()) .filter(v => v) // Remove empty values .map(v => this.toSnakeCase(v)); if (enumValues.length === 0) { this.log('No valid enum values were provided. Using "default" as fallback.'); enumValues.push('default'); } const defaultPrompt = await this.prompt([ { type: 'list', name: 'defaultValue', message: 'Select default value:', choices: enumValues } ]); return { values: enumValues, default: defaultPrompt.defaultValue }; } // Handle boolean type fields async handleBooleanField(fieldName) { // Safety check - skip if already done if (this._done) { return { default: false }; } // Safety check - ensure fieldName is valid if (!fieldName || typeof fieldName !== 'string') { return { default: false }; } const booleanPrompt = await this.prompt([ { type: 'list', name: 'defaultValue', message: `Select default value for ${fieldName}:`, choices: ['true', 'false'], default: 'false' } ]); return { default: booleanPrompt.defaultValue === 'true' }; } async promptForUserRoles() { // Skip if we're already done if (this._promptingCompleted) { return; } this.log('First, configure the User roles:'); const enumPrompt = await handlePrompt(this, [{ type: 'input', name: 'enumValues', message: 'Enter comma-separated enum values for User roles:', default: 'public,guest,admin', validate: (input) => { if (!input || input.trim() === '') { return 'At least one enum value is required'; } return true; } }]); // Convert enum values to snake_case const enumValues = enumPrompt.enumValues .split(',') .map(v => v.trim()) .filter(v => v) // Remove empty values .map(v => this.toSnakeCase(v)); if (enumValues.length === 0) { this.log('No valid enum values were provided. Using "default" as fallback.'); enumValues.push('default'); } const defaultPrompt = await handlePrompt(this, [{ type: 'list', name: 'defaultValue', message: 'Select default value:', choices: enumValues }]); this.userRoleConfig = { values: enumValues, default: defaultPrompt.defaultValue }; } async promptForSchemaName() { // Skip if we're already done if (this._promptingCompleted) { return this.answers.schemaName; } const schemaPrompt = await handlePrompt(this, [{ type: 'input', name: 'schemaName', message: 'What would you like to name your schema file?', default: 'RapidSchema.yml' }]); // Add .yml extension if not present let schemaName = schemaPrompt.schemaName; if (!schemaName.toLowerCase().endsWith('.yml')) { schemaName += '.yml'; } return schemaName; } async handleValidations(fieldName, fieldType) { // Safety check - skip if already done if (this._done || !fieldName || !fieldType) { return []; } const validations = []; let addMore = true; while (addMore) { // Get available validation types for this field type const availableValidations = this.validationTypes[fieldType] || ['presence']; // Filter out validations already added const remainingValidations = availableValidations.filter( v => !validations.some(existing => existing.type === v) ); if (remainingValidations.length === 0) { this.log('All available validations have been added for this field.'); break; } const validationPrompt = await this.prompt([ { type: 'list', name: 'type', message: `Select validation type for ${fieldName}:`, choices: remainingValidations } ]); const validation = { type: validationPrompt.type }; // Handle specific validation types if (validation.type === 'range') { const rangePrompt = await this.prompt([ { type: 'input', name: 'min', message: 'Enter minimum value:', default: '0' }, { type: 'input', name: 'max', message: 'Enter maximum value:', default: '100' } ]); validation.min = parseInt(rangePrompt.min); validation.max = parseInt(rangePrompt.max); } // Ask for custom message with field name included in default const defaultMessage = this.defaultMessages[validation.type].replace('field_name', fieldName); const messagePrompt = await this.prompt([ { type: 'input', name: 'message', message: 'Enter custom validation message (press enter to use default):', default: defaultMessage } ]); validation.message = messagePrompt.message; validations.push(validation); if (remainingValidations.length <= 1) { break; // No more validations available } // Ask if user wants to add another validation const addMorePrompt = await this.prompt([ { type: 'confirm', name: 'addMore', message: 'Would you like to add another validation for this field?', default: false } ]); addMore = addMorePrompt.addMore; } return validations; } async promptForField(modelName) { // Skip if we're already done if (this._promptingCompleted || this._done) { return null; } const fieldPrompt = await this.prompt([ { type: 'input', name: 'fieldName', message: `Enter a field name for ${modelName}:`, validate: (input) => { if (!input || input.trim() === '') { return 'Field name is required'; } return true; } } ]); const fieldName = fieldPrompt.fieldName.trim(); // Determine if this is a foreign key field const isForeignKey = fieldName.endsWith('_id'); // Determine available types based on field name let types = isForeignKey ? this.relationshipTypes : this.standardTypes; // If this is a relationship field, format the choices to be more readable if (isForeignKey) { // Extract the base entity name (e.g., "company" from "company_id") const baseEntity = fieldName.slice(0, -3); // Remove "_id" // Format the choices to include the relationship context types = this.relationshipTypes.map(relationType => { // Display as "company has_many branch" but store as "has_many" return { name: `${baseEntity} ${relationType} ${modelName.toLowerCase()}`, value: relationType }; }); } const typePrompt = await this.prompt([ { type: 'list', name: 'fieldType', message: `Select type for ${fieldName}:`, choices: types } ]); const fieldType = typePrompt.fieldType; // Handle enum, boolean, and polymorphic configs as before let enumConfig = null; let booleanConfig = null; let polymorphicConfig = null; let validations = []; try { if (fieldType === 'Enum') { enumConfig = await this.handleEnumField(fieldName); } else if (fieldType === 'Boolean') { booleanConfig = await this.handleBooleanField(fieldName); } else if (fieldType === 'Polymorphic') { polymorphicConfig = await this.handlePolymorphicField(fieldName); } // Ask if user wants to add validations const addValidationsPrompt = await this.prompt([ { type: 'confirm', name: 'addValidations', message: 'Would you like to add validations for this field?', default: true } ]); if (addValidationsPrompt.addValidations) { validations = await this.handleValidations(fieldName, fieldType); } } catch (error) { this.log(`Error handling field type or validations: ${error.message}`); // Provide default values as before } return { name: fieldName, type: fieldType, enumConfig, booleanConfig, polymorphicConfig, validations }; } // Handle polymorphic field configuration async handlePolymorphicField(fieldName) { // Safety check - skip if already done if (this._done) { return { models: [] }; } // Safety check - ensure fieldName is valid if (!fieldName || typeof fieldName !== 'string') { return { models: [] }; } // Prompt for the models that can be referenced by this polymorphic relationship const polymorphicPrompt = await this.prompt([ { type: 'input', name: 'models', message: `Enter comma-separated model names that can be referenced by ${fieldName} (e.g., Post,Comment,User):`, validate: (input) => { if (!input || input.trim() === '') { return 'At least one model is required for a polymorphic relationship'; } return true; } } ]); // Parse and clean the input const models = polymorphicPrompt.models .split(',') .map(model => model.trim()) .filter(model => model !== ''); return { models }; } async promptForModel() { // Skip if we're already done if (this._promptingCompleted) { return null; } const addModelPrompt = await this.prompt([ { type: 'confirm', name: 'addModel', message: 'Would you like to add a model?', default: true } ]); if (!addModelPrompt.addModel) { return null; } const modelPrompt = await this.prompt([ { type: 'input', name: 'modelName', message: 'Enter a model name (in singular form):', validate: (input) => { if (!input || input.trim() === '') { return 'Model name is required'; } const formattedInput = this.formatModelName(input); // Check if model name is reserved if (this.reservedModels.includes(formattedInput)) { return `${formattedInput} is a reserved model name and cannot be used`; } // Check if model is already added if (this.models.includes(formattedInput)) { return `${formattedInput} has already been added`; } return true; } } ]); return this.formatModelName(modelPrompt.modelName); } async prompting() { // Prevent multiple executions if (this._promptingCompleted) { return; } this.log('Welcome to the Rapid Schema generator'); this.log('\nNote: User and Company models will be automatically created with default fields'); try { // Step 1: Prompt for User roles await this.promptForUserRoles(); // Step 2: Prompt for schema name this.answers = { schemaName: await this.promptForSchemaName() }; // If authOnly is true, stop here if (this.options.authOnly) { this._promptingCompleted = true; this._done = true; return; } // Step 3: Start model collection while (true) { const modelName = await this.promptForModel(); // Exit the loop if no model is requested if (modelName === null) { break; } this.models.push(modelName); this.modelFields[modelName] = []; this.modelFieldTypes[modelName] = {}; this.modelFieldEnums[modelName] = {}; this.modelFieldBooleans[modelName] = {}; // Initialize boolean configs this.modelFieldPolymorphics[modelName] = {}; // Initialize polymorphic configs this.modelFieldValidations[modelName] = {}; // Initialize validation storage // Collect fields for this model while (true) { const field = await this.promptForField(modelName); if (!field) { break; } this.modelFields[modelName].push(field.name); this.modelFieldTypes[modelName][field.name] = field.type; if (field.type === 'Enum') { this.modelFieldEnums[modelName][field.name] = field.enumConfig; } else if (field.type === 'Boolean') { this.modelFieldBooleans[modelName][field.name] = field.booleanConfig; } else if (field.type === 'Polymorphic') { this.modelFieldPolymorphics[modelName][field.name] = field.polymorphicConfig; } // Add validations if they exist if (field.validations && field.validations.length > 0) { this.modelFieldValidations[modelName][field.name] = field.validations; } // Ask if user wants to add another field const addFieldPrompt = await this.prompt([ { type: 'confirm', name: 'addField', message: `Would you like to add another field to ${modelName}?`, default: true } ]); if (!addFieldPrompt.addField) { break; } } } // Mark prompting as completed to prevent re-execution this._promptingCompleted = true; this._done = true; } catch (error) { console.error(`An error occurred: ${error.message}`); console.error(error.stack); // Even on error, mark as completed to prevent infinite loops this._promptingCompleted = true; this._done = true; } } writing() { // Prevent multiple executions if (this._writingCompleted) { return; } try { // Create _schema directory if it doesn't exist const schemaDir = path.join(process.cwd(), './backend/_schema'); if (!fs.existsSync(schemaDir)) { fs.mkdirSync(schemaDir, { recursive: true }); } // Make sure we have valid user role config if (!this.userRoleConfig || !this.userRoleConfig.values || !this.userRoleConfig.default) { this.userRoleConfig = { values: ['admin', 'user', 'guest'], default: 'user' }; } // Create schema file with reserved models and user-defined models const schemaContent = `# This schema was generated using rapid-generator # Reserved models are automatically included models: # Reserved Models Company: attributes: name: type: String validates: presence: message: "name can't be blank" uniqueness: message: "name has already been taken" code: type: String validates: presence: message: "code can't be blank" uniqueness: message: "code has already been taken" active: type: Boolean default: true validates: presence: message: "active can't be blank" User: attributes: email: type: String validates: presence: message: "email can't be blank" uniqueness: message: "email has already been taken" email: message: "email must be a valid email address" encrypted_password: type: String validates: presence: message: "password can't be blank" strongPassword: message: "password must include at least one uppercase letter, one lowercase letter, one digit, and one special character" reset_password_token: type: String reset_password_sent_at: type: Time remember_created_at: type: Time first_name: type: String validates: presence: message: "first_name can't be blank" last_name: type: String validates: presence: message: "last_name can't be blank" telephone: type: String validates: presence: message: "telephone can't be blank" accept_terms: type: Boolean validates: presence: message: "accept_terms can't be blank" role: type: Enum values: [${this.userRoleConfig.values.join(', ')}] default: ${this.userRoleConfig.default} company_id: type: has_many validates: presence: message: "company_id can't be blank" # User-defined Models ${this.models.map(model => ` ${model}: attributes: ${this.modelFields[model].map(field => { const fieldType = this.modelFieldTypes[model][field]; let fieldDefinition = ''; if (fieldType === 'Enum') { const enumConfig = this.modelFieldEnums[model][field]; fieldDefinition = `${field}:\n type: ${fieldType}\n values: [${enumConfig.values.join(', ')}]\n default: ${enumConfig.default}`; } else if (fieldType === 'Boolean') { const booleanConfig = this.modelFieldBooleans[model][field]; if (booleanConfig) { fieldDefinition = `${field}:\n type: ${fieldType}\n default: ${booleanConfig.default}`; } else { fieldDefinition = `${field}:\n type: ${fieldType}`; } } else if (fieldType === 'Polymorphic') { const polymorphicConfig = this.modelFieldPolymorphics[model][field]; if (polymorphicConfig && polymorphicConfig.models && polymorphicConfig.models.length > 0) { fieldDefinition = `${field}:\n type: ${fieldType}\n models: [${polymorphicConfig.models.join(', ')}]`; } else { fieldDefinition = `${field}:\n type: ${fieldType}`; } } else { fieldDefinition = `${field}:\n type: ${fieldType}`; } // Add validations if they exist with proper nesting const validations = this.modelFieldValidations[model]?.[field]; if (validations && validations.length > 0) { const validationStr = validations.map(v => { if (v.type === 'range') { return ` ${v.type}:\n minimum: ${v.min}\n maximum: ${v.max}\n message: "${v.message}"`; } return ` ${v.type}:\n message: "${v.message}"`; }).join('\n'); fieldDefinition += '\n validates:\n' + validationStr; } return ` ${fieldDefinition}`; }).join('\n')}`).join('\n\n')} `; this.fs.write( path.join(schemaDir, this.answers.schemaName || 'RapidSchema.yml'), schemaContent ); // Mark writing as completed this._writingCompleted = true; } catch (error) { console.error(`An error occurred during writing: ${error.message}`); console.error(error.stack); // Mark as completed even on error this._writingCompleted = true; } } end() { // Already handled by done flag if (this._done) { return; } // Set flag to prevent further enum handling and re-execution this._done = true; try { this.log('\nSchema generator completed successfully!'); this.log(`Created schema file: ${this.answers?.schemaName || 'RapidSchema.yml'}`); this.log(`Added reserved models: ${this.reservedModels.join(', ')}`); if (this.userRoleConfig) { this.log(`User roles: ${this.userRoleConfig.values.join(', ')} (default: ${this.userRoleConfig.default})`); } if (this.models && this.models.length > 0) { this.log(`Added user-defined models: ${this.models.join(', ')}`); this.log('\nModel fields:'); this.models.forEach(model => { this.log(`\n ${model}:`); if (this.modelFields[model]) { this.modelFields[model].forEach(field => { const fieldType = this.modelFieldTypes[model][field]; let fieldInfo = ''; if (fieldType === 'Enum' && this.modelFieldEnums[model][field]) { const enumConfig = this.modelFieldEnums[model][field]; fieldInfo = `${field}: ${fieldType}[${enumConfig.values.join(', ')}](default: ${enumConfig.default})`; } else if (fieldType === 'Boolean' && this.modelFieldBooleans[model][field]) { const booleanConfig = this.modelFieldBooleans[model][field]; fieldInfo = `${field}: ${fieldType}(default: ${booleanConfig.default})`; } else { fieldInfo = `${field}: ${fieldType}`; } this.log(` ${fieldInfo}`); }); } }); } } catch (error) { console.error(`An error occurred at the end: ${error.message}`); } } };