Source

libs/index.js

'use strict'
// @ts-check

const { version } = require('../package.json')
const Util = require('util')
const { promisify } = Util
const _ = require('lodash')
const ReplaceInFiles = require('replace-in-files')
const Fs = require('fs')
const Path = require('path')
const Inflector = require('inflected')
const { Command } = require('commander')
const Process = require('process')
const Colors = require('colors/safe')
const SequelizeAuto = require('sequelize-auto')
const Config = require('config')

/**
 *
 *
 * @class Scaffolding
 */
class Scaffolding {
  /**
   *Creates an instance of Scaffolding.
   * @param {IScaffoldingConfig} [options={}]
   * @memberof Scaffolding
   */
  constructor(options = {}) {
    this._commander = new Command()
    this._sequelizeAuto = undefined
    this._logicalFieldTemplate = `\t\tlogical: {
\t\t\ttype: new DataTypes.VIRTUAL(DataTypes.INTEGER(1), ['logical']),
\t\t\tallowNull: true,
\t\t\tfield: 'logical',
\t\t\tget() {
\t\t\t\treturn this.getDataValue('logical')
\t\t\t},
\t\t\tset(val) {}
\t\t}`
    this._attributes = `		id: {
      type: DataTypes.BIGINT,
      allowNull: false,
      primaryKey: true,
      autoIncrement: true,
      field: 'id'
    },
    status: {
      type: DataTypes.ENUM('active', 'inactive', 'pending'),
      values: ['active', 'inactive', 'pending'],
      allowNull: false,
      defaultValue: 'pending',
      field: 'status',
      validate: {
        isIn: [
          ['active', 'inactive', 'pending']
        ]
      }
    },
    createdId: {
      type: DataTypes.INTEGER(8).UNSIGNED,
      allowNull: true,
      field: 'created_id'
    },
    createdAt: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
      field: 'created_at'
    },
    updatedId: {
      type: DataTypes.INTEGER(8).UNSIGNED,
      allowNull: true,
      field: 'updated_id'
    },
    updatedAt: {
      type: DataTypes.DATE,
      allowNull: false,
      defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
      field: 'updated_at'
    },
    deletedId: {
      type: DataTypes.INTEGER(8).UNSIGNED,
      allowNull: true,
      field: 'deleted_id'
    },
    deletedAt: {
      type: DataTypes.DATE,
      allowNull: true,
      field: 'deleted_at'
    },
${this._logicalFieldTemplate}`
    this._references = []
    this._files = []
    this._args = {}

    /** @type {IScaffoldingConfig} */
    this._options = this._deepExtend(
      {
        path: {
          controllers: './controllers/',
          routes: './routes/',
          models: './models/',
          schemas: './schemas/'
        }
      },
      options
    )

    this._init()
  }

  /**
   * @private
   * @memberof Scaffolding
   */
  _init() {
    this._commander
      .command('generate <name>')
      .version(process.env.npm_package_version || version, '-v, --version', 'SHOW CURRENT VERSION')
      .alias('g')
      .description('[SCAFFOLDING CLI]: SCAFFOLDING [controller|route]')
      .option('-m, --model', 'SCAFFOLDING modle')
      .option('-c, --controller', 'SCAFFOLDING controller')
      .option('-r, --route', 'SCAFFOLDING route')
      .option('-s, --schema', 'SCAFFOLDING schema')
      .option('-f, --force', 'CREATE FORCIBLY IF FILE ALREADY EXISTS')
      .option('-a, --run-all', 'CREATE [controller|route|modle|schema]')
      .option('-t, --type <items>', 'SCAFFOLDING TYPE', value => (value || []).split(','), [])
      .helpOption('-h, --help', 'READ MORE INFORMATION')
      .on('--help', this._cmdHelp.bind(this))
      .action(this._cmdAction.bind(this))

    this._commander.version(process.env.npm_package_version || version, '-v, --version', 'SHOW CURRENT VERSION')
  }

  /**
   *
   *
   * @param {string[]} [payload=Process.argv]
   * @return {Promise<Object>}
   * @memberof Scaffolding
   */
  async run(payload = Process.argv) {
    return await Promise.resolve(this._commander.parse(payload))
  }

