sequelize-modeler
Version:
Extended model class for sequelize with rest api.
766 lines (725 loc) • 23.8 kB
JavaScript
const Sequelize = require('sequelize')
const moment = require('moment')
const log = require('console-emoji')
const coolors = require('node-coolors')
const Catalog = require('./services/catalog')
const camelize = require('./utils').camelize
const dequerylize = require('./utils').dequerylize
/**
* @author N4cho!
* Model
* --------
* @description _Class that extends functionality and versatility of Sequelize models_
* @alias Model
* @class Model
* @extends {Sequelize.Model}
* @typedef {Model} Model
* @typedef {typeof Model} Builder
* @typedef {{
* integer: number
* decimal: number,
* bigint: number,
* float: number,
* real: number,
* double: number,
* string: string,
* text: string,
* citext: string,
* date: import('moment').Moment,
* dateonly: import('moment').Moment,
* boolean: boolean,
* enum: [any],
* array: [any],
* json: Object,
* jsonb: Object,
* blob: BlobPart,
* uuid: string,
* cidr: any,
* inet: any,
* macaddr: any,
* range: [any],
* geometry: any,
* all: any,
* }} Empty
*
* @typedef {{key:string
* as:string
* label:string
* dataType:string
* required:boolean
* defaultValue:any
* format:string
* autoIncrement:boolean
* }} Field
* @typedef {import('express').Request} req
* @typedef {import('express').Response} res
* @typedef {import('express').NextFunction} next
* @typedef {import('express').Router} Router
*
* @callback errorCallback
* @param {req} req
* @param {res} res
* @param {next} next
* @param {Error} e
*
* @callback failedCallback
* @param {req} req
* @param {res} res
* @param {next} next
* @param {Model} instance
*
* @callback Middleware
* @param {req} req
* @param {res} res
* @param {next} next
*/
class Model extends Sequelize.Model {
/**
* @author N4cho!
*
* @description List of numric dataTypes contained in the generated fields
*
* @readonly
* @static
* @memberof Model
*/
static get numerics(){
return ['integer','decimal','bigint','float','real','double']
}
/**
* @author N4cho!
*
* @description List of date dataTypes contained in the generated fields supported by moment.js
*
* @readonly
* @static
* @memberof Model
*/
static get momentables(){
return ['date','dateonly']
}
/**
* @author N4cho!
*
* @description initialization of the Model itself
* @readonly
* @static
* @memberof Model
*/
static init(config) {
super.init(config.fields, config.options)
this.configuration = config
this.connectionId = config.options.connectionId
this.pk = config.options.primaryKey
config.options.dateFormat ? this.setDefaultDateFormat(config.options.dateFormat) : this.setDefaultDateFormat(Catalog.getDefaultDateFormat(this.connectionId))
Catalog.registerModel({model:this, connection:{id:config.options.connectionId, connection: config.options.sequelize}})
for(let key in config.fields){
let field = config.fields[key]
Object.defineProperty(this.prototype, field.as, {
get: function(){ return this[key]; },
set: function(value){ this[key] = value; },
enumerable: true,
configurable: true
})
}
return this
}
static getModelList(){
return Catalog.getModelList()
}
static getDefaultDateFormat(){
return this.defaultDateFormat
}
static setDefaultDateFormat(defaultDateFormat){
return this.defaultDateFormat
}
static addAssociations(assocs){
this.associationMap = {}
for(let association of assocs){
this.associationMap[association.foreignKey] = association
}
}
static getApiName(){
if(this.apiname){
return this.apiname
}
else{
return camelize(this.name,'_')
}
}
static setApiName(apiname){
this.apiname = apiname
}
static setFields(fields){
this.aliasFields = fields
}
/**
* @author N4cho!
* @description Returns the list of fields of the model
* @static
* @returns {[Field]}
* @memberof Model
*/
static getFields(){
if(this.aliasFields){
return this.aliasFields
}
else{
return Object.keys(this.configuration.fields).map(fieldName => {
// let field = {visible:true, cardVisible:true, filterable:true}
field.key = fieldName
field.filterable = this.configuration.fields[fieldName].filterable || true
field.visible = this.configuration.fields[fieldName].visible || true
field.as = this.configuration.fields[fieldName].as || fieldName.toLocaleLowerCase()
field.label = this.configuration.fields[fieldName].label || fieldName.toLocaleLowerCase()
field.dataType = this.configuration.fields[fieldName].type.key.toLocaleLowerCase()
field.required = !this.configuration.fields[fieldName].allowNull
field.defaultValue = this.configuration.fields[fieldName].defaultValue
field.format = this.configuration.fields[fieldName].format
field.autoIncrement = this.configuration.fields[fieldName].autoIncrement ? this.configuration.fields[fieldName].autoIncrement : false
return field
})
}
}
static setPk(pk){
this.pk = pk
}
static getPk(){
return this.pk
}
/**
* @author N4cho!
* @static
* @param {Object} source
* @returns {Promise<boolean>}
* @memberof Model
*/
static async validate(source) {
await this.build(source).validate()
}
/**
* @author N4cho!
* @description Builds a model instance from json data in aliased format with optional configurations
* - supports transaction (config.transaction)
* - supports specific format for date fileds (config.dateFormat)
* - supports building non persistent instance (config.save)
* @static
* @param {Object} json
* @param {Object} config
* @param {boolean} config.save
* @param {import('sequelize').Transaction} config.transaction
* @param {string} config.dateFormat
* @param {Empty} config.empty
* @returns {Model}
* @memberof Model
*/
static async fromJson(json, {save = true, transaction, dateformat, empty} = { }) {
let fields = this.getFields()
let dateformat = dateFormat || this.getDefaultDateFormat()
let buildJson = {}
let addJson = {}
try {
for(let key in json){
let field = fields.find( f => f.as === key)
if(field && !field.autoIncrement){
if(this.numerics.includes(field.dataType) && json[key] !== null && json[key] !== undefined){
if(field.dataType === 'integer'){
obj[field.as] = parseInt(obj[field.as], 10)
}
else{
obj[field.as] = parseFloat(obj[field.as])
}
}
if(this.momentables.includes(field.dataType) && json[key] !== null && json[key] !== undefined){
json[key] = moment(obj[field.as]).format(field.format || dateformat)
}
if(field.required){
if((json[key] === null || json[key] === undefined) && field.defaultValue !== undefined){
buildJson[field.key] = field.defaultValue
}
else if((json[key] === null || json[key] === undefined) && empty[field.dataType] !== undefined){
buildJson[field.key] = empty[field.dataType]
}
else if((json[key] === null || json[key] === undefined) && empty.all !== undefined){
buildJson[field.key] = empty.all
}
else{
buildJson[field.key] = json[key]
}
}
else{
if((json[key] === null || json[key] === undefined) && field.defaultValue !== undefined){
addJson[field.key] = field.defaultValue
}
else if((json[key] === null || json[key] === undefined) && empty[field.dataType] !== undefined){
addJson[field.key] = empty[field.dataType]
}
else if((json[key] === null || json[key] === undefined) && empty.all !== undefined){
addJson[field.key] = empty.all
}
else{
addJson[field.key] = json[key]
}
}
}
}
let instance = this.build(buildJson)
for(let key in addJson){
instance[key] = addJson[key]
}
if(save){
await instance.save({transaction})
}
return instance
}
catch (err) {
throw err
}
}
static belongsTo(target,options){
let modelName = 'Target Model'
if(typeof target === 'string'){
modelName = target
target = Catalog.getModels(this.connectionId)[target]
}
if(target){
super.belongsTo(target,options)
}else{
log(coolors.bgRed(`:rage: ${modelName} does not ${coolors.bright('exist')}`))
}
}
static hasOne(target,options){
let modelName = 'Target Model'
if(typeof target === 'string'){
modelName = target
target = Catalog.getModels(this.connectionId)[target]
}
if(target){
super.hasOne(target,options)
}else{
log(coolors.bgRed(`:rage: ${modelName} does not ${coolors.bright('exist')}`))
}
}
static hasMany(target,options){
let modelName = 'Target Model'
if(typeof target === 'string'){
modelName = target
target = Catalog.getModels(this.connectionId)[target]
}
if(target){
super.hasMany(target,options)
}else{
log(coolors.bgRed(`:rage: ${modelName} does not ${coolors.bright('exist')}`))
}
}
static belongsToMany(target,options){
let modelName = 'Target Model'
if(typeof target === 'string'){
modelName = target
target = Catalog.getModels(this.connectionId)[target]
}
if(target){
super.belongsToMany(target,options)
}else{
log(coolors.bgRed(`:rage: ${modelName} does not ${coolors.bright('exist')}`))
}
}
/**
* @author N4cho!
* @description Generates an API Rest router for the given model.
* Options :
* ---------
* - Accepts specific model or name of model (requires connection id or object) or function(must return a model)
* - Accepts an error callback that excecutes when the request fails
* - Accepts a fail callback that excecutes when an instance cannot be created (post only)
* - Accepts custom apiname for the url of the models (ex: href:'http:localhost:80/${apiname}')
* - Accepts extra middlwares to be places before or after the generated ones on each method and meta
* - Accepts a field list to be used by the model either for the creation or the format method
* - Can make the generated middlewares excecute the ```next``` function and append the instance or an error to ```req.body._carried_```
* - Can toggle which method must be generated
* @static
* @param {Object} config - configuration
* @param {Builder} config.model - Model to be used
* @param {import('./services/catalog').Connection|string} config.connection - Connection to be used when model is string
* @param {failedCallback} config.failed - callback to be excecuted when creation fails (req,res,next,instance)
* @param {errorCallback} config.error - callbback to be excecuted in case of an internal error (req,res,next,error)
* @param {Empty} config.empty - callbback to be excecuted in case of an internal error (req,res,next,error)
* @param {string} config.apiname - name used to build href of models
* @param {boolean} [config.next=false] - toggle the excecution of ```next()```
* @param {Object} config.middlewares - extra optional middlwares to be added
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.get - place indicates if middlware must be excecuted before or after the generated one
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.getOne - place indicates if middlware must be excecuted before or after the generated one
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.post - place indicates if middlware must be excecuted before or after the generated one
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.put - place indicates if middlware must be excecuted before or after the generated one
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.delet - place indicates if middlware must be excecuted before or after the generated one
* @param {[{place:'before'|'after', handler:Middleware}]} config.middlewares.meta - place indicates if middlware must be excecuted before or after the generated one
* @param {boolean} config.get - toggles the '[GET]' method
* @param {boolean} config.getOne - toggles the '[GET] /:id' endpoint
* @param {boolean} config.post - toggles the '[POST]' method
* @param {boolean} config.put - toggles the '[PUT]' method
* @param {boolean} config.delet - toggles the '[DELETE]' method
* @param {boolean} config.meta - toggles the '/meta' endpoint
* @param {[Field]} config.fields - list of fields to be used by the model
* @returns {Router}
* @memberof Model
*/
static generateApi({connection, model, failed, error, empty, apiname, next=false, middlewares = {getOne:[], get:[], post:[], put:[], delet:[], meta:[]}, getOne=true, get=true, post=true, put=true, delet=true, meta=true, fields}={}){
/** @returns {Model} */
let decypherModel = (req,res,next) => {
if(model){
if(typeof model === 'function'){
return model(req,res,next)
}
else if(typeof model === 'object'){
return model
}
else if(connection && typeof connection === 'object'){
return connection.Models[model]
}
else if(connection){
return Catalog.getConnection(connection).Models[model]
}
}
else{
return this
}
}
const express = require('express')
const router = express.Router()
const _next = next
if(meta){
let before = middlewares.meta.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.meta.filter(m => m.place === 'after').map(m => m.handler)
router.route('/meta').get(...before, async (req,res,next) => {
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
try {
let tableName = _model.configuration.options.tableName
let { visiblefields, limit=25, offset=0, order, where={}, expand=false } = dequerylize(req.query, fields)
let count = await _model.count({where})
let response = {
visible: visiblefields.map(field => {field.key = '' + field.as; delete field.as ; return field}),
entries: count,
name: tableName,
filters: fields.filter(field => field.filterable).map(field => field.key),
required: fields.filter(field => field.required).map(field => field.key)
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
res.status(500).send('Internal Server Error').end()
}
}
}
},...after)
}
if(get){
let before = middlewares.get.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.get.filter(m => m.place === 'after').map(m => m.handler)
router.get('/',...before, async(req, res, next) => {
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
try {
let response
let { visiblefields, limit=25, offset=0, order, where={}, expand=false } = dequerylize(req.query, fields)
const { docs:instances, pages, total } = await _model.paginate({where, order, page: offset+1, paginate: limit})
if(instances.length > 0){
response = instances.map(x => x.format({expand, visiblefields, apiname, empty}))
}else{
response = []
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
console.log(e)
res.status(500).send('Internal Server Error').end()
}
}
}
},...after)
}
if(getOne){
let before = middlewares.getOne.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.getOne.filter(m => m.place === 'after').map(m => m.handler)
router.get('/:id', ...before, async(req, res, next) => {
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
try {
let id = req.params.id
let { visiblefields } = dequerylize(req.query, fields)
let response = await _model.findOne({where:{[_model.getPk()] : id}})
if(response){
response = response.format({expand, visiblefields, apiname, empty})
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
res.status(500).send('Internal Server Error').end()
}
}
}
}, ...after)
}
if(post){
let before = middlewares.post.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.post.filter(m => m.place === 'after').map(m => m.handler)
router.post('/',...before, async(req, res, next) => {
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
try {
let response
let instance = await _model.fromJson(data,{empty})
if(instance){
await instance.save()
response = instance.format({expand, fields, apiname})
}
else{
response = false
if(failed){
failed(req,res,next,instance)
}
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
res.status(500).send('Internal Server Error').end()
}
}
}
}, ...after)
}
if(put){
let before = middlewares.put.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.put.filter(m => m.place === 'after').map(m => m.handler)
router.put('/:id', ...before, async (req, res, next) => {
try{
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
const id = req.params.id
const data = req.body
let response
let exists = await _model.exists(id)
if(exists){
let instance = await _model.findOne({where:{[_model.getPk()]: id}})
for(let key in data){
let field = _model.getFields().find(x => x.as === key)
if(field && data[key]){
if(_model.numerics.includes(field.dataType) && data[key] !== null){
if(field.dataType === 'integer'){
instance[field.key] = parseInt(data[key],10)
}
else{
instance[field.key] = parseFloat(data[key])
}
}
else if(_model.momentables.includes(field.dataType) && data[key] !== null){
instance[field.key] = moment(data[key], moment.getDefaultDateFormat())
}
else{
instance[field.key] = data[key]
}
}
}
}
let result = await instance.save()
if(result){
response = result.format({expand, fields, apiname, empty})
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
res.status(500).send('Internal Server Error').end()
}
}
}
}, ...after)
}
if(delet){
let before = middlewares.delet.filter(m => m.place === 'before' || m.place === undefined).map(m => typeof m === 'function' ? m : m.handler)
let after = middlewares.delet.filter(m => m.place === 'after').map(m => m.handler)
router.delete('/:id', ...before, async (req, res, next) => {
let _model = decypherModel(req,res,next)
fields = fields || _model.getFields()
apiname = apiname || _model.getApiName()
let id = req.params.id
try{
let exists = await _model.exists(id)
let response = exists
if ( exists ){
let instance = await _model.findOne({where:{
[_model.getPk()]: id}
})
await instance.destroy()
}
if(_next){
req.body._carried_ = response
next()
}
else{
res.json(response)
}
}catch(e){
if(_next){
req.body._carried_ = e
next()
}
else{
if(error){
error(req,res,next,e)
}
else{
res.status(500).send('Internal Server Error').end()
}
}
}
}, ...after)
}
return router
}
/**
* @author N4cho!
* @description Returns the model instance in aliased json format.
* Options
* -------
* - TODO: Can format eagerloaded associations with its respective or a specified format
* - Accepts a list of fields to format de model to
* - Accepts different specifications or the generated link to the instace endpoint
* - Accepts specific format to display dates
* - Accepts a specific value for empty fields in the model
* @param {Object} config - Configuration
* @param {boolean} config.expand - TODO: toggle expansion of model
* @param {[Field]} config.fields - list of fields to format de model to
* @param {string} config.apiname - name to build the link of the model
* @param {string} config.dateFormat - custom format to display date fields
* @param {Empty} config.empty - custom value to fill empty fields
* @param {string|false} [config.link='href'] - name of the property wich will contain the model link, false omits this property
*/
format({expand, fields, apiname, dateFormat, empty, link = 'href'} = { }) {
let numerics = Object.getPrototypeOf(this).constructor.numerics
let momentables = Object.getPrototypeOf(this).constructor.momentables
let dateformat = dateFormat || this._getDateFormat() || Object.getPrototypeOf(this).constructor.getDefaultDateFormat()
fields = fields ? fields : Object.getPrototypeOf(this).constructor.getFields()
if(!apiname){
apiname = camelize(Object.getPrototypeOf(this).constructor.name.toLowerCase(),'_')
}
let pk = Object.getPrototypeOf(this).constructor.getPk()
let obj = {}
let url = `${process.env.HOST}${(process.env.STAGE > 1)?':'+process.env.PORT:''}/${apiname}/${this[pk]}`
if(fields && fields === 'link'){
return {[link]:url}
}
for (let field of fields) {
if( this[field.key] !== null && this[field.key] !== undefined ){
obj[field.as] = this[field.key]
if(numerics.includes(field.dataType)){
if(field.dataType === 'integer'){
obj[field.as] = parseInt(obj[field.as], 10)
}
else{
obj[field.as] = parseFloat(obj[field.as])
}
}
else if(momentables.includes(field.dataType)){
obj[field.as] = moment(obj[field.as]).format(field.format || dateformat)
}
}
else{
if(empty && empty[field.dataType] !== undefined){
obj[field.as] = empty[field.dataType]
}
else if((empty && empty.all !== undefined)){
obj[field.as] = empty.all
}
else{
obj[field.as] = null
}
}
}
if(link){
obj[link] = url
}
return obj
}
_getDateFormat(){
return this._dateFormat
}
_setDateFormat(dateFormat){
this._dateFormat = dateFormat
}
}
module.exports = Model