import ErrorBuilder from "./ErrorBuilder"; /** * Classe permettant de gérer les tables simples */ class QueryBuilder { /** * Initialisation de la classe * @param {Object} params * @param {Object} models */ constructor(params, models) { this.params = params; this.models = models; this.includes = []; } /** * Méthode permettant de générer le lien vers une ressource * @param {String} href * @param {Number} currentPage * @param {Number} maxPage * @param {String} page */ static createLink(href, currentPage, maxPage, page) { if ( (page === "first" && currentPage > 1) || (page === "last" && currentPage < maxPage) || (page === "prev" && currentPage > 1) || (page === "next" && currentPage < maxPage) ) { let newIndex = 0; // eslint-disable-next-line default-case switch (page) { case "first": newIndex = 1; break; case "last": newIndex = maxPage; break; case "prev": newIndex = currentPage - 1; break; case "next": newIndex = currentPage + 1; break; } return { href: href.replace(`page=${currentPage}`, `page=${newIndex}`), methdod: "GET" }; } return undefined; } /** * Méthde permettant de générer les élements de pagination * @param {Object} req * @param {Number} total * @param {Number} limit * @param {Number} skip * @return {Object} */ static createPagination(req, total, limit, skip) { const maxPage = Math.ceil(total / limit); // Nombre total de pages const currentPage = Math.ceil(skip / limit) + 1; // Numéro de la page courante const href = `${req.protocol}://${req.get("host")}${req.originalUrl}`; // Lien vers la page actuelle return { paging: { first: QueryBuilder.createLink(href, currentPage, maxPage, "first"), prev: QueryBuilder.createLink(href, currentPage, maxPage, "prev"), next: QueryBuilder.createLink(href, currentPage, maxPage, "next"), last: QueryBuilder.createLink(href, currentPage, maxPage, "last") }, total, maxPage }; } /** * Fonction permettant de remplacer les attributs du genre $lte pat Op.lte * @param {Object} obj * @return {Object} */ replaceKeys(obj) { const { Op } = this.models.Sequelize; let newObject = {}; if (Array.isArray(obj)) { newObject = []; for (let i = 0; i < obj.length; i += 1) { const value = typeof obj[i] === "object" ? this.replaceKeys(obj[i]) : obj[i]; newObject.push(value); } } else { Object.keys(obj).map(key => { const value = typeof obj[key] === "object" ? this.replaceKeys(obj[key]) : obj[key]; if (key.indexOf("$") === 0 && key.slice(-1) !== "$") { switch (key) { case "$or": newObject[Op.or] = value; break; case "$lte": newObject[Op.lte] = value; break; case "$gte": newObject[Op.gte] = value; break; case "$contains": newObject[Op.contains] = [value]; break; case "$in": newObject[Op.in] = value.split(","); break; case "$like": newObject[Op.like] = value; break; default: newObject[key] = [value]; break; } } else { newObject[key] = value; } return true; }); } return newObject; } /** * Fonction permettant de savoir si un utilisateur a assez de droit pour effectuer une action * @param {Object} req * @return {Boolean} */ haveRight(req) { let allowedRole = "all"; if (!req.user) { req.user = {}; } if (!req.user.role) { req.user.role = "guest"; } // eslint-disable-next-line default-case switch (req.method) { case "POST": allowedRole = this.params.crud.write; break; case "PATCH": case "PUT": allowedRole = this.params.crud.edit; break; case "GET": allowedRole = this.params.crud.read; break; case "DELETE": allowedRole = this.params.crud.delete; break; } return allowedRole === "all" || allowedRole.indexOf(req.user.role) !== -1; } /** * Fonction permettant d'ajouter des restructions sur un get * @param {Object} req * @return {Object} */ restrictOn(req) { const where = {}; if (!this.params || !this.params.restrictOn) { return where; } const _overrideWhere = restrictions => { if (!restrictions) { return false; } for (let i = 0; i < restrictions.length; i += 1) { const restrict = restrictions[i]; const value = restrict.type === "raw" ? restrict.value : req[restrict.type][restrict.value]; if (restrict.roles.indexOf(req.user.role) !== -1) { where[restrict.field] = value; } } return true; }; _overrideWhere(this.params.restrictOn[req.getType]); switch (req.method) { case "PATCH": _overrideWhere(this.params.restrictOn.update); break; case "DELETE": _overrideWhere(this.params.restrictOn.delete); break; default: // Do nothing } return where; } /** * Fonction permettant de surcharger des valeurs * @param {Object} req * @param {String} method * @return {Object} */ override(req, method) { let overrideValues = {}; if (!this.params.override) { return overrideValues; } const params = this.params.override ? this.params.override[method] : {}; if (!params) { return overrideValues; } // On surcharge certains paramètres passé en query if (params.filters) { Object.keys(params.filters).map(column => { if (req.query[column]) { const value = req.query[column]; const query = JSON.parse( JSON.stringify(params.filters[column]).replace( new RegExp("_TERM_", "g"), value ) ); overrideValues = Object.assign( overrideValues, this.replaceKeys(query) ); } return true; }); } // On rajoute des paramètres à la requête if (params.params) { for (let i = 0; i < params.params.length; i += 1) { const currentParam = params.params[i]; overrideValues[currentParam.append] = req[currentParam.from][currentParam.value]; } } return overrideValues; } /** * Fonction permettant de charger la liste des relations à inclures lors d'un get * @param {Object} req * @param {Array} include * @return {Mixed} */ setInclusions(req, include) { if (!req.user) { req.user = {}; } if (!req.user.role) { req.user.role = "guest"; } const includes = []; const listOfIncludes = include || this.params.includes; for (let i = 0; i < listOfIncludes.length; i += 1) { const current = listOfIncludes[i]; // const include = current.collection; if ( !current.requiredRole || current.requiredRole.indexOf(req.user.role) !== -1 ) { let currentInclude = null; if (!current.model) { currentInclude = current.collection; } else { currentInclude = { as: current.collection, model: current.model, required: current.required || false }; // Pour cette inclusion il y a des filtres à appliquer if (current.restrictOn || current.include) { currentInclude.where = {}; if (current.include) { currentInclude.include = this.setInclusions(req, current.include); } // On parcours la liste des règles d'inclusion pour ce modèle if (current.restrictOn) { for (let j = 0; j < current.restrictOn.length; j += 1) { const currentRestriction = current.restrictOn[j]; // Cette restriction s'applique à tout le monde (pas de field roles) // ou alors elle s'applique juste sur une liste de groupes if ( !currentRestriction.roles || currentRestriction.roles.indexOf(req.user.role) !== -1 ) { if (currentRestriction.type === "raw") { currentInclude.where[currentRestriction.field] = currentRestriction.value; } } } } } } includes.push(currentInclude); } } // Mode reccursif if (include) { return includes; } this.includes = includes; return true; } /** * Fonction permettant de peupler une table de jointure (belongsToMany) * @param {Object} item * @param {String} key * @param {Array} values * @param {String} mode * @param {Function} callback */ createBelonsTo(item, key, values, mode, callback) { const collection = this.params.belongsToMany[key]; const action = `${mode}${collection}`; item[action](values) .then(() => { callback(); }) .catch(callback); } deleteBelongsTo(item, key, values, callback) { const collection = this.params.belongsToMany[key]; const action = `remove${collection}`; item[action](values) .then(() => { callback(); }) .catch(callback); } /** * Méthode interne permettant de créer un item * @param {Object} req * @param {Object} value * @param {Function} callback */ insertItem(req, value, callback) { const values = {}; const _done = item => { let createdItem = item; if (this.params.format) { const formatRules = req.user ? this.params.format[req.user.role] : null; if (formatRules) { createdItem = this.formatItem(item, formatRules); } } callback(null, createdItem); }; // On converti les 'null' en null (formData par exemple) Object.keys(value).map(key => { if (value[key] === "null") { values[key] = null; } else { values[key] = value[key]; } return true; }); // Création de l'élément this.models[this.params.model] .create(values) .then(item => { if (this.params.belongsToMany) { const needCreate = []; let created = 0; const _next = err => { if (err) { // TODO: break and revert } else { created += 1; if (created === needCreate.length) { _done(item); } } }; Object.keys(this.params.belongsToMany).map(key => { if (values[key]) { needCreate.push(key); } return true; }); if (needCreate.length > 0) { for (let i = 0; i < needCreate.length; i += 1) { const currentKey = needCreate[i]; this.createBelonsTo( item, currentKey, values[currentKey], "set", _next ); } } else { _done(item); } } else { _done(item); } }) .catch(err => { switch (err.name) { case "SequelizeUniqueConstraintError": callback(new ErrorBuilder(409.1, "Duplicate item")); break; case "SequelizeForeignKeyConstraintError": callback(new ErrorBuilder(406.2, "Bad foreign key")); break; default: callback(new ErrorBuilder(500, err)); } }); } /** * Fonction permettant de formater un item en fonction du user * @param {Object} item * @param {Object} formatRule * @return {Object} */ formatItem(item, formatRule) { const formated = {}; Object.keys(formatRule).map(key => { // TODO: revoir ce switch switch (typeof formatRule[key]) { case "string": if (item) { formated[key] = item[key] || null; } break; case "object": if (Array.isArray(item[key])) { formated[key] = []; for (let i = 0; i < item[key].length; i += 1) { formated[key].push( this.formatItem(item[key][i], formatRule[key]) ); } } else { formated[key] = this.formatItem(item[key], formatRule[key]); } break; default: // Do nothing } return true; }); return formated; } /** * Fonction permettant de formater une liste d'items en fonction du user * @param {Object} req * @param {Object} items * @return {Object} */ formatItems(req, items) { if (!this.params.format) { return items; } const formatRules = this.params.format[req.user.role]; if (!formatRules) { return items; } const formated = []; for (let i = 0; i < items.length; i += 1) { formated.push(this.formatItem(items[i], formatRules)); } return formated; } /** * Méthode permettant de vérifier que les valeurs reçues dans le body sont valides * @param {Object} req * @param {Function} callback */ checkCreateOneValues(req, callback) { // On regarde s'il faut surcharger les valeurs du body avec des valeurs dérivées (req.user, req.params...) if (this.params.override && this.params.override.create) { if (this.params.override.create.body) { for (let i = 0; i < this.params.override.create.body.length; i += 1) { const override = this.params.override.create.body[i]; req.body[override.append] = req[override.from][override.value]; } } } // On teste le body const { error, value } = this.params.validate.create.validate(req.body, { abortEarly: false }); if (error) { callback(new ErrorBuilder(406.1, error)); } else { callback(null, value); } } /** * Fonction permettant de vérifier et de surcharger des valeurs lors d'un get * @param {Object} req * @param {Function} callback * @return {Boolean} */ createQuery(req, callback) { this.setInclusions(req); const query = {}; let where = {}; let order = []; // On test les droits if (!this.haveRight(req)) { callback(new ErrorBuilder(401.1, "You're not allowed")); return false; } // On teste la query (ou les params) const toValidate = req.getType === "list" ? req.query : req.params; if (!toValidate) { callback( new ErrorBuilder( 406.0, `Missing ${req.getType === "list" ? "query" : "params"}` ) ); return false; } const { error, value } = this.params.validate[ req.getType ].validate(toValidate, { abortEarly: false }); // Un paramètre n'est pas bon dans la query if (error) { callback(new ErrorBuilder(406.1, error)); return false; } // On vire, pour le moment la liste des filtres un peu particuliers let listOfIgnoredFilters = [ "limit", "page", "sort", "order", this.params.itemId ]; if (this.params.removeKeys && this.params.removeKeys[req.getType]) { listOfIgnoredFilters = listOfIgnoredFilters.concat( this.params.removeKeys[req.getType] ); } if ( this.params.override && this.params.override[req.getType] && this.params.override[req.getType].filters ) { Object.keys(this.params.override[req.getType].filters).map(key => { listOfIgnoredFilters.push(key); return true; }); } // On rajoute les filtres autorisés Object.keys(value).map(key => { if (listOfIgnoredFilters.indexOf(key) === -1) { where[key] = value[key]; } return true; }); if (req.getType === "list") { // Aucune pagination n'est passée, on set celle par défaut if (!value.page || !value.limit) { value.page = 1; value.limit = 50; } // Un tri est spécifié if (value.order && value.sort) { order = [[value.sort, value.order.toUpperCase()]]; } } else { // On get un item. on set son id where.id = value[this.params.itemId]; } // S'il y a des restrictions (genre un utilisateur n'a le droit de voir que tel ou tel items) const restrict = this.restrictOn(req); where = Object.assign(where, restrict); // On regarde s'il n'y a pas des valeurs à overrider const override = this.override(req, req.getType); where = Object.assign(where, override); if (order) { query.order = order; } query.distinct = true; // On supprime les id en double (jointure de type hasmany) query.where = where; // On rajoute des filtres if (this.includes) { query.include = this.includes; // On set la liste des modèles à inclure } // Hack pour faire un recherche dans les nested de type hasMany query.subQuery = false; callback(null, query, { start: value.page * value.limit - value.limit, limit: value.limit }); return true; } } module.exports = QueryBuilder;