UNPKG

igo

Version:

Igo is a Node.js Web Framework based on Express

624 lines (531 loc) 16.3 kB
const _ = require('lodash'); const Sql = require('./Sql'); const dbs = require('./dbs'); const logger = require('../logger'); const DataTypes = require('./DataTypes'); // const merge = (includes, includeParam) => { // console.dir({MERGE: { includes, includeParam}}, { depth: 99 }); if (_.isString(includeParam)) { if (!includes[includeParam]) { includes[includeParam] = {}; } return; } _.each(includeParam, (value, key) => { if (includes[key]) { if (_.isString(includes[key])) { includes[key] = {}; } merge(includes[key], value); } else { includes[key] = value; } }); // console.dir({RESULT: { includes }}, { depth: 99 }); }; // module.exports = class Query { constructor(modelClass, verb = 'select') { this.modelClass = modelClass; this.schema = modelClass.schema; this.query = { table: modelClass.schema.table, select: null, verb: verb, where: [], whereNot: [], joins: [], order: [], distinct: null, group: null, includes: {}, options: {}, scopes: [ 'default' ] }; // filter on subclass const key = _.findKey(this.schema.subclasses, { name: this.modelClass.name }); if (key) { this.query.where.push({ [this.schema.subclass_column]: key }); } } // UPDATE async update(values) { this.query.verb = 'update'; this.values(values); return await this.execute(); } // DELETE async delete() { this.query.verb = 'delete'; return await this.execute(); } async destroy() { console.log('* Query.destroy() deprecated. Please use Query.delete() instead'); return await this.delete(); } // FROM from(table) { this.query.table = table; return this; } // WHERE where(where, params) { where = params !== undefined ? [where, params] : where; this.query.where.push(where); return this; } // WHERE NOT whereNot(whereNot) { this.query.whereNot.push(whereNot); return this; } // VALUES values(values) { this.query.values = _.transform(values, (result, value, key) => { const column = this.schema.colsByAttr[key]; if (column) { if (DataTypes[column.type]) { result[column.name] = DataTypes[column.type].set(value); } else { // unknown type logger.warn(`Unknown type '${column.type}' for column '${column.name}'`); } } else { // unknown column (ignore) } }, {}); return this; } // FIRST async first() { this.query.limit = 1; this.query.take = 'first'; return await this.execute(); } // LAST async last() { this.query.limit = 1; this.query.take = 'last'; return await this.execute(); } // LIMIT limit(limit, offset) { this.query.limit = limit; if (offset) { logger.warn('Query.limit: offset is deprecated, use offset() instead'); } return this; } // OFFSET offset(offset) { this.query.offset = offset; return this; } // PAGE page(page, nb) { this.query.page = parseInt(page, 10) || 1; this.query.page = Math.max(1, this.query.page); this.query.nb = parseInt(nb, 10) || 25; return this; } // SCOPE scope(scope) { this.query.scopes.push(scope); return this; } // UNSCOPED unscoped() { this.query.scopes.length = 0; return this; } // LIST async list() { return await this.execute(); } // SELECT select(select) { this.query.select = select; return this; } // COUNT async count() { const countQuery = new Query(this.modelClass); countQuery.query = _.cloneDeep(this.query); countQuery.query.verb = 'count'; countQuery.query.limit = 1; delete countQuery.query.page; delete countQuery.query.nb; const rows = await countQuery.execute(); const count = rows && rows[0] && Number(rows[0].count) || 0; return count; } // JOIN join(join, columns, type) { if (_.isString(join)) { return this.joinOne(join, columns, type); } else if (_.isArray(join)) { return this.joinMany(join, columns, type); } else if (_.isObject(join)) { return this.joinNested(join); } throw new Error('Invalid join argument. Must be a string, array, or object.'); } // JOIN ONE joinOne(associationName, columns, type = 'LEFT') { const { query } = this; const association = this._findAssociation(associationName, this.schema); query.joins.push({ src_schema: this.schema, association, columns, type, src_alias: this.schema.table }); return this; } // JOIN MANY joinMany(associationNames, columns, type = 'LEFT') { _.forEach(associationNames, name => this.joinOne(name, columns, type)); return this; } // JOIN NESTED joinNested(nestedAssociations) { const processJoin = (join, current_schema, current_alias) => { if (_.isString(join)) { this._addJoin(join, null, 'LEFT', current_schema, current_alias); return; } if (_.isArray(join)) { join.forEach(j => processJoin(j, current_schema, current_alias)); return; } if (_.isObject(join)) { _.each(join, (value, key) => { const new_join_alias = key; const association = this._addJoin(key, null, 'LEFT', current_schema, current_alias); const next_schema = association[2].schema; processJoin(value, next_schema, new_join_alias); }); } }; processJoin(nestedAssociations, this.schema, this.schema.table); return this; } // Helper to find association _findAssociation(associationName, src_schema) { const association = _.find(src_schema.associations, assoc => assoc[1] === associationName); if (!association) { throw new Error(`Missing association '${associationName}' on '${src_schema.table}' schema.`); } if (association[0] !== 'belongs_to') { throw new Error(`Association '${associationName}' on '${src_schema.table}' schema is not a 'belongs_to' association.`); } return association; } // Helper to add join to query.joins _addJoin(associationName, columns, type, src_schema, src_alias_for_join) { const association = this._findAssociation(associationName, src_schema); this.query.joins.push({ src_schema, association, columns, type, src_alias: src_alias_for_join }); return association; } // SCOPES applyScopes() { const { query, schema } = this; _.forOwn(query.scopes, (scope) => { if (!schema.scopes[scope]) { return; } schema.scopes[scope](this); }); } // INCLUDES includes(includeParams) { const { query } = this; const pushInclude = includeParam => { merge(query.includes, includeParam); }; _.forEach(_.concat([], includeParams), pushInclude); return this; } // FIND async find(id) { if (id === null || id === undefined) { return null; } if (_.isString(id) || _.isNumber(id)) { return await this.where({ id }).first(); } if (_.isArray(id)) { id = _.compact(id); if (id.length === 0) { return null; } return await this.where({ id }).first(); } return await this.where(id).first(); } // ORDER BY order(order) { this.query.order.push(order); return this; } // DISTINCT distinct(columns) { this.query.distinct = _.isArray(columns) ? columns : [ columns ]; return this; } // GROUP group(columns) { this.query.group = _.castArray(columns); return this; } // QUERY OPTIONS options(options) { _.merge(this.query.options, options); return this; } getDb() { return dbs[this.schema.database]; } // Vérifie si la query est compatible avec le mode optimisé _checkOptimizedCompatibility() { const { query } = this; // Joins avec colonnes personnalisées (aliases dans ORDER BY) if (query.joins.some(j => j.columns)) { return false; } // Raw SQL where qui référence des aliases de joins const joinAliases = query.joins.map(j => j.association[1]); const rawWheres = query.where.filter(w => _.isArray(w) || _.isString(w)); for (const w of rawWheres) { const sql = _.isArray(w) ? w[0] : w; if (joinAliases.some(alias => sql.includes(`\`${alias}\``))) { return false; } } return true; } // generate SQL toSQL() { const { query } = this; const db = this.getDb(); const sql = new Sql(this.query, db.driver.dialect)[this.query.verb + 'SQL'](); query.generated = sql; return sql; } // async paginate() { const { query } = this; if (!query.page) { return; } const count = await this.count(); const nb_pages = Math.ceil(count / query.nb); query.page = Math.min(query.page, nb_pages); query.page = Math.max(query.page, 1); query.offset = (query.page - 1) * query.nb; query.limit = query.nb; const links = []; const page = this.query.page; const start = Math.max(1, page - 5); for (let i = 0; i < 10; i++) { const p = start + i; if (p <= nb_pages) { links.push({ page: p, current: page === p }); } } return { page: this.query.page, nb: this.query.nb, previous: page > 1 ? page - 1 : null, next: page < nb_pages ? page + 1 : null, start: query.offset + 1, end: query.offset + Math.min(query.nb, count - query.offset), nb_pages, count, links, }; } // async loadAssociation(include, rows) { let schema = this.schema; let association = null; let parts, path = null; if (include.indexOf('.') !== -1) { // nested include parts = include.split('.'); path = parts.slice(0, parts.length - 1).join('.') + '.'; for (const part of parts) { association = _.find(schema.associations, (assoc) => { return assoc[1] === part; }); schema = association ? association[2].schema : null; } } else { association = _.find(schema.associations, (association) => { return association[1] === include; }); } if (!association) { throw new Error(`Missing association '${include}' on '${schema.table}' schema.`); } const [type, attr, Obj, column = attr + '_id', ref_column = 'id', extraWhere] = association; let column_path = column; if (path) { if (type === 'has_many') { column_path = parts.slice(0, parts.length - 2).join('.'); if (column_path) { column_path += '.'; } column_path += ref_column; } else { column_path = path + column; } } const ids = _.chain(rows).flatMap(column_path).uniq().compact().value(); const defaultValue = () => (type === 'has_many' ? [] : null); if (ids.length === 0) { _.forEach(rows, (row) => row[attr] = defaultValue()); return; } const where = { [ref_column]: ids }; const subincludes = this.query.includes[include]; let query = Obj.includes(subincludes).where(where); if (extraWhere) { query.where(extraWhere); } const objs = await query.list(); const objsByKey = {}; _.forEach(objs, (obj) => { const key = obj[ref_column]; if (type === 'has_many') { objsByKey[key] = objsByKey[key] || []; objsByKey[key].push(obj); } else { objsByKey[key] = obj; } }); const attr_path = path ? path + attr : attr; _.forEach(rows, (row) => { const value = _.get(row, column_path); if (!Array.isArray(value)) { _.set(row, attr_path, objsByKey[value] || defaultValue()); return; } row[attr] = _.chain(value).flatMap(id => objsByKey[id]).compact().value(); }); } // async execute() { const { query, schema } = this; const db = this.getDb(); const { dialect } = db.driver; const { esc } = dialect; if (schema.scopes) { this.applyScopes(); } if (query.order.length === 0 && (query.take === 'first' || query.take === 'last')) { const order = query.take === 'first' ? 'ASC' : 'DESC'; // Default sort by primary key _.forEach(schema.primary, (key) => { query.order.push(`${esc}${schema.table}${esc}.${esc}${key}${esc} ${order}`); }); } // force limit to 1 for first/last if (query.take === 'first' || query.take === 'last') { query.limit = 1; } // Auto-activation du mode optimisé : pagination + joins → pattern 3 phases if (query.verb === 'select' && query.page && query.joins.length > 0) { if (!this._checkOptimizedCompatibility()) { logger.warn(`[Query] Optimized pagination skipped for '${query.table}': join aliases are not supported, use 'dot' notation instead.`); } else { const PaginatedOptimizedQuery = require('./PaginatedOptimizedQuery'); return await PaginatedOptimizedQuery.fromQuery(this).executeOptimized(); } } const pagination = await this.paginate(); let rows = await this.runQuery(); if (query.verb === 'insert') { const insertId = dialect.insertId(rows); return { insertId }; } else if (query.verb !== 'select') { return rows; } if (query.distinct || query.group) { return rows; } else if (query.limit === 1 && (!rows || rows.length === 0)) { return null; } else if (query.verb === 'select') { rows = _.each(rows, row => { schema.parseTypes(row); // parse joins values _.forEach(this.query.joins, (join) => { const { src_schema, association } = join; const [assoc_type, name, Obj, src_column, column] = association; Obj.schema.parseTypes(row, `${name}__`); }); }); } if (query.verb === 'select') { rows = _.map(rows, row => { const instance = this.newInstance(row); if (this.query.joins.length === 0) { return instance; } const createdInstances = new Map(); createdInstances.set(this.schema, instance); _.forEach(this.query.joins, (join) => { const { src_schema, association } = join; const [assoc_type, name, Obj, src_column, column] = association; const table_alias = name; const params = {}; Obj.schema.columns.forEach(col => { const alias = `${table_alias}__${col.attr}`; params[col.attr] = row[alias]; delete instance[alias]; }); const joinInstance = this.newInstance(params, Obj); const parentInstance = createdInstances.get(src_schema); if (parentInstance) { parentInstance[name] = joinInstance || null; if (joinInstance) { createdInstances.set(Obj.schema, joinInstance); } } }); return instance; }); } // Load associations for (let include of _.keys(query.includes)) { await this.loadAssociation(include, rows); } if (pagination) { return { pagination, rows }; } if (query.limit === 1) { return rows[0]; } return rows; } // run the query async runQuery() { const { query } = this; const sqlQuery = this.toSQL(); const db = this.getDb(); return await db.query(sqlQuery.sql, sqlQuery.params, query.options); } // new instance of model object newInstance(row, instanceClass=this.modelClass) { // let instanceClass = this.modelClass; if (_.every(this.schema.primary, key => row[key] === null)) { return null; // no primary key, no instance } const type = row[this.schema.subclass_column]; if (this.schema.subclasses && type) { instanceClass = this.schema.subclasses[type]; } return new instanceClass(row); } };