  /**
   *
   * @private
   * @param {string} name
   * @param {Object} options
   * @memberof Scaffolding
   */
  async _cmdAction(name, options) {
    try {
      if (_.isEmpty(name)) {
        throw new TypeError('Please specify a parameter: [name]')
      }

      let {
        type = [],
        runAll = false,
        force = false,
        controller = false,
        route = false,
        model = false,
        schema = false
      } = options

      controller = controller || !_.isUndefined(type.find(t => t === 'controller')) || runAll
      route = route || !_.isUndefined(type.find(t => t === 'route')) || runAll
      model = model || !_.isUndefined(type.find(t => t === 'model')) || runAll
      schema = schema || !_.isUndefined(type.find(t => t === 'schema')) || runAll

      this._args = _.assign(
        {},
        {
          type,
          name,
          controller,
          route,
          model,
          schema,
          runAll,
          force
        }
      )

      if (controller) {
        await this._createController(name)
      }

      if (route) {
        this._createRoute(name)
      }

      if (model) {
        await this._createModel(name)
      }

      if (schema) {
        await this._createSchema(name)
      }

      if (!_.size(this._files)) {
        throw new TypeError('Please specify at least 1 type: [controller|route|modle|schema]')
      }

      await this._replaceFiles()
    } catch (error) {
      this._log(error, 'error')
      this._showHelp(false, 'red')
    }
  }

  /**
   *
   * @memberof Scaffolding
   */
  async _replaceFiles() {
    try {
      const name = _.get(this._args, 'name')
      const base = {
        files: this._files,
        optionsForFiles: {
          ignore: ['**/node_modules/**']
        },
        allowEmptyPaths: false,
        saveOldFile: false,
        encoding: 'utf8',
        onlyFindPathsWithoutReplace: false,
        returnPaths: true,
        returnCountOfMatchesByPaths: true
      }

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{CLAZZ\}\}/, 'g'),
          to: this._toModelizeAndPluralize(name)
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{PLURAL\}\}/, 'g'),
          to: this._toResource(name)
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{SINGULAR\}\}/, 'g'),
          to: this._toUnderscore(name)
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{FIELDS\}\}/, 'g'),
          to: this._attributes
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{RELATIONS\}\}/, 'g'),
          to: this._getModelReferencesAsString()
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{TABLE\}\}/, 'g'),
          to: this._toTableize(name)
        })
      )

      await ReplaceInFiles(
        this._deepExtend(base, {
          from: new RegExp(/\{\{MODEL\}\}/, 'g'),
          to: this._toModelClassify(name)
        })
      )

      this._log(`SCAFFOLDING DONE... ${JSON.stringify(this._files)}`, 'log')
    } catch (error) {
      this._log(error, 'error')
      throw error
    }
  }

  /**
   * @private
   * @description create controller for `express`
   * @param {string} [name='']
   * @returns {string}
   * @memberof Scaffolding
   */
  async _createController(name = '') {
    const src = this._getTemplatePath('controllers')
    const dist = this._getControllerPath(name)

    return this._copyTemplate(src, dist)
  }

  /**
   * @private
   * @description create route definition for `express`
   * @param {string} [name='']
   * @returns {string}
   * @memberof Scaffolding
   */
  async _createRoute(name = '') {
    const src = this._getTemplatePath('routes')
    const dist = this._getRoutePath(name)

    return this._copyTemplate(src, dist)
  }

  /**
   * @private
   * @description create model class for `Sequelize` with typescript using `SequelizeAuto`
   * @param {string} [name='']
   * @returns {string}
   * @memberof Scaffolding
   */
  async _createModel(name = '') {
    try {
      const src = this._getTemplatePath('models')
      const dist = this._getModelPath(name)
      const options = {
        encoding: 'utf-8'
      }

      await this._runSequelizeAuto(name)
      if (this._fileExists(dist)) {
        this._setModelAttributes(name, Fs.readFileSync(dist, options))
        this._setModelReferences()
        Fs.unlinkSync(dist)
      }

      return this._copyTemplate(src, dist)
    } catch (error) {
      this._log(error, 'error')
    }
  }

  /**
   * @private
   * @description create empty schema for `MongoDB`
   * @param {string} [name='']
   * @returns {string}
   * @memberof Scaffolding
   */
  async _createSchema(name = '') {
    const src = this._getTemplatePath('schemas')
    const dist = this._getSchemaPath(name)

    return this._copyTemplate(src, dist)
  }

  /**
   * @private
   * @description copy from template to be SCAFFOLDING
   * @param {string} src
   * @param {string} dist
   * @returns {string}
   * @memberof Scaffolding
   */
  _copyTemplate(src, dist) {
    try {
      const force = _.get(this._args, 'force')
      if (!this._fileExists(src)) {
        throw new TypeError(`Specified template does not exists!: ${src}`)
      }

      if (this._fileExists(dist)) {
        if (!force) {
          throw new TypeError(`Specified file already exists!: ${dist}`)
        }

        Fs.unlinkSync(dist)
      }

      Fs.copyFileSync(src, dist)
      this._log(`Generated file: ${dist}`, 'log')
      this._files.push(dist)

      return dist
    } catch (error) {
      this._log(error, 'error')
      throw error
    }
  }

  /**
   *
   * @private
   * @param {string} [name='']
   * @returns {Promise<any>}
   * @memberof Scaffolding
   */
  async _runSequelizeAuto(name = '') {
    try {
      const options = _.cloneDeep(Config.util.toObject(Config.db[Process.env.NODE_ENV || 'development']))
      const {
        user,
        username,
        password,
        host,
        database,
        port,
        dialect,
        dialectOptions,
        pool,
        timezone,
        define,
        benchmark
      } = options
      const tables = [this._toTableize(name)]
      const camelCase = true
      const indentation = 1
      this._sequelizeAuto = new SequelizeAuto(database, username, password, {
        user,
        username,
        password,
        database,
        host,
        port,
        dialect,
        dialectOptions,
        pool,
        timezone,
        define,
        benchmark,
        tables,
        camelCase,
        indentation
      })

      await promisify(this._sequelizeAuto.run).bind(this._sequelizeAuto)()
      return await this._sleep(1000)
    } catch (error) {
      this._log(error, 'error')
      throw error
    }
  }

  /**
   *
   * @private
   * @param {string} [name='']
   * @param {string} [str='']
   * @returns {void}
   * @memberof Scaffolding
   */
  _setModelAttributes(name = '', str = '') {
    const table = this._toTableize(name)
    const clazz = this._toModelClassify(name)

    this._attributes = str
      // eslint-disable-next-line no-useless-escape
      .replace(new RegExp(/\/\*\sjshint\sindent\:\s1\s\*\//, 'g'), '')
      // eslint-disable-next-line no-useless-escape
      .replace(new RegExp(/module\.exports\s\=\sfunction\(sequelize,\sDataTypes\)\s\{/, 'g'), '')
      .replace(
        new RegExp(
          /\t{1,10}logical:\s\{\n\t{1,10}type:\sDataTypes\.INTEGER\(1\),\n\t{1,10}allowNull:\strue,\n\t{1,10}field:\s'logical'\n\t{1,10}\}/,
          'g'
        ),
        this._logicalFieldTemplate
      )
      .replace(new RegExp('\\s\\sreturn\\ssequelize\\.define\\(\'' + clazz + '\',\\s\\{', 'g'), '')
      .replace(new RegExp('\\treturn\\ssequelize\\.define\\(\'' + clazz + '\',\\s\\{', 'g'), '')
      .replace(new RegExp('\\},\\s\\{\\n\\t\\ttableName:\\s\'' + table + '\'\\n\t\\}\\);\\n\\};', 'g'), '')
      .replace(new RegExp(/\n{2,3}/, 'g'), '')
      .replace(new RegExp(/\n\t\n/, 'g'), '')
      .replace(new RegExp(/\t{3}/, 'g'), '        ')
      .replace(new RegExp(/\t{2}/, 'g'), '      ')
  }

  /**
   *
   * @private
   * @memberof Scaffolding
   */
  _setModelReferences() {
    const regexp = new RegExp(/references:\s\{\n\s{1,16}model:\s'(\w{1,128})',/, 'g')
    while (true) {
      const result = regexp.exec(this._attributes)
      const model = RegExp.$1
      if (_.isEmpty(result)) {
        break
      }

      if (!_.isEmpty(model)) {
        this._references.push(model)
        continue
      }
    }
  }

  /**
   *
   * @private
   * @returns {string}
   * @memberof Scaffolding
   */
  _getModelReferencesAsString() {
    const tab = '    '
    const br = '\n'
    if (_.isEmpty(this._references)) {
      return '\t\t// TODO: implement your relations'
    }

    return this._references.map(ref => `${tab}this.belongsTo(models.${this._toModelClassify(ref)})`).join(br)
  }

  /**
   *
   * @private
   * @param {string} [filename='']
   * @returns {boolean}
   * @memberof Scaffolding
   */
  _fileExists(filename = '') {
    return Fs.existsSync(filename)
  }

  /**
   *
   * @private
   * @param {boolean} [withExit=false]
   * @param {TScaffoldingLogColor} [color='green']
   * @returns {void}
   * @memberof Scaffolding
   */
  _showHelp(withExit = false, color = 'green') {
    const handler = txt => Colors[color].bind(Colors)(txt)
    if (withExit) {
      return this._commander.help(handler)
    }

    return this._commander.outputHelp(handler)
  }

  /**
   * @private
   * @description show command help
   * @returns {void}
   * @memberof Scaffolding
   */
  _cmdHelp() {
    this._log('', 'log')

    this._log('  Generate Template via CLI:', 'debug')
    this._log('    $ node cli generate <name> -c -m', 'log')
    this._log('    $ node cli g <name>', 'log')
    this._log('', 'log')

    this._log('  Examples:', 'debug')

    this._log('    # Create a new controller', 'debug')
    this._log('    $ node cli generate your_class_name --controller ', 'log')
    this._log('    $ node cli g your_class_name -c ', 'log')
    this._log('', 'log')

    this._log('    # Create a new route', 'debug')
    this._log('    $ node cli generate your_class_name --route ', 'log')
    this._log('    $ node cli g your_class_name -r ', 'log')
    this._log('', 'log')

    this._log('    # Create a new model ', 'debug')
    this._log('    # [!!!IMPORTANT!!!]: you must create a table on corresponded database before running', 'error')
    this._log('    $ node cli generate your_class_name --model ', 'log')
    this._log('    $ node cli g your_class_name -m ', 'log')
    this._log('', 'log')

    this._log('    # Create a new schema for mongoDB', 'debug')
    this._log('    $ node cli generate your_class_name --schema ', 'log')
    this._log('    $ node cli g your_class_name -s ', 'log')
    this._log('', 'log')

    this._log('    # Create all-in-one(controller/route/model/schema)', 'debug')
    this._log('    $ node cli generate your_class_name --run-all ', 'log')
    this._log('    $ node cli g your_class_name -a ', 'log')
    this._log('', 'log')

    this._log('    # Or you can specify with the type parameter as a list (comma separated)', 'debug')
    this._log('    $ node cli generate your_class_name --type "controller,route,model"', 'log')
    this._log('    $ node cli g your_class_name -t "controller,route,model"', 'log')
    this._log('', 'log')

    this._log('    # Run with force option ', 'debug')
    this._log('    # [!!!IMPORTANT!!!]: running with this options will overwrite the exists file!', 'error')
    this._log('    $ node cli generate your_class_name --force --controller ', 'log')
    this._log('    $ node cli g your_class_name -f -c ', 'log')
    this._log('', 'log')
  }

  /**
   *
   * @private
   * @returns {string}
   * @memberof Scaffolding
   */
  _getControllerDir() {
    return Path.resolve(Process.cwd(), this._options.path.controllers)
  }

  /**
   *
   * @private
   * @returns {string}
   * @memberof Scaffolding
   */
  _getRouteDir() {
    return Path.resolve(Process.cwd(), this._options.path.routes)
  }

  /**
   *
   * @private
   * @returns {string}
   * @memberof Scaffolding
   */
  _getModelDir() {
    return Path.resolve(Process.cwd(), this._options.path.models)
  }

  /**
   *
   * @private
   * @returns {string}
   * @memberof Scaffolding
   */
  _getSchemaDir() {
    return Path.resolve(Process.cwd(), this._options.path.schemas)
  }

  /**
   * @private
   * @param {string} [type='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getTemplatePath(type = '') {
    const filename = `${type}.tpl`
    const dir = Path.resolve(__dirname, '../templates/')

    return Path.join(dir, filename)
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getRoutePath(str = '') {
    return Path.join(this._getRouteDir(), `${this._getSingularFileName(str)}.js`)
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getControllerPath(str = '') {
    return Path.join(this._getControllerDir(), `${this._getSingularFileName(str)}.js`)
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getModelPath(str = '') {
    return Path.join(this._getModelDir(), `${this._getPluralFileName(str)}.js`)
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getSchemaPath(str = '') {
    return Path.join(this._getSchemaDir(), `${this._getSingularFileName(str)}.js`)
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getSingularFileName(str = '') {
    return _.toLower(Inflector.singularize(Inflector.underscore(str)))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _getPluralFileName(str = '') {
    return _.toLower(Inflector.pluralize(Inflector.underscore(str)))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toSingular(str = '') {
    return Inflector.singularize(Inflector.camelize(Inflector.underscore(str), false))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toPluralize(str = '') {
    return Inflector.pluralize(Inflector.camelize(Inflector.underscore(str), false))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toClassify(str = '') {
    return Inflector.classify(Inflector.camelize(Inflector.underscore(str)))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toModelizeAndPluralize(str = '') {
    return Inflector.pluralize(Inflector.classify(Inflector.camelize(Inflector.underscore(str), true)))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toModelize(str = '') {
    return Inflector.singularize(Inflector.camelize(Inflector.underscore(str), false))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toModelClassify(str = '') {
    return Inflector.pluralize(Inflector.camelize(Inflector.underscore(str), false))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toTableize(str = '') {
    return Inflector.tableize(Inflector.classify(Inflector.camelize(Inflector.underscore(str))))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toResource(str = '') {
    return Inflector.underscore(Inflector.pluralize(Inflector.camelize(Inflector.underscore(str), false)))
  }

  /**
   *
   * @private
   * @param {string} [str='']
   * @returns {string}
   * @memberof Scaffolding
   */
  _toUnderscore(str = '') {
    return Inflector.underscore(Inflector.singularize(Inflector.camelize(Inflector.underscore(str), false)))
  }

  /**
   *
   * @private
   * @param {Object} src
   * @param {Object} extend
   * @returns {Object}
   * @memberof Scaffolding
   */
  _deepExtend(src, extend) {
    return _.assign(_.cloneDeep(src), extend)
  }

  /**
   *
   * @private
   * @param {*} val
   * @param {TScaffoldingLogLevel} [level='error']
   * @returns {void}
   * @memberof Scaffolding
   */
  _log(val, level = 'error') {
    /** @type {TScaffoldingLogColor} */
    let color = 'white'
    switch (level) {
      case 'error':
        color = 'red'
        break

      case 'warn':
        color = 'yellow'
        break

      case 'info':
        color = 'cyan'
        break

      case 'log':
        color = 'green'
        break

      case 'debug':
        color = 'white'
        break

      default:
        color = 'gray'
        break
    }

    const logger = _.bind(_.get(console, level), console)
    const args = _.bind(_.get(Colors, color))(val)

    return logger(args)
  }

  /**
   *
   * @private
   * @static
   * @param {number} [ms=1000]
   * @returns {Promise<void>}
   * @memberof Scaffolding
   */
  async _sleep(ms = 100) {
    return new Promise(resolve => setTimeout(resolve, ms))
  }
}

module.exports = new Scaffolding()